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_DUMP_API = "com.hermes.wechat.DUMP_WECHAT_API"; // 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<>(); 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; // 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; @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; // Tablet login hooks (main process + any process) hookInTabletEnv(cl); hookProbe(cl); hookLoginHistoryUIInitView(cl); hookLoginUIInitView(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); } // ── 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 == 34) { // 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); // Force status=4 (delivered) so no spinner appears. // Real WeChat gets status=4 via CDN upload→server ACK push, which // we skip. Directly setting it here is safe for filehelper sends. cv.put("status", 4); // 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); // Also set the in-memory f9 status field via setter (r1). // Without this, K(0)'s subsequent convertTo() reads the stale // in-memory status=1 and overwrites our status=4 back to 1. if (sF9SetterMap != null && sF9SetterMap.containsKey("status")) { try { XposedHelpers.callMethod(p.thisObject, sF9SetterMap.get("status"), 4); } catch (Throwable ignored) {} } XposedBridge.log("[WeChatHook] convertTo: forced status=4 (CV+field) for voice hook send"); } // 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 w11.s1.b(talker, p1.IMAGE, 0, localImagePath) → w11.r1 → r1.b(). // WeChat handles thumbnail generation and CDN upload internally. private static void sendImageViaAPI(String talker, String imagePath) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); Class s1Cls = XposedHelpers.findClass("w11.s1", cl); Class p1Cls = XposedHelpers.findClass("w11.p1", cl); // p1.IMAGE is ordinal 1 (confirmed from enum dump: TEXT=0, IMAGE=1) Object[] p1Values = (Object[]) p1Cls.getMethod("values").invoke(null); if (p1Values.length < 2) throw new IllegalStateException("p1 enum too short: " + p1Values.length); Object imageEnum = p1Values[1]; XposedBridge.log("[WeChatHook] sendImageViaAPI: s1.b talker=" + talker + " p1=" + imageEnum + "(" + imageEnum.getClass().getSimpleName() + ")" + " path=" + imagePath); // XposedHelpers.callStaticMethod has no explicit-paramTypes overload; just pass args // and let findMethodBestMatch resolve b(String, w11.p1, int, Object). Object r1 = XposedHelpers.callStaticMethod(s1Cls, "b", talker, imageEnum, 0, imagePath); if (r1 == null) throw new IllegalStateException("w11.s1.b() returned null"); dumpBuilderMethods(r1, "s1.b[image]"); XposedBridge.log("[WeChatHook] sendImageViaAPI: calling r1.b() on " + r1.getClass().getName()); XposedHelpers.callMethod(r1, "b"); XposedBridge.log("[WeChatHook] sendImageViaAPI: OK to=" + talker); } // ── 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; } // ── 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 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 with injector armed (imgPath + lvbuffer) ─────────────── // hookF9ImgPathInjector fires on every f9 ctor called inside r0 constructor, // injecting imgPath (=voiceId) and lvbuffer (=SILK bytes) before the DB insert. long ts = System.currentTimeMillis(); Class r0Class = XposedHelpers.findClass("w11.r0", cl); sPendingVoiceId.set(voiceId); sPendingLvBuffer.set(syntheticLv); sCalledByUs = true; Object r0; try { 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(); } // ── Populate r0.i with the DB-bound f9 so r0.K(0) can access r0.i.get(0) ── // r0.K(int) is the delivery-success handler; r0.I(int) is the failure handler. // Both internally do r0.i.get(0) to locate the message in memory. // sDatabaseF9 is the f9 that called convertTo() → the one actually in the DB. try { Object dbF9 = sDatabaseF9.get(); if (dbF9 != null) { @SuppressWarnings("unchecked") java.util.List rOi = (java.util.List) XposedHelpers.getObjectField(r0, "i"); if (rOi != null && rOi.isEmpty()) { rOi.add(dbF9); XposedBridge.log("[WeChatHook] sendVoiceViaAPI: added dbF9 to r0.i size=" + rOi.size()); } else { XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.i already " + (rOi == null ? "null" : "size=" + rOi.size())); } } else { XposedBridge.log("[WeChatHook] sendVoiceViaAPI: sDatabaseF9 is null — r0.i not populated"); } } catch (Throwable t) { XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.i populate failed: " + t); } finally { sDatabaseF9.remove(); } // ── 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. 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 + ")"); // onSceneEnd(int code, int errCode, String msg, m1 scene) // Real u0 calls r0.K(code) on success, r0.I(code) on failure. // code=0 from WeChat server means success. if ("onSceneEnd".equals(method.getName()) && args != null && args.length >= 4) { Object scene = args[3]; // w11.r0 implementing m1 if (scene != null) { try { int code = args[0] instanceof Integer ? (Integer) args[0] : 0; String handler = (code == 0) ? "K" : "I"; XposedHelpers.callMethod(scene, handler, code); XposedBridge.log("[WeChatHook] voice-cb: r0." + handler + "(" + code + ") called OK"); } catch (Throwable e2) { XposedBridge.log("[WeChatHook] voice-cb: r0.K/I() failed: " + e2); } } } return null; }); XposedHelpers.setObjectField(r0, "d", stub); } catch (Throwable t) { XposedBridge.log("[WeChatHook] u0 stub failed (non-fatal): " + t); } // ── Skip H() — message was already INSERTed into DB inside the r0 constructor // (convertTo fires before H). H() only triggers CDN upload + server delivery, // which fails for us (no CDN upload path), causing WeChat to delete the message // on server error. With status=4 already set, the message shows as delivered. XposedBridge.log("[WeChatHook] sendVoiceViaAPI: OK (no-H) to=" + talker + " voiceId=" + voiceId + " durationMs=" + durationMs); } // ── 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()); // Dump the builder's methods so we know how to set image/audio dumpBuilderMethods(ret, "s1." + mName); } 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 { final Class m4Class = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.m4", cl); 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); try { // m4 constructor is m4(LoginHistoryUI), but obj is LoginPasswordUI // (a subclass). XposedHelpers.newInstance infers types from runtime // class which is LoginPasswordUI, not LoginHistoryUI — no match. // Use explicit param type to force the right constructor lookup. Class loginHistoryUIClass = XposedHelpers.findClass( "com.tencent.mm.plugin.account.ui.LoginHistoryUI", obj.getClass().getClassLoader()); tabletBtn.setOnClickListener( (View.OnClickListener) XposedHelpers.newInstance( m4Class, new Class[]{loginHistoryUIClass}, obj)); } catch (Throwable e) { XposedBridge.log("[WeChatHook] onResume setListener failed: " + e); } XposedBridge.log("[WeChatHook] onResume -> N VISIBLE + m4 attached for " + cls); } }); XposedBridge.log("[WeChatHook] Hooked Activity.onResume for tablet button"); } catch (Throwable t) { XposedBridge.log("[WeChatHook] hookProbe/onResume failed: " + t); } } 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"); } hookF9ConvertTo(sWeChatCL, procName); hookF9ImgPathInjector(sWeChatCL, procName); registerSendReceiver(ctx, 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) { // 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); 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, 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); } } // 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) throws Throwable { ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; if (cl == null) throw new IllegalStateException("classloader null"); Class tg3t1 = XposedHelpers.findClass("tg3.t1", cl); Object sendMgr = XposedHelpers.callStaticMethod(tg3t1, "a"); if (sendMgr == null) throw new IllegalStateException("tg3.t1.a() returned null"); XposedBridge.log("[WeChatHook] sendMgr=" + sendMgr.getClass().getName()); // Ni(content, talker, isGroup, extraKl) — 4-param overload for text XposedHelpers.callMethod(sendMgr, "Ni", new Class[]{String.class, String.class, boolean.class, Object.class}, text, talker, false, null); } 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 text = intent.getStringExtra("text"); if (talker == null || talker.isEmpty() || text == null || text.isEmpty()) { XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'"); return; } XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName + " pid=" + android.os.Process.myPid() + " to=" + talker + " len=" + text.length()); new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, 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); // 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; } } }