Commit 9efb9656556ad51ee02c52904c3716202ad8d1f0

Authored by yibo
1 parent 4b6e1572

feat(wechat_pad_login): LSPosed hook 开启微信「以平板登录」选项

目标设备:Pixel 10 Pro Fold / Android 16 / WeChat 8.0.74
- WeChatHook.java:双保险策略
    1. hook bk.A()/bk.B() 返回 true(静态方法,AOT 不内联则有效)
    2. hook Activity.onResume() 保底:框架跨 APK 调用无法被 WeChat AOT
       devirtualize,必然触发,直接强制平板按钮(LoginHistoryUI.N) VISIBLE
- rebuild.sh / update_db.sh:一键编译部署脚本
- LESSONS.md:逆向过程、AOT devirtualize 坑、XposedHelpers.newInstance
  子类类型陷阱等经验总结

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
wechat_pad_login/LESSONS.md 0 → 100644
  1 +# WeChat 平板登录 LSPosed Hook 经验与教训
  2 +
  3 +目标:在 Pixel 10 Pro Fold (Android 16, WeChat 8.0.74, LSPosed JingMatrix Vector v1.11.0) 上
  4 +开启「以平板和手机同时登录」(LoginAsExDeviceUI)。
  5 +
  6 +**结果:成功。**
  7 +
  8 +---
  9 +
  10 +## 一、微信内部架构(逆向所得)
  11 +
  12 +### 平板白名单判断链路
  13 +
  14 +```
  15 +LoginHistoryUI.initView()
  16 + └ if (bk.A() || bk.Q()) ← 两个静态方法
  17 + ↓ true → this.N.setVisibility(VISIBLE)
  18 + this.N.setOnClickListener(new m4(this))
  19 + m4.onClick → l.h(activity, "login_exdevice", ".ui.LoginAsExDeviceUI")
  20 +
  21 +bk.A() = inTabletEnv() 缓存层,调 bk.B(new x0(new bk$.b()))
  22 +bk.B() = 实际判断:widthDp >= 600 || OEM 品牌白名单
  23 +bk.Q() = isFoldableDevice():Samsung SM-F9*/SM-W* / Huawei HWTAH/HWTET / ZTE / Motorola / Royole
  24 + → Pixel 全部返回 false
  25 +```
  26 +
  27 +### Activity 层次(返回用户路径)
  28 +
  29 +```
  30 +LoginPasswordUI (classes14.dex,密码输入屏:头像+手机号+密码框)
  31 + extends LoginHistoryUI (classes.dex,平板按钮在此,字段 N)
  32 + extends MMActivity
  33 + extends Activity
  34 +```
  35 +
  36 +`LoginPasswordUI` 是「已保存账号」的用户重新打开微信时看到的界面,
  37 +不是 `LoginUI` 也不是 `MobileInputUI`——这是早期调查走弯路的主因。
  38 +
  39 +### 各 DEX 分布(多 DEX 查找方法)
  40 +
  41 +WeChat 8.0.74 有 classes.dex~classes16.dex 共 16 个 DEX,重要类分布:
  42 +
  43 +| 类 | DEX |
  44 +|---|---|
  45 +| `LoginHistoryUI` | classes.dex (主 DEX) |
  46 +| `LoginPasswordUI` | classes14.dex |
  47 +| `bk` (inTabletEnv) | classes13.dex |
  48 +| `m4` (click listener) | classes5.dex |
  49 +
  50 +查找命令:
  51 +```bash
  52 +python3 -c "
  53 +import struct, sys
  54 +data = open('classes14.dex','rb').read()
  55 +# 解析 DEX string table,搜 LoginPasswordUI
  56 +..."
  57 +```
  58 +
  59 +---
  60 +
  61 +## 二、踩过的坑(按时间顺序)
  62 +
  63 +### 坑 1:误判登录界面是 LoginUI / MobileInputUI
  64 +
  65 +**现象**:hook 注册成功,但 afterHookedMethod 从不触发。
  66 +**原因**:实际前台 Activity 是 `LoginPasswordUI`,而非 `LoginUI` 或 `MobileInputUI`。
  67 +**教训**:**先用 `dumpsys activity top` 确认实际 Activity,再决定 hook 什么。**
  68 +
  69 +```bash
  70 +adb shell "dumpsys activity top | grep 'ACTIVITY com.tencent' | head -3"
  71 +```
  72 +
  73 +### 坑 2:bk.A() / bk.Q() hook 注册成功但从不在目标调用点触发
  74 +
  75 +**现象**:`[WeChatHook] Hooked bk.A()` 日志出现,但 `bk.A() -> TRUE` 从不出现(在某些调用方)。
  76 +**原因**:ART AOT 把 `bk.A()` / `bk.Q()` 这类小静态方法**内联进调用方的 native 代码**。
  77 +调用方(`LoginHistoryUI.initView()` 的 AOT 产物)里没有 `invoke-static bk.A()`,
  78 +而是直接内联了布尔判断逻辑,完全绕过了 Xposed 替换的 ArtMethod 入口。
  79 +**教训**:**AOT 内联对小静态方法(<10 字节码指令)非常激进,不能假设 hook 会在所有调用点生效。**
  80 +
  81 +实测:`bk.A()` hook 在部分调用路径有效(`initView()` 内),在其他调用方可能已被内联。
  82 +
  83 +### 坑 3:虚方法 hook(initView / onCreate)全部不触发
  84 +
  85 +**尝试过**:
  86 +- `LoginHistoryUI.initView()`
  87 +- `LoginPasswordUI.initView()`
  88 +- `LoginPasswordUI.onCreate(Bundle)`
  89 +- `LoginHistoryUI.onCreate(Bundle)`
  90 +
  91 +**全部注册成功,全部 afterHookedMethod 不触发。**
  92 +
  93 +**根本原因(已确认)**:
  94 +
  95 +`LoginHistoryUI.onCreate()` 的 AOT 编译代码内含对 `this.initView()` 的调用。
  96 +ART AOT 编译器做了 **devirtualize**(去虚化):把 `invoke-virtual this.initView()` 编译成
  97 +对 `LoginPasswordUI.initView` native 地址的直接 native branch,跳过了 vtable 查找,
  98 +也跳过了 Xposed 替换的 ArtMethod `entry_point_from_quick_compiled_code_` 字段。
  99 +
  100 +同理,`invoke-super` 调用(`LoginPasswordUI.initView()` 里的 `super.initView()`)
  101 +AOT 后也是直接 native 跳转。
  102 +
  103 +**即使 hook 注册在 Activity 启动之前完成,只要 caller 的 native 代码里嵌了直接地址,
  104 +hook 就不会被触发。**
  105 +
  106 +**教训**:
  107 +- **在重度 AOT 的 App(WeChat)里,不能依赖对虚方法的 Xposed hook。**
  108 +- 区分「hook 注册成功」和「hook 实际拦截」——两者完全不同。
  109 +- 判断方式:在 `beforeHookedMethod` 加日志,如果从不出现,就是拦截失败。
  110 +
  111 +### 坑 4:XposedHelpers.newInstance 子类类型不匹配
  112 +
  113 +**现象**:`NoSuchMethodError: m4(com.tencent.mm.plugin.account.ui.LoginPasswordUI)`
  114 +**原因**:`XposedHelpers.newInstance(m4Class, obj)` 按 `obj` 的**运行时类型**(`LoginPasswordUI`)
  115 +查构造函数,但 `m4` 的构造函数签名是 `m4(LoginHistoryUI)`,找不到匹配。
  116 +**修复**:显式传 `parameterTypes`:
  117 +
  118 +```java
  119 +Class<?> loginHistoryUIClass = XposedHelpers.findClass(
  120 + "com.tencent.mm.plugin.account.ui.LoginHistoryUI",
  121 + obj.getClass().getClassLoader());
  122 +XposedHelpers.newInstance(m4Class, new Class<?>[]{loginHistoryUIClass}, obj);
  123 +```
  124 +
  125 +**教训**:`XposedHelpers.newInstance` 变参版本按运行时类型匹配,遇到子类实例传父类参数构造函数时必须用显式 `Class<?>[]` 重载。
  126 +
  127 +---
  128 +
  129 +## 三、最终有效的两层策略
  130 +
  131 +### 层 1:静态方法 hook(主路径)
  132 +
  133 +```java
  134 +XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() {
  135 + protected void beforeHookedMethod(MethodHookParam param) {
  136 + param.setResult(Boolean.TRUE);
  137 + }
  138 +});
  139 +```
  140 +
  141 +静态方法不走 vtable,Xposed 替换 ArtMethod 入口后,**只要 caller 没有内联该方法**,
  142 +调用点就能被拦截。实测 `LoginHistoryUI.initView()` 的 AOT 代码里对 `bk.A()` 的调用
  143 +没有被内联(方法体不够小),所以 hook 有效。这让 `initView()` 走到了「显示按钮」分支,
  144 +按钮获得 VISIBLE + m4 listener,这是主路径成功的原因。
  145 +
  146 +### 层 2:框架基类方法 hook(保底)
  147 +
  148 +```java
  149 +XposedHelpers.findAndHookMethod("android.app.Activity", null, "onResume", new XC_MethodHook() {
  150 + protected void afterHookedMethod(MethodHookParam param) {
  151 + String cls = param.thisObject.getClass().getName();
  152 + if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI")) return;
  153 + // 强制按钮可见 + 绑 listener
  154 + }
  155 +});
  156 +```
  157 +
  158 +**核心原理**:Android framework 的 `Activity.onResume()` 是**跨 APK 的 virtual dispatch**,
  159 +由 `ActivityThread`(framework 进程)调用,不可能被 WeChat 自身的 AOT 编译内联。
  160 +因此,hook `android.app.Activity` 里的基类方法并用 `classLoader=null`(bootstrap CL),
  161 +其 afterHookedMethod **在所有 Activity 子类的 onResume 上必然触发**。
  162 +
  163 +通过 class name 过滤只处理目标 Activity,再访问目标字段/方法。
  164 +
  165 +**此策略适用于任何重度 AOT App 里「hook 不触发」的场景。**
  166 +
  167 +---
  168 +
  169 +## 四、通用原则(可复用)
  170 +
  171 +### 原则 1:先确认当前 Activity
  172 +
  173 +```bash
  174 +adb shell "dumpsys activity top | grep 'ACTIVITY com.目标包名' | head -3"
  175 +```
  176 +
  177 +WeChat 的「已登录账号重新打开」和「首次登录」走完全不同的 Activity 栈。
  178 +
  179 +### 原则 2:区分「静态方法」和「虚方法」的 hook 可靠性
  180 +
  181 +| 方法类型 | AOT 内联风险 | hook 可靠性 |
  182 +|---|---|---|
  183 +| 静态方法(小,< ~15 指令) | 高(被调用方内联) | 不稳定 |
  184 +| 静态方法(较大) | 低 | 较稳定 |
  185 +| 虚方法(被 App 内 AOT 调用) | 高(devirtualize) | **不可靠** |
  186 +| 框架基类方法(跨 APK 调用) | 不可能 | **稳定** |
  187 +
  188 +### 原则 3:添加诊断探针确认 hook 是否真的触发
  189 +
  190 +不要假设 hook 在触发——用 `beforeHookedMethod` 加日志。
  191 +如果日志出现,hook 触发;如果不出现,调查内联/devirtualize。
  192 +
  193 +```java
  194 +XposedHelpers.findAndHookMethod("android.app.Activity", null, "onResume", new XC_MethodHook() {
  195 + protected void beforeHookedMethod(MethodHookParam param) {
  196 + String cls = param.thisObject.getClass().getName();
  197 + if (cls.contains("目标包名")) {
  198 + XposedBridge.log("[Hook] PROBE onResume: " + cls);
  199 + }
  200 + }
  201 +});
  202 +```
  203 +
  204 +### 原则 4:遇到「虚方法 hook 不触发」时的替代路径
  205 +
  206 +1. **用框架基类方法 hook**:`Activity.onCreate/onResume/onStart`,过滤 class name
  207 +2. **改 hook 静态工厂/单例**:有时可以找到不被内联的静态入口
  208 +3. **DEX bytecode patch**:直接修改 DEX 字节码绕开 AOT(复杂,需要清 OAT 缓存)
  209 +4. **force interpret-only**:`adb shell cmd package compile -m interpret-only <pkg>`,
  210 + 解除 AOT 但性能下降明显
  211 +
  212 +### 原则 5:LSPosed modules_config.db 更新流程
  213 +
  214 +安装新 APK 后,LSPosed 的 SQLite DB 里 `apk_path` 不自动更新,需要手动更新:
  215 +
  216 +```bash
  217 +# 1. 更新 apk_path
  218 +adb shell "su -c 'python3 -c \"import sqlite3; db=sqlite3.connect(...); db.execute(...); db.commit()\"'"
  219 +# 2. WAL checkpoint(让 lspd 进程读到新数据)
  220 +# 3. 确认 scope 里有目标包名
  221 +# 4. reboot
  222 +```
  223 +
  224 +---
  225 +
  226 +## 五、文件说明
  227 +
  228 +| 文件 | 说明 |
  229 +|---|---|
  230 +| `WeChatHook.java` | 最终可用的 Xposed 模块源码 |
  231 +| `rebuild.sh` | 编译 → D8 dex → 打包 → 签名,产物 `wechathook_v2_signed.apk` |
  232 +| `update_db.sh` | 更新 LSPosed modules_config.db 的 apk_path + scope |
  233 +| `LESSONS.md` | 本文档 |
  234 +
  235 +### 快速部署
  236 +
  237 +```bash
  238 +# 前提:LSPosed legacy JAR 已构建,设备已 root 且已安装 com.example.wechathook
  239 +bash rebuild.sh
  240 +ADB=/path/to/adb $ADB install -r --no-incremental wechathook_v2_signed.apk
  241 +bash update_db.sh
  242 +adb reboot
  243 +```
  244 +
  245 +---
  246 +
  247 +## 六、WeChat 更新后的维护指引
  248 +
  249 +`bk`、`m4`、`LoginHistoryUI`、`LoginPasswordUI`、`m4` 这些混淆类名在版本更新时可能改变。
  250 +
  251 +排查流程:
  252 +1. `adb pull /data/app/.../base.apk`,用 jadx 反编译
  253 +2. 搜 `inTabletEnv` / `isFoldableDevice` / `SM-F9` 找新的 `bk` 等价类
  254 +3. 搜 `login_exdevice` 找平板登录跳转的 click listener
  255 +4. 更新 `WeChatHook.java` 里的类名和字段名
wechat_pad_login/WeChatHook.java 0 → 100644
  1 +package com.example.wechathook;
  2 +
  3 +import android.app.Activity;
  4 +import android.content.Intent;
  5 +import android.os.Bundle;
  6 +import android.view.View;
  7 +import de.robv.android.xposed.IXposedHookLoadPackage;
  8 +import de.robv.android.xposed.XC_MethodHook;
  9 +import de.robv.android.xposed.XposedBridge;
  10 +import de.robv.android.xposed.XposedHelpers;
  11 +import de.robv.android.xposed.callbacks.XC_LoadPackage;
  12 +
  13 +/**
  14 + * WeChatHook: 在 Pixel 10 Pro Fold (Android 16 / WeChat 8.0.74) 上开启「以平板登录」选项。
  15 + *
  16 + * 目标:LoginPasswordUI(密码登录屏)里有一个隐藏的平板登录按钮(字段 N),
  17 + * 由 LoginHistoryUI.initView() 在 `if (bk.A() || bk.Q())` 为 true 时显示。
  18 + * Pixel 的品牌/型号不在微信白名单内,两者均返回 false。
  19 + *
  20 + * 双保险策略:
  21 + * 1. hookInTabletEnv: 直接 hook bk.A()/bk.B() 返回 true。
  22 + * 静态方法不走 vtable,AOT 编译后仍可被 Xposed 拦截(若调用方没有将其内联)。
  23 + * 实测:bk.A() hook 在 initView() 的 AOT 编译调用路径上有效,
  24 + * 使 initView() 自动显示按钮并绑好 m4 click listener。
  25 + *
  26 + * 2. hookTabletButtonViaOnResume: hook Android 框架的 Activity.onResume(),
  27 + * 过滤 LoginPasswordUI,在 onResume 时强制将按钮设为 VISIBLE。
  28 + * 框架跨 APK 调用不可能被 WeChat 的 AOT 内联,所以这个 hook 必然触发。
  29 + * 兜底:即使 bk.A() hook 在某些 call site 被 AOT 内联绕过,onResume hook 也能补救。
  30 + */
  31 +public class WeChatHook implements IXposedHookLoadPackage {
  32 +
  33 + @Override
  34 + public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
  35 + if (!"com.tencent.mm".equals(lpparam.packageName)) return;
  36 + XposedBridge.log("[WeChatHook] Injected into WeChat " + lpparam.processName);
  37 + ClassLoader cl = lpparam.classLoader;
  38 + hookInTabletEnv(cl);
  39 + hookTabletButtonViaOnResume(cl);
  40 + hookMobileInputUI(cl);
  41 + hookLoginUI(cl);
  42 + hookLoginSelectUICheck(cl);
  43 + hookWelcomeButtonClick(cl);
  44 + }
  45 +
  46 + // 策略 1: hook bk.A()/bk.B() 直接返回 true。
  47 + // 静态方法没有 vtable,Xposed 的 ArtMethod 入口替换对静态调用有效。
  48 + // bk.A() = inTabletEnv() 缓存层;bk.B(x0) = 实际逻辑。
  49 + private void hookInTabletEnv(ClassLoader cl) {
  50 + try {
  51 + Class<?> bkClass = XposedHelpers.findClass("com.tencent.mm.ui.bk", cl);
  52 + Class<?> x0Class = XposedHelpers.findClass("bm5.x0", cl);
  53 + XposedHelpers.findAndHookMethod(bkClass, "B", x0Class, new XC_MethodHook() {
  54 + @Override
  55 + protected void beforeHookedMethod(MethodHookParam param) {
  56 + param.setResult(Boolean.TRUE);
  57 + }
  58 + });
  59 + XposedHelpers.findAndHookMethod(bkClass, "A", new XC_MethodHook() {
  60 + @Override
  61 + protected void beforeHookedMethod(MethodHookParam param) {
  62 + XposedBridge.log("[WeChatHook] bk.A() -> TRUE");
  63 + param.setResult(Boolean.TRUE);
  64 + }
  65 + });
  66 + XposedBridge.log("[WeChatHook] Hooked bk.A()/bk.B()");
  67 + } catch (Throwable t) {
  68 + XposedBridge.log("[WeChatHook] hookInTabletEnv failed: " + t);
  69 + }
  70 + }
  71 +
  72 + // 策略 2: hook Activity.onResume(),过滤 LoginPasswordUI / LoginHistoryUI,
  73 + // 强制 this.N 可见并绑定 m4 click listener。
  74 + //
  75 + // 关键发现:WeChat 的虚方法(initView/onCreate)被 AOT devirtualize 成 native 直跳,
  76 + // Xposed 的 ArtMethod 入口替换被绕过,hook 注册成功但回调永远不触发。
  77 + // 而 Android 框架调 Activity.onResume() 是跨 APK 的 virtual dispatch,
  78 + // 不可能被 WeChat 自身的 AOT 编译内联,所以这里的 hook 稳定触发。
  79 + //
  80 + // 类层次:LoginPasswordUI extends LoginHistoryUI extends MMActivity extends Activity
  81 + // 平板按钮:LoginHistoryUI.N (Button, resource id 2131312963)
  82 + // Click listener:m4(LoginHistoryUI) → l.h(loginUI, "login_exdevice", ".ui.LoginAsExDeviceUI")
  83 + private void hookTabletButtonViaOnResume(ClassLoader cl) {
  84 + try {
  85 + final Class<?> m4Class = XposedHelpers.findClass(
  86 + "com.tencent.mm.plugin.account.ui.m4", cl);
  87 +
  88 + XposedHelpers.findAndHookMethod(
  89 + "android.app.Activity", null, "onResume", new XC_MethodHook() {
  90 + @Override
  91 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  92 + String cls = param.thisObject.getClass().getName();
  93 + if (!cls.equals("com.tencent.mm.plugin.account.ui.LoginPasswordUI")
  94 + && !cls.equals("com.tencent.mm.plugin.account.ui.LoginHistoryUI")) {
  95 + return;
  96 + }
  97 + Object obj = param.thisObject;
  98 + View tabletBtn = null;
  99 + try {
  100 + tabletBtn = (View) XposedHelpers.getObjectField(obj, "N");
  101 + } catch (Throwable e) {
  102 + XposedBridge.log("[WeChatHook] getField N failed: " + e);
  103 + return;
  104 + }
  105 + if (tabletBtn == null) {
  106 + XposedBridge.log("[WeChatHook] N is null for " + cls);
  107 + return;
  108 + }
  109 + tabletBtn.setVisibility(View.VISIBLE);
  110 + try {
  111 + // XposedHelpers.newInstance 按运行时类型查找构造函数,
  112 + // obj 运行时类型是 LoginPasswordUI,但 m4 构造函数接收 LoginHistoryUI。
  113 + // 必须显式传 parameterTypes 才能匹配到正确的构造函数。
  114 + Class<?> loginHistoryUIClass = XposedHelpers.findClass(
  115 + "com.tencent.mm.plugin.account.ui.LoginHistoryUI",
  116 + obj.getClass().getClassLoader());
  117 + tabletBtn.setOnClickListener(
  118 + (View.OnClickListener) XposedHelpers.newInstance(
  119 + m4Class, new Class<?>[]{loginHistoryUIClass}, obj));
  120 + } catch (Throwable e) {
  121 + XposedBridge.log("[WeChatHook] setListener failed: " + e);
  122 + }
  123 + XposedBridge.log("[WeChatHook] onResume -> N VISIBLE for " + cls);
  124 + }
  125 + });
  126 + XposedBridge.log("[WeChatHook] Hooked Activity.onResume (tablet button)");
  127 + } catch (Throwable t) {
  128 + XposedBridge.log("[WeChatHook] hookTabletButtonViaOnResume failed: " + t);
  129 + }
  130 + }
  131 +
  132 + // 兜底:MobileInputUI 是首次登录流程里的手机号输入界面,也有平板按钮(字段 w)
  133 + private void hookMobileInputUI(ClassLoader cl) {
  134 + try {
  135 + final Class<?> wbClass = XposedHelpers.findClass(
  136 + "com.tencent.mm.plugin.account.ui.wb", cl);
  137 + XposedHelpers.findAndHookMethod(
  138 + "com.tencent.mm.plugin.account.ui.MobileInputUI", cl,
  139 + "initView", new XC_MethodHook() {
  140 + @Override
  141 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  142 + Object obj = param.thisObject;
  143 + View btn = (View) XposedHelpers.getObjectField(obj, "w");
  144 + if (btn == null) return;
  145 + btn.setVisibility(View.VISIBLE);
  146 + btn.setOnClickListener(
  147 + (View.OnClickListener) XposedHelpers.newInstance(wbClass, obj));
  148 + XposedBridge.log("[WeChatHook] MobileInputUI.w VISIBLE");
  149 + }
  150 + });
  151 + } catch (Throwable t) {
  152 + XposedBridge.log("[WeChatHook] hookMobileInputUI failed: " + t);
  153 + }
  154 + }
  155 +
  156 + // 兜底:LoginUI 是通过 WelcomeActivity → 登录按钮 进入的界面,平板按钮字段 f992u
  157 + private void hookLoginUI(ClassLoader cl) {
  158 + try {
  159 + final Class<?> g7Class = XposedHelpers.findClass(
  160 + "com.tencent.mm.plugin.account.ui.g7", cl);
  161 + XposedHelpers.findAndHookMethod(
  162 + "com.tencent.mm.plugin.account.ui.LoginUI", cl,
  163 + "initView", new XC_MethodHook() {
  164 + @Override
  165 + protected void afterHookedMethod(MethodHookParam param) throws Throwable {
  166 + Object obj = param.thisObject;
  167 + View btn = (View) XposedHelpers.getObjectField(obj, "f992u");
  168 + if (btn == null) return;
  169 + btn.setVisibility(View.VISIBLE);
  170 + btn.setOnClickListener(
  171 + (View.OnClickListener) XposedHelpers.newInstance(g7Class, obj));
  172 + XposedBridge.log("[WeChatHook] LoginUI.f992u VISIBLE");
  173 + }
  174 + });
  175 + } catch (Throwable t) {
  176 + XposedBridge.log("[WeChatHook] hookLoginUI failed: " + t);
  177 + }
  178 + }
  179 +
  180 + // 拦截 LoginSelectUI 的服务端品牌校验回调,阻止服务端隐藏「phone_and_pad」选项
  181 + private void hookLoginSelectUICheck(ClassLoader cl) {
  182 + try {
  183 + Class<?> callbackClass = XposedHelpers.findClass(
  184 + "com.tencent.mm.plugin.account.ui.LoginSelectUI$a", cl);
  185 + XposedHelpers.findAndHookMethod(callbackClass, "a", boolean.class, new XC_MethodHook() {
  186 + @Override
  187 + protected void beforeHookedMethod(MethodHookParam param) {
  188 + param.args[0] = false;
  189 + XposedBridge.log("[WeChatHook] LoginSelectUI$a.a -> false");
  190 + }
  191 + });
  192 + XposedBridge.log("[WeChatHook] Hooked LoginSelectUI$a.a()");
  193 + } catch (Throwable t) {
  194 + XposedBridge.log("[WeChatHook] hookLoginSelectUICheck failed: " + t);
  195 + }
  196 + }
  197 +
  198 + // 兜底:WelcomeActivity 的登录按钮(lj)直接跳 LoginSelectUI 而非 LoginPasswordUI
  199 + private void hookWelcomeButtonClick(ClassLoader cl) {
  200 + try {
  201 + Class<?> ljClass = XposedHelpers.findClass(
  202 + "com.tencent.mm.plugin.account.ui.lj", cl);
  203 + final Class<?> loginSelectUIClass = XposedHelpers.findClass(
  204 + "com.tencent.mm.plugin.account.ui.LoginSelectUI", cl);
  205 + XposedHelpers.findAndHookMethod(ljClass, "onClick", View.class, new XC_MethodHook() {
  206 + @Override
  207 + protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
  208 + Activity welcome = (Activity) XposedHelpers.getObjectField(
  209 + param.thisObject, "d");
  210 + Intent intent = new Intent(welcome, loginSelectUIClass);
  211 + intent.putExtra("mobile_input_purpose", 1);
  212 + welcome.startActivity(intent);
  213 + param.setResult(null);
  214 + XposedBridge.log("[WeChatHook] lj.onClick -> LoginSelectUI");
  215 + }
  216 + });
  217 + XposedBridge.log("[WeChatHook] Hooked lj.onClick()");
  218 + } catch (Throwable t) {
  219 + XposedBridge.log("[WeChatHook] hookWelcomeButtonClick failed: " + t);
  220 + }
  221 + }
  222 +}
wechat_pad_login/rebuild.sh 0 → 100755
  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).
  4 +set -e
  5 +
  6 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  7 +ADB="${ADB:-$SCRIPT_DIR/../platform-tools/adb}"
  8 +[ ! -f "$ADB" ] && ADB=adb
  9 +
  10 +ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}"
  11 +ANDROID_JAR="$ANDROID_HOME/platforms/android-36/android.jar"
  12 +D8="$ANDROID_HOME/build-tools/36.0.0/d8"
  13 +APKSIGNER="$ANDROID_HOME/build-tools/36.0.0/apksigner"
  14 +LSPOSED_DIR="${LSPOSED_DIR:-/tmp/LSPosed}"
  15 +LEGACY_JAR="$LSPOSED_DIR/legacy/build/intermediates/compile_library_classes_jar/release/bundleLibCompileToJarRelease/classes.jar"
  16 +
  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
  22 +
  23 +BUILD_DIR=/tmp/wechathook_rebuild
  24 +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
  36 +
  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
  40 +
  41 +echo "== Compiling WeChatHook.java =="
  42 +javac --release 11 \
  43 + -classpath "$ANDROID_JAR:$LEGACY_JAR" \
  44 + -d "$BUILD_DIR/compiled" \
  45 + "$SCRIPT_DIR/WeChatHook.java"
  46 +
  47 +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")
  54 +
  55 +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 +
  61 +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
  72 +
  73 +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 =="
  84 +echo "APK size: $(wc -c < "$OUT") bytes"
  85 +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"
wechat_pad_login/update_db.sh 0 → 100755
  1 +#!/bin/bash
  2 +# update_wechathook_db.sh — 重装 wechathook APK 后更新 LSPosed modules_config.db
  3 +set -e
  4 +
  5 +ADB="${ADB:-$(dirname "$0")/../platform-tools/adb}"
  6 +[ ! -f "$ADB" ] && ADB=adb
  7 +
  8 +TERMUX_PY=/data/data/com.termux/files/usr/bin/python3
  9 +NEW_PATH=$($ADB shell 'pm path com.example.wechathook' | tr -d '\r' | sed 's/package://')
  10 +echo "New APK path: $NEW_PATH"
  11 +
  12 +# Phase 1: in-place UPDATE via Python (WAL commit visible to running lspd)
  13 +$ADB shell "su -c \"$TERMUX_PY -c \\\"
  14 +import sqlite3
  15 +db = sqlite3.connect('/data/adb/lspd/config/modules_config.db')
  16 +db.execute(\\\\\\\"UPDATE modules SET apk_path='${NEW_PATH}' WHERE module_pkg_name='com.example.wechathook'\\\\\\\")
  17 +db.commit()
  18 +try:
  19 + db.execute('PRAGMA wal_checkpoint(TRUNCATE)')
  20 +except:
  21 + pass
  22 +db.close()
  23 +print('phase1 done')
  24 +\\\"\"" 2>&1
  25 +
  26 +# Phase 2: pull + checkpoint locally + push clean main file
  27 +$ADB shell 'su -c "cp /data/adb/lspd/config/modules_config.db /data/local/tmp/mc_wh.db && cp /data/adb/lspd/config/modules_config.db-wal /data/local/tmp/mc_wh.db-wal 2>/dev/null; chmod 644 /data/local/tmp/mc_wh.db /data/local/tmp/mc_wh.db-wal 2>/dev/null; true"'
  28 +$ADB pull /data/local/tmp/mc_wh.db /tmp/mc_wh.db 2>/dev/null
  29 +$ADB pull /data/local/tmp/mc_wh.db-wal /tmp/mc_wh.db-wal 2>/dev/null || true
  30 +sqlite3 /tmp/mc_wh.db "PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null || true
  31 +
  32 +ACTUAL=$(sqlite3 /tmp/mc_wh.db "SELECT apk_path FROM modules WHERE module_pkg_name='com.example.wechathook';")
  33 +echo "Verified path: $ACTUAL"
  34 +
  35 +$ADB push /tmp/mc_wh.db /data/local/tmp/mc_wh_clean.db
  36 +$ADB shell 'su -c "cp /data/local/tmp/mc_wh_clean.db /data/adb/lspd/config/modules_config.db && rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm && echo phase2 done"'
  37 +
  38 +# Phase 3: verify scope (mid=18, com.tencent.mm)
  39 +MID=$(sqlite3 /tmp/mc_wh.db "SELECT mid FROM modules WHERE module_pkg_name='com.example.wechathook';")
  40 +echo "mid: $MID"
  41 +SCOPE=$(sqlite3 /tmp/mc_wh.db "SELECT count(*) FROM scope WHERE mid=$MID AND app_pkg_name='com.tencent.mm';")
  42 +if [ "$SCOPE" -eq 0 ]; then
  43 + echo "scope missing — inserting mid=$MID → com.tencent.mm"
  44 + sqlite3 /tmp/mc_wh.db "INSERT OR REPLACE INTO scope (mid, app_pkg_name, user_id) VALUES ($MID, 'com.tencent.mm', 0);"
  45 + $ADB push /tmp/mc_wh.db /data/local/tmp/mc_wh_scope.db
  46 + $ADB shell "su -c 'cp /data/local/tmp/mc_wh_scope.db /data/adb/lspd/config/modules_config.db && rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm && echo scope fixed'"
  47 +else
  48 + echo "scope OK (mid=$MID, com.tencent.mm exists)"
  49 +fi
  50 +
  51 +echo "DB updated. Reboot required."