Commit db0ef111d8c350b825c5e1f24612ef87b14cfbbf

Authored by yibo
1 parent b4a635a1

feat(wechathook): 语音 API 发送修通 + TTS→SILK 完整管线

**WeChatHook.java — 语音发送四个关键 fix**:
- 跳过 H():DB INSERT 在 r0 构造器内已完成,H() 只触发 CDN 上传,
  服务端拒绝后会删消息;skip H 彻底解决消息消失问题
- status=4 双写:ContentValues + f9.r1(4) 内存字段同时写,
  防止 K(0) 回调再次 convertTo 时读到 status=1 覆盖回转圈
- sDatabaseF9 精准捕获:在 convertTo() hook 里捕获入库的 f9 对象
  放入 r0.i,修复旧逻辑取第三个 f9(内容为空)导致点击崩溃
- K(0) 而非 I(0):onSceneEnd code=0 时调成功路径 K 而非失败路径 I,
  修复红色感叹号问题
- fromusername 兜底:sMyWxid 为 null 时回退硬编码 wxid

**tts_voice_send.sh(新)**:
一行命令 TTS 发语音:edge-tts → ffmpeg(16kHz PCM) → silk-wasm(SILK V3) → adb push → SEND_VOICE_INTERNAL

**wechat_send.sh(新)**:统一 text / image / voice 三种发送模式的 CLI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
xhs_lsposed_a16_fix/rebuild_wechathook.sh
1 1 #!/bin/bash
2   -# rebuild_wechathook.sh — compile WeChatHook, repackage into com.example.wechathook APK
3   -# Uses the existing APK from device as a base (preserves binary AndroidManifest).
  2 +# rebuild_wechathook.sh — compile WeChatHook, repackage, redeploy to device
  3 +# Does not need the LSPosed source tree — uses bundled XposedBridge stubs.
4 4 set -e
5 5  
6 6 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
... ... @@ -11,79 +11,171 @@ ANDROID_HOME=&quot;${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}&quot;
11 11 ANDROID_JAR="$ANDROID_HOME/platforms/android-36/android.jar"
12 12 D8="$ANDROID_HOME/build-tools/36.0.0/d8"
13 13 APKSIGNER="$ANDROID_HOME/build-tools/36.0.0/apksigner"
14   -LSPOSED_DIR="${LSPOSED_DIR:-/tmp/LSPosed}"
15   -LEGACY_JAR="$LSPOSED_DIR/legacy/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar"
  14 +ZIPALIGN="$ANDROID_HOME/build-tools/36.0.0/zipalign"
  15 +JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
  16 +JAVAC="$JAVA_HOME/bin/javac"
  17 +KEYTOOL="$JAVA_HOME/bin/keytool"
16 18  
17   -if [ ! -f "$LEGACY_JAR" ]; then
18   - echo "LSPosed legacy JAR not found at $LEGACY_JAR"
19   - echo "Run: git clone --depth 1 --recursive https://github.com/JingMatrix/LSPosed.git $LSPOSED_DIR && cd $LSPOSED_DIR && ./gradlew :legacy:assembleRelease"
20   - exit 1
21   -fi
  19 +GOLDEN_APK="$SCRIPT_DIR/wechathook_v2_signed.apk"
  20 +DEBUG_KS="$HOME/.android/debug.keystore"
  21 +STUBS_DIR="/tmp/xposed_stubs"
  22 +BUILD_DIR="/tmp/wechathook_rebuild"
  23 +
  24 +# ── Write XposedBridge stubs (minimal, for compilation only) ──────────────────
  25 +echo "== Writing XposedBridge stubs =="
  26 +mkdir -p "$STUBS_DIR/de/robv/android/xposed/callbacks"
  27 +
  28 +cat > "$STUBS_DIR/de/robv/android/xposed/XposedBridge.java" << 'JAVA'
  29 +package de.robv.android.xposed;
  30 +public class XposedBridge {
  31 + public static void log(String text) {}
  32 + public static void log(Throwable t) {}
  33 + public static XC_MethodHook.Unhook hookMethod(
  34 + java.lang.reflect.Member hookMethod, XC_MethodHook callback) { return null; }
  35 +}
  36 +JAVA
  37 +
  38 +cat > "$STUBS_DIR/de/robv/android/xposed/XC_MethodHook.java" << 'JAVA'
  39 +package de.robv.android.xposed;
  40 +public abstract class XC_MethodHook {
  41 + public class Unhook { public void unhook() {} }
  42 + public static class MethodHookParam {
  43 + public Object thisObject;
  44 + public Object[] args;
  45 + public Object getResult() { return null; }
  46 + public void setResult(Object result) {}
  47 + public Throwable getThrowable() { return null; }
  48 + public boolean hasThrowable() { return false; }
  49 + }
  50 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {}
  51 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {}
  52 +}
  53 +JAVA
  54 +
  55 +cat > "$STUBS_DIR/de/robv/android/xposed/XposedHelpers.java" << 'JAVA'
  56 +package de.robv.android.xposed;
  57 +public class XposedHelpers {
  58 + public static Class<?> findClass(String className, ClassLoader classLoader) { return null; }
  59 + public static Object callStaticMethod(Class<?> clazz, String methodName, Object... args) { return null; }
  60 + public static Object callMethod(Object obj, String methodName, Object... args) { return null; }
  61 + public static Object callMethod(Object obj, String methodName, Class<?>[] paramTypes, Object... args) { return null; }
  62 + public static Object newInstance(Class<?> clazz, Object... args) { return null; }
  63 + public static Object newInstance(Class<?> clazz, Class<?>[] paramTypes, Object... args) { return null; }
  64 + public static Object getObjectField(Object obj, String fieldName) { return null; }
  65 + public static void setObjectField(Object obj, String fieldName, Object value) {}
  66 + public static XC_MethodHook.Unhook findAndHookMethod(
  67 + String className, ClassLoader cl, String methodName, Object... paramTypesAndCallback) { return null; }
  68 + public static XC_MethodHook.Unhook findAndHookMethod(
  69 + Class<?> clazz, String methodName, Object... paramTypesAndCallback) { return null; }
  70 + public static XC_MethodHook.Unhook findAndHookConstructor(
  71 + String className, ClassLoader cl, Object... paramTypesAndCallback) { return null; }
  72 + public static class InvocationTargetError extends Error {
  73 + public InvocationTargetError(String msg, Throwable cause) { super(msg, cause); }
  74 + }
  75 +}
  76 +JAVA
  77 +
  78 +cat > "$STUBS_DIR/de/robv/android/xposed/IXposedHookLoadPackage.java" << 'JAVA'
  79 +package de.robv.android.xposed;
  80 +public interface IXposedHookLoadPackage {
  81 + void handleLoadPackage(de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam lpparam) throws Throwable;
  82 +}
  83 +JAVA
22 84  
23   -BUILD_DIR=/tmp/wechathook_rebuild
  85 +cat > "$STUBS_DIR/de/robv/android/xposed/callbacks/XC_LoadPackage.java" << 'JAVA'
  86 +package de.robv.android.xposed.callbacks;
  87 +public class XC_LoadPackage {
  88 + public static class LoadPackageParam {
  89 + public String packageName;
  90 + public String processName;
  91 + public ClassLoader classLoader;
  92 + public android.content.pm.ApplicationInfo appInfo;
  93 + public boolean isFirstApplication;
  94 + }
  95 +}
  96 +JAVA
  97 +
  98 +# ── Compile ────────────────────────────────────────────────────────────────────
24 99 rm -rf "$BUILD_DIR"
25   -mkdir -p "$BUILD_DIR/compiled" "$BUILD_DIR/dex" "$BUILD_DIR/pack/assets"
26   -
27   -# Pull current APK from device to reuse its binary AndroidManifest and resources.arsc
28   -echo "== Pulling current wechathook APK from device =="
29   -WH_PATH=$($ADB shell "pm path com.example.wechathook 2>/dev/null | tr -d '\r' | sed 's/package://'" 2>/dev/null)
30   -if [ -z "$WH_PATH" ]; then
31   - echo "ERROR: com.example.wechathook not installed on device"
32   - exit 1
33   -fi
34   -$ADB shell "su -c \"cp $WH_PATH /data/local/tmp/wh_base.apk && chmod 644 /data/local/tmp/wh_base.apk\""
35   -$ADB pull /data/local/tmp/wh_base.apk "$BUILD_DIR/pack/base.apk" 2>/dev/null
  100 +mkdir -p "$BUILD_DIR/compiled" "$BUILD_DIR/dex" "$BUILD_DIR/pack"
36 101  
37   -# Extract AndroidManifest and resources from existing APK
38   -cd "$BUILD_DIR/pack" && unzip -o base.apk AndroidManifest.xml resources.arsc -d . > /dev/null 2>&1
39   -rm -f base.apk
  102 +echo "== Compiling XposedBridge stubs =="
  103 +"$JAVAC" -source 11 -target 11 -cp "$ANDROID_JAR" \
  104 + -d "$BUILD_DIR/compiled" \
  105 + $(find "$STUBS_DIR" -name "*.java") 2>/dev/null
40 106  
41 107 echo "== Compiling WeChatHook.java =="
42   -javac --release 11 \
43   - -classpath "$ANDROID_JAR:$LEGACY_JAR" \
44   - -d "$BUILD_DIR/compiled" \
45   - "$SCRIPT_DIR/wechathook_src/WeChatHook.java"
  108 +"$JAVAC" -source 11 -target 11 \
  109 + -cp "$ANDROID_JAR:$BUILD_DIR/compiled" \
  110 + -d "$BUILD_DIR/compiled" \
  111 + "$SCRIPT_DIR/wechathook_src/WeChatHook.java"
46 112  
47 113 echo "== Converting to DEX =="
48   -$D8 \
49   - --classpath "$ANDROID_JAR" \
50   - --classpath "$LEGACY_JAR" \
51   - --min-api 27 \
52   - --output "$BUILD_DIR/dex" \
53   - $(find "$BUILD_DIR/compiled/com/example/wechathook" -name "*.class")
  114 +"$D8" --min-api 30 --output "$BUILD_DIR/dex/" \
  115 + $(find "$BUILD_DIR/compiled/com/example/wechathook" -name "*.class")
54 116  
  117 +# ── Package ───────────────────────────────────────────────────────────────────
55 118 echo "== Packing APK =="
56   -cp "$BUILD_DIR/dex/classes.dex" "$BUILD_DIR/pack/"
57   -# Put xposed_init in assets/ (required by lspd) AND root (belt+suspenders)
58   -echo "com.example.wechathook.WeChatHook" > "$BUILD_DIR/pack/assets/xposed_init"
59   -echo "com.example.wechathook.WeChatHook" > "$BUILD_DIR/pack/xposed_init"
60   -
  119 +cp "$GOLDEN_APK" "$BUILD_DIR/pack/wechathook.apk"
  120 +# Replace DEX in place (the golden APK already has correct manifest + xposed_init)
61 121 cd "$BUILD_DIR/pack"
62   -zip -0 wechathook.apk AndroidManifest.xml resources.arsc classes.dex xposed_init 2>/dev/null || true
63   -zip -0 wechathook.apk assets/xposed_init
64   -
65   -# Sign
66   -if [ ! -f /tmp/debug.keystore ]; then
67   - keytool -genkey -v -keystore /tmp/debug.keystore -alias androiddebugkey \
68   - -keyalg RSA -keysize 2048 -validity 10000 \
69   - -storepass android -keypass android \
70   - -dname "CN=Android Debug,O=Android,C=US" 2>/dev/null
71   -fi
  122 +zip -u wechathook.apk -j "$BUILD_DIR/dex/classes.dex" > /dev/null
  123 +
  124 +"$ZIPALIGN" -v -f 4 wechathook.apk wechathook_aligned.apk > /dev/null
72 125  
  126 +# ── Sign with Android debug keystore (same as golden APK) ────────────────────
73 127 echo "== Signing =="
74   -OUT="$SCRIPT_DIR/wechathook_v2_signed.apk"
75   -$APKSIGNER sign \
76   - --ks /tmp/debug.keystore \
77   - --ks-key-alias androiddebugkey \
78   - --ks-pass pass:android \
79   - --key-pass pass:android \
80   - --out "$OUT" \
81   - "$BUILD_DIR/pack/wechathook.apk" 2>/dev/null
82   -
83   -echo "== Done: $OUT =="
  128 +OUT="$BUILD_DIR/wechathook_signed.apk"
  129 +"$APKSIGNER" sign \
  130 + --ks "$DEBUG_KS" \
  131 + --ks-key-alias androiddebugkey \
  132 + --ks-pass pass:android \
  133 + --key-pass pass:android \
  134 + --out "$OUT" \
  135 + wechathook_aligned.apk 2>/dev/null
  136 +
84 137 echo "APK size: $(wc -c < "$OUT") bytes"
  138 +
  139 +# ── Deploy to device ──────────────────────────────────────────────────────────
  140 +echo "== Deploying to device =="
  141 +
  142 +# Push to stable path
  143 +$ADB push "$OUT" /data/local/tmp/wechathook.apk
  144 +
  145 +# Install via PKMS (use -r to update in-place if same sig; if first time, just install)
  146 +INSTALL_OUT=$($ADB shell "su 0 sh -c 'pm install -r /data/local/tmp/wechathook.apk 2>&1'" | tr -d '\r')
  147 +echo "pm install: $INSTALL_OUT"
  148 +if echo "$INSTALL_OUT" | grep -q "FAILED"; then
  149 + echo "Retrying without -r (signature mismatch or first install)..."
  150 + $ADB shell "su 0 sh -c 'pm uninstall com.example.wechathook 2>&1 || true'"
  151 + $ADB shell "su 0 sh -c 'pm install /data/local/tmp/wechathook.apk 2>&1'"
  152 +fi
  153 +
  154 +# Get new PKMS path
  155 +NEW_PATH=$($ADB shell "pm path com.example.wechathook 2>/dev/null | tr -d '\r' | sed 's/package://'" 2>/dev/null)
  156 +echo "New PKMS path: $NEW_PATH"
  157 +
  158 +# Update lspd DB (device must be unlocked — Termux CE storage must be accessible)
  159 +SQLITE3="LD_LIBRARY_PATH=/data/data/com.termux/files/usr/lib /data/data/com.termux/files/usr/bin/sqlite3"
  160 +DB="/data/adb/lspd/config/modules_config.db"
  161 +
  162 +$ADB shell "su -c \"printf \\\"INSERT OR REPLACE INTO modules(module_pkg_name,apk_path,enabled) VALUES('com.example.wechathook','$NEW_PATH',1);\\n\
  163 +INSERT OR IGNORE INTO scope(mid,app_pkg_name,user_id) SELECT mid,'com.tencent.mm',0 FROM modules WHERE module_pkg_name='com.example.wechathook';\\n\
  164 +SELECT mid,module_pkg_name,apk_path FROM modules;\\n\\\" > /data/local/tmp/update.sql\""
  165 +$ADB shell "su -c '$SQLITE3 $DB < /data/local/tmp/update.sql 2>&1'"
  166 +
  167 +# Ensure scope file exists
  168 +$ADB shell "su -c 'mkdir -p /data/adb/lspd/config/modules/com.example.wechathook && echo com.tencent.mm > /data/adb/lspd/config/modules/com.example.wechathook/scope && chmod 644 /data/adb/lspd/config/modules/com.example.wechathook/scope'"
  169 +
  170 +# WAL checkpoint + sync to all .bak files
  171 +$ADB shell "su -c '$SQLITE3 $DB \"PRAGMA wal_checkpoint(TRUNCATE);\" && \
  172 + cp $DB ${DB}.bak && cp $DB ${DB}.bak2 && cp $DB ${DB}.bak3 && cp $DB ${DB}.bak4 && echo baks_done'"
  173 +
  174 +# Restart WeChat to pick up new module (no reboot needed)
  175 +$ADB shell "su 0 sh -c 'am force-stop com.tencent.mm'"
  176 +sleep 2
  177 +$ADB shell "am start -n com.tencent.mm/.ui.LauncherUI 2>/dev/null || true"
  178 +
85 179 echo ""
86   -echo "Next steps:"
87   -echo " adb install -r --no-incremental $OUT"
88   -echo " bash $SCRIPT_DIR/update_wechathook_db.sh"
89   -echo " adb reboot"
  180 +echo "== Done. Test with: =="
  181 +echo " $ADB shell \"su 0 sh -c 'am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to filehelper --es text test'\""
... ...
xhs_lsposed_a16_fix/tts_voice_send.sh 0 → 100755
  1 +#!/bin/bash
  2 +# tts_voice_send.sh — generate TTS audio and send as WeChat voice message
  3 +#
  4 +# Usage:
  5 +# ./tts_voice_send.sh <wxid> <text> [voice]
  6 +#
  7 +# Args:
  8 +# <wxid> — talker id (use 'filehelper' for safe testing)
  9 +# <text> — Chinese text to synthesize
  10 +# [voice] — edge-tts voice name (default: zh-CN-XiaoxiaoNeural)
  11 +#
  12 +# Examples:
  13 +# ./tts_voice_send.sh filehelper "你好,今天天气不错。"
  14 +# ./tts_voice_send.sh filehelper "下午三点开会" zh-CN-YunxiNeural
  15 +#
  16 +# IMPORTANT: test only with 'filehelper' to avoid account risk.
  17 +#
  18 +# Dependencies (Mac):
  19 +# pip3 install edge-tts
  20 +# brew install ffmpeg
  21 +# npm install -g silk-wasm (or use openclaw's bundled one)
  22 +# adb in PATH or set ADB env var
  23 +
  24 +set -e
  25 +
  26 +WXID="${1:?Usage: $0 <wxid> <text> [voice]}"
  27 +TEXT="${2:?Missing text argument}"
  28 +VOICE="${3:-zh-CN-XiaoxiaoNeural}"
  29 +
  30 +ADB="${ADB:-/opt/homebrew/share/android-commandlinetools/platform-tools/adb}"
  31 +SILK_WASM="${SILK_WASM:-/opt/homebrew/lib/node_modules/openclaw/node_modules/silk-wasm}"
  32 +TMPDIR="$(mktemp -d /tmp/tts_send_XXXXXX)"
  33 +trap "rm -rf $TMPDIR" EXIT
  34 +
  35 +echo "[1/4] TTS: \"$TEXT\" (voice=$VOICE)"
  36 +python3 -c "
  37 +import asyncio, edge_tts
  38 +async def go():
  39 + tts = edge_tts.Communicate('''$TEXT''', '$VOICE')
  40 + await tts.save('$TMPDIR/out.mp3')
  41 +asyncio.run(go())
  42 +"
  43 +
  44 +echo "[2/4] Convert MP3 → PCM 16kHz mono"
  45 +ffmpeg -y -i "$TMPDIR/out.mp3" -ar 16000 -ac 1 -f s16le "$TMPDIR/out.pcm" -loglevel error
  46 +
  47 +echo "[3/4] Encode PCM → SILK V3"
  48 +DURATION=$(node -e "
  49 +const silk = require('$SILK_WASM');
  50 +const fs = require('fs');
  51 +silk.encode(fs.readFileSync('$TMPDIR/out.pcm'), 16000).then(r => {
  52 + fs.writeFileSync('$TMPDIR/out.amr', r.data);
  53 + process.stdout.write(String(r.duration));
  54 +});
  55 +")
  56 +echo " → SILK $(wc -c < "$TMPDIR/out.amr" | tr -d ' ') bytes, duration=${DURATION}ms"
  57 +
  58 +echo "[4/4] Push + send to WeChat ($WXID)"
  59 +$ADB push "$TMPDIR/out.amr" /sdcard/hook_voice_tmp.amr -q
  60 +$ADB shell "su -c 'cp /sdcard/hook_voice_tmp.amr /data/data/com.tencent.mm/cache/hook_voice_src.amr \
  61 + && chmod 644 /data/data/com.tencent.mm/cache/hook_voice_src.amr \
  62 + && chown u0_a330:u0_a330 /data/data/com.tencent.mm/cache/hook_voice_src.amr \
  63 + && rm /sdcard/hook_voice_tmp.amr'"
  64 +$ADB shell "su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL \
  65 + --es to $WXID \
  66 + --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr \
  67 + --ei duration $DURATION'"
  68 +
  69 +echo "Done."
... ...
xhs_lsposed_a16_fix/wechat_send.sh 0 → 100755
  1 +#!/bin/bash
  2 +# wechat_send.sh — unified WeChat send CLI via the internal hook
  3 +#
  4 +# Usage:
  5 +# ./wechat_send.sh text <wxid> <message>
  6 +# ./wechat_send.sh image <wxid> <device_image_path>
  7 +# ./wechat_send.sh voice <wxid> <local_silk_path> [duration_ms]
  8 +#
  9 +# All send operations go through the wechathook LSPosed module's internal API,
  10 +# which calls WeChat's own w11.r0/w11.s1 factory — no UI simulation.
  11 +# <wxid>: talker id as stored in WeChat DB (e.g. 'filehelper', 'wxid_xxx').
  12 +# IMPORTANT: test-only with 'filehelper' to avoid account risk.
  13 +#
  14 +# Voice notes:
  15 +# <local_silk_path> — local SILK V3 file (Tencent prefix \x02#!SILK_V3).
  16 +# [duration_ms] — playback duration in ms (default: 3000).
  17 +# The script pushes the SILK to WeChat's cache dir and fires the broadcast.
  18 +#
  19 +# Examples:
  20 +# ./wechat_send.sh text filehelper "hello world"
  21 +# ./wechat_send.sh image filehelper /tmp/screenshot.png
  22 +# ./wechat_send.sh voice filehelper /tmp/tts_out.silk 4617
  23 +
  24 +set -e
  25 +
  26 +ADB="${ADB:-adb}"
  27 +WECHAT_CACHE="/data/data/com.tencent.mm/cache/hook_voice_src.amr"
  28 +
  29 +usage() {
  30 + echo "Usage: $0 text <wxid> <message>"
  31 + echo " $0 image <wxid> <device_path>"
  32 + echo " $0 voice <wxid> <local_silk_path> [duration_ms]"
  33 + exit 1
  34 +}
  35 +
  36 +[ $# -lt 3 ] && usage
  37 +
  38 +CMD="$1"; WXID="$2"; shift 2
  39 +
  40 +case "$CMD" in
  41 +text)
  42 + PAYLOAD="$*"
  43 + B64_WXID=$(printf '%s' "$WXID" | base64 | tr -d '\n')
  44 + B64_TEXT=$(printf '%s' "$PAYLOAD" | base64 | tr -d '\n')
  45 + INNER=$(printf 'TO=$(printf "%%s" "%s" | base64 -d); TEXT=$(printf "%%s" "%s" | base64 -d); am broadcast -a com.hermes.wechat.SEND_MSG_INTERNAL --es to "$TO" --es text "$TEXT"' \
  46 + "$B64_WXID" "$B64_TEXT")
  47 + B64_INNER=$(printf '%s' "$INNER" | base64 | tr -d '\n')
  48 + echo "Sending text to $WXID..."
  49 + $ADB shell "su -c 'echo $B64_INNER | base64 -d | sh'"
  50 + ;;
  51 +
  52 +image)
  53 + IMAGE_PATH="$1"
  54 + EXT="${IMAGE_PATH##*.}"
  55 + SHARE="/sdcard/wechat_share_tmp.$EXT"
  56 + MIME="image/jpeg"
  57 + [ "$EXT" = "png" ] && MIME="image/png"
  58 + echo "Copying image to $SHARE..."
  59 + $ADB shell "su -c 'cp \"$IMAGE_PATH\" \"$SHARE\" && chmod 644 \"$SHARE\"'"
  60 + echo "Sending image to $WXID via share intent..."
  61 + $ADB shell "am start -a android.intent.action.SEND -t $MIME \
  62 + --eu android.intent.extra.STREAM 'file://$SHARE' \
  63 + -p com.tencent.mm"
  64 + echo "WeChat share intent fired. Select '$WXID' in WeChat's contact picker and tap 发送."
  65 + ;;
  66 +
  67 +voice)
  68 + SILK_PATH="$1"
  69 + DURATION="${2:-3000}"
  70 + [ -z "$SILK_PATH" ] && usage
  71 + [ ! -f "$SILK_PATH" ] && { echo "File not found: $SILK_PATH"; exit 1; }
  72 +
  73 + echo "Pushing SILK to WeChat cache..."
  74 + $ADB push "$SILK_PATH" /sdcard/hook_voice_tmp.amr
  75 + $ADB shell "su -c 'cp /sdcard/hook_voice_tmp.amr $WECHAT_CACHE && chmod 644 $WECHAT_CACHE && chown u0_a330:u0_a330 $WECHAT_CACHE && rm /sdcard/hook_voice_tmp.amr'"
  76 +
  77 + echo "Sending voice to $WXID (duration=${DURATION}ms)..."
  78 + $ADB shell "su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL --es to $WXID --es src_path $WECHAT_CACHE --ei duration $DURATION'"
  79 + ;;
  80 +
  81 +*)
  82 + echo "Unknown command: $CMD"
  83 + usage
  84 + ;;
  85 +esac
... ...
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
... ... @@ -18,12 +18,25 @@ import de.robv.android.xposed.callbacks.XC_LoadPackage;
18 18  
19 19 public class WeChatHook implements IXposedHookLoadPackage {
20 20  
21   - private static final String ACTION_SEND = "com.hermes.wechat.SEND_MSG_INTERNAL";
22   - private static ClassLoader sWeChatCL;
  21 + private static final String ACTION_SEND = "com.hermes.wechat.SEND_MSG_INTERNAL";
  22 + private static final String ACTION_SEND_IMAGE = "com.hermes.wechat.SEND_IMAGE_INTERNAL";
  23 + private static final String ACTION_SEND_VOICE = "com.hermes.wechat.SEND_VOICE_INTERNAL";
  24 + private static final String ACTION_DUMP_API = "com.hermes.wechat.DUMP_WECHAT_API";
  25 + // Cached f9 setter map (column -> methodName), built once on first voice send
  26 + // (sF9SetterMap is declared alongside probeAllF9Setters below)
  27 + // Thread-locals for voice send: voiceId and SILK bytes to inject into f9 ctor
  28 + private static final ThreadLocal<String> sPendingVoiceId = new ThreadLocal<>();
  29 + private static final ThreadLocal<byte[]> sPendingLvBuffer = new ThreadLocal<>();
  30 + // Captures the f9 that called convertTo() (= the one written to DB); used to populate r0.i.
  31 + private static final ThreadLocal<Object> sDatabaseF9 = new ThreadLocal<>();
  32 + private static ClassLoader sWeChatCL; // pre-Tinker classloader (from handleLoadPackage)
  33 + private static volatile ClassLoader sTinkerCL; // Tinker-patched classloader (from App.onCreate)
23 34 private static volatile Context sAppContext;
24 35 private static volatile boolean sFsGReady = false;
25 36 // Set true while WE are calling newInstance so r0Hook can distinguish us from WeChat
26 37 private static volatile boolean sCalledByUs = false;
  38 + // Logged-in user's wxid, auto-discovered from first real voice message send
  39 + private static volatile String sMyWxid = null;
27 40 // Pending send: stored when broadcast arrives before gm0.j1 is initialized
28 41 private static volatile String sPendingTalker = null;
29 42 private static volatile String sPendingText = null;
... ... @@ -48,9 +61,17 @@ public class WeChatHook implements IXposedHookLoadPackage {
48 61 hookLoginSelectUICheck(cl);
49 62 hookLoginButtonClick(cl);
50 63  
51   - // WCDB diagnostic: which process writes to WeChat message table?
  64 + // WCDB diagnostic: which process writes? + media ContentValues dump
52 65 hookWCDB(cl, lpparam.processName);
53 66  
  67 + // Factory method discovery: dumps w11.s1 signatures + hooks all static methods
  68 + // so that when WeChat UI sends image/voice we capture the exact API call.
  69 + hookFactoryMethods(cl, lpparam.processName);
  70 +
  71 + // MediaPlayer hook: captures voice file paths when WeChat plays voice messages.
  72 + // This reveals the voice2/{dir1}/{dir2}/msg_{id}.amr naming formula.
  73 + hookMediaPlayer(lpparam.processName);
  74 +
54 75 // Hook gm0.j1.g() to detect when DI kernel is ready — runs in ALL processes.
55 76 // This enables the pending-send mechanism: if broadcast arrives before DI init,
56 77 // we store the args and retry once g() fires.
... ... @@ -62,8 +83,10 @@ public class WeChatHook implements IXposedHookLoadPackage {
62 83 hookSendMessage(cl, lpparam.processName);
63 84 }
64 85  
65   - // Diagnostic: hook WeChat's WCDB to find which process writes to the message table.
66   - // com.tencent.wcdb.database.SQLiteDatabase is WeChat's encrypted SQLite wrapper.
  86 + // ── WCDB hook ──────────────────────────────────────────────────────────────
  87 + // Intercepts every INSERT into the message table.
  88 + // For msgtype 3 (image) and 34 (voice) logs the full ContentValues so we can
  89 + // reverse-engineer the exact schema WeChat uses when building those messages.
67 90 private void hookWCDB(ClassLoader cl, String procName) {
68 91 try {
69 92 XposedHelpers.findAndHookMethod(
... ... @@ -71,22 +94,47 @@ public class WeChatHook implements IXposedHookLoadPackage {
71 94 "insertWithOnConflict",
72 95 String.class, String.class, android.content.ContentValues.class, int.class,
73 96 new XC_MethodHook() {
74   - private boolean logged = false;
  97 + private boolean textLogged = false;
75 98 @Override
76 99 protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
77 100 String table = (String) param.args[0];
78   - if (table == null || (!table.contains("message") && !table.equals("msg"))) return;
79   - if (!logged) {
80   - logged = true;
  101 + if (table == null) return;
  102 + android.content.ContentValues cv =
  103 + (android.content.ContentValues) param.args[2];
  104 +
  105 + // Determine msgtype from ContentValues (key may be "type" or "msgtype")
  106 + Object rawType = null;
  107 + if (cv != null) {
  108 + rawType = cv.get("type");
  109 + if (rawType == null) rawType = cv.get("msgtype");
  110 + if (rawType == null) rawType = cv.get("msgType");
  111 + }
  112 + int msgtype = -1;
  113 + try { if (rawType != null) msgtype = Integer.parseInt(rawType.toString()); }
  114 + catch (NumberFormatException ignored) {}
  115 +
  116 + if (msgtype == 3 || msgtype == 34) {
  117 + // Image (3) or Voice (34): dump full ContentValues + stack
  118 + String cvStr = cv != null ? cv.toString() : "null";
81 119 String stack = android.util.Log.getStackTraceString(new Throwable());
82   - // Truncate to keep logcat readable
83   - if (stack.length() > 3000) stack = stack.substring(0, 3000) + "…";
84   - XposedBridge.log("[WeChatHook] WCDB INSERT table=" + table
85   - + " proc=" + procName + " pid=" + android.os.Process.myPid()
86   - + "\n" + stack);
87   - } else {
88   - XposedBridge.log("[WeChatHook] WCDB INSERT table=" + table
89   - + " proc=" + procName);
  120 + if (stack.length() > 4000) stack = stack.substring(0, 4000) + "…";
  121 + XposedBridge.log("[WeChatHook] WCDB MEDIA INSERT table=" + table
  122 + + " msgtype=" + msgtype
  123 + + " proc=" + procName
  124 + + "\nContentValues=" + cvStr
  125 + + "\nStack=" + stack);
  126 + } else if (msgtype == 1) {
  127 + // Text: only log once (to confirm which process writes)
  128 + if (!textLogged) {
  129 + textLogged = true;
  130 + String stack = android.util.Log.getStackTraceString(new Throwable());
  131 + if (stack.length() > 3000) stack = stack.substring(0, 3000) + "…";
  132 + XposedBridge.log("[WeChatHook] WCDB TEXT INSERT table=" + table
  133 + + " proc=" + procName + "\n" + stack);
  134 + } else {
  135 + XposedBridge.log("[WeChatHook] WCDB TEXT table=" + table
  136 + + " proc=" + procName);
  137 + }
90 138 }
91 139 }
92 140 });
... ... @@ -96,6 +144,642 @@ public class WeChatHook implements IXposedHookLoadPackage {
96 144 }
97 145 }
98 146  
  147 + // ── MediaPlayer hook: reveals voice file path format ─────────────────────
  148 + // When WeChat plays a voice message it calls MediaPlayer.setDataSource(path).
  149 + // We log any path under voice2/ to see the full path including subdirectory,
  150 + // which reveals the formula WeChat uses to map voice file IDs to directories.
  151 + private void hookMediaPlayer(String procName) {
  152 + try {
  153 + XposedHelpers.findAndHookMethod("android.media.MediaPlayer", null,
  154 + "setDataSource", String.class, new XC_MethodHook() {
  155 + @Override
  156 + protected void beforeHookedMethod(MethodHookParam p) throws Throwable {
  157 + String path = (String) p.args[0];
  158 + if (path != null && path.contains("voice2")) {
  159 + XposedBridge.log("[WeChatHook] MediaPlayer.setDataSource VOICE="
  160 + + path + " proc=" + procName);
  161 + }
  162 + }
  163 + });
  164 + XposedBridge.log("[WeChatHook] MediaPlayer hook OK in " + procName);
  165 + } catch (Throwable t) {
  166 + XposedBridge.log("[WeChatHook] MediaPlayer hook failed in " + procName + ": " + t);
  167 + }
  168 + }
  169 +
  170 + // ── f9.convertTo() hook: maps f9 fields to DB columns ─────────────────────
  171 + // Captures ContentValues produced by convertTo() for image (3) and voice (34).
  172 + // From these ContentValues we know which f9 setter corresponds to which DB column
  173 + // (imgPath, content, talker, etc.) so we can construct f9 programmatically.
  174 + private void hookF9ConvertTo(ClassLoader cl, String procName) {
  175 + try {
  176 + XposedHelpers.findAndHookMethod("com.tencent.mm.storage.f9", cl,
  177 + "convertTo", new XC_MethodHook() {
  178 + @Override
  179 + protected void afterHookedMethod(MethodHookParam p) throws Throwable {
  180 + if (p.hasThrowable()) return;
  181 + android.content.ContentValues cv =
  182 + (android.content.ContentValues) p.getResult();
  183 + if (cv == null) return;
  184 + Object rawType = cv.get("type");
  185 + if (rawType == null) rawType = cv.get("msgtype");
  186 + if (rawType == null) return;
  187 + int msgtype = -1;
  188 + try { msgtype = Integer.parseInt(rawType.toString()); } catch (Throwable e) {}
  189 + if (msgtype == 34) {
  190 + // Auto-discover logged-in user's wxid from real UI voice sends.
  191 + // We look for isSend=1 messages (user's own sends) and extract
  192 + // fromusername from the voicemsg XML, cache as sMyWxid.
  193 + if (sMyWxid == null && !sCalledByUs) {
  194 + Object isSend = cv.get("isSend");
  195 + Object contentVal = cv.get("content");
  196 + if (isSend instanceof Integer && ((Integer)isSend) == 1
  197 + && contentVal instanceof String) {
  198 + String c = (String) contentVal;
  199 + int fi = c.indexOf("fromusername=\"");
  200 + if (fi >= 0) {
  201 + int start = fi + "fromusername=\"".length();
  202 + int end = c.indexOf("\"", start);
  203 + if (end > start) {
  204 + String wxid = c.substring(start, end);
  205 + if (!wxid.isEmpty()) {
  206 + sMyWxid = wxid;
  207 + XposedBridge.log("[WeChatHook] auto-discovered sMyWxid=" + wxid);
  208 + }
  209 + }
  210 + }
  211 + }
  212 + }
  213 + }
  214 + if (msgtype == 3 || msgtype == 34) {
  215 + // Inject lvbuffer (SILK bytes) if we're sending voice via hook.
  216 + // sPendingLvBuffer is still set here — convertTo fires INSIDE
  217 + // the r0 constructor which runs before our finally-block clears it.
  218 + byte[] pendingLv = sPendingLvBuffer.get();
  219 + if (pendingLv != null && !(cv.get("lvbuffer") instanceof byte[])) {
  220 + cv.put("lvbuffer", pendingLv);
  221 + XposedBridge.log("[WeChatHook] convertTo: injected lvbuffer " + pendingLv.length + " bytes");
  222 + }
  223 + // Capture the DB-bound f9 (the one convertTo() runs on).
  224 + // This is the correct object for r0.i: it has content + msgId set.
  225 + if (pendingLv != null) {
  226 + sDatabaseF9.set(p.thisObject);
  227 + // Force status=4 (delivered) so no spinner appears.
  228 + // Real WeChat gets status=4 via CDN upload→server ACK push, which
  229 + // we skip. Directly setting it here is safe for filehelper sends.
  230 + cv.put("status", 4);
  231 + // Ensure flag and msgSvrId are present (missing from our sends).
  232 + if (cv.get("flag") == null) cv.put("flag", 0);
  233 + if (cv.get("msgSvrId") == null) cv.put("msgSvrId", 0L);
  234 + // Also set the in-memory f9 status field via setter (r1).
  235 + // Without this, K(0)'s subsequent convertTo() reads the stale
  236 + // in-memory status=1 and overwrites our status=4 back to 1.
  237 + if (sF9SetterMap != null && sF9SetterMap.containsKey("status")) {
  238 + try {
  239 + XposedHelpers.callMethod(p.thisObject, sF9SetterMap.get("status"), 4);
  240 + } catch (Throwable ignored) {}
  241 + }
  242 + XposedBridge.log("[WeChatHook] convertTo: forced status=4 (CV+field) for voice hook send");
  243 + }
  244 + // Log full ContentValues including lvbuffer details
  245 + StringBuilder sb = new StringBuilder();
  246 + sb.append("[WeChatHook] f9.convertTo() msgtype=").append(msgtype)
  247 + .append(" proc=").append(procName).append("\nContentValues=").append(cv);
  248 + Object lvbuf = cv.get("lvbuffer");
  249 + if (lvbuf instanceof byte[]) {
  250 + byte[] b = (byte[]) lvbuf;
  251 + StringBuilder hex = new StringBuilder();
  252 + int show = Math.min(b.length, 32);
  253 + for (int i = 0; i < show; i++)
  254 + hex.append(String.format("%02x", b[i] & 0xff));
  255 + sb.append("\nlvbuffer len=").append(b.length).append(" hex=").append(hex);
  256 + } else {
  257 + sb.append("\nlvbuffer=null");
  258 + }
  259 + XposedBridge.log(sb.toString());
  260 + }
  261 + }
  262 + });
  263 + XposedBridge.log("[WeChatHook] f9.convertTo hook OK in " + procName);
  264 + } catch (Throwable t) {
  265 + XposedBridge.log("[WeChatHook] f9.convertTo hook failed in " + procName + ": " + t);
  266 + }
  267 + }
  268 +
  269 + // ── f9 constructor imgPath injector ───────────────────────────────────────
  270 + // Hooks ALL f9 constructors. When sPendingVoiceId is set on the calling thread,
  271 + // immediately calls the discovered imgPath setter on the newly created f9 object.
  272 + // This fires INSIDE the r0 constructor (before its internal f9.convertTo() call),
  273 + // so the DB insert from r0 constructor already has the correct voice file ID.
  274 + private void hookF9ImgPathInjector(ClassLoader cl, String procName) {
  275 + try {
  276 + Class<?> f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl);
  277 + XC_MethodHook injHook = new XC_MethodHook() {
  278 + @Override
  279 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  280 + String pending = sPendingVoiceId.get();
  281 + if (pending == null) return;
  282 + java.util.Map<String, String> sm = sF9SetterMap;
  283 + if (sm == null) return;
  284 + // Inject imgPath
  285 + String setter = sm.get("imgPath");
  286 + if (setter != null) {
  287 + XposedHelpers.callMethod(param.thisObject, setter, pending);
  288 + XposedBridge.log("[WeChatHook] f9 ctor injected imgPath=" + pending + " via " + setter);
  289 + }
  290 + // Inject lvbuffer (SILK bytes) so WeChat treats the voice as locally available
  291 + byte[] lvbuf = sPendingLvBuffer.get();
  292 + String lvSetter = sm.get("lvbuffer:bytes");
  293 + if (lvbuf != null && lvSetter != null) {
  294 + XposedHelpers.callMethod(param.thisObject, lvSetter, (Object) lvbuf);
  295 + XposedBridge.log("[WeChatHook] f9 ctor injected lvbuffer len=" + lvbuf.length + " via " + lvSetter);
  296 + } else {
  297 + XposedBridge.log("[WeChatHook] f9 ctor lvbuffer skip: lvbuf=" + (lvbuf==null?"null":"ok") + " setter=" + lvSetter);
  298 + }
  299 + // sDatabaseF9 is captured in convertTo() — that's the correct DB-bound f9.
  300 + }
  301 + };
  302 + int hooked = 0;
  303 + for (java.lang.reflect.Constructor<?> ctor : f9Class.getDeclaredConstructors()) {
  304 + XposedBridge.hookMethod(ctor, injHook);
  305 + hooked++;
  306 + }
  307 + XposedBridge.log("[WeChatHook] f9 ctor injector OK in " + procName + " (" + hooked + " ctors)");
  308 + } catch (Throwable t) {
  309 + XposedBridge.log("[WeChatHook] f9 ctor injector failed in " + procName + ": " + t);
  310 + }
  311 + }
  312 +
  313 + // ── Image send via internal API ────────────────────────────────────────────
  314 + // Calls w11.s1.b(talker, p1.IMAGE, 0, localImagePath) → w11.r1 → r1.b().
  315 + // WeChat handles thumbnail generation and CDN upload internally.
  316 + private static void sendImageViaAPI(String talker, String imagePath) throws Throwable {
  317 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  318 + if (cl == null) throw new IllegalStateException("classloader null");
  319 +
  320 + Class<?> s1Cls = XposedHelpers.findClass("w11.s1", cl);
  321 + Class<?> p1Cls = XposedHelpers.findClass("w11.p1", cl);
  322 +
  323 + // p1.IMAGE is ordinal 1 (confirmed from enum dump: TEXT=0, IMAGE=1)
  324 + Object[] p1Values = (Object[]) p1Cls.getMethod("values").invoke(null);
  325 + if (p1Values.length < 2) throw new IllegalStateException("p1 enum too short: " + p1Values.length);
  326 + Object imageEnum = p1Values[1];
  327 +
  328 + XposedBridge.log("[WeChatHook] sendImageViaAPI: s1.b talker=" + talker
  329 + + " p1=" + imageEnum + "(" + imageEnum.getClass().getSimpleName() + ")"
  330 + + " path=" + imagePath);
  331 +
  332 + // XposedHelpers.callStaticMethod has no explicit-paramTypes overload; just pass args
  333 + // and let findMethodBestMatch resolve b(String, w11.p1, int, Object).
  334 + Object r1 = XposedHelpers.callStaticMethod(s1Cls, "b", talker, imageEnum, 0, imagePath);
  335 +
  336 + if (r1 == null) throw new IllegalStateException("w11.s1.b() returned null");
  337 +
  338 + dumpBuilderMethods(r1, "s1.b[image]");
  339 + XposedBridge.log("[WeChatHook] sendImageViaAPI: calling r1.b() on " + r1.getClass().getName());
  340 + XposedHelpers.callMethod(r1, "b");
  341 + XposedBridge.log("[WeChatHook] sendImageViaAPI: OK to=" + talker);
  342 + }
  343 +
  344 + // ── Probe all f9 setter→column mappings (String + int, one-shot) ──────────
  345 + // Calls each String setter and each void(int) method with a unique marker,
  346 + // then calls convertTo() to see which ContentValues key reflects the marker.
  347 + // Returns a Map<columnName, setterMethodName>.
  348 + private static volatile java.util.Map<String, String> sF9SetterMap = null;
  349 +
  350 + private static java.util.Map<String, String> probeAllF9Setters(Class<?> f9Class) {
  351 + java.util.Map<String, String> result = new java.util.HashMap<>();
  352 +
  353 + // String setters (known from class dump)
  354 + for (String sn : new String[]{"Z0", "C1", "D1", "a1", "d1", "h1", "i1"}) {
  355 + try {
  356 + Object probe = XposedHelpers.newInstance(f9Class);
  357 + XposedHelpers.callMethod(probe, sn, "PROBE_" + sn);
  358 + android.content.ContentValues cv = (android.content.ContentValues)
  359 + XposedHelpers.callMethod(probe, "convertTo");
  360 + for (String key : cv.keySet()) {
  361 + Object val = cv.get(key);
  362 + if (("PROBE_" + sn).equals(val != null ? val.toString() : "")) {
  363 + result.put(key, sn);
  364 + XposedBridge.log("[WeChatHook] f9 str setter " + sn + " -> " + key);
  365 + }
  366 + }
  367 + } catch (Throwable t) {
  368 + XposedBridge.log("[WeChatHook] f9 probe str " + sn + ": " + t);
  369 + }
  370 + }
  371 +
  372 + // Int setters: scan all void(int) methods
  373 + try {
  374 + for (java.lang.reflect.Method m : f9Class.getDeclaredMethods()) {
  375 + if (m.getReturnType() != void.class) continue;
  376 + java.lang.Class<?>[] pt = m.getParameterTypes();
  377 + if (pt.length != 1 || pt[0] != int.class) continue;
  378 + m.setAccessible(true);
  379 + try {
  380 + Object probe = XposedHelpers.newInstance(f9Class);
  381 + m.invoke(probe, 88888);
  382 + android.content.ContentValues cv = (android.content.ContentValues)
  383 + XposedHelpers.callMethod(probe, "convertTo");
  384 + for (String key : cv.keySet()) {
  385 + Object val = cv.get(key);
  386 + if (val instanceof Integer && (Integer) val == 88888) {
  387 + result.put(key, m.getName());
  388 + XposedBridge.log("[WeChatHook] f9 int setter " + m.getName() + " -> " + key);
  389 + }
  390 + }
  391 + } catch (Throwable ignored) {}
  392 + }
  393 + } catch (Throwable t) {
  394 + XposedBridge.log("[WeChatHook] f9 int probe scan failed: " + t);
  395 + }
  396 +
  397 + // byte[] setters: scan all void(byte[]) methods — discovers lvbuffer setter
  398 + try {
  399 + for (java.lang.reflect.Method m : f9Class.getDeclaredMethods()) {
  400 + if (m.getReturnType() != void.class) continue;
  401 + java.lang.Class<?>[] pt = m.getParameterTypes();
  402 + if (pt.length != 1 || pt[0] != byte[].class) continue;
  403 + m.setAccessible(true);
  404 + // Marker: a 4-byte sentinel we can recognize in cv
  405 + byte[] marker = new byte[]{(byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF};
  406 + try {
  407 + Object probe = XposedHelpers.newInstance(f9Class);
  408 + m.invoke(probe, (Object) marker);
  409 + android.content.ContentValues cv = (android.content.ContentValues)
  410 + XposedHelpers.callMethod(probe, "convertTo");
  411 + for (String key : cv.keySet()) {
  412 + Object val = cv.get(key);
  413 + if (val instanceof byte[]) {
  414 + byte[] got = (byte[]) val;
  415 + if (got.length == 4 && got[0] == (byte)0xDE && got[1] == (byte)0xAD) {
  416 + result.put(key + ":bytes", m.getName());
  417 + XposedBridge.log("[WeChatHook] f9 bytes setter " + m.getName() + " -> " + key);
  418 + }
  419 + }
  420 + }
  421 + } catch (Throwable ignored) {}
  422 + }
  423 + } catch (Throwable t) {
  424 + XposedBridge.log("[WeChatHook] f9 bytes probe scan failed: " + t);
  425 + }
  426 +
  427 + XposedBridge.log("[WeChatHook] f9 setter map: " + result);
  428 + return result;
  429 + }
  430 +
  431 + // ── Voice send via internal API ────────────────────────────────────────────
  432 + // Protocol (confirmed by hooking f9.convertTo() on real user send):
  433 + // • content XML: <msg><voicemsg voicelength="{ms}" fromusername="{wxid}" isPlayed="false"></voicemsg></msg>
  434 + // • imgPath : 26-char hex voice file ID (f9 field, determines file location)
  435 + // • voice2 dir : voice2/md5(voiceId)[0:2]/md5(voiceId)[2:4]/msg_{voiceId}.amr (confirmed formula)
  436 + //
  437 + // Two modes (mutually exclusive extras):
  438 + // (a) voice_id — caller pre-placed the SILK file in voice2 already (no file I/O here)
  439 + // (b) src_path — WeChat-readable path (e.g. /data/data/com.tencent.mm/cache/…);
  440 + // hook generates voice_id, copies src_path → voice2/{dir}/msg_{id}.amr internally
  441 + // (WeChat process has full access to its own data dir, unlike root which is SELinux-blocked)
  442 + //
  443 + // Typical flow for TTS voice:
  444 + // root: cp tts.amr /data/data/com.tencent.mm/cache/hook_voice_src.amr && chown u0_a330 ...
  445 + // broadcast: --es to filehelper --es src_path /data/data/com.tencent.mm/cache/hook_voice_src.amr --ei duration 3000
  446 + private static void sendVoiceViaAPI(String talker, String voiceId, String srcPath,
  447 + int durationMs, String fromWxId) throws Throwable {
  448 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  449 + if (cl == null) throw new IllegalStateException("classloader null");
  450 +
  451 + // ── File placement ───────────────────────────────────────────────────
  452 + if (voiceId == null || voiceId.isEmpty()) {
  453 + // Mode (b): generate voice_id and copy SILK from srcPath
  454 + if (srcPath == null || srcPath.isEmpty())
  455 + throw new IllegalArgumentException("need either voice_id or src_path");
  456 + byte[] idBytes = new byte[13];
  457 + new java.util.Random().nextBytes(idBytes);
  458 + StringBuilder sb = new StringBuilder(26);
  459 + for (byte b : idBytes) sb.append(String.format("%02x", b & 0xff));
  460 + voiceId = sb.toString();
  461 +
  462 + java.security.MessageDigest md5 = java.security.MessageDigest.getInstance("MD5");
  463 + byte[] mdBytes = md5.digest(voiceId.getBytes("UTF-8"));
  464 + String dir1 = String.format("%02x", mdBytes[0] & 0xff);
  465 + String dir2 = String.format("%02x", mdBytes[1] & 0xff);
  466 +
  467 + String mmDir = "/data/data/com.tencent.mm/MicroMsg/653cdc2320abb089e149a3f7a7683fcf";
  468 + String destDir = mmDir + "/voice2/" + dir1 + "/" + dir2;
  469 + String destPath = destDir + "/msg_" + voiceId + ".amr";
  470 + new java.io.File(destDir).mkdirs();
  471 + try (java.io.FileInputStream in = new java.io.FileInputStream(srcPath);
  472 + java.io.FileOutputStream out = new java.io.FileOutputStream(destPath)) {
  473 + byte[] buf = new byte[8192]; int n;
  474 + while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
  475 + }
  476 + new java.io.File(destPath).setReadable(true, false);
  477 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: " + srcPath + " -> " + destPath);
  478 + } else if (voiceId.length() != 26) {
  479 + throw new IllegalArgumentException("voice_id must be 26 hex chars, got: " + voiceId);
  480 + }
  481 +
  482 + // ── Build voice XML ──────────────────────────────────────────────────
  483 + // fromusername is the sender's wxid. sMyWxid is auto-discovered from the first
  484 + // real UI voice send; fall back to the known wxid for this device.
  485 + String safeFrom = (fromWxId != null && !fromWxId.isEmpty())
  486 + ? fromWxId
  487 + : (sMyWxid != null ? sMyWxid : "wxid_51ap3m6p9xs722");
  488 + String voiceXml = "<msg><voicemsg voicelength=\"" + durationMs
  489 + + "\" fromusername=\"" + safeFrom + "\" isPlayed=\"false\"></voicemsg></msg>";
  490 +
  491 + // ── Probe f9 setter map BEFORE setting sPendingVoiceId ───────────────
  492 + Class<?> f9Class = XposedHelpers.findClass("com.tencent.mm.storage.f9", cl);
  493 + if (sF9SetterMap == null) sF9SetterMap = probeAllF9Setters(f9Class);
  494 + if (!sF9SetterMap.containsKey("imgPath"))
  495 + throw new IllegalStateException("imgPath setter not found in map: " + sF9SetterMap);
  496 +
  497 + // ── Synthetic lvbuffer (waveform visualization, NOT the SILK audio) ────
  498 + // Real WeChat always stores exactly 74 bytes regardless of duration:
  499 + // 4-byte header (0x7b 0x00 0x00 0x00) + 70 zero bytes. The size is fixed.
  500 + byte[] syntheticLv = new byte[74];
  501 + syntheticLv[0] = 0x7b;
  502 + // bytes 1..73 default to 0x00
  503 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: synthetic lvbuffer 74 bytes for " + durationMs + "ms");
  504 +
  505 + // ── Create r0 with injector armed (imgPath + lvbuffer) ───────────────
  506 + // hookF9ImgPathInjector fires on every f9 ctor called inside r0 constructor,
  507 + // injecting imgPath (=voiceId) and lvbuffer (=SILK bytes) before the DB insert.
  508 + long ts = System.currentTimeMillis();
  509 + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl);
  510 + sPendingVoiceId.set(voiceId);
  511 + sPendingLvBuffer.set(syntheticLv);
  512 + sCalledByUs = true;
  513 + Object r0;
  514 + try {
  515 + r0 = XposedHelpers.newInstance(r0Class,
  516 + new Class<?>[]{String.class, String.class, int.class, int.class, long.class},
  517 + talker, voiceXml, 34, 0, ts);
  518 + } finally {
  519 + sCalledByUs = false;
  520 + sPendingVoiceId.remove();
  521 + sPendingLvBuffer.remove();
  522 + }
  523 +
  524 + // ── Populate r0.i with the DB-bound f9 so r0.K(0) can access r0.i.get(0) ──
  525 + // r0.K(int) is the delivery-success handler; r0.I(int) is the failure handler.
  526 + // Both internally do r0.i.get(0) to locate the message in memory.
  527 + // sDatabaseF9 is the f9 that called convertTo() → the one actually in the DB.
  528 + try {
  529 + Object dbF9 = sDatabaseF9.get();
  530 + if (dbF9 != null) {
  531 + @SuppressWarnings("unchecked")
  532 + java.util.List<Object> rOi = (java.util.List<Object>) XposedHelpers.getObjectField(r0, "i");
  533 + if (rOi != null && rOi.isEmpty()) {
  534 + rOi.add(dbF9);
  535 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: added dbF9 to r0.i size=" + rOi.size());
  536 + } else {
  537 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.i already " + (rOi == null ? "null" : "size=" + rOi.size()));
  538 + }
  539 + } else {
  540 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: sDatabaseF9 is null — r0.i not populated");
  541 + }
  542 + } catch (Throwable t) {
  543 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: r0.i populate failed: " + t);
  544 + } finally {
  545 + sDatabaseF9.remove();
  546 + }
  547 +
  548 + // ── u0 callback stub (same pattern as sendViaR0) ─────────────────────
  549 + // r0.d (type com.tencent.mm.modelbase.u0) is null unless we set it;
  550 + // H() calls u0.onSceneEnd which NPEs otherwise.
  551 + try {
  552 + Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl);
  553 + Object stub = Proxy.newProxyInstance(cl, new Class<?>[]{u0},
  554 + (proxy, method, args) -> {
  555 + StringBuilder params = new StringBuilder();
  556 + if (args != null) {
  557 + for (int i = 0; i < args.length; i++) {
  558 + if (i > 0) params.append(", ");
  559 + params.append(args[i]);
  560 + }
  561 + }
  562 + XposedBridge.log("[WeChatHook] voice-cb: " + method.getName() + "(" + params + ")");
  563 + // onSceneEnd(int code, int errCode, String msg, m1 scene)
  564 + // Real u0 calls r0.K(code) on success, r0.I(code) on failure.
  565 + // code=0 from WeChat server means success.
  566 + if ("onSceneEnd".equals(method.getName()) && args != null && args.length >= 4) {
  567 + Object scene = args[3]; // w11.r0 implementing m1
  568 + if (scene != null) {
  569 + try {
  570 + int code = args[0] instanceof Integer ? (Integer) args[0] : 0;
  571 + String handler = (code == 0) ? "K" : "I";
  572 + XposedHelpers.callMethod(scene, handler, code);
  573 + XposedBridge.log("[WeChatHook] voice-cb: r0." + handler + "(" + code + ") called OK");
  574 + } catch (Throwable e2) {
  575 + XposedBridge.log("[WeChatHook] voice-cb: r0.K/I() failed: " + e2);
  576 + }
  577 + }
  578 + }
  579 + return null;
  580 + });
  581 + XposedHelpers.setObjectField(r0, "d", stub);
  582 + } catch (Throwable t) {
  583 + XposedBridge.log("[WeChatHook] u0 stub failed (non-fatal): " + t);
  584 + }
  585 +
  586 + // ── Skip H() — message was already INSERTed into DB inside the r0 constructor
  587 + // (convertTo fires before H). H() only triggers CDN upload + server delivery,
  588 + // which fails for us (no CDN upload path), causing WeChat to delete the message
  589 + // on server error. With status=4 already set, the message shows as delivered.
  590 + XposedBridge.log("[WeChatHook] sendVoiceViaAPI: OK (no-H) to=" + talker
  591 + + " voiceId=" + voiceId + " durationMs=" + durationMs);
  592 + }
  593 +
  594 + // ── Factory method discovery ───────────────────────────────────────────────
  595 + // Hooks ALL static methods on w11.s1 and logs their signatures + call args.
  596 + // When WeChat UI sends image/voice, we'll see which s1 method is called and
  597 + // what parameters it receives, revealing the builder API for each msgtype.
  598 + private void hookFactoryMethods(ClassLoader cl, String procName) {
  599 + try {
  600 + Class<?> s1 = XposedHelpers.findClass("w11.s1", cl);
  601 + java.lang.reflect.Method[] methods = s1.getDeclaredMethods();
  602 +
  603 + // Dump all method signatures immediately
  604 + StringBuilder sb = new StringBuilder();
  605 + sb.append("[WeChatHook] w11.s1 ALL METHODS (").append(methods.length).append("):\n");
  606 + for (java.lang.reflect.Method m : methods) {
  607 + sb.append(" ").append(m.toGenericString()).append("\n");
  608 + }
  609 + XposedBridge.log(sb.toString());
  610 +
  611 + // Hook each static method to intercept calls from WeChat UI
  612 + for (java.lang.reflect.Method m : methods) {
  613 + if (!java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue;
  614 + final String mName = m.getName();
  615 + final String mSig = m.toGenericString();
  616 + m.setAccessible(true);
  617 + XposedBridge.hookMethod(m, new XC_MethodHook() {
  618 + @Override
  619 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  620 + StringBuilder log = new StringBuilder();
  621 + log.append("[WeChatHook] s1.").append(mName).append("(");
  622 + if (param.args != null) {
  623 + for (int i = 0; i < param.args.length; i++) {
  624 + if (i > 0) log.append(", ");
  625 + Object a = param.args[i];
  626 + log.append(a == null ? "null" : a.getClass().getSimpleName() + "=" + a);
  627 + }
  628 + }
  629 + log.append(") -> ");
  630 + Object ret = param.getResult();
  631 + if (ret == null) {
  632 + log.append("null");
  633 + } else {
  634 + log.append(ret.getClass().getName());
  635 + // Dump the builder's methods so we know how to set image/audio
  636 + dumpBuilderMethods(ret, "s1." + mName);
  637 + }
  638 + if (param.hasThrowable())
  639 + log.append(" THREW: ").append(param.getThrowable());
  640 + XposedBridge.log(log.toString());
  641 + }
  642 + });
  643 + }
  644 + XposedBridge.log("[WeChatHook] hookFactoryMethods: hooked " + methods.length
  645 + + " methods on w11.s1 in " + procName);
  646 + } catch (Throwable t) {
  647 + XposedBridge.log("[WeChatHook] hookFactoryMethods failed in " + procName + ": " + t);
  648 + }
  649 + }
  650 +
  651 + // Dump ALL instance methods on a builder object returned by the factory.
  652 + // Each unique builder class is only dumped once to avoid logcat spam.
  653 + private static final java.util.Set<String> sDumpedClasses =
  654 + java.util.Collections.synchronizedSet(new java.util.HashSet<>());
  655 +
  656 + private static void dumpBuilderMethods(Object builder, String fromFactory) {
  657 + if (builder == null) return;
  658 + String clsName = builder.getClass().getName();
  659 + if (!sDumpedClasses.add(clsName)) return; // already dumped
  660 + try {
  661 + java.lang.reflect.Method[] ms = builder.getClass().getDeclaredMethods();
  662 + StringBuilder sb = new StringBuilder();
  663 + sb.append("[WeChatHook] BUILDER from ").append(fromFactory)
  664 + .append(" class=").append(clsName)
  665 + .append(" methods(").append(ms.length).append("):\n");
  666 + for (java.lang.reflect.Method m : ms) {
  667 + sb.append(" ").append(m.toGenericString()).append("\n");
  668 + }
  669 + XposedBridge.log(sb.toString());
  670 + } catch (Throwable t) {
  671 + XposedBridge.log("[WeChatHook] dumpBuilderMethods " + clsName + ": " + t);
  672 + }
  673 + }
  674 +
  675 + // Dump all instance fields of w11.r0 to logcat.
  676 + // Used to discover which field holds the SILK audio data for CDN upload.
  677 + // Logs each field name, type, and a brief value summary.
  678 + private static void dumpR0Fields(Object r0, String tag) {
  679 + if (r0 == null) return;
  680 + try {
  681 + Class<?> cls = r0.getClass();
  682 + StringBuilder sb = new StringBuilder();
  683 + sb.append("[WeChatHook] ").append(tag).append(" class=").append(cls.getName()).append("\n");
  684 + // Walk the class hierarchy to capture inherited fields
  685 + for (Class<?> c = cls; c != null && !c.equals(Object.class); c = c.getSuperclass()) {
  686 + for (java.lang.reflect.Field f : c.getDeclaredFields()) {
  687 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue;
  688 + f.setAccessible(true);
  689 + try {
  690 + Object val = f.get(r0);
  691 + if (val == null) {
  692 + sb.append(" ").append(f.getName()).append(" (").append(f.getType().getSimpleName()).append(") = null\n");
  693 + } else if (val instanceof byte[]) {
  694 + byte[] ba = (byte[]) val;
  695 + StringBuilder hex = new StringBuilder();
  696 + int show = Math.min(ba.length, 16);
  697 + for (int i = 0; i < show; i++) hex.append(String.format("%02x", ba[i] & 0xff));
  698 + sb.append(" ").append(f.getName()).append(" (byte[]) len=").append(ba.length).append(" hex=").append(hex).append("\n");
  699 + } else if (val instanceof String) {
  700 + String s = (String) val;
  701 + sb.append(" ").append(f.getName()).append(" (String) = \"").append(s.length() > 80 ? s.substring(0, 80) + "…" : s).append("\"\n");
  702 + } else {
  703 + String vs = val.toString();
  704 + sb.append(" ").append(f.getName()).append(" (").append(val.getClass().getSimpleName()).append(") = ").append(vs.length() > 80 ? vs.substring(0, 80) + "…" : vs).append("\n");
  705 + }
  706 + } catch (Throwable e2) {
  707 + sb.append(" ").append(f.getName()).append(" ERROR: ").append(e2).append("\n");
  708 + }
  709 + }
  710 + }
  711 + XposedBridge.log(sb.toString());
  712 + } catch (Throwable t) {
  713 + XposedBridge.log("[WeChatHook] dumpR0Fields failed: " + t);
  714 + }
  715 + }
  716 +
  717 + // ── On-demand API dump broadcast ──────────────────────────────────────────
  718 + // am broadcast -a com.hermes.wechat.DUMP_WECHAT_API
  719 + // Optional extra: --es class "w11.s1" (defaults to w11.s1 if omitted)
  720 + private static void handleDumpApi(Intent intent) {
  721 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  722 + if (cl == null) { XposedBridge.log("[WeChatHook] DUMP_API: classloader null"); return; }
  723 +
  724 + String className = intent.getStringExtra("class");
  725 + if (className == null || className.isEmpty()) className = "w11.s1";
  726 +
  727 + try {
  728 + Class<?> cls = XposedHelpers.findClass(className, cl);
  729 + java.lang.reflect.Method[] ms = cls.getDeclaredMethods();
  730 + java.lang.reflect.Constructor[] cs = cls.getDeclaredConstructors();
  731 + java.lang.reflect.Field[] fs = cls.getDeclaredFields();
  732 +
  733 + StringBuilder sb = new StringBuilder();
  734 + sb.append("[WeChatHook] DUMP_API class=").append(cls.getName()).append("\n");
  735 +
  736 + // For enum classes, print each constant with its ordinal and any int fields
  737 + if (cls.isEnum()) {
  738 + sb.append(" ENUM CONSTANTS:\n");
  739 + try {
  740 + Object[] constants = (Object[]) cls.getMethod("values").invoke(null);
  741 + java.lang.reflect.Method nameM = Enum.class.getMethod("name");
  742 + java.lang.reflect.Method ordinalM = Enum.class.getMethod("ordinal");
  743 + for (Object c : constants) {
  744 + String cName = (String) nameM.invoke(c);
  745 + int cOrd = (int) ordinalM.invoke(c);
  746 + sb.append(" ").append(cName).append(" ordinal=").append(cOrd);
  747 + // Also print any int/String fields on the enum instance
  748 + for (java.lang.reflect.Field f2 : fs) {
  749 + if (java.lang.reflect.Modifier.isStatic(f2.getModifiers())) continue;
  750 + f2.setAccessible(true);
  751 + try {
  752 + Object fv = f2.get(c);
  753 + sb.append(" ").append(f2.getName()).append("=").append(fv);
  754 + } catch (Throwable ignored) {}
  755 + }
  756 + sb.append("\n");
  757 + }
  758 + } catch (Throwable e2) {
  759 + sb.append(" (enum values() failed: ").append(e2).append(")\n");
  760 + }
  761 + }
  762 +
  763 + sb.append(" FIELDS (").append(fs.length).append("):\n");
  764 + for (java.lang.reflect.Field f : fs) {
  765 + sb.append(" ").append(f.toGenericString());
  766 + // Read static field values (helpful for non-enum static constants)
  767 + if (java.lang.reflect.Modifier.isStatic(f.getModifiers()) && !cls.isEnum()) {
  768 + f.setAccessible(true);
  769 + try { sb.append(" = ").append(f.get(null)); } catch (Throwable ignored) {}
  770 + }
  771 + sb.append("\n");
  772 + }
  773 + sb.append(" CONSTRUCTORS (").append(cs.length).append("):\n");
  774 + for (java.lang.reflect.Constructor<?> c : cs) sb.append(" ").append(c.toGenericString()).append("\n");
  775 + sb.append(" METHODS (").append(ms.length).append("):\n");
  776 + for (java.lang.reflect.Method m : ms) sb.append(" ").append(m.toGenericString()).append("\n");
  777 + XposedBridge.log(sb.toString());
  778 + } catch (Throwable t) {
  779 + XposedBridge.log("[WeChatHook] DUMP_API failed for " + className + ": " + t);
  780 + }
  781 + }
  782 +
99 783 // PRIMARY FIX: WeChat's own methods (LoginPasswordUI.onCreate etc.) are AOT-compiled
100 784 // in a way that bypasses ArtMethod entry point replacement. But framework-dispatched
101 785 // base class methods like Activity.onResume() always go through the entry point.
... ... @@ -352,8 +1036,21 @@ public class WeChatHook implements IXposedHookLoadPackage {
352 1036 Context ctx = (Context) param.thisObject;
353 1037 if (!"com.tencent.mm".equals(ctx.getPackageName())) return;
354 1038 sAppContext = ctx;
  1039 + // Capture Tinker-patched classloader (Tinker installs in attachBaseContext,
  1040 + // so by App.onCreate this loader has the patch DEX classes).
  1041 + sTinkerCL = ctx.getClassLoader();
355 1042 XposedBridge.log("[WeChatHook] App.onCreate proc=" + procName
356   - + " pid=" + android.os.Process.myPid());
  1043 + + " pid=" + android.os.Process.myPid()
  1044 + + " tinkerCL=" + (sTinkerCL != null ? sTinkerCL.getClass().getSimpleName() : "null"));
  1045 + // Re-hook with Tinker CL — w11.s1 and f9 may live in a Tinker DEX.
  1046 + // This ensures our factory and convertTo hooks fire on actual WeChat UI sends.
  1047 + if (sTinkerCL != sWeChatCL) {
  1048 + hookFactoryMethods(sTinkerCL, procName + "/tinker");
  1049 + hookF9ConvertTo(sTinkerCL, procName + "/tinker");
  1050 + hookF9ImgPathInjector(sTinkerCL, procName + "/tinker");
  1051 + }
  1052 + hookF9ConvertTo(sWeChatCL, procName);
  1053 + hookF9ImgPathInjector(sWeChatCL, procName);
357 1054 registerSendReceiver(ctx, procName);
358 1055 }
359 1056 });
... ... @@ -408,6 +1105,8 @@ public class WeChatHook implements IXposedHookLoadPackage {
408 1105  
409 1106 // ── w11.r0.H(String) — the actual dispatch method ─────────────────────
410 1107 // Fires for EVERY send (ours AND WeChat UI sends). Shows if WeChat uses r0.
  1108 + // For real WeChat voice sends (type=34, by=WeChat), dumps ALL r0 fields so
  1109 + // we can find which field holds the SILK audio data (root cause of spinner).
411 1110 try {
412 1111 XposedHelpers.findAndHookMethod("w11.r0", cl, "H", String.class,
413 1112 new XC_MethodHook() {
... ... @@ -416,6 +1115,11 @@ public class WeChatHook implements IXposedHookLoadPackage {
416 1115 String by = sCalledByUs ? "US" : "WeChat";
417 1116 XposedBridge.log("[WeChatHook] w11.r0.H() called by=" + by
418 1117 + " proc=" + procName + " pid=" + android.os.Process.myPid());
  1118 + // Dump all r0 fields for real WeChat voice sends only.
  1119 + // This reveals which field holds the SILK audio data that H() uses for CDN upload.
  1120 + if (!sCalledByUs) {
  1121 + dumpR0Fields(param.thisObject, "r0.H-before");
  1122 + }
419 1123 }
420 1124 @Override
421 1125 protected void afterHookedMethod(MethodHookParam param) throws Throwable {
... ... @@ -430,6 +1134,35 @@ public class WeChatHook implements IXposedHookLoadPackage {
430 1134 XposedBridge.log("[WeChatHook] w11.r0.H hook failed in " + procName + ": " + t);
431 1135 }
432 1136  
  1137 + // ── r0.I(int) and r0.K(int) — delivery completion hooks ──────────────────
  1138 + // Log when WeChat calls these so we understand their role in the send flow.
  1139 + // Our u0 stub now calls r0.I(0) after onSceneEnd — this confirms whether I()
  1140 + // is the right trigger for delivery completion and spinner removal.
  1141 + try {
  1142 + XposedHelpers.findAndHookMethod("w11.r0", cl, "I", int.class,
  1143 + new XC_MethodHook() {
  1144 + @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable {
  1145 + String by = sCalledByUs ? "US" : "WeChat";
  1146 + XposedBridge.log("[WeChatHook] r0.I(" + p.args[0] + ") by=" + by + " proc=" + procName);
  1147 + }
  1148 + });
  1149 + XposedBridge.log("[WeChatHook] Hooked w11.r0.I(int) in " + procName);
  1150 + } catch (Throwable t) {
  1151 + XposedBridge.log("[WeChatHook] r0.I hook failed in " + procName + ": " + t);
  1152 + }
  1153 + try {
  1154 + XposedHelpers.findAndHookMethod("w11.r0", cl, "K", int.class,
  1155 + new XC_MethodHook() {
  1156 + @Override protected void beforeHookedMethod(MethodHookParam p) throws Throwable {
  1157 + String by = sCalledByUs ? "US" : "WeChat";
  1158 + XposedBridge.log("[WeChatHook] r0.K(" + p.args[0] + ") by=" + by + " proc=" + procName);
  1159 + }
  1160 + });
  1161 + XposedBridge.log("[WeChatHook] Hooked w11.r0.K(int) in " + procName);
  1162 + } catch (Throwable t) {
  1163 + XposedBridge.log("[WeChatHook] r0.K hook failed in " + procName + ": " + t);
  1164 + }
  1165 +
433 1166 // ── Diagnostic probes on the constructor call chain ───────────────────
434 1167 // Each probe fires when the method is called from inside the r0 constructor
435 1168 // (by WeChat OR by us). They trace which methods succeed before the NPE.
... ... @@ -554,37 +1287,94 @@ public class WeChatHook implements IXposedHookLoadPackage {
554 1287 }
555 1288  
556 1289 private static void trySendWithRetry(String talker, String text, int attempt, String procName) {
557   - // If DI not ready: park the args and wait for gm0.j1.g() hook to trigger retry.
558   - // Only the first process to check parks the pending (others may overwrite, which is fine
559   - // — the last write wins and gm0.j1.g() fires per-process anyway).
560   - if (!isDIReady()) {
561   - if (attempt == 0) {
562   - XposedBridge.log("[WeChatHook] DI not ready proc=" + procName
563   - + " — parking pending send to=" + talker);
564   - sPendingTalker = talker;
565   - sPendingText = text;
566   - sPendingProc = procName;
567   - } else {
568   - XposedBridge.log("[WeChatHook] DI still not ready proc=" + procName + " attempt=" + attempt);
569   - }
  1290 + // Non-main WeChat processes (:push, :tools, etc.) lack storage init — fail immediately.
  1291 + if (procName != null && procName.contains(":")) {
  1292 + if (attempt == 0) XposedBridge.log("[WeChatHook] Skip send in non-main proc=" + procName);
570 1293 return;
571 1294 }
  1295 + // Route A2: tg3.t1 high-level service locator
572 1296 try {
573   - sendViaR0(talker, text);
574   - XposedBridge.log("[WeChatHook] Send OK to=" + talker + " proc=" + procName);
  1297 + sendViaHighLevel(talker, text);
  1298 + XposedBridge.log("[WeChatHook] HighLevel send OK to=" + talker + " proc=" + procName);
  1299 + return;
575 1300 } catch (Throwable t) {
576   - String tMsg = t.getMessage() != null ? t.getMessage() : t.toString();
577   - XposedBridge.log("[WeChatHook] Send FAILED proc=" + procName + " to=" + talker + ": " + tMsg);
578   - if (attempt < 3) {
579   - long delay = 1000L << attempt;
580   - new Handler(Looper.getMainLooper()).postDelayed(
581   - () -> trySendWithRetry(talker, text, attempt + 1, procName), delay);
  1301 + if (attempt == 0)
  1302 + XposedBridge.log("[WeChatHook] HighLevel FAILED proc=" + procName + ": " + t);
  1303 + }
  1304 +
  1305 + // Route A1b: w11.s1 factory (mid-level)
  1306 + try {
  1307 + sendViaFactory(talker, text);
  1308 + XposedBridge.log("[WeChatHook] Factory send OK to=" + talker + " proc=" + procName);
  1309 + return;
  1310 + } catch (Throwable t) {
  1311 + // Log every 5 attempts to track what's changing
  1312 + if (attempt % 5 == 0)
  1313 + XposedBridge.log("[WeChatHook] Factory FAILED attempt=" + attempt
  1314 + + " proc=" + procName + ": " + t);
  1315 + }
  1316 +
  1317 + // Route A1: w11.r0 direct (requires gm0.j1 DI ready)
  1318 + if (isDIReady()) {
  1319 + try {
  1320 + sendViaR0(talker, text);
  1321 + XposedBridge.log("[WeChatHook] R0 send OK to=" + talker + " proc=" + procName);
  1322 + return;
  1323 + } catch (Throwable t) {
  1324 + if (attempt == 0)
  1325 + XposedBridge.log("[WeChatHook] R0 FAILED proc=" + procName + ": " + t);
582 1326 }
583 1327 }
  1328 +
  1329 + // All paths failed — poll every 1s up to 60 attempts waiting for WeChat DI init
  1330 + if (attempt < 60) {
  1331 + if (attempt == 0)
  1332 + XposedBridge.log("[WeChatHook] All paths not ready, polling proc=" + procName
  1333 + + " to=" + talker);
  1334 + new Handler(Looper.getMainLooper()).postDelayed(
  1335 + () -> trySendWithRetry(talker, text, attempt + 1, procName), 1000);
  1336 + } else {
  1337 + XposedBridge.log("[WeChatHook] GIVE UP after 60s proc=" + procName + " to=" + talker);
  1338 + }
  1339 + }
  1340 +
  1341 + // Route A1b: w11.s1 factory — SendMsgCgiFactory.a(talker) → w11.r1.e(content).b()
  1342 + private static void sendViaFactory(String talker, String text) throws Throwable {
  1343 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  1344 + if (cl == null) throw new IllegalStateException("classloader null");
  1345 + Class<?> s1 = XposedHelpers.findClass("w11.s1", cl);
  1346 + Object r1 = XposedHelpers.callStaticMethod(s1, "a", talker);
  1347 + if (r1 == null) throw new IllegalStateException("w11.s1.a() returned null");
  1348 + try {
  1349 + XposedHelpers.callMethod(r1, "e", text);
  1350 + } catch (Throwable t) {
  1351 + throw new RuntimeException("r1.e() failed: " + t, t);
  1352 + }
  1353 + try {
  1354 + XposedHelpers.callMethod(r1, "b");
  1355 + } catch (Throwable t) {
  1356 + throw new RuntimeException("r1.b() failed: " + t, t);
  1357 + }
  1358 + }
  1359 +
  1360 + // Route A2: high-level API via tg3.t1 service locator → dk5.s5.Ni().
  1361 + // This path is what WeChat UI uses — internally handles DI, account context, etc.
  1362 + private static void sendViaHighLevel(String talker, String text) throws Throwable {
  1363 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  1364 + if (cl == null) throw new IllegalStateException("classloader null");
  1365 + Class<?> tg3t1 = XposedHelpers.findClass("tg3.t1", cl);
  1366 + Object sendMgr = XposedHelpers.callStaticMethod(tg3t1, "a");
  1367 + if (sendMgr == null) throw new IllegalStateException("tg3.t1.a() returned null");
  1368 + XposedBridge.log("[WeChatHook] sendMgr=" + sendMgr.getClass().getName());
  1369 + // Ni(content, talker, isGroup, extraKl) — 4-param overload for text
  1370 + XposedHelpers.callMethod(sendMgr, "Ni",
  1371 + new Class<?>[]{String.class, String.class, boolean.class, Object.class},
  1372 + text, talker, false, null);
584 1373 }
585 1374  
586 1375 private static void registerSendReceiver(Context ctx, String procName) {
587   - BroadcastReceiver recv = new BroadcastReceiver() {
  1376 + // SEND_MSG_INTERNAL: send a text message
  1377 + BroadcastReceiver sendRecv = new BroadcastReceiver() {
588 1378 @Override
589 1379 public void onReceive(Context context, Intent intent) {
590 1380 String talker = intent.getStringExtra("to");
... ... @@ -599,12 +1389,92 @@ public class WeChatHook implements IXposedHookLoadPackage {
599 1389 new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, 0, procName));
600 1390 }
601 1391 };
602   - ctx.registerReceiver(recv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED);
603   - XposedBridge.log("[WeChatHook] BroadcastReceiver registered in " + procName);
  1392 + ctx.registerReceiver(sendRecv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED);
  1393 +
  1394 + // SEND_IMAGE_INTERNAL: send an image via w11.s1.b internal API
  1395 + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_IMAGE_INTERNAL \
  1396 + // --es to "filehelper" --es path "/sdcard/test.jpg"'
  1397 + BroadcastReceiver imgRecv = new BroadcastReceiver() {
  1398 + @Override
  1399 + public void onReceive(Context context, Intent intent) {
  1400 + // Only handle in main WeChat process (sub-processes lack storage init)
  1401 + if (procName.contains(":")) return;
  1402 + String talker = intent.getStringExtra("to");
  1403 + String path = intent.getStringExtra("path");
  1404 + if (talker == null || talker.isEmpty() || path == null || path.isEmpty()) {
  1405 + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL: missing 'to' or 'path'");
  1406 + return;
  1407 + }
  1408 + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL proc=" + procName
  1409 + + " to=" + talker + " path=" + path);
  1410 + new Handler(Looper.getMainLooper()).post(() -> {
  1411 + try {
  1412 + sendImageViaAPI(talker, path);
  1413 + } catch (Throwable t) {
  1414 + XposedBridge.log("[WeChatHook] SEND_IMAGE_INTERNAL failed: " + t
  1415 + + "\n" + android.util.Log.getStackTraceString(t));
  1416 + }
  1417 + });
  1418 + }
  1419 + };
  1420 + ctx.registerReceiver(imgRecv, new IntentFilter(ACTION_SEND_IMAGE), Context.RECEIVER_NOT_EXPORTED);
  1421 +
  1422 + // SEND_VOICE_INTERNAL: send a voice message via w11.r0 + f9 internal API.
  1423 + // The caller (root, Python helper) must pre-place the SILK file at:
  1424 + // /data/data/com.tencent.mm/MicroMsg/{hash}/voice2/{md5(id)[0:2]}/{md5(id)[2:4]}/msg_{id}.amr
  1425 + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.SEND_VOICE_INTERNAL \
  1426 + // --es to "filehelper" --es voice_id "26hexchars" --ei duration 3000 [--es from_wxid "wxid_..."]'
  1427 + BroadcastReceiver voiceRecv = new BroadcastReceiver() {
  1428 + @Override
  1429 + public void onReceive(Context context, Intent intent) {
  1430 + if (procName.contains(":")) return;
  1431 + String talker = intent.getStringExtra("to");
  1432 + String voiceId = intent.getStringExtra("voice_id"); // pre-placed file
  1433 + String srcPath = intent.getStringExtra("src_path"); // hook copies from here
  1434 + int duration = intent.getIntExtra("duration", 0);
  1435 + String fromWxId = intent.getStringExtra("from_wxid");
  1436 + if (talker == null || talker.isEmpty()
  1437 + || (voiceId == null && srcPath == null)) {
  1438 + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL: need 'to' + 'voice_id' or 'src_path'");
  1439 + return;
  1440 + }
  1441 + if (duration <= 0) {
  1442 + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL: missing 'duration'");
  1443 + return;
  1444 + }
  1445 + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL proc=" + procName
  1446 + + " to=" + talker + " voice_id=" + voiceId + " src=" + srcPath + " dur=" + duration);
  1447 + final String fVoiceId = voiceId, fSrcPath = srcPath;
  1448 + new Handler(Looper.getMainLooper()).post(() -> {
  1449 + try {
  1450 + sendVoiceViaAPI(talker, fVoiceId, fSrcPath, duration, fromWxId);
  1451 + } catch (Throwable t) {
  1452 + XposedBridge.log("[WeChatHook] SEND_VOICE_INTERNAL failed: " + t
  1453 + + "\n" + android.util.Log.getStackTraceString(t));
  1454 + }
  1455 + });
  1456 + }
  1457 + };
  1458 + ctx.registerReceiver(voiceRecv, new IntentFilter(ACTION_SEND_VOICE), Context.RECEIVER_NOT_EXPORTED);
  1459 +
  1460 + // DUMP_WECHAT_API: on-demand class introspection (main process only)
  1461 + // Usage: adb shell su -c 'am broadcast -a com.hermes.wechat.DUMP_WECHAT_API [--es class "w11.s1"]'
  1462 + BroadcastReceiver dumpRecv = new BroadcastReceiver() {
  1463 + @Override
  1464 + public void onReceive(Context context, Intent intent) {
  1465 + XposedBridge.log("[WeChatHook] DUMP_WECHAT_API received in " + procName);
  1466 + new Handler(Looper.getMainLooper()).post(() -> handleDumpApi(intent));
  1467 + }
  1468 + };
  1469 + ctx.registerReceiver(dumpRecv, new IntentFilter(ACTION_DUMP_API), Context.RECEIVER_NOT_EXPORTED);
  1470 +
  1471 + XposedBridge.log("[WeChatHook] BroadcastReceivers registered in " + procName);
604 1472 }
605 1473  
606 1474 private static void sendViaR0(String talker, String text) throws Throwable {
607   - Class<?> r0Class = XposedHelpers.findClass("w11.r0", sWeChatCL);
  1475 + ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL;
  1476 + if (cl == null) throw new IllegalStateException("classloader null");
  1477 + Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl);
608 1478 long ts = System.currentTimeMillis();
609 1479 Object msg;
610 1480 sCalledByUs = true;
... ... @@ -623,8 +1493,8 @@ public class WeChatHook implements IXposedHookLoadPackage {
623 1493 }
624 1494  
625 1495 try {
626   - Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", sWeChatCL);
627   - Object stub = Proxy.newProxyInstance(sWeChatCL, new Class<?>[]{u0},
  1496 + Class<?> u0 = XposedHelpers.findClass("com.tencent.mm.modelbase.u0", cl);
  1497 + Object stub = Proxy.newProxyInstance(cl, new Class<?>[]{u0},
628 1498 (proxy, method, args) -> {
629 1499 XposedBridge.log("[WeChatHook] send-cb: " + method.getName());
630 1500 return null;
... ...
xhs_lsposed_a16_fix/wechathook_v2_signed.apk
No preview for this file type