package com.example.wechathook; import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.view.View; import java.lang.reflect.Proxy; import de.robv.android.xposed.IXposedHookLoadPackage; import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import de.robv.android.xposed.XposedHelpers; import de.robv.android.xposed.callbacks.XC_LoadPackage; public class WeChatHook implements IXposedHookLoadPackage { private static final String ACTION_SEND = "com.hermes.wechat.SEND_MSG_INTERNAL"; private static final String ACTION_SEND_IMAGE = "com.hermes.wechat.SEND_IMAGE_INTERNAL"; private static final String ACTION_SEND_VOICE = "com.hermes.wechat.SEND_VOICE_INTERNAL"; private static final String ACTION_SEND_VOICE_CGI = "com.hermes.wechat.SEND_VOICE_CGI"; private static final String ACTION_ARM_SWAP = "com.hermes.wechat.ARM_VOICE_SWAP"; private static final String ACTION_DUMP_API = "com.hermes.wechat.DUMP_WECHAT_API"; // ── Voice hot-swap state (the route that actually delivers) ──────────────── // Fully-synthetic voice (SEND_VOICE_INTERNAL) can build the message shell but // can never upload the SILK to WeChat's CDN (upload is locked inside jg0.x's // Kotlin coroutine). So instead we let the user trigger a REAL recording (hold // "按住说话") and intercept jg0.x.w — the CDN uploadAttach step — replacing the // recorded SILK bytes (w21.t.a) with our TTS SILK. WeChat's own machinery then // uploads + delivers our audio. Armed by ARM_VOICE_SWAP; consumed once. private static volatile boolean sSwapArmed = false; private static volatile byte[] sSwapSilk = null; // TTS SILK bytes to inject private static volatile int sSwapDuration = 0; // ms, replaces the recorded duration // Safety: an arm auto-expires at this wall-clock time (set to arm + audio duration // + 10s slack). The swap fires only on release AFTER a full hold, so the window must // outlast the hold; past it, a stale arm silently dies instead of hijacking a later // unrelated recording. private static volatile long sSwapExpiresAt = 0; // Cached f9 setter map (column -> methodName), built once on first voice send // (sF9SetterMap is declared alongside probeAllF9Setters below) // Thread-locals for voice send: voiceId and SILK bytes to inject into f9 ctor private static final ThreadLocal sPendingVoiceId = new ThreadLocal<>(); // Set only while sendVoiceViaCgi inserts its local f9 bubble: the cgi already // uploaded+delivered, so force status=4 (sent) — unlike the hot-swap path which // needs H() to set status after CDN upload. private static final ThreadLocal sVoiceCgiInsert = new ThreadLocal<>(); private static final ThreadLocal sPendingLvBuffer = new ThreadLocal<>(); // Captures the f9 that called convertTo() (= the one written to DB); used to populate r0.i. private static final ThreadLocal sDatabaseF9 = new ThreadLocal<>(); private static ClassLoader sWeChatCL; // pre-Tinker classloader (from handleLoadPackage) private static volatile ClassLoader sTinkerCL; // Tinker-patched classloader (from App.onCreate) private static volatile Context sAppContext; private static volatile boolean sFsGReady = false; // Set true while WE are calling newInstance so r0Hook can distinguish us from WeChat private static volatile boolean sCalledByUs = false; // Network dispatcher captured from a real WeChat r0 at H()-entry; needed by CDN upload private static volatile Object sCapturedDispatcher = null; private static volatile Object sPt0E0Instance = null; // pt0.e0 singleton, captured from r() hook // Keeps synthetic-voice-cgi i/deferred objects strongly referenced (the deferred // holds the CgiLifecycleObserver weakly, so without this GC kills the in-flight send). private static final java.util.List sVoiceCgiKeepAlive = new java.util.concurrent.CopyOnWriteArrayList<>(); private static volatile Object sImgPlugin = null; // b80.u instance, captured from pj/Bi hook private static volatile Object sRealImgParams = null; // last real o70.h, captured for replication private static volatile Object sRealImgK = null; // last real o70.k (oj insert object) private static final ThreadLocal sPendingMsgLocalId = new ThreadLocal<>(); // msgId from DB insert // Logged-in user's wxid, auto-discovered from first real voice message send private static volatile String sMyWxid = null; // Pending send: stored when broadcast arrives before gm0.j1 is initialized private static volatile String sPendingTalker = null; private static volatile String sPendingText = null; private static volatile String sPendingProc = null; // ── 群 @ 自学习(atuserlist):从微信自己发的真 @ 消息学到 r0 里 msgSource 存哪个字段 ── private static volatile String sAtFieldPath = null; // "field" 或 "field.subField" private static volatile boolean sAtLoaded = false; private static final String AT_FIELD_FILE = "/data/data/com.tencent.mm/files/.at_field"; @Override public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { if (!"com.tencent.mm".equals(lpparam.packageName)) return; // Use android.util.Log directly so it appears even before VectorLegacyBridge is ready android.util.Log.i("WeChatHookEarly", "INIT proc=" + lpparam.processName + " pid=" + android.os.Process.myPid()); XposedBridge.log("[WeChatHook] Injected into WeChat " + lpparam.processName + " pid=" + android.os.Process.myPid()); ClassLoader cl = lpparam.classLoader; sWeChatCL = cl; // ── Pad login (以平板登录) — WORKING via device-model spoof (2026-07-07) ──── // CONFIRMED ROOT CAUSE: WeChat's tablet gate is a device-MODEL whitelist, NOT an // account "体验资格" (beta trial) check. Spoofing Build to a Samsung Galaxy Tab // (SM-X700) let the SAME old 小火子 account — the one we'd blamed for 体验资格 // expiry — log in as a secondary/pad device. So the earlier "体验资格 gate" // conclusion was WRONG; the blocker was always the Pixel model failing bk.B/Q. // 1) hookDeviceSpoof → Build + ro.product.* = Samsung tablet (passes bk.B/Q + // the server secondary-device handshake). // 2) hookInTabletEnv → bk.A/B → true (inTabletEnv), belt-and-suspenders. // 3) hookLoginHistoryUIInitView/hookLoginUIInitView → force the "以平板登录" // button (field N) VISIBLE on LoginPasswordUI/LoginHistoryUI.onCreate + m4 click. // RUNTIME TOGGLE (default OFF = clean un-spoofed Pixel, lowest 风控/ban risk): // all 4 pad-login hooks install ONLY when the flag file exists. Flip it with the // spoof_toggle shortcut (touch/rm the flag + restart WeChat). Keeping spoof off // normally avoids the device-fingerprint mismatch that trips WeChat risk control // (forced logout + 实名验证). Turn on only when you actually need pad login. // When pad login breaks later, Hermes first-responds via the on-device skill // wechat-pad-login-repair (diagnose logs/screen → no-rebuild fixes → escalate // Case B). Recipe: wechat_pad_login/LESSONS.md + HERMES_REPAIR_SKILL.md. if (isDeviceSpoofEnabled()) { XposedBridge.log("[WeChatHook] pad-spoof flag ON — installing device spoof + tablet-login hooks"); hookDeviceSpoof(cl, lpparam.processName); hookInTabletEnv(cl); hookLoginHistoryUIInitView(cl); hookLoginUIInitView(cl); } else { XposedBridge.log("[WeChatHook] pad-spoof flag OFF — clean Pixel, no spoof/tablet hooks"); } // hookProbe(cl); // hookLoginSelectUICheck(cl); // hookLoginButtonClick(cl); // WCDB diagnostic: which process writes? + media ContentValues dump hookWCDB(cl, lpparam.processName); // Factory method discovery: dumps w11.s1 signatures + hooks all static methods // so that when WeChat UI sends image/voice we capture the exact API call. hookFactoryMethods(cl, lpparam.processName); // MediaPlayer hook: captures voice file paths when WeChat plays voice messages. // This reveals the voice2/{dir1}/{dir2}/msg_{id}.amr naming formula. hookMediaPlayer(lpparam.processName); // Hook gm0.j1.g() to detect when DI kernel is ready — runs in ALL processes. // This enables the pending-send mechanism: if broadcast arrives before DI init, // we store the args and retry once g() fires. hookDIInit(cl, lpparam.processName); // Send hook in ALL WeChat processes — we need to find which one can actually send. // The broadcast receiver registered in each process will all receive the intent; // whichever process has gm0.j1 initialized will succeed. hookSendMessage(cl, lpparam.processName); // Hook w11.r0.H(String) to trace CDN upload: log r0 String fields + file access. hookR0H(cl, lpparam.processName); // Capture the live network dispatcher com.tencent.mm.modelbase.m2 (MicroMsg.RDispatcher) // at construction. Synthetic voice sends need the REAL m2 to actually reach the // network; without it we'd fall back to a fake $Proxy that never sends. hookDispatcher(cl, lpparam.processName); // Instrument the voice-upload service w21.g1 (voiceinfo table): on a REAL voice // send, dump the exact w21.w0 upload-task fields so we can replicate them for // synthetic voice (the last missing step = CDN audio upload). hookVoiceUpload(cl, lpparam.processName); // Image send: hook the non-suspend plugin methods b80.u.pj/Bi (o70.h) — the // actual image-send entry the coroutine calls. Capture the b80.u instance + // dump the full o70.h a REAL send builds, so we can replicate it directly. hookImageSend(cl, lpparam.processName); // Capture the SendImgProxyUI launch Intent (real gallery send goes through it), // so we can replay the whole real send flow via `am start`. hookSendImgProxyUI(cl, lpparam.processName); // Hook pt0.e0 — the voice-send coroutine class found in the stack trace. // Real voice sends don't use r0.H(); we need to find where CDN upload actually fires. hookPt0E0(cl, lpparam.processName); // Surgical voice-send entry probe (jg0.x + qi3.f0/g0 only). On this fresh // reinstall the send runs in the base CL (no Tinker patch yet), so hook here. hookVoiceProbe(cl, lpparam.processName); // Voice hot-swap: intercept jg0.x.w (CDN uploadAttach) to replace the recorded // SILK with our TTS audio during a real (user- or A11y-triggered) recording. // This is the ONE route that actually delivers synthetic voice. hookVoiceSwap(cl, lpparam.processName); // Trace the CDN upload submission (dn.m task + assigned aeskey/fileId) on a real // voice send — the layer full-synthesis was missing (addSendTask / CdnManager). hookCdnTrace(cl, lpparam.processName); // Capture the real uploadvoice cgi request (this device uses inline-cgi, not CDN). hookVoiceCgiCapture(cl, lpparam.processName); hookVoiceCgiResult(cl, lpparam.processName); // 贴图号发布逆向(wechat_send_internal/BRANDSERVICE_PUBLISH_RESEARCH.md §3.5): // both the mini-program (k2/y7) path and the WebView "luggage" JS-API path // funnel into this one sink before dispatch (local coroutine or IPC to // PROCESS_MAIN). Read-only: dumps TransferRequestInfo/y24 fields, does not // touch args or return value. LSPosed-installed (Zygote-time), NOT a live // Frida spawn/attach — that approach broke WeChat's main-process cold start // twice in one session (see doc §3.6/3.7); this hook exists precisely to // avoid repeating that mistake. hookBrandservicePublishTransfer(cl, lpparam.processName); // Broad catch-all: js1.m.c() only ever saw plain webview traffic for this flow // (§3.9), so also watch the generic internal-cgi builder unfiltered in case the // real publish submission is a native NetScene call instead. hookAnyCgiCapture(cl, lpparam.processName); // Image submission for brandservice: js1.m.c()/bizh5webtransfer never showed a // submit call, traced instead to a separate CDN service (§4 of doc). hookBrandserviceCdnUpload(cl, lpparam.processName); hookGenericImgCdnSubmit(cl, lpparam.processName); } // ── WCDB hook ────────────────────────────────────────────────────────────── // Intercepts every INSERT into the message table. // For msgtype 3 (image) and 34 (voice) logs the full ContentValues so we can // reverse-engineer the exact schema WeChat uses when building those messages. private void hookWCDB(ClassLoader cl, String procName) { try { XposedHelpers.findAndHookMethod( "com.tencent.wcdb.database.SQLiteDatabase", cl, "insertWithOnConflict", String.class, String.class, android.content.ContentValues.class, int.class, new XC_MethodHook() { private boolean textLogged = false; @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { String table = (String) param.args[0]; if (table == null) return; android.content.ContentValues cv = (android.content.ContentValues) param.args[2]; // Determine msgtype from ContentValues (key may be "type" or "msgtype") Object rawType = null; if (cv != null) { rawType = cv.get("type"); if (rawType == null) rawType = cv.get("msgtype"); if (rawType == null) rawType = cv.get("msgType"); } int msgtype = -1; try { if (rawType != null) msgtype = Integer.parseInt(rawType.toString()); } catch (NumberFormatException ignored) {} if (msgtype == 3 || msgtype == 34) { // Image (3) or Voice (34): dump full ContentValues + stack String cvStr = cv != null ? cv.toString() : "null"; String stack = android.util.Log.getStackTraceString(new Throwable()); if (stack.length() > 4000) stack = stack.substring(0, 4000) + "…"; XposedBridge.log("[WeChatHook] WCDB MEDIA INSERT table=" + table + " msgtype=" + msgtype + " proc=" + procName + "\nContentValues=" + cvStr + "\nStack=" + stack); } else if (msgtype == 1) { // Text: only log once (to confirm which process writes) if (!textLogged) { textLogged = true; String stack = android.util.Log.getStackTraceString(new Throwable()); if (stack.length() > 3000) stack = stack.substring(0, 3000) + "…"; XposedBridge.log("[WeChatHook] WCDB TEXT INSERT table=" + table + " proc=" + procName + "\n" + stack); } else { XposedBridge.log("[WeChatHook] WCDB TEXT table=" + table + " proc=" + procName); } } } }); XposedBridge.log("[WeChatHook] WCDB hook OK in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] WCDB hook failed in " + procName + ": " + t); } } // ── MediaPlayer hook: reveals voice file path format ───────────────────── // When WeChat plays a voice message it calls MediaPlayer.setDataSource(path). // We log any path under voice2/ to see the full path including subdirectory, // which reveals the formula WeChat uses to map voice file IDs to directories. private void hookMediaPlayer(String procName) { try { XposedHelpers.findAndHookMethod("android.media.MediaPlayer", null, "setDataSource", String.class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable { String path = (String) p.args[0]; if (path != null && path.contains("voice2")) { XposedBridge.log("[WeChatHook] MediaPlayer.setDataSource VOICE=" + path + " proc=" + procName); } } }); XposedBridge.log("[WeChatHook] MediaPlayer hook OK in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] MediaPlayer hook failed in " + procName + ": " + t); } } // ── f9.convertTo() hook: maps f9 fields to DB columns ───────────────────── // Captures ContentValues produced by convertTo() for image (3) and voice (34). // From these ContentValues we know which f9 setter corresponds to which DB column // (imgPath, content, talker, etc.) so we can construct f9 programmatically. private void hookF9ConvertTo(ClassLoader cl, String procName) { try { XposedHelpers.findAndHookMethod("com.tencent.mm.storage.f9", cl, "convertTo", new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) throws Throwable { if (p.hasThrowable()) return; android.content.ContentValues cv = (android.content.ContentValues) p.getResult(); if (cv == null) return; Object rawType = cv.get("type"); if (rawType == null) rawType = cv.get("msgtype"); if (rawType == null) return; int msgtype = -1; try { msgtype = Integer.parseInt(rawType.toString()); } catch (Throwable e) {} if (msgtype == 3 && !sCalledByUs) { // Dump full call stack once for a real IMAGE send so we can find // the top-level app entry point to replicate for API image send. XposedBridge.log("[WeChatHook] convertTo IMAGE STACK (real send):\n" + android.util.Log.getStackTraceString(new Exception())); } if (msgtype == 34) { // Dump call stack once for real sends to find the CDN upload caller. if (!sCalledByUs) { XposedBridge.log("[WeChatHook] convertTo STACK (real send):\n" + android.util.Log.getStackTraceString(new Exception())); } // Auto-discover logged-in user's wxid from real UI voice sends. // We look for isSend=1 messages (user's own sends) and extract // fromusername from the voicemsg XML, cache as sMyWxid. if (sMyWxid == null && !sCalledByUs) { Object isSend = cv.get("isSend"); Object contentVal = cv.get("content"); if (isSend instanceof Integer && ((Integer)isSend) == 1 && contentVal instanceof String) { String c = (String) contentVal; int fi = c.indexOf("fromusername=\""); if (fi >= 0) { int start = fi + "fromusername=\"".length(); int end = c.indexOf("\"", start); if (end > start) { String wxid = c.substring(start, end); if (!wxid.isEmpty()) { sMyWxid = wxid; XposedBridge.log("[WeChatHook] auto-discovered sMyWxid=" + wxid); } } } } } } if (msgtype == 3 || msgtype == 34) { // Inject lvbuffer (SILK bytes) if we're sending voice via hook. // sPendingLvBuffer is still set here — convertTo fires INSIDE // the r0 constructor which runs before our finally-block clears it. byte[] pendingLv = sPendingLvBuffer.get(); if (pendingLv != null && !(cv.get("lvbuffer") instanceof byte[])) { cv.put("lvbuffer", pendingLv); XposedBridge.log("[WeChatHook] convertTo: injected lvbuffer " + pendingLv.length + " bytes"); } // Capture the DB-bound f9 (the one convertTo() runs on). // This is the correct object for r0.i: it has content + msgId set. if (pendingLv != null) { sDatabaseF9.set(p.thisObject); // Capture msgLocalId for pt0.e0.s() CDN trigger try { Object msgIdObj = cv.get("msgId"); if (msgIdObj != null) { long mid = Long.parseLong(msgIdObj.toString()); sPendingMsgLocalId.set(mid); XposedBridge.log("[WeChatHook] convertTo: captured msgLocalId=" + mid); } } catch (Throwable ignored) {} // Hot-swap path: do NOT force status=4 (H() would skip CDN // upload if the msg already looks delivered). But the cgi // path (sendVoiceViaCgi) already uploaded+delivered via the // uploadvoice cgi, so force status=4 (sent) — else the sender // bubble spins "正在发送中" forever. if (Boolean.TRUE.equals(sVoiceCgiInsert.get())) { cv.put("status", 4); XposedBridge.log("[WeChatHook] convertTo: cgi insert — forced status=4 (sent)"); } // Ensure flag and msgSvrId are present (missing from our sends). if (cv.get("flag") == null) cv.put("flag", 0); if (cv.get("msgSvrId") == null) cv.put("msgSvrId", 0L); XposedBridge.log("[WeChatHook] convertTo: initial INSERT"); } // Log full ContentValues including lvbuffer details StringBuilder sb = new StringBuilder(); sb.append("[WeChatHook] f9.convertTo() msgtype=").append(msgtype) .append(" proc=").append(procName).append("\nContentValues=").append(cv); Object lvbuf = cv.get("lvbuffer"); if (lvbuf instanceof byte[]) { byte[] b = (byte[]) lvbuf; StringBuilder hex = new StringBuilder(); int show = Math.min(b.length, 32); for (int i = 0; i < show; i++) hex.append(String.format("%02x", b[i] & 0xff)); sb.append("\nlvbuffer len=").append(b.length).append(" hex=").append(hex); } else { sb.append("\nlvbuffer=null"); } XposedBridge.log(sb.toString()); } } }); XposedBridge.log("[WeChatHook] f9.convertTo hook OK in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] f9.convertTo hook failed in " + procName + ": " + t); } } // ── f9 constructor imgPath injector ─────────────────────────────────────── // Hooks ALL f9 constructors. When sPendingVoiceId is set on the calling thread, // immediately calls the discovered imgPath setter on the newly created f9 object. // This fires INSIDE the r0 constructor (before its internal f9.convertTo() call), // so the DB insert from r0 constructor already has the correct voice file ID. private void hookF9ImgPathInjector(ClassLoader cl, String procName) { try { Class f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl); XC_MethodHook injHook = new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { String pending = sPendingVoiceId.get(); if (pending == null) return; java.util.Map sm = sF9SetterMap; if (sm == null) return; // Inject imgPath String setter = sm.get("imgPath"); if (setter != null) { XposedHelpers.callMethod(param.thisObject, setter, pending); XposedBridge.log("[WeChatHook] f9 ctor injected imgPath=" + pending + " via " + setter); } // Inject lvbuffer (SILK bytes) so WeChat treats the voice as locally available byte[] lvbuf = sPendingLvBuffer.get(); String lvSetter = sm.get("lvbuffer:bytes"); if (lvbuf != null && lvSetter != null) { XposedHelpers.callMethod(param.thisObject, lvSetter, (Object) lvbuf); XposedBridge.log("[WeChatHook] f9 ctor injected lvbuffer len=" + lvbuf.length + " via " + lvSetter); } else { XposedBridge.log("[WeChatHook] f9 ctor lvbuffer skip: lvbuf=" + (lvbuf==null?"null":"ok") + " setter=" + lvSetter); } // sDatabaseF9 is captured in convertTo() — that's the correct DB-bound f9. } }; int hooked = 0; for (java.lang.reflect.Constructor ctor : f9Class.getDeclaredConstructors()) { XposedBridge.hookMethod(ctor, injHook); hooked++; } XposedBridge.log("[WeChatHook] f9 ctor injector OK in " + procName + " (" + hooked + " ctors)"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] f9 ctor injector failed in " + procName + ": " + t); } } // ── Image send via internal API ──────────────────────────────────────────── // Calls the gallery's own static image-send helper: // j33.d0.k(long imgId, String path, String talker, boolean, int, boolean, // GalleryItem$MediaItem) // This is exactly what the album picker calls (traced via a real image send's // convertTo stack → b80.u.oj → b80.s coroutine → built from o70.h; d0.k is the // top-level builder). It builds o70.h (PreBuildParams) and launches the send // coroutine internally (thumbnail gen + CDN upload + DB insert), fire-and-forget. // The old w11.s1.b(IMAGE) path built an incomplete r1 → n1.a() NPE; abandoned. private static void sendImageViaAPI(String talker, String imagePath) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); // Replicate the REAL image send captured by hookImageSend: build the exact // o70.h (PreBuildParams) that b80.u.pj() receives on a genuine send, then call // b80.u.pj(o70.h) directly (non-suspend), skipping the whole gallery/coroutine UI. // // Captured real o70.h (fields are single letters a–l): // a=r6 preBuildFolder, e=imgPath, g=toUserName(talker), c=source(0), d=0, // f=imgId(0 ok), h=compressType(0), k=Uri(null for pj), b=UUID(auto) // // r6 folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") (= g80.o.c7()). // plugin b80.u = i95.n0.c(o70.e.class) (fallback to hook-captured instance). // b80.u image plugin = i95.n0.c(o70.e.class) (fallback to hook-captured instance). Object imgPlugin = sImgPlugin; if (imgPlugin == null) { Class n0 = XposedHelpers.findClass("i95.n0", cl); Class o70e = XposedHelpers.findClass("o70.e", cl); imgPlugin = XposedHelpers.callStaticMethod(n0, "c", o70e); } if (imgPlugin == null) throw new IllegalStateException("b80.u image plugin null"); // r6 pre-build folder = lp0.b.h0(gm0.j1.b().j() + "_MsgImgUploadFSC") (= g80.o.c7()). Object m = XposedHelpers.callStaticMethod(XposedHelpers.findClass("gm0.j1", cl), "b"); Object prefix = XposedHelpers.callMethod(m, "j"); Class lp0b = XposedHelpers.findClass("lp0.b", cl); Object r6 = XposedHelpers.callStaticMethod(lp0b, "h0", prefix + "_MsgImgUploadFSC"); // Build o70.k (the batch send object) — fields captured from a real oj(o70.k): // a=r6, b=UUID(auto), c=talker, d=source, f=compressType, g=List Class o70k = XposedHelpers.findClass("o70.k", cl); Object k = XposedHelpers.newInstance(o70k, r6); XposedHelpers.setObjectField(k, "c", talker); // toUserName // source (field d) — real send used 3; set via reflection (int field). try { java.lang.reflect.Field df = o70k.getDeclaredField("d"); df.setAccessible(true); df.setInt(k, 3); } catch (Throwable ignored) {} // o70.i(imgPath, Uri, Long imgId) — give it a file:// Uri so the CDN upload has // a readable source (real sends pass a MediaStore content:// Uri). Class o70i = XposedHelpers.findClass("o70.i", cl); android.net.Uri fileUri = android.net.Uri.fromFile(new java.io.File(imagePath)); Object item = XposedHelpers.newInstance(o70i, new Class[]{String.class, android.net.Uri.class, Long.class}, imagePath, fileUri, Long.valueOf(0L)); java.util.List list = new java.util.ArrayList<>(); list.add(item); XposedHelpers.setObjectField(k, "g", list); String clientId = (String) XposedHelpers.getObjectField(k, "b"); // Replicate b80.s coroutine's per-item prebuild loop: build o70.h from k+item, // then pj (stage compressed image + thumbnail into r6). Class o70h = XposedHelpers.findClass("o70.h", cl); Object h = XposedHelpers.newInstance(o70h, r6); XposedHelpers.callMethod(h, "b", clientId); // clientId (field b) XposedHelpers.setObjectField(h, "e", imagePath); // imgPath XposedHelpers.setObjectField(h, "g", talker); // toUserName XposedHelpers.setObjectField(h, "k", fileUri); // Uri (source for staging/upload) try { java.lang.reflect.Field hc = o70h.getDeclaredField("c"); hc.setAccessible(true); hc.setInt(h, 3); } catch (Throwable ignored) {} // source // d(rotate)/h(compressType) default 0. f(imgId) 0. XposedBridge.log("[WeChatHook] sendImageViaAPI: built o70.k+o70.h e=" + imagePath + " g=" + talker + " plugin=" + imgPlugin.getClass().getName()); sCalledByUs = true; try { if (!(Boolean) XposedHelpers.callMethod(imgPlugin, "Bi", h, "thumb.jpg")) { XposedHelpers.callMethod(imgPlugin, "pj", h); // prebuild (stage) try { XposedHelpers.callMethod(imgPlugin, "nj", h, 10000); } catch (Throwable ignored) {} } java.util.ArrayList res = (java.util.ArrayList) XposedHelpers.callMethod(imgPlugin, "oj", k); // insert + build descriptors XposedBridge.log("[WeChatHook] sendImageViaAPI: oj() DONE to=" + talker + " res=" + (res == null ? "null" : "size=" + res.size())); // ── Last mile: submit each o70.l descriptor to the CDN upload service ── // oj() only inserts the msg (f9) + ImgInfo (b0) and RETURNS List // upload descriptors — it does NOT start the CDN upload. The real flow // (pe5.f.a) loops the list and, per item, builds r70.g + k70.i0 crossParams // and calls the d1 img-send service's oj(r70.g, o70.l), which launches the // CDN upload coroutine (e80.c). Without this, the full image never uploads // (thumbnail renders but the send spins forever). jadx field names f30xxxN // map to the real single-letter dex field N (verified: o70.k "d"=source). // v70.w.oj builds a ViewModel via ViewModelProvider.get → must run on the // main thread; the broadcast receiver already dispatches us there. if (res != null && !res.isEmpty()) { Class n0 = XposedHelpers.findClass("i95.n0", cl); Object cdnSvc = XposedHelpers.callStaticMethod( n0, "c", XposedHelpers.findClass("xs.d1", cl)); // → v70.w (MsgImgFeatureService) String selfWxid = null; try { selfWxid = (String) XposedHelpers.callStaticMethod( XposedHelpers.findClass("c01.z1", cl), "r"); } catch (Throwable ignored) {} if (selfWxid == null) selfWxid = ""; // API accepts "" (pe5.f.a does the same) Class i0cls = XposedHelpers.findClass("k70.i0", cl); Class r70g = XposedHelpers.findClass("r70.g", cl); int compressType = 0; try { java.lang.reflect.Field cf = o70k.getDeclaredField("f"); cf.setAccessible(true); compressType = cf.getInt(k); } catch (Throwable ignored) {} int submitted = 0; for (Object l : res) { // crossParams: only source (field a) matters for a plain photo; // mirror quote(t)/long(u) from the descriptor (usually null/0). Object crossParams = XposedHelpers.newInstance(i0cls); try { java.lang.reflect.Field af = i0cls.getDeclaredField("a"); af.setAccessible(true); af.setInt(crossParams, 3); } catch (Throwable ignored) {} try { XposedHelpers.setObjectField(crossParams, "t", XposedHelpers.getObjectField(l, "s")); } catch (Throwable ignored) {} try { java.lang.reflect.Field lr = l.getClass().getDeclaredField("r"); lr.setAccessible(true); java.lang.reflect.Field iu = i0cls.getDeclaredField("u"); iu.setAccessible(true); iu.setLong(crossParams, lr.getLong(l)); } catch (Throwable ignored) {} String builtPath = (String) XposedHelpers.getObjectField(l, "e"); // pre-built compressed path Object gVar = XposedHelpers.newInstance(r70g, new Class[]{String.class, int.class, String.class, String.class, i0cls}, builtPath, compressType, selfWxid, talker, crossParams); XposedHelpers.callMethod(cdnSvc, "oj", gVar, l); // → e80.c CDN upload coroutine submitted++; } XposedBridge.log("[WeChatHook] sendImageViaAPI: CDN submit DONE items=" + submitted + " self=" + selfWxid + " compress=" + compressType); } } finally { sCalledByUs = false; } } // ── Probe all f9 setter→column mappings (String + int, one-shot) ────────── // Calls each String setter and each void(int) method with a unique marker, // then calls convertTo() to see which ContentValues key reflects the marker. // Returns a Map. private static volatile java.util.Map sF9SetterMap = null; private static java.util.Map probeAllF9Setters(Class f9Class) { java.util.Map result = new java.util.HashMap<>(); // String setters (known from class dump) for (String sn : new String[]{"Z0", "C1", "D1", "a1", "d1", "h1", "i1"}) { try { Object probe = XposedHelpers.newInstance(f9Class); XposedHelpers.callMethod(probe, sn, "PROBE_" + sn); android.content.ContentValues cv = (android.content.ContentValues) XposedHelpers.callMethod(probe, "convertTo"); for (String key : cv.keySet()) { Object val = cv.get(key); if (("PROBE_" + sn).equals(val != null ? val.toString() : "")) { result.put(key, sn); XposedBridge.log("[WeChatHook] f9 str setter " + sn + " -> " + key); } } } catch (Throwable t) { XposedBridge.log("[WeChatHook] f9 probe str " + sn + ": " + t); } } // Int setters: scan all void(int) methods try { for (java.lang.reflect.Method m : f9Class.getDeclaredMethods()) { if (m.getReturnType() != void.class) continue; java.lang.Class[] pt = m.getParameterTypes(); if (pt.length != 1 || pt[0] != int.class) continue; m.setAccessible(true); try { Object probe = XposedHelpers.newInstance(f9Class); m.invoke(probe, 88888); android.content.ContentValues cv = (android.content.ContentValues) XposedHelpers.callMethod(probe, "convertTo"); for (String key : cv.keySet()) { Object val = cv.get(key); if (val instanceof Integer && (Integer) val == 88888) { result.put(key, m.getName()); XposedBridge.log("[WeChatHook] f9 int setter " + m.getName() + " -> " + key); } } } catch (Throwable ignored) {} } } catch (Throwable t) { XposedBridge.log("[WeChatHook] f9 int probe scan failed: " + t); } // byte[] setters: scan all void(byte[]) methods — discovers lvbuffer setter try { for (java.lang.reflect.Method m : f9Class.getDeclaredMethods()) { if (m.getReturnType() != void.class) continue; java.lang.Class[] pt = m.getParameterTypes(); if (pt.length != 1 || pt[0] != byte[].class) continue; m.setAccessible(true); // Marker: a 4-byte sentinel we can recognize in cv byte[] marker = new byte[]{(byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF}; try { Object probe = XposedHelpers.newInstance(f9Class); m.invoke(probe, (Object) marker); android.content.ContentValues cv = (android.content.ContentValues) XposedHelpers.callMethod(probe, "convertTo"); for (String key : cv.keySet()) { Object val = cv.get(key); if (val instanceof byte[]) { byte[] got = (byte[]) val; if (got.length == 4 && got[0] == (byte)0xDE && got[1] == (byte)0xAD) { result.put(key + ":bytes", m.getName()); XposedBridge.log("[WeChatHook] f9 bytes setter " + m.getName() + " -> " + key); } } } } catch (Throwable ignored) {} } } catch (Throwable t) { XposedBridge.log("[WeChatHook] f9 bytes probe scan failed: " + t); } XposedBridge.log("[WeChatHook] f9 setter map: " + result); return result; } // ── Hook w11.r0.H(String) — CDN upload entry-point trace ───────────────── // Logs every r0.H() call: dumps all String+File fields on r0 so we can identify // which field contains the audio file path that H() uploads to CDN. // Hook ALL methods on pt0.e0 (voice-send coroutine class found in stack trace). // Goal: find which method triggers CDN upload AFTER f9.convertTo() returns. // pt0.e0.k returns f9 objects and fires in multiple threads — CDN upload entry point. private void hookPt0E0(ClassLoader cl, String procName) { try { Class cls = XposedHelpers.findClass("pt0.e0", cl); java.lang.reflect.Method[] methods = cls.getDeclaredMethods(); StringBuilder sig = new StringBuilder("[WeChatHook] pt0.e0 methods(" + methods.length + "):"); for (java.lang.reflect.Method m : methods) sig.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); XposedBridge.log(sig.toString()); for (java.lang.reflect.Method m : methods) { final String mName = m.getName(); m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { // Capture singleton from ANY method (not just r()), so it's // available even when no real voice send has happened this session. if (sPt0E0Instance == null && p.thisObject != null) { sPt0E0Instance = p.thisObject; XposedBridge.log("[WeChatHook] pt0.e0 singleton captured via " + mName + ": " + p.thisObject); } if ("k".equals(mName)) { // k(2): confirmed signature k(String talker, Long msgLocalId) // CDN worker calls this to dequeue next upload task. // Only log first call per send to avoid spam. Object a0 = p.args != null && p.args.length > 0 ? p.args[0] : null; Object a1 = p.args != null && p.args.length > 1 ? p.args[1] : null; XposedBridge.log("[WeChatHook] pt0.e0.k ENTER talker=" + a0 + " msgLocalId=" + (a1 instanceof Long ? ((Long)a1).longValue() : a1)); } else if ("r".equals(mName)) { // Save pt0.e0 singleton for later use in sendVoiceViaAPI. if (sPt0E0Instance == null && p.thisObject != null) { sPt0E0Instance = p.thisObject; XposedBridge.log("[WeChatHook] pt0.e0 singleton captured: " + p.thisObject); } XposedBridge.log("[WeChatHook] pt0.e0.r ENTER, dumping thisObject fields:"); if (p.thisObject != null) { Class c = p.thisObject.getClass(); while (c != null && !c.equals(Object.class)) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { f.setAccessible(true); try { Object v = f.get(p.thisObject); XposedBridge.log("[WeChatHook] " + c.getSimpleName() + "." + f.getName() + "=" + (v == null ? "null" : (v instanceof String || v instanceof Number || v instanceof Boolean) ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v))); } catch (Throwable ignored) {} } c = c.getSuperclass(); } } } else if ("s".equals(mName)) { // s(3): CDN upload trigger. Log args + full f9 hierarchy for diff analysis. StringBuilder sb = new StringBuilder("[WeChatHook] pt0.e0.s ENTER args:"); if (p.args != null) for (int i = 0; i < p.args.length; i++) { Object a = p.args[i]; if (a == null) { sb.append(" [").append(i).append("]=null"); } else if (a instanceof String) { sb.append(" [").append(i).append("]=String(\"").append(a).append("\")"); } else if (a instanceof Number) { sb.append(" [").append(i).append("]=").append(a); } else { sb.append(" [").append(i).append("]=").append(a.getClass().getName()).append("@").append(System.identityHashCode(a)); } } XposedBridge.log(sb.toString()); // Dump ALL fields of f9 (args[2]) across full parent hierarchy if (p.args != null && p.args.length > 2 && p.args[2] != null) { Object f9 = p.args[2]; StringBuilder fb = new StringBuilder("[WeChatHook] pt0.e0.s f9 DUMP:"); Class c = f9.getClass(); while (c != null && !c.equals(Object.class)) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { f.setAccessible(true); try { Object v = f.get(f9); String vs; if (v == null) vs = "null"; else if (v instanceof byte[]) vs = "byte[" + ((byte[])v).length + "]"; else if (v instanceof String || v instanceof Number || v instanceof Boolean) vs = v.toString(); else vs = v.getClass().getSimpleName() + "@" + System.identityHashCode(v); fb.append("\n ").append(c.getSimpleName()).append(".").append(f.getName()).append("=").append(vs); } catch (Throwable ignored) {} } c = c.getSuperclass(); } XposedBridge.log(fb.toString()); } } else if (p.args != null && p.args.length == 2 && ("a".equals(mName) || "b".equals(mName) || "l".equals(mName) || "n".equals(mName) || "q".equals(mName))) { // Log 2-arg entry methods — one of these is likely the // "enqueue voice for send" entry point called by WeChat UI StringBuilder sb2 = new StringBuilder( "[WeChatHook] pt0.e0." + mName + "(2) ENTER"); if (p.args != null) for (int i = 0; i < p.args.length; i++) { Object a = p.args[i]; if (a == null) sb2.append(" arg").append(i).append("=null"); else if (a instanceof String) sb2.append(" arg").append(i).append("=\"").append(a).append("\""); else if (a instanceof Number) sb2.append(" arg").append(i).append("=").append(a); else sb2.append(" arg").append(i).append("=").append(a.getClass().getName()).append("@").append(System.identityHashCode(a)); } XposedBridge.log(sb2.toString()); } else { XposedBridge.log("[WeChatHook] pt0.e0." + mName + " ENTER"); } } @Override protected void afterHookedMethod(MethodHookParam p) { if (p.hasThrowable()) { XposedBridge.log("[WeChatHook] pt0.e0." + mName + " THREW " + p.getThrowable()); } else if ("k".equals(mName)) { Object ret = p.getResult(); if (ret != null) { // Dump ALL declared fields on f9 (first call only, to learn field names) StringBuilder fb = new StringBuilder("[WeChatHook] pt0.e0.k OK f9=").append(ret.getClass().getSimpleName()); java.lang.reflect.Field[] fields = ret.getClass().getDeclaredFields(); for (java.lang.reflect.Field f : fields) { f.setAccessible(true); try { Object v = f.get(ret); fb.append(" ").append(f.getName()).append("=").append( v == null ? "null" : (v instanceof String || v instanceof Number || v instanceof Boolean) ? v.toString() : v.getClass().getSimpleName() + "@" + System.identityHashCode(v)); } catch (Throwable ignored) {} } XposedBridge.log(fb.toString()); } else { XposedBridge.log("[WeChatHook] pt0.e0.k OK ret=null"); } } else { XposedBridge.log("[WeChatHook] pt0.e0." + mName + " OK ret=" + p.getResult()); } } }); } XposedBridge.log("[WeChatHook] hookPt0E0: hooked " + methods.length + " methods in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookPt0E0 failed in " + procName + ": " + t); } } // ── Surgical voice-send entry probe ────────────────────────────────────── // SAFE version: hooks ONLY the voice-send-specific classes (jg0.x coordinator // + qi3.f0/g0 voice-message objects), NOT generic coroutine classes, so it // won't add anti-tamper risk to the live Pad session. Goal: find the entry // method that receives (talker, silk file/bytes, durationMs) in plain form, // which we can then call synthetically to get a real, delivered voice send. private static String fmtArg(Object a) { if (a == null) return "null"; if (a instanceof byte[]) return "byte[" + ((byte[]) a).length + "]"; if (a instanceof String) return "\"" + a + "\""; if (a instanceof Number || a instanceof Boolean) return a.toString(); return a.getClass().getName() + "@" + System.identityHashCode(a); } // Recursively dump an object's non-static fields, bounded by depth, to find // where scalar values (talker string / silk path / duration) are captured. private static void dumpDeep(Object o, String prefix, int depth, StringBuilder sb) { if (o == null || depth < 0) return; for (Class c = o.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); Object v; try { v = f.get(o); } catch (Throwable t) { continue; } sb.append("\n").append(prefix).append(f.getName()).append("=").append(fmtArg(v)); // Recurse into non-scalar, non-array WeChat/kotlin objects. if (v != null && depth > 0 && !(v instanceof String) && !(v instanceof Number) && !(v instanceof Boolean) && !(v instanceof byte[]) && !v.getClass().isArray() && !v.getClass().getName().startsWith("java.") && !v.getClass().getName().startsWith("android.")) { dumpDeep(v, prefix + " ", depth - 1, sb); } } } } private void hookVoiceProbe(ClassLoader cl, String procName) { // jg0.x — voice send coordinator; hook ALL methods to find the entry. try { Class xClass = XposedHelpers.findClass("jg0.x", cl); int n = 0; for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { final String mn = m.getName(); m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { // ── Voice hot-swap (the route that actually delivers) ────────── // jg0.x.w(jg0.x, qi3.d0, w21.w0, int type, int durMs, w21.t silk, Cont) // is the CDN uploadAttach step. On the REAL (non-resume) call, arg5 // (w21.t) holds the recorded SILK in its single byte[] field. When // armed, replace it with our TTS SILK so WeChat uploads our audio. // (This lives in the probe hook because this fork won't stack a 2nd // hook on an already-hooked method — see hookVoiceSwap note.) if ("w".equals(mn) && sSwapArmed && sSwapSilk != null && System.currentTimeMillis() < sSwapExpiresAt && p.args != null && p.args.length == 7 && p.args[5] != null) { try { Object tHolder = p.args[5]; java.lang.reflect.Field byteF = null; for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) if (f.getType() == byte[].class) { byteF = f; break; } if (byteF != null) { byteF.setAccessible(true); byte[] orig = (byte[]) byteF.get(tHolder); byteF.set(tHolder, sSwapSilk); if (orig != null) for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { if (f.getType() != int.class) continue; f.setAccessible(true); if (f.getInt(tHolder) == orig.length) f.setInt(tHolder, sSwapSilk.length); } if (sSwapDuration > 0) p.args[4] = sSwapDuration; XposedBridge.log("[WeChatHook] VOICE SWAP DONE: " + (orig == null ? "?" : orig.length) + "B -> " + sSwapSilk.length + "B dur -> " + p.args[4]); sSwapArmed = false; sSwapSilk = null; // one-shot } } catch (Throwable t) { XposedBridge.log("[WeChatHook] VOICE SWAP failed: " + t); } } StringBuilder sb = new StringBuilder("[WeChatHook] jg0.x." + mn + "("); if (p.args != null) for (int i = 0; i < p.args.length; i++) { if (i > 0) sb.append(", "); sb.append(fmtArg(p.args[i])); } sb.append(")"); // Deep-dump the send-carrying methods: D's lambda (arg0) and // w's descriptor args — to locate talker/silk/duration fields. if ("D".equals(mn) && p.args != null && p.args.length > 0) { sb.append("\n [D arg0 pu.m0 deep]:"); dumpDeep(p.args[0], " ", 3, sb); } else if ("w".equals(mn) && p.args != null) { for (int i = 0; i < p.args.length; i++) { if (p.args[i] != null && !(p.args[i] instanceof Number)) { sb.append("\n [w arg").append(i).append(" deep]:"); dumpDeep(p.args[i], " ", 2, sb); } } } XposedBridge.log(sb.toString()); } }); n++; } XposedBridge.log("[WeChatHook] hookVoiceProbe: jg0.x " + n + " methods in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceProbe jg0.x failed: " + t); } // qi3.f0 / qi3.g0 — voice-message domain objects; dump ctor args + fields. for (String cn : new String[]{"qi3.f0", "qi3.g0"}) { try { Class c = XposedHelpers.findClass(cn, cl); for (java.lang.reflect.Constructor ctor : c.getDeclaredConstructors()) { ctor.setAccessible(true); final String fcn = cn; XposedBridge.hookMethod(ctor, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) { StringBuilder sb = new StringBuilder("[WeChatHook] " + fcn + ".("); if (p.args != null) for (int i = 0; i < p.args.length; i++) { if (i > 0) sb.append(", "); sb.append(fmtArg(p.args[i])); } sb.append(") fields:"); for (Class cc = p.thisObject.getClass(); cc != null && !cc.equals(Object.class); cc = cc.getSuperclass()) { for (java.lang.reflect.Field f : cc.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); try { sb.append("\n ").append(f.getName()).append("=").append(fmtArg(f.get(p.thisObject))); } catch (Throwable ignored) {} } } XposedBridge.log(sb.toString()); } }); } XposedBridge.log("[WeChatHook] hookVoiceProbe: " + cn + " ctor hooked in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceProbe " + cn + " failed: " + t); } } } // ── Find the voice-send entry point ────────────────────────────────────── // pu.o0 is the outer coroutine, launched by the real entry point (async, so // it doesn't appear in the jg0.x.D stack). Hooking pu.o0's CONSTRUCTOR gives // a synchronous stack trace revealing the method that starts a voice send. // Also dumps qi3.f0/g0 constructors (the voice-message domain objects that // carry talker/silk/duration). private void hookVoiceEntry(ClassLoader cl, String procName) { // pu.o0 constructor — stack trace = the real send entry point. for (String cn : new String[]{"pu.o0", "pu.m0"}) { try { Class c = XposedHelpers.findClass(cn, cl); for (java.lang.reflect.Constructor ctor : c.getDeclaredConstructors()) { ctor.setAccessible(true); final String fcn = cn; final int np = ctor.getParameterTypes().length; XposedBridge.hookMethod(ctor, new XC_MethodHook() { private volatile boolean logged = false; @Override protected void afterHookedMethod(MethodHookParam p) { if (logged) return; logged = true; StringBuilder sb = new StringBuilder( "[WeChatHook] " + fcn + ".(" + np + ") args:"); if (p.args != null) for (int i = 0; i < p.args.length; i++) { Object a = p.args[i]; sb.append("\n arg[").append(i).append("]=") .append(a == null ? "null" : a.getClass().getName()); } sb.append("\n").append(android.util.Log.getStackTraceString(new Exception())); XposedBridge.log(sb.toString()); } }); } XposedBridge.log("[WeChatHook] hookVoiceEntry: " + cn + " ctor hooked in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceEntry " + cn + " failed: " + t); } } // qi3.f0 / qi3.g0 constructors — dump their args (talker/silk/duration). for (String cn : new String[]{"qi3.f0", "qi3.g0"}) { try { Class c = XposedHelpers.findClass(cn, cl); StringBuilder info = new StringBuilder("[WeChatHook] " + cn + " super=" + c.getSuperclass().getName() + " fields:"); for (java.lang.reflect.Field f : c.getDeclaredFields()) info.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); XposedBridge.log(info.toString()); for (java.lang.reflect.Constructor ctor : c.getDeclaredConstructors()) { ctor.setAccessible(true); final String fcn = cn; XposedBridge.hookMethod(ctor, new XC_MethodHook() { private volatile boolean logged = false; @Override protected void afterHookedMethod(MethodHookParam p) { if (logged) return; logged = true; StringBuilder sb = new StringBuilder( "[WeChatHook] " + fcn + ". args:"); if (p.args != null) for (int i = 0; i < p.args.length; i++) { Object a = p.args[i]; String vs; if (a == null) vs = "null"; else if (a instanceof byte[]) vs = "byte[" + ((byte[])a).length + "]"; else if (a instanceof String || a instanceof Number || a instanceof Boolean) vs = a.getClass().getSimpleName() + "(" + a + ")"; else vs = a.getClass().getName(); sb.append("\n arg[").append(i).append("]=").append(vs); } // Dump the constructed object's fields too for (Class cc = p.thisObject.getClass(); cc != null && !cc.equals(Object.class); cc = cc.getSuperclass()) { for (java.lang.reflect.Field f : cc.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); try { Object v = f.get(p.thisObject); String vs; if (v == null) vs = "null"; else if (v instanceof byte[]) vs = "byte[" + ((byte[])v).length + "]"; else if (v instanceof String || v instanceof Number || v instanceof Boolean) vs = v.toString(); else vs = v.getClass().getSimpleName(); sb.append("\n this.").append(f.getName()).append("=").append(vs); } catch (Throwable ignored) {} } } XposedBridge.log(sb.toString()); } }); } XposedBridge.log("[WeChatHook] hookVoiceEntry: " + cn + " ctor hooked in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceEntry " + cn + " failed: " + t); } } } // ── Hook jg0.j / jg0.x / g9 ───────────────────────────────────────────── // Reveals the internal structure needed to call pt0.e0.r() synthetically. // jg0.j captures the g9 (voice message / f9 subclass) as a field. // jg0.x.D sets up jg0.j and starts the coroutine. // g9 extends f9 (or is f9) with M9/N9 DB-insert methods. private void hookJg0(ClassLoader cl, String procName) { // ── jg0.j (the inner coroutine whose invokeSuspend calls pt0.e0.r) ──── try { Class jClass = XposedHelpers.findClass("jg0.j", cl); StringBuilder jInfo = new StringBuilder("[WeChatHook] jg0.j class=" + jClass.getName() + " super=" + jClass.getSuperclass().getName() + " ownFields:"); for (java.lang.reflect.Field f : jClass.getDeclaredFields()) jInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); jInfo.append(" | superFields:"); for (java.lang.reflect.Field f : jClass.getSuperclass().getDeclaredFields()) jInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); XposedBridge.log(jInfo.toString()); // Hook invokeSuspend — fires every time a jg0.j coroutine step runs. // We only care about the FIRST call (state=0, label=0) to see captured fields. try { XposedHelpers.findAndHookMethod(jClass, "invokeSuspend", Object.class, new XC_MethodHook() { private volatile boolean sLogged = false; @Override protected void beforeHookedMethod(MethodHookParam p) { if (sLogged) return; sLogged = true; StringBuilder sb = new StringBuilder( "[WeChatHook] jg0.j.invokeSuspend FIRST fields:"); for (Class c = p.thisObject.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); try { Object v = f.get(p.thisObject); sb.append("\n ").append(c.getSimpleName()) .append(".").append(f.getName()).append("=") .append(v == null ? "null" : v.getClass().getName() + "@" + System.identityHashCode(v)); } catch (Throwable ignored) {} } } XposedBridge.log(sb.toString()); } }); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookJg0 jg0.j.invokeSuspend hook failed: " + t); } XposedBridge.log("[WeChatHook] hookJg0: jg0.j hooked in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookJg0 jg0.j failed: " + t); } // ── jg0.x (coordinator that creates jg0.j and calls D to enqueue send) ── try { Class xClass = XposedHelpers.findClass("jg0.x", cl); StringBuilder xInfo = new StringBuilder("[WeChatHook] jg0.x methods:"); for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) xInfo.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); xInfo.append(" | ownFields:"); for (java.lang.reflect.Field f : xClass.getDeclaredFields()) xInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); XposedBridge.log(xInfo.toString()); // Hook D() — the method called from pu.o0.invokeSuspend(84) that enqueues the send for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { if (!"D".equals(m.getName())) continue; m.setAccessible(true); final String sig = m.toGenericString(); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { StringBuilder sb = new StringBuilder( "[WeChatHook] jg0.x.D ENTER sig=" + sig); // args if (p.args != null) for (int i = 0; i < p.args.length; i++) { Object a = p.args[i]; sb.append("\n arg[").append(i).append("]=") .append(a == null ? "null" : a.getClass().getName() + "@" + System.identityHashCode(a)); // If it looks like a voice-msg / f9 subclass, dump its fields if (a != null) { for (Class c2 = a.getClass(); c2 != null && !c2.equals(Object.class); c2 = c2.getSuperclass()) { for (java.lang.reflect.Field f2 : c2.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue; f2.setAccessible(true); try { Object v2 = f2.get(a); String vs; if (v2 == null) vs = "null"; else if (v2 instanceof byte[]) vs = "byte[" + ((byte[])v2).length + "]"; else if (v2 instanceof String || v2 instanceof Number || v2 instanceof Boolean) vs = v2.toString(); else vs = v2.getClass().getSimpleName(); sb.append("\n ").append(c2.getSimpleName()) .append(".").append(f2.getName()).append("=").append(vs); } catch (Throwable ignored2) {} } } } } // thisObject fields for (Class c = p.thisObject.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); try { Object v = f.get(p.thisObject); sb.append("\n this.").append(c.getSimpleName()) .append(".").append(f.getName()).append("=") .append(v == null ? "null" : v.getClass().getName()); } catch (Throwable ignored) {} } } XposedBridge.log(sb.toString()); } }); } XposedBridge.log("[WeChatHook] hookJg0: jg0.x hooked in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookJg0 jg0.x failed: " + t); } // ── g9 (likely a subclass of f9; M9/N9 are the DB-insert methods) ───── try { Class g9Class = XposedHelpers.findClass("com.tencent.mm.storage.g9", cl); StringBuilder g9Info = new StringBuilder("[WeChatHook] g9 super=" + g9Class.getSuperclass().getName() + " methods:"); for (java.lang.reflect.Method m : g9Class.getDeclaredMethods()) g9Info.append(" ").append(m.getName()).append("(").append(m.getParameterTypes().length).append(")"); g9Info.append(" | ownFields:"); for (java.lang.reflect.Field f : g9Class.getDeclaredFields()) g9Info.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); XposedBridge.log(g9Info.toString()); for (java.lang.reflect.Method m : g9Class.getDeclaredMethods()) { if (!"M9".equals(m.getName()) && !"N9".equals(m.getName())) continue; m.setAccessible(true); final String mName2 = m.getName(); final String mSig2 = m.toGenericString(); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { StringBuilder sb = new StringBuilder( "[WeChatHook] g9." + mName2 + " ENTER sig=" + mSig2 + " thisClass=" + p.thisObject.getClass().getName()); if (p.args != null) for (int i = 0; i < p.args.length; i++) sb.append(" arg[").append(i).append("]=") .append(p.args[i] == null ? "null" : p.args[i].getClass().getName()); XposedBridge.log(sb.toString()); } @Override protected void afterHookedMethod(MethodHookParam p) { if (p.hasThrowable()) XposedBridge.log("[WeChatHook] g9." + mName2 + " THREW: " + p.getThrowable()); else XposedBridge.log("[WeChatHook] g9." + mName2 + " OK ret=" + p.getResult()); } }); } XposedBridge.log("[WeChatHook] hookJg0: g9 hooked in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookJg0 g9 failed: " + t); } // ── pu.o0 (outer coroutine that calls jg0.x.D) ─────────────────────── // Hook invokeSuspend to see what it captures (especially the g9/voice data). try { Class puClass = XposedHelpers.findClass("pu.o0", cl); StringBuilder puInfo = new StringBuilder("[WeChatHook] pu.o0 super=" + puClass.getSuperclass().getName() + " | ownFields:"); for (java.lang.reflect.Field f : puClass.getDeclaredFields()) puInfo.append(" ").append(f.getName()).append("(").append(f.getType().getSimpleName()).append(")"); XposedBridge.log(puInfo.toString()); XposedHelpers.findAndHookMethod(puClass, "invokeSuspend", Object.class, new XC_MethodHook() { private volatile boolean sLogged2 = false; @Override protected void beforeHookedMethod(MethodHookParam p) { if (sLogged2) return; sLogged2 = true; StringBuilder sb = new StringBuilder( "[WeChatHook] pu.o0.invokeSuspend FIRST fields:"); for (Class c = p.thisObject.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); try { Object v = f.get(p.thisObject); sb.append("\n ").append(c.getSimpleName()) .append(".").append(f.getName()).append("=") .append(v == null ? "null" : v.getClass().getName() + "@" + System.identityHashCode(v)); } catch (Throwable ignored) {} } } XposedBridge.log(sb.toString()); } }); XposedBridge.log("[WeChatHook] hookJg0: pu.o0 hooked in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookJg0 pu.o0 failed: " + t); } } private void hookR0H(ClassLoader cl, String procName) { try { Class r0Class = XposedHelpers.findClass("w11.r0", cl); int hooked = 0; // Hook ALL methods named H on r0 — real voice sends may use H() or H(int), not just H(String). for (java.lang.reflect.Method m : r0Class.getDeclaredMethods()) { if (!m.getName().equals("H")) continue; final String sig = m.toGenericString(); final boolean isHString = m.getParameterTypes().length == 1 && m.getParameterTypes()[0] == String.class; m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable { String by = sCalledByUs ? "US" : "WeChat"; XposedBridge.log("[WeChatHook] r0." + sig + " ENTER by=" + by + " proc=" + procName); // Capture real dispatcher from any real WeChat r0 H() call. if (!sCalledByUs) { try { Object disp = XposedHelpers.getObjectField(p.thisObject, "dispatcher"); if (disp != null && !(disp instanceof java.lang.reflect.Proxy)) { sCapturedDispatcher = disp; XposedBridge.log("[WeChatHook] hookR0H: REAL dispatcher=" + disp.getClass().getName() + " from " + sig); } } catch (Throwable ignored) {} // Also dump r0 fields on first real send to capture full state. dumpR0Fields(p.thisObject, "r0.H-WeChat by=" + by + " proc=" + procName); } else if (isHString) { dumpR0Fields(p.thisObject, "r0.H-ENTER by=US proc=" + procName); } } @Override protected void afterHookedMethod(MethodHookParam p) throws Throwable { String by = sCalledByUs ? "US" : "WeChat"; XposedBridge.log("[WeChatHook] r0.H() DONE by=" + by + " proc=" + procName + (p.hasThrowable() ? " THREW=" + p.getThrowable() : " OK")); } }); hooked++; } XposedBridge.log("[WeChatHook] hookR0H: hooked " + hooked + " H() overloads in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookR0H failed in " + procName + ": " + t); } } // ── Capture live network dispatcher (com.tencent.mm.modelbase.m2) ──────────── // m2 = MicroMsg.RDispatcher, the com.tencent.mm.network.s the send scene needs. // We grab the instance at construction (network init, before any synthetic send) // into sCapturedDispatcher, so sendVoiceViaAPI can hand r0 the REAL dispatcher. private void hookDispatcher(ClassLoader cl, String procName) { try { Class m2 = XposedHelpers.findClass("com.tencent.mm.modelbase.m2", cl); XC_MethodHook grab = new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { if (sCapturedDispatcher == null && p.thisObject != null && !(p.thisObject instanceof java.lang.reflect.Proxy)) { sCapturedDispatcher = p.thisObject; XposedBridge.log("[WeChatHook] hookDispatcher: captured REAL m2=" + p.thisObject + " in " + procName); } } }; int n = 0; // Ctor: catches m2 constructed AFTER we install (network re-init). for (java.lang.reflect.Constructor c : m2.getDeclaredConstructors()) { c.setAccessible(true); XposedBridge.hookMethod(c, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) { sCapturedDispatcher = p.thisObject; XposedBridge.log("[WeChatHook] hookDispatcher: captured REAL m2 (ctor)=" + p.thisObject + " in " + procName); } }); n++; } // Instance methods: catches an ALREADY-existing m2 (constructed before our // hook / persistent connection) on its next network call. After capture the // callback is a single null check — negligible overhead. int mh = 0; for (java.lang.reflect.Method m : m2.getDeclaredMethods()) { if (java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue; if (java.lang.reflect.Modifier.isAbstract(m.getModifiers())) continue; try { m.setAccessible(true); XposedBridge.hookMethod(m, grab); mh++; } catch (Throwable ignored) {} } XposedBridge.log("[WeChatHook] hookDispatcher: hooked " + n + " ctor(s) + " + mh + " method(s) on m2 in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookDispatcher failed in " + procName + ": " + t); } } // ── Instrument voice-upload service w21.g1 (voiceinfo table) ───────────────── // On a REAL voice send, WeChat builds a w21.w0 upload-task and calls g1.L0(w0) // (insert) / g1.P0(file, w0) (upload). Dump every w0 field on those calls so we // learn the exact template (FileName/ClientId/MsgLocalId/VoiceLength/MsgTalker/ // Status correlation) needed to synthesize the upload for hook-sent voice. // ── Voice hot-swap: replace recorded SILK with our TTS at the CDN-upload step ─ // jg0.x.w(jg0.x self, qi3.d0, w21.w0, int type, int durationMs, w21.t silk, Cont) // is WeChat's uploadAttach step (confirmed by real-recording capture + tinker // decompile). w21.t has exactly one byte[] field (f443506a) = the SILK audio // about to be sent to CDN. When armed, we overwrite that byte[] with our TTS // SILK and the duration int (arg4). WeChat then uploads + delivers OUR audio, // reusing the entire real send pipeline we cannot rebuild. // Hook runs in the process where recording happens (main). Verified swap target, // but does its own field dump on every fire so a first real test confirms layout. private void hookVoiceSwap(ClassLoader cl, String procName) { try { Class xClass = XposedHelpers.findClass("jg0.x", cl); int hooked = 0; for (java.lang.reflect.Method m : xClass.getDeclaredMethods()) { if (!"w".equals(m.getName())) continue; // hook EVERY 'w' overload m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { // Absolute-first, arg-safe entry log so we KNOW the hook runs. int n = (p.args == null) ? 0 : p.args.length; XposedBridge.log("[WeChatHook] SWAPHOOK ENTER w nargs=" + n + " armed=" + sSwapArmed); try { // Real uploadAttach call: (jg0.x, qi3.d0, w21.w0, int, int, w21.t, Cont). // Coroutine resumptions re-call w with nulls — skip those. if (n != 7 || p.args[5] == null) return; Object tHolder = p.args[5]; // w21.t (SILK holder) java.lang.reflect.Field byteF = null; for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { if (f.getType() == byte[].class) { byteF = f; break; } } if (byteF == null) return; byteF.setAccessible(true); byte[] orig = (byte[]) byteF.get(tHolder); XposedBridge.log("[WeChatHook] SWAPHOOK real w: silk=" + (orig == null ? "null" : orig.length + "B") + " durArg=" + p.args[4] + " holder=" + tHolder.getClass().getName()); if (!sSwapArmed || sSwapSilk == null) return; byteF.set(tHolder, sSwapSilk); if (orig != null) { for (java.lang.reflect.Field f : tHolder.getClass().getDeclaredFields()) { if (f.getType() != int.class) continue; f.setAccessible(true); if (f.getInt(tHolder) == orig.length) f.setInt(tHolder, sSwapSilk.length); } } if (sSwapDuration > 0) p.args[4] = sSwapDuration; XposedBridge.log("[WeChatHook] VOICE SWAP DONE: " + (orig == null ? "?" : orig.length) + "B -> " + sSwapSilk.length + "B, dur -> " + p.args[4]); sSwapArmed = false; // one-shot sSwapSilk = null; } catch (Throwable t) { XposedBridge.log("[WeChatHook] VOICE SWAP failed: " + t + "\n" + android.util.Log.getStackTraceString(t)); } } }); hooked++; } XposedBridge.log("[WeChatHook] hookVoiceSwap: hooked jg0.x.w x" + hooked + " in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceSwap failed in " + procName + ": " + t); } } // ── Full field dump across the whole class hierarchy (for RE capture) ──────── private static void dumpFieldsDeep(Object o, String tag) { if (o == null) { XposedBridge.log("[WeChatHook] " + tag + " = null"); return; } try { StringBuilder sb = new StringBuilder("[WeChatHook] " + tag + " (" + o.getClass().getName() + "):"); for (Class c = o.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); Object v; try { v = f.get(o); } catch (Throwable t) { continue; } String vs = v == null ? "null" : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]" : (v instanceof String || v instanceof Number || v instanceof Boolean ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v))); if (vs.length() > 200) vs = vs.substring(0, 200) + "…"; sb.append("\n ").append(f.getName()).append(" (").append(f.getType().getSimpleName()).append(") = ").append(vs); } } XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] dumpFieldsDeep err: " + t); } } // ── Trace the CDN upload submission for voice (the missing full-synthesis layer) ─ // Real voice send: jg0.x.u builds a dn.m task (field_fileType=15, field_fullpath= // voice2 amr, field_mediaId="VideoData"+id, aesKey/fileId empty) and drives the CDN // engine (h2.d=addSendTask → l1.u → native CdnManager.startC2CUpload). The native // side ASSIGNS aeskey+fileId, returned in dn.h to l1.keep_onUploadSuccessed, then // jg0.x.u writes them back to the voiceinfo protobuf + newsendmsg. We OBSERVE only // (no modification) so a real recording still delivers, and we capture the exact // dn.m template + assigned aeskey/fileId to replicate CDN upload for synthetic voice. private void hookCdnTrace(ClassLoader cl, String procName) { // 1) h2.d(dn.m) = addSendTask — dump the full task struct on submit. try { Class h2 = XposedHelpers.findClass("com.tencent.mm.modelcdntran.h2", cl); Class dnm = XposedHelpers.findClass("dn.m", cl); XposedBridge.hookMethod(h2.getDeclaredMethod("d", dnm), new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { XposedBridge.log("[WeChatHook] CDNTRACE h2.d(addSendTask) ENTER"); if (p.args != null && p.args.length > 0) dumpFieldsDeep(p.args[0], "CDNTRACE addSendTask dn.m"); } @Override protected void afterHookedMethod(MethodHookParam p) { XposedBridge.log("[WeChatHook] CDNTRACE h2.d ret=" + p.getResult()); } }); XposedBridge.log("[WeChatHook] hookCdnTrace: hooked h2.d(addSendTask) in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookCdnTrace h2.d failed in " + procName + ": " + t); } // 2) l1.keep_onUpload* — the native CDN callback carrying assigned aeskey/fileId (dn.h). try { Class l1 = XposedHelpers.findClass("com.tencent.mm.modelcdntran.l1", cl); int n = 0; for (java.lang.reflect.Method m : l1.getDeclaredMethods()) { if (!m.getName().startsWith("keep_onUpload")) continue; m.setAccessible(true); final String mn = m.getName(); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) { XposedBridge.log("[WeChatHook] CDNTRACE l1." + mn + " nargs=" + (p.args == null ? 0 : p.args.length)); if (p.args != null) for (Object a : p.args) { if (a != null && a.getClass().getName().equals("dn.h")) dumpFieldsDeep(a, "CDNTRACE " + mn + " dn.h"); } } }); n++; } XposedBridge.log("[WeChatHook] hookCdnTrace: hooked l1.keep_onUpload* x" + n + " in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookCdnTrace l1 failed in " + procName + ": " + t); } // 3) jg0.x.u — the orchestrator (builds dn.m, writes aeskey back). Log entry to // confirm it fires + timing vs the CDN calls. try { Class x = XposedHelpers.findClass("jg0.x", cl); int n = 0; for (java.lang.reflect.Method m : x.getDeclaredMethods()) { if (!"u".equals(m.getName())) continue; m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { XposedBridge.log("[WeChatHook] CDNTRACE jg0.x.u ENTER nargs=" + (p.args == null ? 0 : p.args.length)); if (p.args != null && p.args.length > 1 && p.args[1] != null) dumpFieldsDeep(p.args[1], "CDNTRACE jg0.x.u w21.w0"); } }); n++; } XposedBridge.log("[WeChatHook] hookCdnTrace: hooked jg0.x.u x" + n + " in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookCdnTrace jg0.x.u failed in " + procName + ": " + t); } } // ── Capture the real uploadvoice cgi request (this device's actual voice path) ── // This device uses the INLINE-cgi branch of jg0.x.w (not CDN): it builds a // com.tencent.mm.modelbase.l reqresp with url /cgi-bin/micromsg-bin/uploadvoice, // request pb r45.cv6 carrying the SILK inline in field 7. The scene // (modelbase.i.f70699d, a NetScene = m1 subclass) can be submitted via z2.b — the // SAME queue our text send uses. Hook modelbase.l.a() (the reqresp builder, fires // mid-jg0.x.w) filtered to the uploadvoice url, and dump every cv6 field index so // we can replicate the exact wire request for fully-synthetic voice. // Diagnostic: hook modelbase.i.k(errType,errCode,errMsg,respPb,scene) = scene-end // callback. Logs the result of any uploadvoice send (resp pb = r45.dv6), so we can // tell if our synthetic z2.b send was dispatched + accepted (errCode 0) or rejected. private void hookVoiceCgiResult(ClassLoader cl, String procName) { try { Class iCls = XposedHelpers.findClass("com.tencent.mm.modelbase.i", cl); Class pbCls = XposedHelpers.findClass("com.tencent.mm.protobuf.f", cl); Class m1Cls = XposedHelpers.findClass("com.tencent.mm.modelbase.m1", cl); int n = 0; for (java.lang.reflect.Method m : iCls.getDeclaredMethods()) { Class[] pt = m.getParameterTypes(); // onSceneEnd: (int, int, String, protobuf.f, m1) if (pt.length == 5 && pt[0] == int.class && pt[1] == int.class && pt[2] == String.class && pbCls.isAssignableFrom(pt[3]) && m1Cls.isAssignableFrom(pt[4])) { m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { try { Object resp = p.args[3]; if (resp == null || !resp.getClass().getName().equals("r45.dv6")) return; long svr = 0; try { Object v = XposedHelpers.callMethod(resp, "getLong", 11); if (v != null) svr = (Long) v; } catch (Throwable t) {} XposedBridge.log("[WeChatHook] VOICECGI-RESULT errType=" + p.args[0] + " errCode=" + p.args[1] + " errMsg=" + p.args[2] + " respClass=" + resp.getClass().getName() + " svrMsgId(11)=" + svr); } catch (Throwable t) { XposedBridge.log("[WeChatHook] VOICECGI-RESULT err: " + t); } } }); n++; } } XposedBridge.log("[WeChatHook] hookVoiceCgiResult: hooked i.onSceneEnd x" + n + " in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceCgiResult failed in " + procName + ": " + t); } } // ── 贴图号发布 sink: js1.m.c(TransferRequestInfo, zq1.g0) ──────────────────── //反编译确认(见 BRANDSERVICE_PUBLISH_RESEARCH.md §3.5): brandservice/webview // 的 luggage JS-API 层(com.tencent.mm.plugin.webview.luggage.jsapi.c.d())和 // 小程序侧的 k2/y7 都把 reqUrl/reqBody/cgiCmdId 填进 TransferRequestInfo 后调 // 这一个方法。字段映射: TransferRequestInfo{d=type, e=h5Url, f=scope, h=y24, // n=needVerifySignature, i=is_security_check}; y24{d=reqUrl, e=reqBody(JSON串), // f=cgiCmdId, h=method, g=LinkedList headers(cw3{d=key,e=value})}。 private static void dumpFieldsByName(Object obj, String[] names, StringBuilder sb, String indent) { if (obj == null) { sb.append(indent).append("(null)\n"); return; } for (String name : names) { try { java.lang.reflect.Field f = obj.getClass().getDeclaredField(name); f.setAccessible(true); Object v = f.get(obj); String vs = v == null ? "null" : v.toString(); if (vs.length() > 4000) vs = vs.substring(0, 4000) + "…(truncated)"; sb.append(indent).append(name).append(" = ").append(vs).append("\n"); } catch (Throwable t) { sb.append(indent).append(name).append(" = \n"); } } } private void hookBrandservicePublishTransfer(ClassLoader cl, String procName) { try { Class sinkCls = XposedHelpers.findClass("js1.m", cl); final Class reqInfoCls = XposedHelpers.findClass( "com.tencent.mm.plugin.brandservice.api.TransferRequestInfo", cl); int hooked = 0; for (java.lang.reflect.Method m : sinkCls.getDeclaredMethods()) { Class[] pt = m.getParameterTypes(); if (pt.length != 2 || !reqInfoCls.isAssignableFrom(pt[0])) continue; m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { try { Object reqInfo = p.args[0]; StringBuilder sb = new StringBuilder( "[WeChatHook] BRANDSVC-SINK js1.m.c() called in " + procName + "\n"); if (reqInfo == null) { sb.append(" reqInfo = null\n"); XposedBridge.log(sb.toString()); return; } dumpFieldsByName(reqInfo, new String[]{"d", "e", "f", "n", "i"}, sb, " "); Object h; try { java.lang.reflect.Field hf = reqInfo.getClass().getDeclaredField("h"); hf.setAccessible(true); h = hf.get(reqInfo); } catch (Throwable t) { h = null; } if (h == null) { sb.append(" h (y24, reqUrl/reqBody/cgiCmdId container) = null\n"); } else { sb.append(" h (y24):\n"); dumpFieldsByName(h, new String[]{"d", "e", "f", "h"}, sb, " "); try { java.lang.reflect.Field gf = h.getClass().getDeclaredField("g"); gf.setAccessible(true); Object headers = gf.get(h); sb.append(" g (headers) = ").append(headers == null ? "null" : headers.toString()).append("\n"); } catch (Throwable t) { sb.append(" g (headers) = \n"); } } XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] BRANDSVC-SINK dump err: " + t); } } }); hooked++; } XposedBridge.log("[WeChatHook] hookBrandservicePublishTransfer: hooked js1.m.c x" + hooked + " in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookBrandservicePublishTransfer failed in " + procName + ": " + t); } } private void hookVoiceCgiCapture(ClassLoader cl, String procName) { try { Class lCls = XposedHelpers.findClass("com.tencent.mm.modelbase.l", cl); final Class pbCls = XposedHelpers.findClass("com.tencent.mm.protobuf.f", cl); XposedBridge.hookMethod(lCls.getDeclaredMethod("a"), new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { try { // Field names differ base-vs-tinker, so locate by TYPE: the url String // (contains cgi-bin) + the request/response protobufs (protobuf.f). Object self = p.thisObject; String url = null; java.util.List pbs = new java.util.ArrayList<>(); for (Class c = self.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); Object v; try { v = f.get(self); } catch (Throwable t) { continue; } if (v instanceof String && ((String) v).contains("cgi-bin")) url = (String) v; else if (v != null && pbCls.isInstance(v)) pbs.add(v); } } if (url == null || !url.contains("uploadvoice")) return; // gate: voice cgi only StringBuilder sb = new StringBuilder("[WeChatHook] VOICECGI url=" + url + " pbCount=" + pbs.size()); int idx = 0; for (Object req : pbs) { sb.append("\n -- pb#").append(idx++).append(" ").append(req.getClass().getName()).append(" --"); for (int i = 0; i <= 25; i++) { Object val = null; String kind = ""; try { Object c = XposedHelpers.callMethod(req, "getCustom", i); if (c != null) { val = "custom:" + c.getClass().getName(); kind = "C"; } } catch (Throwable t) {} if (val == null) { try { Object s = XposedHelpers.callMethod(req, "getString", i); if (s != null) { val = s; kind = "S"; } } catch (Throwable t) {} } if (val == null) { try { Object iv = XposedHelpers.callMethod(req, "getInteger", i); if (iv != null && ((Integer) iv) != 0) { val = iv; kind = "I"; } } catch (Throwable t) {} } if (val == null) { try { Object lv = XposedHelpers.callMethod(req, "getLong", i); if (lv != null && ((Long) lv) != 0L) { val = lv; kind = "L"; } } catch (Throwable t) {} } if (val != null) { String vs = String.valueOf(val); if (vs.length() > 140) vs = vs.substring(0, 140) + "…"; sb.append("\n [").append(i).append("](").append(kind).append(")=").append(vs); } } } XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] VOICECGI err: " + t); } } }); XposedBridge.log("[WeChatHook] hookVoiceCgiCapture: hooked modelbase.l.a() in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceCgiCapture failed in " + procName + ": " + t); } } // 贴图号发布探测: js1.m.c() 抓到的全是 mp.weixin.qq.com 网页态调用(真实测过,见 // BRANDSERVICE_PUBLISH_RESEARCH.md §3.9),没有一次带 protobuf reqBody/cgiCmdId 的—— // 说明真正的"提交发布"根本不走 brandservice/webview 那条 JS-API 桥,大概率是原生侧 // (Flutter platform channel 之后的 Kotlin/Java 代码)直接建的内部 cgi 调用,类似文字/ // 语音发送那条 NetSceneQueue 路子。与其继续在混淆的 Flutter 桥接代码里找具体是哪个类, // 直接照抄 hookVoiceCgiCapture 但去掉 "uploadvoice" 过滤——modelbase.l.a() 是所有内部 // 二进制 cgi 调用的公共构造入口,不管发起方是谁,只要真是这条路子就跑不掉这个方法。 private void hookAnyCgiCapture(ClassLoader cl, String procName) { try { Class lCls = XposedHelpers.findClass("com.tencent.mm.modelbase.l", cl); final Class pbCls = XposedHelpers.findClass("com.tencent.mm.protobuf.f", cl); XposedBridge.hookMethod(lCls.getDeclaredMethod("a"), new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { try { Object self = p.thisObject; String url = null; java.util.List pbs = new java.util.ArrayList<>(); for (Class c = self.getClass(); c != null && !c.equals(Object.class); c = c.getSuperclass()) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); Object v; try { v = f.get(self); } catch (Throwable t) { continue; } if (v instanceof String && ((String) v).contains("cgi-bin")) url = (String) v; else if (v != null && pbCls.isInstance(v)) pbs.add(v); } } if (url == null) return; // no gate otherwise — log every internal cgi call StringBuilder sb = new StringBuilder("[WeChatHook] ANYCGI url=" + url + " pbCount=" + pbs.size()); int idx = 0; for (Object req : pbs) { sb.append("\n -- pb#").append(idx++).append(" ").append(req.getClass().getName()).append(" --"); // wxartrappsvr/route (r45.zt5) is a generic RPC multiplexer: field // `d` is a LinkedList, each re7{d=type int, e=protobuf.g // body bytes, f=route/action String}. Flat getString/getInteger // scanning below never sees this (it's a List, not a scalar field), // which is why earlier passes thought this call carried no payload. // Dump it explicitly so we can see what's actually being routed. if (req.getClass().getName().equals("r45.zt5")) { try { java.lang.reflect.Field lf = req.getClass().getDeclaredField("d"); lf.setAccessible(true); java.util.LinkedList items = (java.util.LinkedList) lf.get(req); sb.append("\n zt5.d LinkedList size=").append(items.size()); int ridx = 0; for (Object item : items) { String typeStr = "?", routeStr = "?", bodyStr = "?"; try { java.lang.reflect.Field df = item.getClass().getDeclaredField("d"); df.setAccessible(true); typeStr = String.valueOf(df.get(item)); } catch (Throwable t) {} try { java.lang.reflect.Field ff = item.getClass().getDeclaredField("f"); ff.setAccessible(true); Object fv = ff.get(item); routeStr = fv == null ? "null" : fv.toString(); } catch (Throwable t) {} try { java.lang.reflect.Field ef = item.getClass().getDeclaredField("e"); ef.setAccessible(true); Object ev = ef.get(item); if (ev != null) { Object asStr = XposedHelpers.callMethod(ev, "i"); bodyStr = asStr == null ? "null" : asStr.toString(); if (bodyStr.length() > 500) bodyStr = bodyStr.substring(0, 500) + "…"; } } catch (Throwable t) { bodyStr = ""; } sb.append("\n re7#").append(ridx++) .append(" type(d)=").append(typeStr) .append(" route(f)=").append(routeStr) .append(" body(e.i())=").append(bodyStr); } } catch (Throwable t) { sb.append("\n zt5 dump err: ").append(t); } } for (int i = 0; i <= 25; i++) { Object val = null; String kind = ""; try { Object c = XposedHelpers.callMethod(req, "getCustom", i); if (c != null) { val = "custom:" + c.getClass().getName(); kind = "C"; } } catch (Throwable t) {} if (val == null) { try { Object s = XposedHelpers.callMethod(req, "getString", i); if (s != null) { val = s; kind = "S"; } } catch (Throwable t) {} } if (val == null) { try { Object iv = XposedHelpers.callMethod(req, "getInteger", i); if (iv != null && ((Integer) iv) != 0) { val = iv; kind = "I"; } } catch (Throwable t) {} } if (val == null) { try { Object lv = XposedHelpers.callMethod(req, "getLong", i); if (lv != null && ((Long) lv) != 0L) { val = lv; kind = "L"; } } catch (Throwable t) {} } if (val != null) { String vs = String.valueOf(val); if (vs.length() > 200) vs = vs.substring(0, 200) + "…"; sb.append("\n [").append(i).append("](").append(kind).append(")=").append(vs); } } } XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] ANYCGI err: " + t); } } }); XposedBridge.log("[WeChatHook] hookAnyCgiCapture: hooked modelbase.l.a() (unfiltered) in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookAnyCgiCapture failed in " + procName + ": " + t); } } // 贴图号发布路线 §5.5:js1.m.c()/bizh5webtransfer 的 protobuf-cgi 框架(modelbase.l) // 三次全流程真机验证都只见 mp.weixin.qq.com 只读态调用(预览/统计/埋点),从没见过 // 提交语义——很可能提交压根不走这套框架,而是走跟普通消息发图一样的独立二进制 CDN // 上传服务:Lv70/w;->oj(Lr70/g;Lo70/l;)(见 wechat-image-send-internals.md)。被动 // 观察任意调用方(不只是我们自己 sendImageViaAPI 时构造的调用),看贴图号发布是否 // 复用这同一个服务。 private void hookGenericImgCdnSubmit(ClassLoader cl, String procName) { try { Class wCls = XposedHelpers.findClass("v70.w", cl); java.lang.reflect.Method m = wCls.getDeclaredMethod("oj", XposedHelpers.findClass("r70.g", cl), XposedHelpers.findClass("o70.l", cl)); m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { try { StringBuilder sb = new StringBuilder( "[WeChatHook] IMGCDN-SUBMIT v70.w.oj() called by=" + (sCalledByUs ? "US" : "REAL") + " in " + procName + "\n r70.g params:\n"); dumpAllFields(p.args[0], sb, " "); sb.append(" o70.l preBuildResultItem:\n"); dumpAllFields(p.args[1], sb, " "); XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] IMGCDN-SUBMIT dump err: " + t); } } }); XposedBridge.log("[WeChatHook] hookGenericImgCdnSubmit: hooked v70.w.oj in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookGenericImgCdnSubmit failed in " + procName + ": " + t); } } // 贴图号发布图片提交(BRANDSERVICE_PUBLISH_RESEARCH.md §4):js1.m.c()/bizh5webtransfer // 那条纯文本 transfer 链路里从没见过提交调用——反编译顺着 // LuggageUploadMediaFileManager$UploadMediaFileTask.z() 往下追,图片走的是完全独立的 // CDN 提交服务:Lm11/l0;->d1(Lk70/h0;ZJ)Lm11/b0;(m11.l0 单例经 Lm11/b1;->Di() 拿到, // 跟消息发图 v70.w.oj/b80.u.oj 是两套不同的服务)。k70.h0 = 提交参数(本地文件路径等), // m11.b0 = CDN 提交结果(约 40 个混淆字段,字段语义未知,先全量 dump 出来看)。 private static void dumpAllFields(Object obj, StringBuilder sb, String indent) { if (obj == null) { sb.append(indent).append("(null)\n"); return; } for (java.lang.reflect.Field f : obj.getClass().getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; try { f.setAccessible(true); Object v = f.get(obj); String vs = v == null ? "null" : v.toString(); if (vs.length() > 300) vs = vs.substring(0, 300) + "…"; sb.append(indent).append(f.getName()).append(" (").append(f.getType().getSimpleName()) .append(") = ").append(vs).append("\n"); } catch (Throwable t) { sb.append(indent).append(f.getName()).append(" = \n"); } } } private void hookBrandserviceCdnUpload(ClassLoader cl, String procName) { try { Class l0Cls = XposedHelpers.findClass("m11.l0", cl); final Class h0Cls = XposedHelpers.findClass("k70.h0", cl); int hooked = 0; for (java.lang.reflect.Method m : l0Cls.getDeclaredMethods()) { if (!"d1".equals(m.getName())) continue; Class[] pt = m.getParameterTypes(); if (pt.length == 0 || !h0Cls.isAssignableFrom(pt[0])) continue; m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { try { StringBuilder sb = new StringBuilder( "[WeChatHook] BRANDSVC-CDN m11.l0.d1() called in " + procName + "\n k70.h0 params:\n"); dumpAllFields(p.args[0], sb, " "); XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] BRANDSVC-CDN before err: " + t); } } @Override protected void afterHookedMethod(MethodHookParam p) { try { StringBuilder sb = new StringBuilder( "[WeChatHook] BRANDSVC-CDN m11.l0.d1() returned in " + procName + "\n m11.b0 result:\n"); dumpAllFields(p.getResult(), sb, " "); XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] BRANDSVC-CDN after err: " + t); } } }); hooked++; } XposedBridge.log("[WeChatHook] hookBrandserviceCdnUpload: hooked m11.l0.d1 x" + hooked + " in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookBrandserviceCdnUpload failed in " + procName + ": " + t); } } private void hookVoiceUpload(ClassLoader cl, String procName) { try { Class g1 = XposedHelpers.findClass("w21.g1", cl); int mh = 0; for (java.lang.reflect.Method m : g1.getDeclaredMethods()) { final String mName = m.getName(); // L0(w0)=insert, P0(file,w0)=upload, D0/J0/y0/z0=lookups. Dump w0 on all. if (!("L0".equals(mName) || "P0".equals(mName))) continue; m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { try { // find the w0 arg (w21.w0) among args Object w0 = null; if (p.args != null) for (Object a : p.args) if (a != null && a.getClass().getName().equals("w21.w0")) { w0 = a; break; } StringBuilder sb = new StringBuilder("[WeChatHook] g1." + mName + " by=" + (sCalledByUs ? "US" : "REAL") + " args=["); if (p.args != null) for (int i = 0; i < p.args.length; i++) { Object a = p.args[i]; sb.append(i > 0 ? ", " : "").append(a == null ? "null" : (a instanceof String ? "\"" + a + "\"" : a.getClass().getSimpleName())); } sb.append("]"); if (w0 != null) { sb.append("\n w21.w0 DUMP:"); for (java.lang.reflect.Field f : w0.getClass().getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); Object v = f.get(w0); String vs = v == null ? "null" : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]" : (v instanceof String || v instanceof Number || v instanceof Boolean ? v.toString() : v.getClass().getSimpleName())); sb.append("\n ").append(f.getName()).append("=").append(vs); } } XposedBridge.log(sb.toString()); } catch (Throwable ignored) {} } }); mh++; } XposedBridge.log("[WeChatHook] hookVoiceUpload: hooked " + mh + " g1 method(s) in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookVoiceUpload failed in " + procName + ": " + t); } } // ── Image send: hook b80.u.pj/Bi (the real non-suspend send entry) ─────────── // b80.u (implements o70.e, = n0.c(o70.e.class)) is the image plugin. The send // coroutine b80.s calls b80.u.Bi(o70.h,String) then b80.u.pj(o70.h). We hook both // to (1) capture the live b80.u instance and (2) on a REAL image send, dump the // full o70.h (PreBuildParams) field-by-field — that's the exact recipe to replicate. private static void dumpO70h(Object h, String tag) { try { StringBuilder sb = new StringBuilder("[WeChatHook] " + tag + " o70.h DUMP:"); for (java.lang.reflect.Field f : h.getClass().getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); Object v = f.get(h); String vs = v == null ? "null" : (v instanceof byte[] ? "byte[" + ((byte[]) v).length + "]" : (v instanceof String || v instanceof Number || v instanceof Boolean || v instanceof android.net.Uri ? v.toString() : v.getClass().getName() + "@" + System.identityHashCode(v))); sb.append("\n ").append(f.getName()).append(" (").append(f.getType().getSimpleName()) .append(") = ").append(vs); } XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] dumpO70h err: " + t); } } // Hook SendImgProxyUI.onCreate → dump the launch Intent extras. Real gallery image // sends run through this proxy Activity ($$a.run → b80.s coroutine → send). Capturing // its Intent lets us replay the WHOLE real flow via `am start` with the same extras. private void hookSendImgProxyUI(ClassLoader cl, String procName) { try { XposedHelpers.findAndHookMethod( "com.tencent.mm.ui.chatting.SendImgProxyUI", cl, "onCreate", android.os.Bundle.class, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) { try { android.content.Intent it = (android.content.Intent) XposedHelpers.callMethod(p.thisObject, "getIntent"); if (it == null) { XposedBridge.log("[WeChatHook] SendImgProxyUI: intent null"); return; } android.os.Bundle ex = it.getExtras(); StringBuilder sb = new StringBuilder("[WeChatHook] SendImgProxyUI INTENT:"); sb.append("\n action=").append(it.getAction()); sb.append("\n data=").append(it.getDataString()); if (ex != null) for (String key : ex.keySet()) { Object v = ex.get(key); String vs = v == null ? "null" : (v instanceof java.util.ArrayList ? "ArrayList" + v : (v.getClass().isArray() ? v.getClass().getSimpleName() + java.util.Arrays.deepToString(new Object[]{v}) : v.toString())); if (vs.length() > 300) vs = vs.substring(0, 300) + "…"; sb.append("\n [").append(key).append("] (") .append(v == null ? "?" : v.getClass().getName()).append(") = ").append(vs); } XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] SendImgProxyUI dump err: " + t); } } }); XposedBridge.log("[WeChatHook] hookSendImgProxyUI OK in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookSendImgProxyUI failed in " + procName + ": " + t); } } private void hookImageSend(ClassLoader cl, String procName) { try { Class u = XposedHelpers.findClass("b80.u", cl); int n = 0; for (java.lang.reflect.Method m : u.getDeclaredMethods()) { final String mn = m.getName(); if (!("pj".equals(mn) || "Bi".equals(mn) || "oj".equals(mn))) continue; Class[] pts = m.getParameterTypes(); if (pts.length < 1) continue; final boolean isOj = "oj".equals(mn); // oj(o70.k) = the insert/send step if (!isOj && !pts[0].getName().equals("o70.h")) continue; m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { if (sImgPlugin == null && p.thisObject != null) { sImgPlugin = p.thisObject; XposedBridge.log("[WeChatHook] hookImageSend: captured b80.u=" + p.thisObject); } if (!sCalledByUs && p.args != null && p.args.length > 0 && p.args[0] != null) { if (isOj) { // o70.k — the insert object. Dump its full field tree. Object k = p.args[0]; sRealImgK = k; XposedBridge.log("[WeChatHook] b80.u.oj REAL o70.k class=" + k.getClass().getName()); dumpO70h(k, "b80.u.oj REAL o70.k"); } else { sRealImgParams = p.args[0]; dumpO70h(p.args[0], "b80.u." + mn + " REAL"); } } } }); n++; } XposedBridge.log("[WeChatHook] hookImageSend: hooked " + n + " b80.u method(s) in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookImageSend failed in " + procName + ": " + t); } } // ── Voice send via internal API ──────────────────────────────────────────── // Protocol (confirmed by hooking f9.convertTo() on real user send): // • content XML: // • imgPath : 26-char hex voice file ID (f9 field, determines file location) // • voice2 dir : voice2/md5(voiceId)[0:2]/md5(voiceId)[2:4]/msg_{voiceId}.amr (confirmed formula) // // Two modes (mutually exclusive extras): // (a) voice_id — caller pre-placed the SILK file in voice2 already (no file I/O here) // (b) src_path — WeChat-readable path (e.g. /data/data/com.tencent.mm/cache/…); // hook generates voice_id, copies src_path → voice2/{dir}/msg_{id}.amr internally // (WeChat process has full access to its own data dir, unlike root which is SELinux-blocked) // // Typical flow for TTS voice: // root: cp tts.amr /data/data/com.tencent.mm/cache/hook_voice_src.amr && chown u0_a330 ... // broadcast: --es to filehelper --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr --ei duration 3000 // ── Fully-synthetic voice via the uploadvoice cgi (this device's real path) ── // Captured from a real send (VOICECGI): voice audio uploads INLINE via // /cgi-bin/micromsg-bin/uploadvoice (cmdid 127, req pb r45.cv6), NOT the CDN engine. // The scene (modelbase.g extends m1) submits via z2.b — same queue as our text send. // SILK is chunked at 6000 bytes; each chunk is one cgi with the slice in field 7. // cv6 fields (from capture): 0=self 1=talker 2=netOffset 3=chunkLen // 4="newSendVoice_"+voiceId 6=durMs 7=cu5(silk slice) 8=endFlag 11=msgsource // 12=4 15=long(createId). Last chunk (8=1) response dv6.getLong(11)>0 = delivered. // Builds modelbase.o via its 13-arg constructor (signature-stable) to sidestep the // base-vs-tinker field-name mismatch on modelbase.l. private static void sendVoiceViaCgi(String talker, String srcPath, int durationMs, String fromWxId) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); if (srcPath == null || srcPath.isEmpty()) throw new IllegalArgumentException("need src_path (SILK)"); byte[] silk; try (java.io.FileInputStream in = new java.io.FileInputStream(srcPath)) { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) bos.write(buf, 0, n); silk = bos.toByteArray(); } if (silk.length == 0) throw new IllegalStateException("empty SILK file: " + srcPath); // voiceId (26 hex) + clientmsgid link the chunks to one message. byte[] idBytes = new byte[13]; new java.util.Random().nextBytes(idBytes); StringBuilder vsb = new StringBuilder(26); for (byte b : idBytes) vsb.append(String.format("%02x", b & 0xff)); String voiceId = vsb.toString(); String clientMsgId = "newSendVoice_" + voiceId; String self = (fromWxId != null && !fromWxId.isEmpty()) ? fromWxId : (sMyWxid != null ? sMyWxid : "wxid_51ap3m6p9xs722"); // ── Insert a local f9 voice bubble so the SENDER also sees the message ────── // The uploadvoice cgi uploads+delivers to the recipient but does NOT create the // sender's local row. Place the SILK at voice2/msg_.amr (in-process // write works as the WeChat uid, unlike an external root cp) so local playback // resolves, then construct r0 (Object-ctor) which inserts the f9 with the // voicemsg XML + voiceId injected by hookF9ImgPathInjector. Best-effort: a // failure here only costs the sender-side bubble, the cgi send still runs. String voice2Path = null; try { java.security.MessageDigest md5 = java.security.MessageDigest.getInstance("MD5"); byte[] md = md5.digest(voiceId.getBytes("UTF-8")); String d1 = String.format("%02x", md[0] & 0xff), d2 = String.format("%02x", md[1] & 0xff); String mmDir = "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf"; String destDir = mmDir + "/voice2/" + d1 + "/" + d2; voice2Path = destDir + "/msg_" + voiceId + ".amr"; new java.io.File(destDir).mkdirs(); try (java.io.FileOutputStream out = new java.io.FileOutputStream(voice2Path)) { out.write(silk); } new java.io.File(voice2Path).setReadable(true, false); String voiceXml = ""; Class f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl); if (sF9SetterMap == null) sF9SetterMap = probeAllF9Setters(f9Class); byte[] syntheticLv = new byte[74]; syntheticLv[0] = 0x7b; Class r0Class = XposedHelpers.findClass("w11.r0", cl); sPendingVoiceId.set(voiceId); sPendingLvBuffer.set(syntheticLv); sVoiceCgiInsert.set(Boolean.TRUE); sCalledByUs = true; Object r0; try { r0 = XposedHelpers.newInstance(r0Class, new Class[]{String.class, String.class, int.class, int.class, Object.class}, talker, voiceXml, 34, 0, voice2Path); // ctor inserts the f9 (status forced to 4=sent) } finally { sCalledByUs = false; sPendingVoiceId.remove(); sPendingLvBuffer.remove(); sVoiceCgiInsert.remove(); } XposedBridge.log("[WeChatHook] sendVoiceViaCgi: local f9 bubble inserted voiceId=" + voiceId + " r0=" + (r0 == null ? "null" : r0.getClass().getName())); } catch (Throwable t) { XposedBridge.log("[WeChatHook] sendVoiceViaCgi: local f9 insert failed (bubble only, send continues): " + t); } Class cv6C = XposedHelpers.findClass("r45.cv6", cl); Class dv6C = XposedHelpers.findClass("r45.dv6", cl); Class cu5C = XposedHelpers.findClass("r45.cu5", cl); Class oCls = XposedHelpers.findClass("com.tencent.mm.modelbase.o", cl); Class iCls = XposedHelpers.findClass("com.tencent.mm.modelbase.i", cl); Class m1Cls = XposedHelpers.findClass("com.tencent.mm.modelbase.m1", cl); Class pq5g = XposedHelpers.findClass("pq5.g", cl); Class km5d = XposedHelpers.findClass("km5.d", cl); // i.l() (no-arg → pq5.g) registers the response consumer + returns the deferred; // pq5.g.l(long) (long → km5.d) is the terminal subscribe that fires the cgi. java.lang.reflect.Method runM = null; for (java.lang.reflect.Method m : iCls.getDeclaredMethods()) if (m.getParameterCount() == 0 && m.getReturnType() == pq5g) { runM = m; runM.setAccessible(true); break; } java.lang.reflect.Method subM = null; for (java.lang.reflect.Method m : pq5g.getDeclaredMethods()) { Class[] pt = m.getParameterTypes(); if (pt.length == 1 && pt[0] == long.class && m.getReturnType() == km5d) { subM = m; subM.setAccessible(true); break; } } if (runM == null || subM == null) throw new IllegalStateException("i.l()/pq5.g.l(long) not found: run=" + runM + " sub=" + subM); // modelbase.o(req, resp, url, cmdid, 0, 0, true, 0, 0, false, 0, false, null) — find by arg count. java.lang.reflect.Constructor oCtor = null; for (java.lang.reflect.Constructor c : oCls.getDeclaredConstructors()) if (c.getParameterTypes().length == 13) { oCtor = c; break; } if (oCtor == null) throw new IllegalStateException("modelbase.o 13-arg ctor not found"); oCtor.setAccessible(true); // modelbase.i.p(o) — locate by signature (one param assignable from modelbase.o). java.lang.reflect.Method pM = null; for (java.lang.reflect.Method m : iCls.getDeclaredMethods()) { Class[] pt = m.getParameterTypes(); if (m.getReturnType() == void.class && pt.length == 1 && pt[0].isAssignableFrom(oCls)) { pM = m; break; } } if (pM == null) throw new IllegalStateException("modelbase.i.p(o) not found"); pM.setAccessible(true); // i's scene field (type modelbase.g / m1 subclass). java.lang.reflect.Field sceneF = null; for (java.lang.reflect.Field f : iCls.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; if (m1Cls.isAssignableFrom(f.getType())) { sceneF = f; sceneF.setAccessible(true); break; } } if (sceneF == null) throw new IllegalStateException("modelbase.i scene field not found"); final String URL = "/cgi-bin/micromsg-bin/uploadvoice"; final int CHUNK = 6000; int total = silk.length, chunks = 0; Object lastDv6 = null; sCalledByUs = true; try { for (int off = 0; off < total; off += CHUNK) { int len = Math.min(CHUNK, total - off); boolean last = (off + len >= total); byte[] slice = java.util.Arrays.copyOfRange(silk, off, off + len); Object cv6 = XposedHelpers.newInstance(cv6C); XposedHelpers.callMethod(cv6, "set", 0, self); XposedHelpers.callMethod(cv6, "set", 1, talker); XposedHelpers.callMethod(cv6, "set", 2, Integer.valueOf(off)); XposedHelpers.callMethod(cv6, "set", 3, Integer.valueOf(len)); XposedHelpers.callMethod(cv6, "set", 4, clientMsgId); XposedHelpers.callMethod(cv6, "set", 6, Integer.valueOf(durationMs)); Object cu5 = XposedHelpers.newInstance(cu5C); XposedHelpers.callMethod(cu5, "e", slice, 0, len); // cu5.e(byte[],off,len) XposedHelpers.callMethod(cv6, "set", 7, cu5); XposedHelpers.callMethod(cv6, "set", 8, Integer.valueOf(last ? 1 : 0)); XposedHelpers.callMethod(cv6, "set", 11, ""); // msgsource (empty for filehelper) XposedHelpers.callMethod(cv6, "set", 12, Integer.valueOf(4)); // [15] = per-message create id (long). Real sends carry it; derive a // large positive nonce from the voiceId so the server has a stable value. long createId = (System.currentTimeMillis() << 20) ^ (voiceId.hashCode() & 0xffffffffL); if (createId < 0) createId = -createId; XposedHelpers.callMethod(cv6, "set", 15, Long.valueOf(createId)); Object dv6 = XposedHelpers.newInstance(dv6C); lastDv6 = dv6; Object o = oCtor.newInstance(cv6, dv6, URL, 127, 0, 0, Boolean.TRUE, 0, 0, Boolean.FALSE, 0, Boolean.FALSE, null); Object iInst = XposedHelpers.newInstance(iCls); pM.invoke(iInst, o); Object scene = sceneF.get(iInst); // Dispatch: i.l() registers the response consumer + returns the deferred, // then pq5.g.l(timeout) subscribes → the cgi actually runs. Bare z2.b(g) // doesn't wire the callback, so the scene never completes. Object deferred = runM.invoke(iInst); sVoiceCgiKeepAlive.add(iInst); // deferred holds i weakly if (deferred != null) { sVoiceCgiKeepAlive.add(deferred); subM.invoke(deferred, 30000L); } chunks++; XposedBridge.log("[WeChatHook] sendVoiceViaCgi: chunk off=" + off + " len=" + len + " last=" + last + " scene=" + (scene == null ? "null" : scene.getClass().getName()) + " deferred=" + (deferred == null ? "null" : deferred.getClass().getName())); } } finally { sCalledByUs = false; } XposedBridge.log("[WeChatHook] sendVoiceViaCgi: submitted " + chunks + " chunk(s) to=" + talker + " voiceId=" + voiceId + " silk=" + total + "B dur=" + durationMs + " self=" + self); // Read the server response off the SAME dv6 we passed (WeChat parses the cgi // reply into it). getLong(11) = assigned server msgId; >0 means the server // accepted the voice = delivered. Poll a few times since z2.b is async. final Object fDv6 = lastDv6; final String fVoice = voiceId; if (fDv6 != null) { final Handler h = new Handler(Looper.getMainLooper()); final int[] tries = {0}; Runnable poll = new Runnable() { @Override public void run() { tries[0]++; try { long svrMsgId = 0; try { Object v = XposedHelpers.callMethod(fDv6, "getLong", 11); if (v != null) svrMsgId = (Long) v; } catch (Throwable t) {} int netOff = 0; try { Object v = XposedHelpers.callMethod(fDv6, "getInteger", 2); if (v != null) netOff = (Integer) v; } catch (Throwable t) {} if (svrMsgId != 0 || netOff != 0 || tries[0] >= 6) { StringBuilder sb = new StringBuilder("[WeChatHook] sendVoiceViaCgi RESP try=" + tries[0] + " voiceId=" + fVoice + " svrMsgId(11)=" + svrMsgId + " netOffset(2)=" + netOff + " dv6:"); for (int i = 0; i <= 15; i++) { Object val = null; try { Object s = XposedHelpers.callMethod(fDv6, "getString", i); if (s != null && !"".equals(s)) val = "S:" + s; } catch (Throwable t) {} if (val == null) { try { Object iv = XposedHelpers.callMethod(fDv6, "getInteger", i); if (iv != null && ((Integer) iv) != 0) val = "I:" + iv; } catch (Throwable t) {} } if (val == null) { try { Object lv = XposedHelpers.callMethod(fDv6, "getLong", i); if (lv != null && ((Long) lv) != 0L) val = "L:" + lv; } catch (Throwable t) {} } if (val != null) sb.append("\n [").append(i).append("]=").append(val); } XposedBridge.log(sb.toString()); } else { h.postDelayed(this, 1500); } } catch (Throwable t) { XposedBridge.log("[WeChatHook] sendVoiceViaCgi RESP err: " + t); } } }; h.postDelayed(poll, 1500); } } private static void sendVoiceViaAPI(String talker, String voiceId, String srcPath, int durationMs, String fromWxId) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); // ── File placement ─────────────────────────────────────────────────── if (voiceId == null || voiceId.isEmpty()) { // Mode (b): generate voice_id and copy SILK from srcPath if (srcPath == null || srcPath.isEmpty()) throw new IllegalArgumentException("need either voice_id or src_path"); byte[] idBytes = new byte[13]; new java.util.Random().nextBytes(idBytes); StringBuilder sb = new StringBuilder(26); for (byte b : idBytes) sb.append(String.format("%02x", b & 0xff)); voiceId = sb.toString(); java.security.MessageDigest md5 = java.security.MessageDigest.getInstance("MD5"); byte[] mdBytes = md5.digest(voiceId.getBytes("UTF-8")); String dir1 = String.format("%02x", mdBytes[0] & 0xff); String dir2 = String.format("%02x", mdBytes[1] & 0xff); String mmDir = "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf"; String destDir = mmDir + "/voice2/" + dir1 + "/" + dir2; String destPath = destDir + "/msg_" + voiceId + ".amr"; new java.io.File(destDir).mkdirs(); try (java.io.FileInputStream in = new java.io.FileInputStream(srcPath); java.io.FileOutputStream out = new java.io.FileOutputStream(destPath)) { byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) out.write(buf, 0, n); } new java.io.File(destPath).setReadable(true, false); XposedBridge.log("[WeChatHook] sendVoiceViaAPI: " + srcPath + " -> " + destPath); } else if (voiceId.length() != 26) { throw new IllegalArgumentException("voice_id must be 26 hex chars, got: " + voiceId); } // ── Build voice XML ────────────────────────────────────────────────── // fromusername is the sender's wxid. sMyWxid is auto-discovered from the first // real UI voice send; fall back to the known wxid for this device. String safeFrom = (fromWxId != null && !fromWxId.isEmpty()) ? fromWxId : (sMyWxid != null ? sMyWxid : "wxid_51ap3m6p9xs722"); String voiceXml = ""; // ── Probe f9 setter map BEFORE setting sPendingVoiceId ─────────────── Class f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl); if (sF9SetterMap == null) sF9SetterMap = probeAllF9Setters(f9Class); if (!sF9SetterMap.containsKey("imgPath")) throw new IllegalStateException("imgPath setter not found in map: " + sF9SetterMap); // ── Synthetic lvbuffer (waveform visualization, NOT the SILK audio) ──── // Real WeChat always stores exactly 74 bytes regardless of duration: // 4-byte header (0x7b 0x00 0x00 0x00) + 70 zero bytes. The size is fixed. byte[] syntheticLv = new byte[74]; syntheticLv[0] = 0x7b; // bytes 1..73 default to 0x00 XposedBridge.log("[WeChatHook] sendVoiceViaAPI: synthetic lvbuffer 74 bytes for " + durationMs + "ms"); // ── Create r0: try Object-param ctor first (real WeChat voice path) ───── // WeChat has two 5-param r0 ctors: // (String, String, int, int, long) — timestamp variant // (String, String, int, int, Object) — audio-path variant (used by real voice sends) // The Object ctor stores the audio file reference that H() reads for CDN upload. // If it works, r0 sets imgPath internally and H() can find the SILK for upload. // Fall back to long ctor if the Object ctor throws. Class r0Class = XposedHelpers.findClass("w11.r0", cl); sPendingVoiceId.set(voiceId); sPendingLvBuffer.set(syntheticLv); sCalledByUs = true; Object r0; try { try { // Pass the voice2 path (where we placed the SILK) as the Object arg. // Real WeChat passes the temp recording file; we pass our already-placed file. String voiceFilePath = srcPath != null ? srcPath : "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf/voice2/" + String.format("%02x", java.security.MessageDigest.getInstance("MD5") .digest(voiceId.getBytes("UTF-8"))[0] & 0xff) + "/" + String.format("%02x", java.security.MessageDigest.getInstance("MD5") .digest(voiceId.getBytes("UTF-8"))[1] & 0xff) + "/msg_" + voiceId + ".amr"; r0 = XposedHelpers.newInstance(r0Class, new Class[]{String.class, String.class, int.class, int.class, Object.class}, talker, voiceXml, 34, 0, voiceFilePath); XposedBridge.log("[WeChatHook] sendVoiceViaAPI: used Object-ctor with path=" + voiceFilePath); } catch (Throwable e1) { XposedBridge.log("[WeChatHook] sendVoiceViaAPI: Object-ctor failed (" + e1 + "), falling back to long-ctor"); long ts = System.currentTimeMillis(); r0 = XposedHelpers.newInstance(r0Class, new Class[]{String.class, String.class, int.class, int.class, long.class}, talker, voiceXml, 34, 0, ts); } } finally { sCalledByUs = false; sPendingVoiceId.remove(); sPendingLvBuffer.remove(); } // Dump r0 fields so we can see which field holds the audio path. dumpR0Fields(r0, "r0-after-ctor by=US"); // ── Leave r0.p == null: take the AUTHENTIC new-send path ────────────────── // The r0 Object-ctor already inserted a proper f9 (content=voiceXml, imgPath // injected by hookF9ImgPathInjector, status=1) via cj().N9(). With p==null, // NetSceneSendMsg.doScene() reloads that f9 fresh from the DB (cj().c6()) — // content/msgsource intact — and assembles + dispatches it, exactly like a // real new send. // // Previously we forced r0.p = sDatabaseF9. That sends doScene down the *resend* // branch (which expects status=5 and logs "should not be resend"), handing the // MsgSource assembler a stale in-memory f9 whose content reads null → // e14.k.fromXml(null) NPE. That was the root-cause blocker; fixed by NOT setting p. sDatabaseF9.remove(); // ── Set dispatcher on r0 (required for CDN upload via H()) ───────────── // r0.dispatcher (type com.tencent.mm.network.s) is null in our synthetic r0. // H() NPEs on `dispatcher.J(z0, o0, int)` if null. // We capture dispatcher from the first real WeChat r0 that enters H() (hookR0H). if (sCapturedDispatcher != null && !(sCapturedDispatcher instanceof java.lang.reflect.Proxy)) { // Real dispatcher captured from a genuine WeChat send — use it directly. try { XposedHelpers.setObjectField(r0, "dispatcher", sCapturedDispatcher); XposedBridge.log("[WeChatHook] sendVoiceViaAPI: set r0.dispatcher REAL class=" + sCapturedDispatcher.getClass().getName()); } catch (Throwable ed) { XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.dispatcher set failed: " + ed); } } else { // No real dispatcher yet — fall through to proxy creation below. // (Cached proxy is reused if already created; fresh one made otherwise.) // Dispatcher not yet captured — search WeChat singletons for it. // gm0.j1.i() returns the message manager which likely holds a dispatcher reference. XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher null, searching singletons..."); outer: for (String[] candidate : new String[][]{ {"gm0.j1", "i"}, // message manager singleton {"tg3.t1", "a"}, // service locator }) { try { Class mgr = XposedHelpers.findClass(candidate[0], cl); Object inst = XposedHelpers.callStaticMethod(mgr, candidate[1]); if (inst == null) continue; // Walk inst's fields recursively (depth 2) looking for dispatcher for (java.lang.reflect.Field f1 : inst.getClass().getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f1.getModifiers())) continue; f1.setAccessible(true); Object v1 = f1.get(inst); if (v1 == null) continue; // Level 1: try r0.dispatcher field name on v1 directly try { Object d = XposedHelpers.getObjectField(v1, "dispatcher"); if (d != null) { sCapturedDispatcher = d; XposedBridge.log("[WeChatHook] dispatcher found: " + candidate[0] + "." + candidate[1] + "()." + f1.getName() + ".dispatcher=" + d.getClass().getName()); XposedHelpers.setObjectField(r0, "dispatcher", d); break outer; } } catch (Throwable ignored1) {} // Level 2: walk v1's fields for (java.lang.reflect.Field f2 : v1.getClass().getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue; f2.setAccessible(true); Object v2 = f2.get(v1); if (v2 == null) continue; try { Object d = XposedHelpers.getObjectField(v2, "dispatcher"); if (d != null) { sCapturedDispatcher = d; XposedBridge.log("[WeChatHook] dispatcher found (L2): " + candidate[0] + "." + candidate[1] + "()." + f1.getName() + "." + f2.getName() + ".dispatcher=" + d.getClass().getName()); XposedHelpers.setObjectField(r0, "dispatcher", d); break outer; } } catch (Throwable ignored2) {} } } } catch (Throwable t2) { XposedBridge.log("[WeChatHook] dispatcher search " + candidate[0] + ": " + t2); } } if (sCapturedDispatcher instanceof java.lang.reflect.Proxy) { // Already have a proxy from a previous voice send — reuse it on this r0. try { XposedHelpers.setObjectField(r0, "dispatcher", sCapturedDispatcher); XposedBridge.log("[WeChatHook] sendVoiceViaAPI: reusing proxy dispatcher on r0"); } catch (Throwable ed2) { XposedBridge.log("[WeChatHook] sendVoiceViaAPI: proxy reuse failed: " + ed2); } } else if (sCapturedDispatcher == null) { // Still not found — create a minimal proxy for s that logs calls. // This lets H() proceed past the NPE; CDN upload may work via callback. try { Class sInterface = XposedHelpers.findClass("com.tencent.mm.network.s", cl); java.lang.reflect.Method[] sMethods = sInterface.getDeclaredMethods(); StringBuilder sb = new StringBuilder("[WeChatHook] s interface methods:\n"); for (java.lang.reflect.Method m : sMethods) sb.append(" ").append(m.toGenericString()).append("\n"); XposedBridge.log(sb.toString()); Object dispProxy = java.lang.reflect.Proxy.newProxyInstance(cl, new Class[]{sInterface}, (proxy, method, args) -> { XposedBridge.log("[WeChatHook] dispatcher.proxy: " + method.getName() + " ret=" + method.getReturnType().getSimpleName()); if ("J".equals(method.getName()) && args != null && args.length >= 2) { // ── Attempt 1: forward J() via l0 method on m05.j ────────────── // method.declaringClass = s (s has own J, NOT inherited from l0). // m05.j implements l0 (not s). Solution: get J() from l0 interface // and invoke THAT on m05.j — valid since m05.j implements l0. try { Object q2arg = args[1]; Object mj = XposedHelpers.getObjectField(q2arg, "e"); if (mj != null) { Class l0Iface = XposedHelpers.findClass("com.tencent.mm.network.l0", cl); // Dump l0 methods for diagnosis StringBuilder l0log = new StringBuilder("[WeChatHook] l0 methods:"); java.lang.reflect.Method foundL0J = null; for (java.lang.reflect.Method l0m : l0Iface.getDeclaredMethods()) { l0log.append(" ").append(l0m.getName()).append("(").append(l0m.getParameterTypes().length).append(")"); if (l0m.getName().equals(method.getName()) && l0m.getParameterTypes().length == method.getParameterTypes().length) { foundL0J = l0m; } } XposedBridge.log(l0log.toString()); if (foundL0J != null) { foundL0J.setAccessible(true); XposedBridge.log("[WeChatHook] J→m05.j via l0.J()"); try { Object result = foundL0J.invoke(mj, args); XposedBridge.log("[WeChatHook] J→m05.j l0 OK result=" + result); return result; } catch (java.lang.reflect.InvocationTargetException ite) { XposedBridge.log("[WeChatHook] J→m05.j l0 threw: " + ite.getCause()); } } else { XposedBridge.log("[WeChatHook] J not found in l0 by name+paramcount"); } } } catch (Throwable jt0) { XposedBridge.log("[WeChatHook] J→m05.j err: " + jt0); } // ── Attempt 2: find s implementor in J() args (skip l0-only classes) ── try { Class sIface = XposedHelpers.findClass("com.tencent.mm.network.s", cl); for (Object searchRoot : new Object[]{args[0], args[1]}) { if (searchRoot == null) continue; for (Class cls2 = searchRoot.getClass(); cls2 != null && !cls2.equals(Object.class); cls2 = cls2.getSuperclass()) { for (java.lang.reflect.Field sf1 : cls2.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(sf1.getModifiers())) continue; sf1.setAccessible(true); Object sv1 = sf1.get(searchRoot); if (sv1 == null || sv1 == proxy) continue; if (sIface.isInstance(sv1)) { sCapturedDispatcher = sv1; XposedBridge.log("[WeChatHook] J: REAL s L1 " + sv1.getClass().getName()); try { return method.invoke(sv1, args); } catch (java.lang.reflect.InvocationTargetException ite) { throw ite.getCause(); } } // L2 sub-fields try { for (Class cls3 = sv1.getClass(); cls3 != null && !cls3.equals(Object.class); cls3 = cls3.getSuperclass()) { for (java.lang.reflect.Field sf2 : cls3.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(sf2.getModifiers())) continue; sf2.setAccessible(true); Object sv2 = sf2.get(sv1); if (sv2 == null || sv2 == proxy) continue; if (sIface.isInstance(sv2)) { sCapturedDispatcher = sv2; XposedBridge.log("[WeChatHook] J: REAL s L2 " + sf1.getName() + "." + sf2.getName() + " " + sv2.getClass().getName()); try { return method.invoke(sv2, args); } catch (java.lang.reflect.InvocationTargetException ite) { throw ite.getCause(); } } } } } catch (Throwable ignored4) {} } } } XposedBridge.log("[WeChatHook] J: no s implementor in args"); } catch (Throwable jse) { XposedBridge.log("[WeChatHook] J: search err: " + jse); } // ── Deep-dump v2 (CDN request) sub-fields ─────────────────── try { Object v2arg = args[0]; StringBuilder cdnDump = new StringBuilder("[WeChatHook] J v2-deep:\n"); // Walk class hierarchy (getDeclaredFields only finds own fields) for (Class cls2 = v2arg.getClass(); cls2 != null && !cls2.equals(Object.class); cls2 = cls2.getSuperclass()) { for (java.lang.reflect.Field sf1 : cls2.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(sf1.getModifiers())) continue; sf1.setAccessible(true); Object sv1 = sf1.get(v2arg); cdnDump.append(" ").append(sf1.getName()).append("=") .append(sv1 == null ? "null" : sv1.getClass().getSimpleName()).append("\n"); if (sv1 == null) continue; for (java.lang.reflect.Field sf2 : sv1.getClass().getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(sf2.getModifiers())) continue; sf2.setAccessible(true); Object sv2 = sf2.get(sv1); String sv2s; if (sv2 == null) { sv2s = "null"; } else if (sv2 instanceof byte[]) { byte[] ba = (byte[]) sv2; StringBuilder hex = new StringBuilder(); for (int xi = 0; xi < Math.min(ba.length, 20); xi++) hex.append(String.format("%02x", ba[xi] & 0xff)); sv2s = "byte[" + ba.length + "]=" + hex; } else { sv2s = sv2.toString(); if (sv2s.length() > 100) sv2s = sv2s.substring(0, 100); } cdnDump.append(" ").append(sf1.getName()).append(".").append(sf2.getName()).append("=").append(sv2s).append("\n"); } } } XposedBridge.log(cdnDump.toString()); } catch (Throwable jde) { XposedBridge.log("[WeChatHook] J v2-dump err: " + jde); } } if (method.getReturnType() == int.class || method.getReturnType() == Integer.class) return 0; if (method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class) return false; return null; }); sCapturedDispatcher = dispProxy; XposedHelpers.setObjectField(r0, "dispatcher", dispProxy); XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher proxy set on r0"); } catch (Throwable ep) { XposedBridge.log("[WeChatHook] sendVoiceViaAPI: dispatcher proxy failed: " + ep); } } } // ── u0 callback stub (same pattern as sendViaR0) ───────────────────── // r0.d (type com.tencent.mm.modelbase.u0) is null unless we set it; // H() calls u0.onSceneEnd which NPEs otherwise. // hStarted gates the u0 stub: ignore premature onSceneEnd (fires synchronously // when we set r0.d, BEFORE H() is called) and only act on H()'s async CDN callback. // Root cause: setting r0.d triggers an immediate "local save done" callback. // If we call K(0) there, WeChat marks the message as "completed" → H() skips CDN. final java.util.concurrent.atomic.AtomicBoolean hStarted = new java.util.concurrent.atomic.AtomicBoolean(false); try { Class u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl); Object stub = Proxy.newProxyInstance(cl, new Class[]{u0}, (proxy, method, args) -> { StringBuilder params = new StringBuilder(); if (args != null) { for (int i = 0; i < args.length; i++) { if (i > 0) params.append(", "); params.append(args[i]); } } XposedBridge.log("[WeChatHook] voice-cb: " + method.getName() + "(" + params + ") hStarted=" + hStarted.get()); // onSceneEnd(int code, int errCode, String msg, m1 scene) // Real m2 dispatch: r0.onGYNetEnd handles the network result itself // (status/msgSvrId update, SendMsgSuccessEvent, K/I). This u0 only needs // to be non-null so that internal onSceneEnd call doesn't NPE — do NOT // call K/I here or success/fail events double-fire. if ("onSceneEnd".equals(method.getName()) && args != null && args.length >= 2) { XposedBridge.log("[WeChatHook] voice onSceneEnd code=" + args[0] + " err=" + args[1]); } return null; }); XposedHelpers.setObjectField(r0, "d", stub); // Premature onSceneEnd fires synchronously during setObjectField above and is suppressed. } catch (Throwable t) { XposedBridge.log("[WeChatHook] u0 stub failed (non-fatal): " + t); } // ── Dispatch via WeChat's REAL NetSceneQueue ───────────────────────────── // Exactly the path the (now-working) internal text send uses. n1.a() does: // ((gm0.n) com.tencent.mm.modelbase.z2.f88087b).f290500a.f290528b.h(scene, 0) // The queue supplies the real network dispatcher and runs doScene properly: // p==null → it reloads our just-inserted voice f9 from the DB, assembles the // MsgSource, uploads the SILK to CDN, and transports to the server. // // This replaces the old manual r0.H()+fake-$Proxy dispatcher, which never // reached the network — real transport is the queue's job (routed via IPC to // the :push connection), NOT a hand-set main-process m2 (which is never even // exercised, so it can't be captured). hStarted.set(true); sCalledByUs = true; try { // z2.b(m1) = WeChat's own "run cgi" helper (IGetNetSceneQueue): enqueues the // scene into the real NetSceneQueue, which supplies the live dispatcher and // runs doScene. Using the static helper avoids hardcoding the queue's field // chain, whose obfuscated names differ between base.apk and the Tinker patch // (the running code). r0 extends m1, so this resolves b(m1) by assignability. Class z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); XposedHelpers.callStaticMethod(z2c, "b", r0); XposedBridge.log("[WeChatHook] sendVoiceViaAPI: enqueued via z2.b(NetSceneQueue) to=" + talker + " voiceId=" + voiceId + " durationMs=" + durationMs); } catch (Throwable t) { XposedBridge.log("[WeChatHook] sendVoiceViaAPI: enqueue threw: " + t + "\n" + android.util.Log.getStackTraceString(t)); } finally { sCalledByUs = false; } // ── Trigger REAL delivery via ResendVoiceMsgEvent{f9} ────────────────── // Decompiling WeChat 8.0.74 revealed the real voice send path: // cg0.d(talker,file) → jg0.x(cg0.d) → sendCgi(m)+uploadAttach(w). // ResendVoiceMsgEvent (com.tencent.mm.autogen.events) carries a single f9 // (field g = am.wr, wr.a = f9). Publishing it makes WeChat's voice-resend // handler run the FULL sendCgi + CDN upload for our already-inserted f9 — // i.e. the same machinery a real send uses. This replaces the dead // r0.H()/msgSvrId-poll path (delivery never happened → msgSvrId stayed 0). final Long msgLocalId = sPendingMsgLocalId.get(); sPendingMsgLocalId.remove(); sDatabaseF9.remove(); final Object fPt0e0 = sPt0E0Instance; final String fTalker = talker; final ClassLoader fCl = cl; // ResendVoiceMsgEvent path CONFIRMED DEAD (2026-07-06): even after marking the // properly-inserted voice msg failed (status=5) and firing the event, WeChat's // resend handler does NOT respond — the message just shows red ! (failed), no // CDN upload. So this force-fails sends for nothing. Disabled. The remaining // real path is to build a voiceinfo/w0 upload task and drive w21.g1.P0() (or // reconstruct jg0.x). Kept for reference. if (false && msgLocalId != null) { new Thread(() -> { try { Thread.sleep(1500); // let the z2.b send settle first // Re-fetch the DB-bound f9 via pt0.e0.k(talker, msgLocalId). Object f9 = null; if (fPt0e0 != null) { try { java.lang.reflect.Method kM = null; for (java.lang.reflect.Method m : fPt0e0.getClass().getDeclaredMethods()) if ("k".equals(m.getName()) && m.getParameterTypes().length == 2) { kM = m; break; } if (kM != null) { kM.setAccessible(true); f9 = kM.invoke(fPt0e0, fTalker, msgLocalId); } } catch (Throwable t) { XposedBridge.log("[WeChatHook] resend: k() failed: " + t); } } if (f9 == null) { XposedBridge.log("[WeChatHook] resend: f9 null for msgLocalId=" + msgLocalId + ", abort"); return; } // Mark the message failed (status=5) + persist, so the resend handler // treats it as a resendable failed voice. f9.r1(int)=status setter; // pt0.e0.s(talker,msgId,f9) persists via cj().lb(). try { XposedHelpers.callMethod(f9, "r1", 5); long mid = ((Number) XposedHelpers.callMethod(f9, "getMsgId")).longValue(); if (fPt0e0 != null) { java.lang.reflect.Method sM = null; for (java.lang.reflect.Method m : fPt0e0.getClass().getDeclaredMethods()) if ("s".equals(m.getName()) && m.getParameterTypes().length == 3) { sM = m; break; } if (sM != null) { sM.setAccessible(true); sM.invoke(fPt0e0, fTalker, mid, f9); } } XposedBridge.log("[WeChatHook] resend: marked f9 status=5 + persisted msgId=" + mid); } catch (Throwable t) { XposedBridge.log("[WeChatHook] resend: status=5 failed: " + t); } // Build ResendVoiceMsgEvent, set its f9 (field g -> wr.a), publish. Class evCls = XposedHelpers.findClass( "com.tencent.mm.autogen.events.ResendVoiceMsgEvent", fCl); Object ev = evCls.newInstance(); Object wr = XposedHelpers.getObjectField(ev, "g"); // am.wr XposedHelpers.setObjectField(wr, "a", f9); XposedHelpers.callMethod(ev, "b", Looper.getMainLooper()); XposedBridge.log("[WeChatHook] resend: ResendVoiceMsgEvent published msgLocalId=" + msgLocalId + " talker=" + fTalker + " f9=" + f9.getClass().getName()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] resend: failed: " + t); } }, "WCHook-resend-" + msgLocalId).start(); } else { XposedBridge.log("[WeChatHook] resend: msgLocalId null, skip"); } } // ── Factory method discovery ─────────────────────────────────────────────── // Hooks ALL static methods on w11.s1 and logs their signatures + call args. // When WeChat UI sends image/voice, we'll see which s1 method is called and // what parameters it receives, revealing the builder API for each msgtype. private void hookFactoryMethods(ClassLoader cl, String procName) { try { Class s1 = XposedHelpers.findClass("w11.s1", cl); java.lang.reflect.Method[] methods = s1.getDeclaredMethods(); // Dump all method signatures immediately StringBuilder sb = new StringBuilder(); sb.append("[WeChatHook] w11.s1 ALL METHODS (").append(methods.length).append("):\n"); for (java.lang.reflect.Method m : methods) { sb.append(" ").append(m.toGenericString()).append("\n"); } XposedBridge.log(sb.toString()); // Hook each static method to intercept calls from WeChat UI for (java.lang.reflect.Method m : methods) { if (!java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue; final String mName = m.getName(); final String mSig = m.toGenericString(); m.setAccessible(true); XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { StringBuilder log = new StringBuilder(); log.append("[WeChatHook] s1.").append(mName).append("("); if (param.args != null) { for (int i = 0; i < param.args.length; i++) { if (i > 0) log.append(", "); Object a = param.args[i]; log.append(a == null ? "null" : a.getClass().getSimpleName() + "=" + a); } } log.append(") -> "); Object ret = param.getResult(); if (ret == null) { log.append("null"); } else { log.append(ret.getClass().getName()); dumpBuilderMethods(ret, "s1." + mName); // Capture dispatcher from the r0 inside r1 (set by factory, null in direct ctor) try { StringBuilder r1Fields = new StringBuilder("[WeChatHook] r1 fields:\n"); for (Class rc = ret.getClass(); rc != null && !rc.equals(Object.class); rc = rc.getSuperclass()) { for (java.lang.reflect.Field rf : rc.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(rf.getModifiers())) continue; rf.setAccessible(true); Object rv = rf.get(ret); String typeName = rv != null ? rv.getClass().getName() : rf.getType().getName(); r1Fields.append(" ").append(rf.getName()).append(" (").append(typeName).append(")"); if (sCapturedDispatcher == null && rv != null) { // Try to get dispatcher from any field, whether it's r0 or a container try { Object disp = XposedHelpers.getObjectField(rv, "dispatcher"); if (disp != null) { sCapturedDispatcher = disp; r1Fields.append(" [dispatcher captured!]"); } } catch (Throwable ignored2) {} } r1Fields.append("\n"); } } XposedBridge.log(r1Fields.toString()); } catch (Throwable r1e) { XposedBridge.log("[WeChatHook] r1 field dump error: " + r1e); } } if (param.hasThrowable()) log.append(" THREW: ").append(param.getThrowable()); XposedBridge.log(log.toString()); } }); } XposedBridge.log("[WeChatHook] hookFactoryMethods: hooked " + methods.length + " methods on w11.s1 in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookFactoryMethods failed in " + procName + ": " + t); } } // Dump ALL instance methods on a builder object returned by the factory. // Each unique builder class is only dumped once to avoid logcat spam. private static final java.util.Set sDumpedClasses = java.util.Collections.synchronizedSet(new java.util.HashSet<>()); private static void dumpBuilderMethods(Object builder, String fromFactory) { if (builder == null) return; String clsName = builder.getClass().getName(); if (!sDumpedClasses.add(clsName)) return; // already dumped try { java.lang.reflect.Method[] ms = builder.getClass().getDeclaredMethods(); StringBuilder sb = new StringBuilder(); sb.append("[WeChatHook] BUILDER from ").append(fromFactory) .append(" class=").append(clsName) .append(" methods(").append(ms.length).append("):\n"); for (java.lang.reflect.Method m : ms) { sb.append(" ").append(m.toGenericString()).append("\n"); } XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] dumpBuilderMethods " + clsName + ": " + t); } } // Dump all instance fields of w11.r0 to logcat. // Used to discover which field holds the SILK audio data for CDN upload. // Logs each field name, type, and a brief value summary. private static void dumpR0Fields(Object r0, String tag) { if (r0 == null) return; try { Class cls = r0.getClass(); StringBuilder sb = new StringBuilder(); sb.append("[WeChatHook] ").append(tag).append(" class=").append(cls.getName()).append("\n"); // Walk the class hierarchy to capture inherited fields for (Class c = cls; c != null && !c.equals(Object.class); c = c.getSuperclass()) { for (java.lang.reflect.Field f : c.getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); try { Object val = f.get(r0); if (val == null) { sb.append(" ").append(f.getName()).append(" (").append(f.getType().getSimpleName()).append(") = null\n"); } else if (val instanceof byte[]) { byte[] ba = (byte[]) val; StringBuilder hex = new StringBuilder(); int show = Math.min(ba.length, 16); for (int i = 0; i < show; i++) hex.append(String.format("%02x", ba[i] & 0xff)); sb.append(" ").append(f.getName()).append(" (byte[]) len=").append(ba.length).append(" hex=").append(hex).append("\n"); } else if (val instanceof String) { String s = (String) val; sb.append(" ").append(f.getName()).append(" (String) = \"").append(s.length() > 80 ? s.substring(0, 80) + "…" : s).append("\"\n"); } else { String vs = val.toString(); sb.append(" ").append(f.getName()).append(" (").append(val.getClass().getSimpleName()).append(") = ").append(vs.length() > 80 ? vs.substring(0, 80) + "…" : vs).append("\n"); } } catch (Throwable e2) { sb.append(" ").append(f.getName()).append(" ERROR: ").append(e2).append("\n"); } } } XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] dumpR0Fields failed: " + t); } } // ── On-demand API dump broadcast ────────────────────────────────────────── // am broadcast -a com.hermes.wechat.DUMP_WECHAT_API // Optional extra: --es class "w11.s1" (defaults to w11.s1 if omitted) private static void handleDumpApi(Intent intent) { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) { XposedBridge.log("[WeChatHook] DUMP_API: classloader null"); return; } String className = intent.getStringExtra("class"); if (className == null || className.isEmpty()) className = "w11.s1"; try { Class cls = XposedHelpers.findClass(className, cl); java.lang.reflect.Method[] ms = cls.getDeclaredMethods(); java.lang.reflect.Constructor[] cs = cls.getDeclaredConstructors(); java.lang.reflect.Field[] fs = cls.getDeclaredFields(); StringBuilder sb = new StringBuilder(); sb.append("[WeChatHook] DUMP_API class=").append(cls.getName()).append("\n"); // For enum classes, print each constant with its ordinal and any int fields if (cls.isEnum()) { sb.append(" ENUM CONSTANTS:\n"); try { Object[] constants = (Object[]) cls.getMethod("values").invoke(null); java.lang.reflect.Method nameM = Enum.class.getMethod("name"); java.lang.reflect.Method ordinalM = Enum.class.getMethod("ordinal"); for (Object c : constants) { String cName = (String) nameM.invoke(c); int cOrd = (int) ordinalM.invoke(c); sb.append(" ").append(cName).append(" ordinal=").append(cOrd); // Also print any int/String fields on the enum instance for (java.lang.reflect.Field f2 : fs) { if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue; f2.setAccessible(true); try { Object fv = f2.get(c); sb.append(" ").append(f2.getName()).append("=").append(fv); } catch (Throwable ignored) {} } sb.append("\n"); } } catch (Throwable e2) { sb.append(" (enum values() failed: ").append(e2).append(")\n"); } } sb.append(" FIELDS (").append(fs.length).append("):\n"); for (java.lang.reflect.Field f : fs) { sb.append(" ").append(f.toGenericString()); // Read static field values (helpful for non-enum static constants) if (java.lang.reflect.Modifier.isStatic(f.getModifiers()) && !cls.isEnum()) { f.setAccessible(true); try { sb.append(" = ").append(f.get(null)); } catch (Throwable ignored) {} } sb.append("\n"); } sb.append(" CONSTRUCTORS (").append(cs.length).append("):\n"); for (java.lang.reflect.Constructor c : cs) sb.append(" ").append(c.toGenericString()).append("\n"); sb.append(" METHODS (").append(ms.length).append("):\n"); for (java.lang.reflect.Method m : ms) sb.append(" ").append(m.toGenericString()).append("\n"); XposedBridge.log(sb.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] DUMP_API failed for " + className + ": " + t); } } // PRIMARY FIX: WeChat's own methods (LoginPasswordUI.onCreate etc.) are AOT-compiled // in a way that bypasses ArtMethod entry point replacement. But framework-dispatched // base class methods like Activity.onResume() always go through the entry point. // We hook onResume, filter for the login screens, and force the tablet button visible. // By onResume time, all views are initialized (onCreate + initView have run). private void hookProbe(ClassLoader cl) { try { XposedHelpers.findAndHookMethod( "android.app.Activity", null, "onResume", new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { String cls = param.thisObject.getClass().getName(); if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI") && !cls.equals("com.tencent.mm.plugin.account.ui.LoginHistoryUI")) { return; } Object obj = param.thisObject; View tabletBtn = null; try { tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); } catch (Throwable e) { XposedBridge.log("[WeChatHook] onResume getField N failed: " + e); } if (tabletBtn == null) { XposedBridge.log("[WeChatHook] onResume: N is null for " + cls); return; } tabletBtn.setVisibility(View.VISIBLE); // CRITICAL: resolve m4 from the Activity's OWN classloader. The login // Activities run under the Tinker CL, but hookProbe was installed with // the pre-Tinker CL. m4's ctor param LoginHistoryUI and the running // Activity are the SAME FQN but from DIFFERENT classloaders, so a // captured-CL m4 fails with "type LoginHistoryUI, got LoginHistoryUI". // Looking up m4 via obj's CL makes the types line up. boolean attached = false; try { ClassLoader runtimeCl = obj.getClass().getClassLoader(); Class m4Class = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.m4", runtimeCl); java.lang.reflect.Constructor chosen = null; for (java.lang.reflect.Constructor c : m4Class.getDeclaredConstructors()) { Class[] pt = c.getParameterTypes(); if (pt.length == 1 && pt[0].isAssignableFrom(obj.getClass())) { chosen = c; break; } } if (chosen != null && View.OnClickListener.class.isAssignableFrom(m4Class)) { chosen.setAccessible(true); tabletBtn.setOnClickListener( (View.OnClickListener) chosen.newInstance(obj)); attached = true; XposedBridge.log("[WeChatHook] onResume: m4 listener attached via " + chosen.getParameterTypes()[0].getName() + " for " + cls); } else { XposedBridge.log("[WeChatHook] onResume: no assignable m4 ctor for " + cls + " (obj CL=" + runtimeCl + ")"); } } catch (Throwable e) { XposedBridge.log("[WeChatHook] onResume setListener failed: " + e); } if (!attached) { XposedBridge.log("[WeChatHook] onResume: N VISIBLE but NO listener for " + cls); } } }); XposedBridge.log("[WeChatHook] Hooked Activity.onResume for tablet button"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookProbe/onResume failed: " + t); } } // ── Device spoof: make WeChat see a Samsung Galaxy Tab S8 (SM-X700) ────────── // For the pad-login experiment: WeChat's tablet detection (bk.B/Q) + the server's // secondary-device validation read android.os.Build fields + ro.product.* props. // Pixel is never a tablet; spoof Build + SystemProperties to a real Samsung tablet // so both the client gate and the server handshake see a legitimate pad model. // Runs in the main WeChat process only. Enabled at RUNTIME by the flag file below // (default OFF). Flip with the spoof_toggle shortcut, then restart WeChat. private static final String SPOOF_FLAG = "/data/data/com.tencent.mm/files/.wcspoof"; static boolean isDeviceSpoofEnabled() { try { return new java.io.File(SPOOF_FLAG).exists(); } catch (Throwable t) { return false; } } private void hookDeviceSpoof(ClassLoader cl, String procName) { if (!isDeviceSpoofEnabled()) return; if (procName.contains(":")) return; // main process only try { // Samsung Galaxy Tab S8 (Wi-Fi) — a well-known Android tablet. final java.util.Map B = new java.util.HashMap<>(); B.put("BRAND", "samsung"); B.put("MANUFACTURER", "samsung"); B.put("MODEL", "SM-X700"); B.put("DEVICE", "gts8wifi"); B.put("PRODUCT", "gts8wifixx"); B.put("BOARD", "gts8wifi"); B.put("HARDWARE", "qcom"); Class build = XposedHelpers.findClass("android.os.Build", cl); for (java.util.Map.Entry e : B.entrySet()) { try { XposedHelpers.setStaticObjectField(build, e.getKey(), e.getValue()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] spoof Build." + e.getKey() + " failed: " + t); } } // ro.product.* + ro.build.characteristics=tablet (the strong tablet signal). // Pixel 有一大堆分区限定 prop(ro.product.{product,vendor,odm,system_ext,system_dlkm, // vendor_dlkm,bootimage}.model 全 = "Pixel 10 Pro Fold")。写死白名单必漏,改**模式匹配**: // 任何 ro.product.*.model / *marketname / *.brand 等一律盖,未来加分区也不漏。 Class sysProp = XposedHelpers.findClass("android.os.SystemProperties", cl); XC_MethodHook propHook = new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) { if (p.args == null || p.args.length == 0 || p.args[0] == null) return; String k = p.args[0].toString(); String v = spoofProp(k); if (v != null) { // 诊断:记录微信实际读了哪个 model/name 源(找泄露源用,验证后可删) if (k.contains("model") || k.contains("marketname") || k.contains("name")) XposedBridge.log("[WeChatHook] spoofProp " + k + " → " + v); p.setResult(v); } } }; for (java.lang.reflect.Method m : sysProp.getDeclaredMethods()) { if ("get".equals(m.getName()) && m.getParameterTypes().length >= 1 && m.getParameterTypes()[0] == String.class && m.getReturnType() == String.class) { XposedBridge.hookMethod(m, propHook); } } // BluetoothAdapter.getName() 是设备名的另一条常见来源(走蓝牙服务 IPC,Settings hook 盖不到)。 try { Class bt = XposedHelpers.findClass("android.bluetooth.BluetoothAdapter", cl); for (java.lang.reflect.Method m : bt.getDeclaredMethods()) { if ("getName".equals(m.getName()) && m.getParameterTypes().length == 0) { XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) { XposedBridge.log("[WeChatHook] BluetoothAdapter.getName() intercepted"); p.setResult("Galaxy Tab S8"); } }); } } } catch (Throwable t) { XposedBridge.log("[WeChatHook] spoof BluetoothAdapter failed: " + t); } // 🔑 微信副设备「设备名」= Build.MANUFACTURER + Settings.Global "device_name", // 后者在 Pixel 上 = 真机营销名("Pixel 10 Pro Fold"),Build/prop 伪装盖不到它, // 每次登录都以 "samsung-Pixel10 Pro Fold" 触发预警。这里把 device_name/bluetooth_name // 一并伪装成三星平板名,让整串设备身份一致。 final java.util.Map S = new java.util.HashMap<>(); S.put("device_name", "Galaxy Tab S8"); S.put("bluetooth_name", "Galaxy Tab S8"); XC_MethodHook settingsHook = new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) { if (p.args == null || p.args.length < 2) return; Object k = p.args[1]; // getString(ContentResolver, String name) if (k != null && S.containsKey(k.toString())) p.setResult(S.get(k.toString())); } }; for (String clsName : new String[]{"android.provider.Settings$Global", "android.provider.Settings$Secure", "android.provider.Settings$System"}) { try { Class sc = XposedHelpers.findClass(clsName, cl); for (java.lang.reflect.Method m : sc.getDeclaredMethods()) { if ("getString".equals(m.getName()) && m.getParameterTypes().length == 2 && m.getParameterTypes()[1] == String.class) { XposedBridge.hookMethod(m, settingsHook); } } } catch (Throwable t) { XposedBridge.log("[WeChatHook] spoof " + clsName + " failed: " + t); } } XposedBridge.log("[WeChatHook] hookDeviceSpoof: → Samsung SM-X700 tablet in " + procName + " (Build.MODEL now=" + XposedHelpers.getStaticObjectField(build, "MODEL") + ", device_name spoofed=Galaxy Tab S8)"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookDeviceSpoof failed in " + procName + ": " + t); } } /** 模式匹配伪装 ro.* 设备身份 prop。命中返回三星平板值,未命中返回 null(放行原值)。 */ private static String spoofProp(String k) { if (k == null) return null; if (k.equals("ro.build.characteristics")) return "tablet"; if (!k.startsWith("ro.product.")) return null; if (k.endsWith("marketname")) return "Galaxy Tab S8"; if (k.endsWith(".model")) return "SM-X700"; if (k.endsWith(".brand") || k.endsWith(".manufacturer")) return "samsung"; if (k.endsWith(".device")) return "gts8wifi"; if (k.endsWith(".name")) return "gts8wifixx"; return null; } private void hookInTabletEnv(ClassLoader cl) { try { Class bkClass = XposedHelpers.findClass("com.tencent.mm.ui.bk", cl); Class x0Class = XposedHelpers.findClass("bm5.x0", cl); XposedHelpers.findAndHookMethod(bkClass, "B", x0Class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { param.setResult(Boolean.TRUE); } }); XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("[WeChatHook] bk.A() called -> TRUE"); param.setResult(Boolean.TRUE); } }); XposedBridge.log("[WeChatHook] Hooked bk.B(x0) + bk.A()"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookInTabletEnv failed: " + t); } } // PRIMARY FIX: Hook LoginPasswordUI.onCreate(Bundle) afterHookedMethod. // // Previous attempts to hook initView() and LoginHistoryUI.initView() both failed // because ART AOT devirtualizes the call to this.initView() inside // LoginHistoryUI.onCreate() — the compiled native code jumps directly past the // Xposed entry point replacement. // // onCreate() itself is called by the Android framework via the ArtMethod pointer // directly (no vtable devirtualization possible), so this hook fires reliably. // By the time afterHookedMethod runs, super.onCreate() has completed which means // LoginHistoryUI.initView() has already run and this.N is assigned. private void hookLoginHistoryUIInitView(ClassLoader cl) { final Class m4Class; try { m4Class = XposedHelpers.findClass("com.tencent.mm.plugin.account.ui.m4", cl); } catch (Throwable t) { XposedBridge.log("[WeChatHook] m4 class not found: " + t); return; } try { XposedHelpers.findAndHookMethod( "com.tencent.mm.plugin.account.ui.LoginPasswordUI", cl, "onCreate", Bundle.class, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { Object obj = param.thisObject; View tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); if (tabletBtn == null) { XposedBridge.log("[WeChatHook] LoginPasswordUI.onCreate: N is null"); return; } tabletBtn.setVisibility(View.VISIBLE); tabletBtn.setOnClickListener( (View.OnClickListener) XposedHelpers.newInstance(m4Class, obj)); XposedBridge.log("[WeChatHook] LoginPasswordUI.onCreate() -> N VISIBLE + m4 attached"); } }); XposedBridge.log("[WeChatHook] Hooked LoginPasswordUI.onCreate()"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookLoginPasswordUI.onCreate failed: " + t); } // Belt-and-suspenders: also hook LoginHistoryUI.onCreate() for any subclass // that doesn't override onCreate but does have the N button. try { XposedHelpers.findAndHookMethod( "com.tencent.mm.plugin.account.ui.LoginHistoryUI", cl, "onCreate", Bundle.class, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { Object obj = param.thisObject; if (obj.getClass().getName().equals( "com.tencent.mm.plugin.account.ui.LoginPasswordUI")) return; View tabletBtn = (View) XposedHelpers.getObjectField(obj, "N"); if (tabletBtn == null) { XposedBridge.log("[WeChatHook] LoginHistoryUI.onCreate: N is null"); return; } tabletBtn.setVisibility(View.VISIBLE); tabletBtn.setOnClickListener( (View.OnClickListener) XposedHelpers.newInstance(m4Class, obj)); XposedBridge.log("[WeChatHook] LoginHistoryUI.onCreate() -> N VISIBLE + m4 attached"); } }); XposedBridge.log("[WeChatHook] Hooked LoginHistoryUI.onCreate()"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookLoginHistoryUI.onCreate failed: " + t); } } private void hookLoginUIInitView(ClassLoader cl) { try { final Class wbClass = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.wb", cl); XposedHelpers.findAndHookMethod( "com.tencent.mm.plugin.account.ui.MobileInputUI", cl, "initView", new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { Object mobileInputUI = param.thisObject; View tabletBtn = (View) XposedHelpers.getObjectField(mobileInputUI, "w"); if (tabletBtn == null) { XposedBridge.log("[WeChatHook] MobileInputUI.w is null"); return; } tabletBtn.setVisibility(View.VISIBLE); tabletBtn.setOnClickListener( (View.OnClickListener) XposedHelpers.newInstance(wbClass, mobileInputUI)); XposedBridge.log("[WeChatHook] MobileInputUI.initView() -> w VISIBLE + wb attached"); } }); XposedBridge.log("[WeChatHook] Hooked MobileInputUI.initView()"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookLoginUIInitView(MobileInputUI) failed: " + t); } try { final Class g7Class = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.g7", cl); XposedHelpers.findAndHookMethod( "com.tencent.mm.plugin.account.ui.LoginUI", cl, "initView", new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { Object loginUI = param.thisObject; View tabletBtn = (View) XposedHelpers.getObjectField(loginUI, "f992u"); if (tabletBtn == null) return; tabletBtn.setVisibility(View.VISIBLE); tabletBtn.setOnClickListener( (View.OnClickListener) XposedHelpers.newInstance(g7Class, loginUI)); XposedBridge.log("[WeChatHook] LoginUI.initView() -> f992u VISIBLE + g7 attached"); } }); XposedBridge.log("[WeChatHook] Hooked LoginUI.initView()"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookLoginUIInitView(LoginUI) failed: " + t); } } private void hookLoginSelectUICheck(ClassLoader cl) { try { Class callbackClass = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.LoginSelectUI$a", cl); XposedHelpers.findAndHookMethod(callbackClass, "a", boolean.class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("[WeChatHook] LoginSelectUI$a.a(" + param.args[0] + ") -> false"); param.args[0] = false; } }); XposedBridge.log("[WeChatHook] Hooked LoginSelectUI$a.a()"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookLoginSelectUICheck failed: " + t); } } private void hookLoginButtonClick(ClassLoader cl) { try { Class ljClass = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.lj", cl); final Class loginSelectUIClass = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.LoginSelectUI", cl); XposedHelpers.findAndHookMethod(ljClass, "onClick", View.class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("[WeChatHook] lj.onClick() -> forcing LoginSelectUI"); Activity welcomeActivity = (Activity) XposedHelpers.getObjectField( param.thisObject, "d"); Intent intent = new Intent(welcomeActivity, loginSelectUIClass); intent.putExtra("mobile_input_purpose", 1); welcomeActivity.startActivity(intent); param.setResult(null); } }); XposedBridge.log("[WeChatHook] Hooked lj.onClick() -> LoginSelectUI"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookLoginButtonClick failed: " + t); } } // ── Send message via internal API ──────────────────────────────────────── // // Registered in ALL WeChat processes. We don't yet know which process has gm0.j1 // initialized — the first process where the receiver fires and succeeds wins. // // Trigger from ADB (must run as root — RECEIVER_NOT_EXPORTED): // adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL \ // --es to "filehelper" --es text "hello"' private void hookSendMessage(ClassLoader cl, String procName) { try { XposedHelpers.findAndHookMethod( "android.app.Application", null, "onCreate", new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { Context ctx = (Context) param.thisObject; if (!"com.tencent.mm".equals(ctx.getPackageName())) return; sAppContext = ctx; // Capture Tinker-patched classloader (Tinker installs in attachBaseContext, // so by App.onCreate this loader has the patch DEX classes). sTinkerCL = ctx.getClassLoader(); XposedBridge.log("[WeChatHook] App.onCreate proc=" + procName + " pid=" + android.os.Process.myPid() + " tinkerCL=" + (sTinkerCL != null ? sTinkerCL.getClass().getSimpleName() : "null")); // Re-hook with Tinker CL — w11.s1 and f9 may live in a Tinker DEX. // This ensures our factory and convertTo hooks fire on actual WeChat UI sends. if (sTinkerCL != sWeChatCL) { hookFactoryMethods(sTinkerCL, procName + "/tinker"); hookF9ConvertTo(sTinkerCL, procName + "/tinker"); hookF9ImgPathInjector(sTinkerCL, procName + "/tinker"); hookR0H(sTinkerCL, procName + "/tinker"); // Hook pt0.e0 in Tinker CL — real voice sends run in Tinker CL. hookPt0E0(sTinkerCL, procName + "/tinker"); // NOTE: hookJg0 / hookVoiceEntry disabled — they hook WeChat's // generic coroutine classes (pu.o0/pu.m0) and every message // DB insert (g9.M9/N9), which adds anti-tamper risk and // destabilizes the fragile Pad login. Re-enable only for a // targeted investigation session, never during normal use. // hookJg0(sTinkerCL, procName + "/tinker"); // hookVoiceEntry(sTinkerCL, procName + "/tinker"); // Surgical probe: jg0.x + qi3.f0/g0 only (safe). hookVoiceProbe(sTinkerCL, procName + "/tinker"); // Image send runs in Tinker CL — hook b80.u.pj/Bi there. hookImageSend(sTinkerCL, procName + "/tinker"); // CDN upload trace runs in Tinker CL (real voice send path). hookCdnTrace(sTinkerCL, procName + "/tinker"); // Capture the real uploadvoice cgi request in Tinker CL. hookVoiceCgiCapture(sTinkerCL, procName + "/tinker"); hookVoiceCgiResult(sTinkerCL, procName + "/tinker"); // js1.m may live in a Tinker DEX too (base-CL hook installed // but confirmed it never fires — same "base CL vs Tinker CL" // gotcha as f9/w11.s1/jg0.x above). Re-hook here. hookBrandservicePublishTransfer(sTinkerCL, procName + "/tinker"); hookAnyCgiCapture(sTinkerCL, procName + "/tinker"); hookBrandserviceCdnUpload(sTinkerCL, procName + "/tinker"); hookGenericImgCdnSubmit(sTinkerCL, procName + "/tinker"); // Pad-login: the login UIs (LoginPasswordUI/LoginHistoryUI/ // LoginUI) + bk run in Tinker CL — the base-CL hooks never // fire (0 N-VISIBLE). Register them here so the "以平板登录" // button is actually forced visible. Gated by the runtime // spoof flag (default OFF = clean, no tablet hooks). if (isDeviceSpoofEnabled()) { hookInTabletEnv(sTinkerCL); hookLoginHistoryUIInitView(sTinkerCL); hookLoginUIInitView(sTinkerCL); } } hookF9ConvertTo(sWeChatCL, procName); hookF9ImgPathInjector(sWeChatCL, procName); registerSendReceiver(ctx, procName); hookAtDiscovery(sWeChatCL, procName); // 群 @ 自学习 } }); XposedBridge.log("[WeChatHook] Send hook registered in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookSendMessage failed in " + procName + ": " + t); } // ── w11.r0 constructor hooks ─────────────────────────────────────────── // Log "by=US" vs "by=WeChat" to tell if WeChat's own UI send uses this ctor. XC_MethodHook r0Hook = new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { String by = sCalledByUs ? "US" : "WeChat"; XposedBridge.log("[WeChatHook] w11.r0 ctor by=" + by + " args=" + param.args.length + " proc=" + procName + " pid=" + android.os.Process.myPid()); if (sCalledByUs) { forceInitDI(); diagMCoreStorage("before-r0-ctor"); } } @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { String by = sCalledByUs ? "US" : "WeChat"; if (param.hasThrowable()) { Throwable t = param.getThrowable(); XposedBridge.log("[WeChatHook] w11.r0 ctor THREW by=" + by + " proc=" + procName + ": " + t); if (sCalledByUs) XposedBridge.log("[WeChatHook] r0-ctor-stack: " + android.util.Log.getStackTraceString(t)); return; } sFsGReady = true; XposedBridge.log("[WeChatHook] w11.r0 ctor OK by=" + by + " proc=" + procName); } }; for (Class[] sig : new Class[][]{ {String.class, String.class, int.class, int.class, long.class}, {String.class, String.class, int.class, int.class, Object.class}, {long.class, int.class, String.class}, {}}) { try { XposedHelpers.findAndHookConstructor("w11.r0", cl, appendHook(sig, r0Hook)); XposedBridge.log("[WeChatHook] Hooked w11.r0(" + sig.length + " params)"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] w11.r0(" + sig.length + "p) hook failed: " + t); } } // ── w11.r0.H(String) — the actual dispatch method ───────────────────── // Fires for EVERY send (ours AND WeChat UI sends). Shows if WeChat uses r0. // For real WeChat voice sends (type=34, by=WeChat), dumps ALL r0 fields so // we can find which field holds the SILK audio data (root cause of spinner). try { XposedHelpers.findAndHookMethod("w11.r0", cl, "H", String.class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { String by = sCalledByUs ? "US" : "WeChat"; XposedBridge.log("[WeChatHook] w11.r0.H() called by=" + by + " proc=" + procName + " pid=" + android.os.Process.myPid()); // Dump all r0 fields for real WeChat voice sends only. // This reveals which field holds the SILK audio data that H() uses for CDN upload. if (!sCalledByUs) { dumpR0Fields(param.thisObject, "r0.H-before"); } } @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { if (param.hasThrowable()) XposedBridge.log("[WeChatHook] w11.r0.H() threw: " + param.getThrowable()); else XposedBridge.log("[WeChatHook] w11.r0.H() OK proc=" + procName); } }); XposedBridge.log("[WeChatHook] Hooked w11.r0.H(String) in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] w11.r0.H hook failed in " + procName + ": " + t); } // ── r0.I(int) and r0.K(int) — delivery completion hooks ────────────────── // Log when WeChat calls these so we understand their role in the send flow. // Our u0 stub now calls r0.I(0) after onSceneEnd — this confirms whether I() // is the right trigger for delivery completion and spinner removal. try { XposedHelpers.findAndHookMethod("w11.r0", cl, "I", int.class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable { String by = sCalledByUs ? "US" : "WeChat"; XposedBridge.log("[WeChatHook] r0.I(" + p.args[0] + ") by=" + by + " proc=" + procName); } }); XposedBridge.log("[WeChatHook] Hooked w11.r0.I(int) in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] r0.I hook failed in " + procName + ": " + t); } try { XposedHelpers.findAndHookMethod("w11.r0", cl, "K", int.class, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable { String by = sCalledByUs ? "US" : "WeChat"; XposedBridge.log("[WeChatHook] r0.K(" + p.args[0] + ") by=" + by + " proc=" + procName); } }); XposedBridge.log("[WeChatHook] Hooked w11.r0.K(int) in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] r0.K hook failed in " + procName + ": " + t); } // ── Diagnostic probes on the constructor call chain ─────────────────── // Each probe fires when the method is called from inside the r0 constructor // (by WeChat OR by us). They trace which methods succeed before the NPE. hookProbeMethod(cl, "gm0.j1", "u", "gm0.j1.u"); hookProbeMethod(cl, "c01.z1", "r", "c01.z1.r"); // gm0.b0.c() is a virtual method — probe it too try { XposedHelpers.findAndHookMethod("gm0.b0", cl, "c", new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() called"); } @Override protected void afterHookedMethod(MethodHookParam p) { if (p.hasThrowable()) XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() THREW: " + p.getThrowable()); else XposedBridge.log("[WeChatHook] PROBE gm0.b0.c() → " + p.getResult()); } }); } catch (Throwable t) { XposedBridge.log("[WeChatHook] gm0.b0.c probe failed: " + t); } } private static Object[] appendHook(Class[] params, XC_MethodHook hook) { Object[] result = new Object[params.length + 1]; System.arraycopy(params, 0, result, 0, params.length); result[params.length] = hook; return result; } private void hookProbeMethod(ClassLoader cl, String cls, String method, String tag) { try { XposedHelpers.findAndHookMethod(cls, cl, method, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { XposedBridge.log("[WeChatHook] PROBE " + tag + "() called"); } @Override protected void afterHookedMethod(MethodHookParam p) { if (p.hasThrowable()) XposedBridge.log("[WeChatHook] PROBE " + tag + "() THREW: " + p.getThrowable()); else XposedBridge.log("[WeChatHook] PROBE " + tag + "() → " + p.getResult()); } }); XposedBridge.log("[WeChatHook] Probe hooked: " + tag); } catch (Throwable t) { XposedBridge.log("[WeChatHook] Probe " + tag + " failed: " + t); } } private static boolean isDIReady() { if (sWeChatCL == null) return false; try { XposedHelpers.callStaticMethod(XposedHelpers.findClass("gm0.j1", sWeChatCL), "i"); return true; } catch (Throwable e) { return false; } } // Hook gm0.j1.g(km0.c) to detect when WeChat DI kernel is initialized. // gm0.j1.g() sets gm0.j1.m (the singleton) and is called lazily — not during // Application.onCreate, so our broadcast often arrives first. After g() fires, // gm0.j1.i() will succeed and w11.r0 can be constructed. private static void hookDIInit(ClassLoader cl, String procName) { try { Class km0cCls = XposedHelpers.findClass("km0.c", cl); XposedHelpers.findAndHookMethod("gm0.j1", cl, "g", km0cCls, new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam p) throws Throwable { XposedBridge.log("[WeChatHook] gm0.j1.g() fired — DI ready in " + procName); String t = sPendingTalker, x = sPendingText, pr = sPendingProc; if (t != null && x != null && procName.equals(pr)) { sPendingTalker = null; sPendingText = null; sPendingProc = null; XposedBridge.log("[WeChatHook] resuming pending send to=" + t + " proc=" + procName); new Handler(Looper.getMainLooper()).postDelayed( () -> trySendWithRetry(t, x, 0, procName), 50); } } }); XposedBridge.log("[WeChatHook] gm0.j1.g hook OK in " + procName); } catch (Throwable t) { XposedBridge.log("[WeChatHook] gm0.j1.g hook failed in " + procName + ": " + t); } } private static void diagMCoreStorage(String where) { if (sWeChatCL == null) return; try { Class j1Cls = XposedHelpers.findClass("gm0.j1", sWeChatCL); Object j1 = XposedHelpers.callStaticMethod(j1Cls, "i"); Object mCS = (j1 != null) ? XposedHelpers.getObjectField(j1, "d") : null; XposedBridge.log("[WeChatHook] " + where + " mCoreStorage=" + mCS); } catch (Throwable e) { XposedBridge.log("[WeChatHook] " + where + " mCoreStorage-check: " + e); } } private static void forceInitDI() { if (sWeChatCL == null) return; try { Class fsgCls = XposedHelpers.findClass("fs.g", sWeChatCL); java.lang.reflect.Field faFld = fsgCls.getDeclaredField("a"); faFld.setAccessible(true); Object[] arr = (Object[]) faFld.get(null); if (arr == null || arr.length == 0 || arr[0] == null) { Class k2Cls = XposedHelpers.findClass("fs.k2", sWeChatCL); java.lang.reflect.Field k2eFld = k2Cls.getDeclaredField("e"); k2eFld.setAccessible(true); Object k2e = k2eFld.get(null); if (k2e == null) { XposedBridge.log("[WeChatHook] forceInitDI: fs.k2.e null"); return; } if (arr == null || arr.length == 0) { arr = new Object[1]; faFld.set(null, arr); } arr[0] = k2e; XposedBridge.log("[WeChatHook] forceInitDI: fs.g.a[0]=" + k2e.getClass().getName()); } } catch (Throwable e) { XposedBridge.log("[WeChatHook] forceInitDI: " + e); } } private static void trySendWithRetry(String talker, String text, int attempt, String procName) { trySendWithRetry(talker, text, null, attempt, procName); } // msgSource 非空 → 群 @(atuserlist)走 HighLevel 路径注入。Factory/R0 兜底路径不带 @ // (降级:@ 丢失但消息仍发出),因为 @ 通知只在主路径实现。 private static void trySendWithRetry(String talker, String text, String msgSource, int attempt, String procName) { // Non-main WeChat processes (:push, :tools, etc.) lack storage init — fail immediately. if (procName != null && procName.contains(":")) { if (attempt == 0) XposedBridge.log("[WeChatHook] Skip send in non-main proc=" + procName); return; } // Route A2: tg3.t1 high-level service locator try { sendViaHighLevel(talker, text, msgSource); XposedBridge.log("[WeChatHook] HighLevel send OK to=" + talker + " proc=" + procName); return; } catch (Throwable t) { if (attempt == 0) XposedBridge.log("[WeChatHook] HighLevel FAILED proc=" + procName + ": " + t); } // Route A1b: w11.s1 factory (mid-level) try { sendViaFactory(talker, text); XposedBridge.log("[WeChatHook] Factory send OK to=" + talker + " proc=" + procName); return; } catch (Throwable t) { // Log every 5 attempts to track what's changing if (attempt % 5 == 0) XposedBridge.log("[WeChatHook] Factory FAILED attempt=" + attempt + " proc=" + procName + ": " + t); } // Route A1: w11.r0 direct (requires gm0.j1 DI ready) if (isDIReady()) { try { sendViaR0(talker, text); XposedBridge.log("[WeChatHook] R0 send OK to=" + talker + " proc=" + procName); return; } catch (Throwable t) { if (attempt == 0) XposedBridge.log("[WeChatHook] R0 FAILED proc=" + procName + ": " + t); } } // All paths failed — poll every 1s up to 60 attempts waiting for WeChat DI init if (attempt < 60) { if (attempt == 0) XposedBridge.log("[WeChatHook] All paths not ready, polling proc=" + procName + " to=" + talker); new Handler(Looper.getMainLooper()).postDelayed( () -> trySendWithRetry(talker, text, msgSource, attempt + 1, procName), 1000); } else { XposedBridge.log("[WeChatHook] GIVE UP after 60s proc=" + procName + " to=" + talker); } } // Route A1b: w11.s1 factory — SendMsgCgiFactory.a(talker) → w11.r1.e(content).b() private static void sendViaFactory(String talker, String text) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); Class s1 = XposedHelpers.findClass("w11.s1", cl); Object r1 = XposedHelpers.callStaticMethod(s1, "a", talker); if (r1 == null) throw new IllegalStateException("w11.s1.a() returned null"); try { XposedHelpers.callMethod(r1, "e", text); } catch (Throwable t) { throw new RuntimeException("r1.e() failed: " + t, t); } try { XposedHelpers.callMethod(r1, "b"); } catch (Throwable t) { throw new RuntimeException("r1.b() failed: " + t, t); } } // ── 群 @ 自学习:辅助 ───────────────────────────────────────── private static void loadAtField() { if (sAtLoaded) return; sAtLoaded = true; try { java.io.File f = new java.io.File(AT_FIELD_FILE); if (f.exists()) { java.io.FileInputStream in = new java.io.FileInputStream(f); byte[] b = new byte[(int) f.length()]; int n = in.read(b); in.close(); String p = new String(b, 0, Math.max(n, 0), "UTF-8").trim(); if (!p.isEmpty()) { sAtFieldPath = p; XposedBridge.log("[WeChatHook] AT loaded field=" + p); } } } catch (Throwable t) { XposedBridge.log("[WeChatHook] AT load failed: " + t); } } private static void recordAt(String path, String xml) { sAtFieldPath = path; XposedBridge.log("[WeChatHook] AT-LEARNED field=" + path + " xml=" + xml); try { java.io.FileOutputStream o = new java.io.FileOutputStream(AT_FIELD_FILE); o.write(path.getBytes("UTF-8")); o.close(); } catch (Throwable t) { XposedBridge.log("[WeChatHook] AT save failed: " + t); } } private static boolean isSimpleVal(Object v) { return v instanceof String || v instanceof Number || v instanceof Boolean || v instanceof Character || v.getClass().isArray() || v.getClass().isPrimitive(); } // 把 value 写进 root 的 path 字段("f" 直接 / "f.sub" 一层嵌套)。 private static boolean setFieldByPath(Object root, String path, String value) { try { String[] parts = path.split("\\."); Object obj = root; for (int i = 0; i < parts.length - 1; i++) { java.lang.reflect.Field f = obj.getClass().getDeclaredField(parts[i]); f.setAccessible(true); obj = f.get(obj); if (obj == null) return false; } java.lang.reflect.Field last = obj.getClass().getDeclaredField(parts[parts.length - 1]); last.setAccessible(true); last.set(obj, value); return true; } catch (Throwable t) { XposedBridge.log("[WeChatHook] setFieldByPath(" + path + ") failed: " + t); return false; } } // 扫 r0 找含 atuserlist 的字段(直接 String + 一层嵌套 String);同时全量 dump 兜底。 private static void scanForAtuserlist(Object r0) { try { if (!"w11.r0".equals(r0.getClass().getName())) return; StringBuilder dump = new StringBuilder("[WeChatHook] AT-DUMP r0 fields:\n"); for (java.lang.reflect.Field f : r0.getClass().getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; f.setAccessible(true); Object v; try { v = f.get(r0); } catch (Throwable e) { continue; } String vs = v == null ? "null" : v.toString(); if (vs.length() > 180) vs = vs.substring(0, 180); dump.append(" ").append(f.getName()).append(":") .append(f.getType().getSimpleName()).append(" = ").append(vs).append("\n"); if (v instanceof String && ((String) v).contains("atuserlist")) { recordAt(f.getName(), (String) v); } else if (v != null && !isSimpleVal(v)) { for (java.lang.reflect.Field sf : v.getClass().getDeclaredFields()) { if (java.lang.reflect.Modifier.isStatic(sf.getModifiers())) continue; if (sf.getType() != String.class) continue; sf.setAccessible(true); Object sv; try { sv = sf.get(v); } catch (Throwable e) { continue; } if (sv instanceof String && ((String) sv).contains("atuserlist")) { recordAt(f.getName() + "." + sf.getName(), (String) sv); } } } } XposedBridge.log(dump.toString()); } catch (Throwable t) { XposedBridge.log("[WeChatHook] scanForAtuserlist failed: " + t); } } // hook 发送派发 z2.b(scene):真实发送(非本 hook)时扫 r0 学 atuserlist 字段,学一次即止。 private void hookAtDiscovery(ClassLoader cl, String procName) { try { loadAtField(); Class z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); int hooked = 0; // hook z2 所有"1 个引用参数"的方法(UI enqueue 方法名不确定,不只 b);扫描内部按 // scene 是不是 w11.r0 过滤,不会误伤别的 scene。学会后全部 short-circuit。 for (java.lang.reflect.Method m : z2c.getDeclaredMethods()) { Class[] pt = m.getParameterTypes(); if (pt.length != 1 || pt[0].isPrimitive()) continue; XposedBridge.hookMethod(m, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam p) { if (sAtFieldPath != null || sCalledByUs) return; // 已学会 / 我们自己的发送 → 跳过 Object scene = (p.args != null && p.args.length > 0) ? p.args[0] : null; if (scene != null) scanForAtuserlist(scene); } }); hooked++; } XposedBridge.log("[WeChatHook] hookAtDiscovery OK in " + procName + " (" + hooked + " z2 methods, learned=" + sAtFieldPath + ")"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookAtDiscovery failed in " + procName + ": " + t); } } // Route A2: high-level API via tg3.t1 service locator → dk5.s5.Ni(). // This path is what WeChat UI uses — internally handles DI, account context, etc. private static void sendViaHighLevel(String talker, String text, String msgSource) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); // Text = same PROVEN path as voice sendVoiceViaAPI: w11.r0 ctor (type=1) inserts // the f9 into the DB, then z2.b(NetSceneQueue) dispatches over the real network. // Text carries no CDN attachment, so unlike voice it completes fully. (The s1.a / // s1.b / dk5.s5.Ni builder paths either produced name-cards or silently failed to // persist — they need hidden r1 fields + MsgRetransmitUI.P6 that we can't set robustly.) // 群 @ 通知:atuserlist 不进 message 行(cv 里没有),而是当 r0 第5参数 obj 传给 // 发送 scene(newsendmsg CGI)。msgSource=wxid,.. // 时对方才收到「有人@我」红点。无 @ 时 obj=null(纯文本原路径,逐字节不变)。 Class r0Class = XposedHelpers.findClass("w11.r0", cl); Object r0; sCalledByUs = true; try { // obj 恒为 null:obj 非 null 时 r0 把它当"消息 key 查已有 f9"(g9.q5)→ NPE,不是 msgsource。 r0 = XposedHelpers.newInstance(r0Class, new Class[]{String.class, String.class, int.class, int.class, Object.class}, talker, text, 1, 0, null); // (talker, content, type=1 TEXT, flag=0, null) } finally { sCalledByUs = false; } // 群 @:把 msgSource 塞进"学到"的字段(sAtFieldPath),对方才收到红点。 // 字段由 hookAtDiscovery 从微信自己的真 @ 发送学得;没学会则只发文字(降级)。 if (msgSource != null && !msgSource.isEmpty()) { loadAtField(); if (sAtFieldPath != null) { boolean ok = setFieldByPath(r0, sAtFieldPath, msgSource); XposedBridge.log("[WeChatHook] @ apply msgsource → " + sAtFieldPath + " ok=" + ok); } else { XposedBridge.log("[WeChatHook] @ field 未学会 — 先用微信手动发一条真 @ 教它"); } } Class z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); sCalledByUs = true; // 别让 discovery hook 扫我们自己的 dispatch try { XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg } finally { sCalledByUs = false; } XposedBridge.log("[WeChatHook] sendViaHighLevel(TEXT via r0-obj+z2.b): OK to=" + talker); } private static void registerSendReceiver(Context ctx, String procName) { // SEND_MSG_INTERNAL: send a text message BroadcastReceiver sendRecv = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String talker = intent.getStringExtra("to"); String rawText = intent.getStringExtra("text"); // Non-ASCII (Chinese) via `am broadcast --es text` gets mangled by the // shell/am → server rejects → stuck "sending". Prefer base64: send with // --es text_b64 and we decode it here intact. String textB64 = intent.getStringExtra("text_b64"); if (textB64 != null && !textB64.isEmpty()) { try { rawText = new String(android.util.Base64.decode(textB64, android.util.Base64.DEFAULT), "UTF-8"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: bad text_b64: " + t); } } final String text = rawText; if (talker == null || talker.isEmpty() || text == null || text.isEmpty()) { XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'/'text_b64'"); return; } // --es at "wxid1,wxid2" → 群 @ 通知(atuserlist)。构造 msgSource,让对方收到红点。 String atList = intent.getStringExtra("at"); final String msgSource = (atList != null && !atList.trim().isEmpty()) ? "" + atList.trim() + "" : null; XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName + " pid=" + android.os.Process.myPid() + " to=" + talker + " len=" + text.length() + (msgSource != null ? " at=" + atList : "")); new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, msgSource, 0, procName)); } }; ctx.registerReceiver(sendRecv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED); // SEND_IMAGE_INTERNAL: send an image via w11.s1.b internal API // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL \ // --es to "filehelper" --es path "/sdcard/test.jpg"' BroadcastReceiver imgRecv = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { // Only handle in main WeChat process (sub-processes lack storage init) if (procName.contains(":")) return; String talker = intent.getStringExtra("to"); String path = intent.getStringExtra("path"); if (talker == null || talker.isEmpty() || path == null || path.isEmpty()) { XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL: missing 'to' or 'path'"); return; } XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL proc=" + procName + " to=" + talker + " path=" + path); new Handler(Looper.getMainLooper()).post(() -> { try { sendImageViaAPI(talker, path); } catch (Throwable t) { XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL failed: " + t + "\n" + android.util.Log.getStackTraceString(t)); } }); } }; ctx.registerReceiver(imgRecv, new IntentFilter(ACTION_SEND_IMAGE), Context.RECEIVER_NOT_EXPORTED); // SEND_VOICE_INTERNAL: send a voice message via w11.r0 + f9 internal API. // The caller (root, Python helper) must pre-place the SILK file at: // /data/data/com.tencent.mm/MicroMsg/{hash}/voice2/{md5(id)[0:2]}/{md5(id)[2:4]}/msg_{id}.amr // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL \ // --es to "filehelper" --es voice_id "26hexchars" --ei duration 3000 [--es from_wxid "wxid_..."]' BroadcastReceiver voiceRecv = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (procName.contains(":")) return; String talker = intent.getStringExtra("to"); String voiceId = intent.getStringExtra("voice_id"); // pre-placed file String srcPath = intent.getStringExtra("src_path"); // hook copies from here int duration = intent.getIntExtra("duration", 0); String fromWxId = intent.getStringExtra("from_wxid"); if (talker == null || talker.isEmpty() || (voiceId == null && srcPath == null)) { XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL: need 'to' + 'voice_id' or 'src_path'"); return; } if (duration <= 0) { XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL: missing 'duration'"); return; } XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL proc=" + procName + " to=" + talker + " voice_id=" + voiceId + " src=" + srcPath + " dur=" + duration); final String fVoiceId = voiceId, fSrcPath = srcPath; new Handler(Looper.getMainLooper()).post(() -> { try { sendVoiceViaAPI(talker, fVoiceId, fSrcPath, duration, fromWxId); } catch (Throwable t) { XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL failed: " + t + "\n" + android.util.Log.getStackTraceString(t)); } }); } }; ctx.registerReceiver(voiceRecv, new IntentFilter(ACTION_SEND_VOICE), Context.RECEIVER_NOT_EXPORTED); // SEND_VOICE_CGI: fully-synthetic voice via the uploadvoice cgi (this device's // real path). No recording, no hot-swap — chunk the SILK and submit uploadvoice // scenes via z2.b. Usage: am broadcast -a com.hermes.wechat.SEND_VOICE_CGI \ // --es to filehelper --es src_path /path/to.silk --ei duration 3000 [--es from_wxid ..] BroadcastReceiver voiceCgiRecv = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (procName.contains(":")) return; // main process only String talker = intent.getStringExtra("to"); String srcPath = intent.getStringExtra("src_path"); int duration = intent.getIntExtra("duration", 0); String fromWxId = intent.getStringExtra("from_wxid"); if (talker == null || talker.isEmpty() || srcPath == null || srcPath.isEmpty()) { XposedBridge.log("[WeChatHook] SEND_VOICE_CGI: need 'to' + 'src_path'"); return; } if (duration <= 0) { XposedBridge.log("[WeChatHook] SEND_VOICE_CGI: missing 'duration'"); return; } XposedBridge.log("[WeChatHook] SEND_VOICE_CGI proc=" + procName + " to=" + talker + " src=" + srcPath + " dur=" + duration); new Handler(Looper.getMainLooper()).post(() -> { try { sendVoiceViaCgi(talker, srcPath, duration, fromWxId); } catch (Throwable t) { XposedBridge.log("[WeChatHook] SEND_VOICE_CGI failed: " + t + "\n" + android.util.Log.getStackTraceString(t)); } }); } }; ctx.registerReceiver(voiceCgiRecv, new IntentFilter(ACTION_SEND_VOICE_CGI), Context.RECEIVER_NOT_EXPORTED); // ARM_VOICE_SWAP: load our TTS SILK into memory and arm the jg0.x.w swap. // After arming, trigger a REAL recording (hold "按住说话") to the target chat — // WeChat records, but jg0.x.w uploads our TTS bytes instead → delivered. // Usage: am broadcast -a com.hermes.wechat.ARM_VOICE_SWAP \ // --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr --ei duration 3200 BroadcastReceiver armRecv = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (procName.contains(":")) return; // main process only (where recording runs) String srcPath = intent.getStringExtra("src_path"); int duration = intent.getIntExtra("duration", 0); if (srcPath == null || srcPath.isEmpty()) { XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP: missing 'src_path'"); return; } try { java.io.File f = new java.io.File(srcPath); byte[] bytes = new byte[(int) f.length()]; try (java.io.FileInputStream in = new java.io.FileInputStream(f)) { int off = 0, n; while (off < bytes.length && (n = in.read(bytes, off, bytes.length - off)) > 0) off += n; } sSwapSilk = bytes; sSwapDuration = duration; sSwapExpiresAt = System.currentTimeMillis() + Math.max(duration, 0) + 10000; sSwapArmed = true; XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP: armed " + bytes.length + "B dur=" + duration + "ms from " + srcPath + " (expires in " + ((sSwapExpiresAt - System.currentTimeMillis()) / 1000) + "s — trigger a real recording now)"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] ARM_VOICE_SWAP failed: " + t); } } }; ctx.registerReceiver(armRecv, new IntentFilter(ACTION_ARM_SWAP), Context.RECEIVER_NOT_EXPORTED); // DUMP_WECHAT_API: on-demand class introspection (main process only) // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.DUMP_WECHAT_API [--es class "w11.s1"]' BroadcastReceiver dumpRecv = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { XposedBridge.log("[WeChatHook] DUMP_WECHAT_API received in " + procName); new Handler(Looper.getMainLooper()).post(() -> handleDumpApi(intent)); } }; ctx.registerReceiver(dumpRecv, new IntentFilter(ACTION_DUMP_API), Context.RECEIVER_NOT_EXPORTED); XposedBridge.log("[WeChatHook] BroadcastReceivers registered in " + procName); } private static void sendViaR0(String talker, String text) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); Class r0Class = XposedHelpers.findClass("w11.r0", cl); long ts = System.currentTimeMillis(); Object msg; sCalledByUs = true; try { try { msg = XposedHelpers.newInstance(r0Class, new Class[]{String.class, String.class, int.class, int.class, long.class}, talker, text, 1, 0, ts); } catch (Throwable t) { msg = XposedHelpers.newInstance(r0Class, new Class[]{String.class, String.class, int.class, int.class, Object.class}, talker, text, 1, 0, (Object) ts); } } finally { sCalledByUs = false; } try { Class u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl); Object stub = Proxy.newProxyInstance(cl, new Class[]{u0}, (proxy, method, args) -> { XposedBridge.log("[WeChatHook] send-cb: " + method.getName()); return null; }); XposedHelpers.setObjectField(msg, "d", stub); } catch (Throwable ignored) {} sCalledByUs = true; try { XposedHelpers.callMethod(msg, "H", ""); } finally { sCalledByUs = false; } } }