docs(tng): 补充 Money Packet 领取台说明,并同步自动拉详情 Hook
说明 activityId、手动缓存模板后再自动拉历史详情的流程,以及 /mmp 双通道与去重行为。
This commit is contained in:
@@ -5,7 +5,8 @@ Android 应用,监听通知栏消息并通过 **双通道**(通知监听 + X
|
||||
> **Hook 架构与扩展指南**:详见 [docs/Hook指南.md](docs/Hook指南.md)(含 **Xposed / LSPosed**、接入新 App)
|
||||
> **Telegram 抓消息专文**:[docs/Telegram抓消息说明.md](docs/Telegram抓消息说明.md)
|
||||
> **手机部署与银行 bypass 操作**:详见 [docs/手机操作手册.md](docs/手机操作手册.md)
|
||||
> **MariBank 风控与 register 载荷**:[docs/MariBank风控与载荷说明.md](docs/MariBank风控与载荷说明.md)
|
||||
> **MariBank 风控与 register 载荷**:[docs/MariBank风控与载荷说明.md](docs/MariBank风控与载荷说明.md)
|
||||
> **TNG Money Packet 领取台**:[docs/TNG_MoneyPacket领取台.md](docs/TNG_MoneyPacket领取台.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ def _mmp_html_page():
|
||||
<main class="main">
|
||||
<div id="empty" class="empty">
|
||||
暂无红包数据<br/>
|
||||
<div class="hint">在手机打开 TNG「红包」详情或排行榜后,领取列表会显示在这里</div>
|
||||
<div class="hint">打开 Money Packet 历史页可自动拉取;若没有数据,先手动点开任意一条详情一次(缓存登录模板),再回历史页</div>
|
||||
</div>
|
||||
<div id="panel" style="display:none">
|
||||
<h2 class="title" id="title">-</h2>
|
||||
@@ -419,22 +419,26 @@ def _mmp_html_page():
|
||||
div.className = 'pkt' + (p.packetId === activeId ? ' active' : '');
|
||||
div.onclick = () => { activeId = p.packetId; loadData(); };
|
||||
div.innerHTML = `
|
||||
<div class="pkt-id">${esc((p.sender ? p.sender + ' · ' : '') + 'RM' + (p.sumClaimed ?? p.total ?? 0))}</div>
|
||||
<div class="pkt-meta">${esc(p.title || '红包')} · ${p.claimantCount || 0} 人领取<br/>
|
||||
${esc(p.latestAt || '')}
|
||||
</div>`;
|
||||
<div class="pkt-id">${esc(shortId(p.packetId))} · ${esc((p.sender || '红包') + ' RM' + (p.sumClaimed ?? p.total ?? 0))}</div>
|
||||
<div class="pkt-meta">${p.claimantCount || 0} 人领取 · ${esc(p.latestAt || '')}</div>`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
renderActive();
|
||||
}
|
||||
|
||||
function shortId(id) {
|
||||
const s = String(id || '');
|
||||
if (s.length >= 8 && s.indexOf('-') > 0) return s.slice(0, 8);
|
||||
return s.slice(0, 16) || '-';
|
||||
}
|
||||
|
||||
function renderActive() {
|
||||
const p = packets.find(x => x.packetId === activeId);
|
||||
if (!p) return;
|
||||
document.getElementById('title').textContent =
|
||||
(p.sender ? p.sender + ' 的红包' : (p.title || '红包详情'));
|
||||
const parts = [];
|
||||
if (p.packetId && !String(p.packetId).startsWith('红包-')) parts.push('编号 ' + p.packetId);
|
||||
if (p.packetId) parts.push('activityId ' + p.packetId);
|
||||
if (p.sender) parts.push('发送人 ' + p.sender);
|
||||
if (p.group) parts.push('群组 ' + p.group);
|
||||
if (p.via) parts.push('来源 ' + (p.via === 'http' ? '网络' : p.via === 'gson' ? '解析' : p.via === 'rpc' ? 'RPC' : p.via));
|
||||
|
||||
120
docs/TNG_MoneyPacket领取台.md
Normal file
120
docs/TNG_MoneyPacket领取台.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# TNG Money Packet 领取台说明
|
||||
|
||||
> 更新:2026-08-04
|
||||
> 相关代码:`TngMoneyPacketHook.java`、`debug-server/server.py`、`AppConfig.DEBUG_SERVER_URLS`
|
||||
|
||||
## 目标
|
||||
|
||||
抓取 TNG eWallet **Money Packet(红包)** 的领取排行:谁领了、领了多少,并在 PC 调试台按**单个红包**展示。
|
||||
|
||||
## 页面与地址
|
||||
|
||||
| 入口 | 地址 |
|
||||
|------|------|
|
||||
| 本机(USB + `adb reverse`) | http://127.0.0.1:8765/mmp |
|
||||
| 局域网 / USB 共享网 | http://<电脑IP>:8765/mmp (当前常见:`http://10.151.104.25:8765/mmp`) |
|
||||
| 通用消息台 | http://127.0.0.1:8765/ |
|
||||
|
||||
手机推送会同时尝试:
|
||||
|
||||
- `http://127.0.0.1:8765`(需 `adb reverse tcp:8765 tcp:8765`)
|
||||
- `http://10.151.104.25:8765`(USB 共享网段;若电脑 IP 变了,改 `AppConfig.DEBUG_SERVER_URLS`)
|
||||
|
||||
启动调试台:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
|
||||
```
|
||||
|
||||
## 红包唯一标识
|
||||
|
||||
正式字段是 **`activityId`**(UUID),例如:
|
||||
|
||||
- `b8c37a58-bb2c-4aa2-83ef-f15466b9211e`
|
||||
- `e5dcf293-4062-4df4-a692-e3bf194d9a37`
|
||||
|
||||
领取台左侧按 `activityId` 分条;详情页会显示完整 ID。
|
||||
转发正文格式示例:
|
||||
|
||||
```text
|
||||
[MMP统计] packet=<activityId> | sender=... | total=1.00 | via=rpc | src=moneyPacketDetail
|
||||
昵称A -> 0.71
|
||||
昵称B -> 0.29
|
||||
```
|
||||
|
||||
金额在 App 内多为 Money 对象 `{"amount":"0.71","cent":"71",...}`,Hook / 调试台会规范成数字再展示。
|
||||
|
||||
## 数据从哪来
|
||||
|
||||
| 来源 | Quake op / 方法 | 内容 |
|
||||
|------|-----------------|------|
|
||||
| 历史列表 | `moneyPacketHistoryList` / `ap.tngdwallet.moneyPacket.list.retrieve` | 仅摘要:`activityId`、总额、时间等,**无领取排行** |
|
||||
| 红包详情 | `moneyPacketDetail` / `ap.tngdwallet.moneyPacket.retrieve` | 含 `activityPoolInfos`(领取人 + 金额) |
|
||||
|
||||
外部 mitm 因证书 pinning **解不开** TNG API 正文;必须用进程内 Xposed(`TngMoneyPacketHook`)。
|
||||
|
||||
消费端详情领取列表字段主要是 **`activityPoolInfos`**(`receiverName` + `amount`),不是商户版的 `receiverList`。
|
||||
|
||||
## 推荐操作流程(自动拉详情)
|
||||
|
||||
历史页本身没有排行数据。自动拉取需要一次「带登录态的详情请求」作模板:
|
||||
|
||||
1. 打开 TNG → **Money Packet**
|
||||
2. **先手动点开任意一条历史红包详情**(看到排行榜即可)
|
||||
- 日志:`cached detail request template`(缓存 session / 请求模板)
|
||||
3. **返回历史列表**,停留几秒
|
||||
- 日志:`history hit → schedule auto detail`
|
||||
- 随后:`auto-detail using template` / `auto-detail ok activityId=... claims=N`
|
||||
4. 刷新领取台:http://127.0.0.1:8765/mmp
|
||||
|
||||
若跳过第 2 步,自动请求常返回 `totalCount:0`(缺 `sessionId` 等),台上会没有领取人。
|
||||
|
||||
也可继续「只点详情」:每进一个详情就会捕获该 `activityId` 的排行。
|
||||
|
||||
## 去重与刷新行为
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 标识 | 以 `activityId` 区分红包 |
|
||||
| 短时去重 | 同一 `activityId` + **相同领取名单**,约 **20 秒内**不重复推送(防双 Hook / 连点) |
|
||||
| 可刷新 | 超过 20 秒再进详情,或领取名单变化,会再次推送 |
|
||||
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后约 **60 秒**内不重复自动拉 |
|
||||
| 调试台 | 同一 `activityId` 只保留最新完整快照做排行;双通道推送有短时内容去重 |
|
||||
|
||||
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。
|
||||
|
||||
## 排障
|
||||
|
||||
```text
|
||||
adb logcat | findstr TngMmp
|
||||
```
|
||||
|
||||
常见日志:
|
||||
|
||||
| 日志 | 含义 |
|
||||
|------|------|
|
||||
| `TngMmp installed` | Hook 已加载 |
|
||||
| `cached RPC task MoneyPacketRpcTask` | 已缓存 RPC 代理 |
|
||||
| `cached detail request template` | 已缓存手动详情请求(自动拉可用) |
|
||||
| `history hit → schedule auto detail` | 历史列表触发自动拉 |
|
||||
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或模板未缓存 |
|
||||
| `auto-detail ok activityId=... claims=N` | 自动拉成功 |
|
||||
| `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 |
|
||||
|
||||
其它检查:
|
||||
|
||||
1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK
|
||||
2. 强停后再开 TNG,使新 Hook 生效
|
||||
3. 调试台进程在跑;USB 时执行过 `adb reverse`
|
||||
4. notiMessage 监听列表勾选了 TNG(Hook 转发依赖主 App 接收广播)
|
||||
5. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、金额/`activityId` 解析 |
|
||||
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp` |
|
||||
| `app/.../AppConfig.java` | `DEBUG_SERVER_URLS` 双地址 |
|
||||
| `app/.../network/DebugForwarder.java` | 向多个调试地址推送 |
|
||||
| `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse |
|
||||
@@ -11,42 +11,25 @@
|
||||
|
||||
---
|
||||
|
||||
## Money Packet 领取统计 — 网络/明文获取能力
|
||||
## Money Packet 领取统计
|
||||
|
||||
目标:群红包 Leaderboard 的 **昵称 + 已领金额**(字段预期 `receiverList` / `claimedAmount` 等)。
|
||||
**专用说明(操作 / 标识 / 自动拉详情 / 排障):** 见 [TNG_MoneyPacket领取台.md](TNG_MoneyPacket领取台.md)。
|
||||
|
||||
### 路线对比
|
||||
### 能力摘要
|
||||
|
||||
| 路线 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 外部 mitm(Charles / mitmproxy) | **不可行(当前)** | TNG API(如 `mpaasgw.tngdigital.com.my`)有 **证书 pinning**,只能看到域名,解不开 HTTPS 正文。`reverse/dumps/mitm_mmp/` 里曾落盘的多为官网 HTML/新闻,**不是** 红包 API。 |
|
||||
| 进程内 Xposed(`TngMoneyPacketHook`) | **代码已接,运行时未实证** | 在 TLS 之后读明文:Hook `OkHttp ResponseBody.string` + `Gson.fromJson`,匹配 `receiverList` / `claimedAmount` / `Mmp*` 模型,经 `HookForwarder` 转发。`MainHook` 已 `install`。 |
|
||||
| 纯日志/UI 自动化 | 兜底 | 无 API 时可读界面,不稳定,不作主路径。 |
|
||||
| 外部 mitm | **不可行** | API 证书 pinning,解不开正文 |
|
||||
| Xposed `TngMoneyPacketHook` | **已实证** | Hook Quake `moneyPacketDetail` / 历史列表触发自动拉详情;标识为 `activityId` |
|
||||
| PC 领取台 | **已上线** | http://127.0.0.1:8765/mmp ,按单个红包展示排行 |
|
||||
|
||||
### 当前缺口
|
||||
### 要点
|
||||
|
||||
1. **尚未在真机打开 Money Packet Leaderboard 做过一次捕获验证** → logcat 里暂无 `TngMmp captured …`。
|
||||
2. TNG 主业务多为 **mPaaS / Quake RPC**,若响应不走 `ResponseBody.string()` / 目标 Gson 类名不符,现有 Hook 会漏;需补 **RPC invoke 返回值 / 其它 JSON 入口**。
|
||||
3. Splash 强拉登录不影响「登录后进群点红包」时的抓包,但影响复测效率。
|
||||
|
||||
### 建议验证步骤(下次动手)
|
||||
|
||||
1. 保持登录态,进群 → 打开红包详情 / Leaderboard。
|
||||
2. 看 LSPosed:`notiMessageHook/TngMmp installed` 与 `captured packet=… claims=N`。
|
||||
3. 若无:对同一次操作抓 logcat 里 URL / 类名,补 Hook 点(Quake `RpcInvocationHandler` 等)。
|
||||
4. 确认 notiMessage / debug-server 是否收到转发内容。
|
||||
1. 历史列表只有摘要;领取排行来自详情 RPC(`activityPoolInfos`)。
|
||||
2. **自动拉详情**:先手动点开任意一条详情缓存请求模板(含 session),再回历史页即可批量拉。
|
||||
3. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。
|
||||
|
||||
### eKYC「验证您的帐户」强制页(2026-08-04)
|
||||
|
||||
`HomeEkycVerifyActivity` 挡首页。测试期:`hookHomeEkycVerifySkip` — finish 该页 + 拦 Intent + `canBypassEkyc`/`enforceEkyc` stub。
|
||||
**注意:** 服务端仍可能在部分功能(转账/红包)二次校验 eKYC,首页跳过不等于全功能可用。
|
||||
|
||||
|
||||
```text
|
||||
packetId / title
|
||||
receiverList[]:
|
||||
- nickname / displayName
|
||||
- claimedAmount(或 amount)
|
||||
```
|
||||
|
||||
按昵称聚合 `claimedAmount` 即可做领取排行。
|
||||
|
||||
@@ -37,8 +37,32 @@ public final class TngMoneyPacketHook {
|
||||
|
||||
private static final ArrayDeque<String> RECENT_KEYS = new ArrayDeque<>();
|
||||
private static final HashSet<String> RECENT_SET = new HashSet<>();
|
||||
/** activityId → 上次转发时间 */
|
||||
private static final java.util.HashMap<String, Long> RECENT_PACKET_AT = new java.util.HashMap<>();
|
||||
/** activityId → 领取指纹 */
|
||||
private static final java.util.HashMap<String, String> RECENT_PACKET_CLAIMS = new java.util.HashMap<>();
|
||||
/** 同一 activityId + 相同领取名单,20 秒内不重复推(防双 Hook / 连点);超时可刷新 */
|
||||
private static final long PACKET_DEDUP_MS = 20_000L;
|
||||
private static final ThreadLocal<String> CURRENT_REQUEST_URL = new ThreadLocal<>();
|
||||
|
||||
/** 历史列表触发的自动拉详情:已排队 / 上次成功时间 */
|
||||
private static final java.util.HashSet<String> AUTO_DETAIL_PENDING = new java.util.HashSet<>();
|
||||
private static final java.util.HashMap<String, Long> AUTO_DETAIL_AT = new java.util.HashMap<>();
|
||||
private static final long AUTO_DETAIL_COOLDOWN_MS = 60_000L;
|
||||
private static volatile Object sMoneyPacketRpcTask;
|
||||
private static volatile ClassLoader sAppClassLoader;
|
||||
private static volatile String sCachedUserId;
|
||||
private static volatile String sCachedSessionId;
|
||||
private static volatile String sCachedLoginId;
|
||||
/** 手动打开详情时抓到的真实请求,自动拉详情时克隆它 */
|
||||
private static volatile Object sTemplateDetailRequest;
|
||||
private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
|
||||
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "TngMmp-auto-detail");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private TngMoneyPacketHook() {
|
||||
}
|
||||
|
||||
@@ -46,6 +70,7 @@ public final class TngMoneyPacketHook {
|
||||
if (!TngRootBypassHook.isTargetPackage(lpparam.packageName)) {
|
||||
return;
|
||||
}
|
||||
sAppClassLoader = lpparam.classLoader;
|
||||
hookOkHttpUrl(lpparam);
|
||||
hookResponseBody(lpparam);
|
||||
hookGsonFromJson(lpparam);
|
||||
@@ -184,9 +209,9 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
|
||||
private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
|
||||
// 只挂 TNG 封装层,避免与底层 RpcInvocationHandler 双触发导致重复
|
||||
String[] rpcClasses = {
|
||||
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
|
||||
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
|
||||
};
|
||||
XC_MethodHook hook = new XC_MethodHook() {
|
||||
@Override
|
||||
@@ -194,14 +219,26 @@ public final class TngMoneyPacketHook {
|
||||
if (param.hasThrowable() || param.getResult() == null) {
|
||||
return;
|
||||
}
|
||||
cacheMoneyPacketRpcTask(param.args);
|
||||
cacheLoginFromInvokeArgs(param.args);
|
||||
String op = extractMoneyPacketRpcOp(param.args);
|
||||
if (op == null) {
|
||||
return;
|
||||
}
|
||||
Object result = param.getResult();
|
||||
// 历史列表:无领取排行,但可用 activityId 自动拉详情
|
||||
if (isHistoryRpcOp(op)) {
|
||||
XposedBridge.log(TAG + " history hit → schedule auto detail");
|
||||
scheduleAutoDetailsFromHistory(result);
|
||||
return;
|
||||
}
|
||||
if (!isDetailRpcOp(op)) {
|
||||
return;
|
||||
}
|
||||
String className = result.getClass().getName();
|
||||
XposedBridge.log(TAG + " rpc hit op=" + op + " type=" + className);
|
||||
try {
|
||||
Object detailReq = extractRpcRequestArg(param.args);
|
||||
String json = buildJsonFromObject(result);
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
json = tryFastjson(result);
|
||||
@@ -210,9 +247,20 @@ public final class TngMoneyPacketHook {
|
||||
XposedBridge.log(TAG + " rpc serialize empty op=" + op);
|
||||
return;
|
||||
}
|
||||
forwardParsedJson(json, op, "rpc");
|
||||
MmpSnapshot probe = parseSnapshot(json);
|
||||
if (probe == null || probe.claims.isEmpty()) {
|
||||
if (probe != null && !probe.claims.isEmpty()) {
|
||||
if (detailReq != null) {
|
||||
sTemplateDetailRequest = cloneRequestShallow(detailReq);
|
||||
cacheLoginFromRequestObject(detailReq);
|
||||
XposedBridge.log(TAG + " cached detail request template"
|
||||
+ " userId=" + stringField(detailReq, "userId", "getUserId")
|
||||
+ " session=" + abbreviate(stringField(detailReq,
|
||||
"sessionId", "getSessionId"))
|
||||
+ " page=" + stringField(detailReq, "page", "getPage")
|
||||
+ " max=" + stringField(detailReq, "maxResult", "getMaxResult"));
|
||||
}
|
||||
forwardParsedJson(json, op, "rpc");
|
||||
} else {
|
||||
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
|
||||
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
|
||||
}
|
||||
@@ -241,6 +289,462 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isHistoryRpcOp(String op) {
|
||||
if (TextUtils.isEmpty(op)) {
|
||||
return false;
|
||||
}
|
||||
String lower = op.toLowerCase(Locale.US);
|
||||
return lower.contains("historylist")
|
||||
|| lower.contains("history")
|
||||
|| lower.contains("list.retrieve")
|
||||
|| lower.equals("moneypackethistorylist");
|
||||
}
|
||||
|
||||
/** 只要详情/领取查询,历史列表走 auto-detail */
|
||||
private static boolean isDetailRpcOp(String op) {
|
||||
if (TextUtils.isEmpty(op) || isHistoryRpcOp(op)) {
|
||||
return false;
|
||||
}
|
||||
String lower = op.toLowerCase(Locale.US);
|
||||
return lower.contains("detail")
|
||||
|| lower.contains("retrieve")
|
||||
|| lower.contains("claim.result")
|
||||
|| lower.contains("leaderboard")
|
||||
|| lower.contains("moneypacketdetail");
|
||||
}
|
||||
|
||||
/** InvocationHandler.invoke(proxy, method, args) → 缓存 MoneyPacketRpcTask 代理 */
|
||||
private static void cacheMoneyPacketRpcTask(Object[] args) {
|
||||
if (args == null || args.length == 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Object proxy = args[0];
|
||||
if (proxy == null || !java.lang.reflect.Proxy.isProxyClass(proxy.getClass())) {
|
||||
return;
|
||||
}
|
||||
if (sMoneyPacketRpcTask != null) {
|
||||
return;
|
||||
}
|
||||
for (Class<?> iface : proxy.getClass().getInterfaces()) {
|
||||
String name = iface.getName();
|
||||
if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) {
|
||||
sMoneyPacketRpcTask = proxy;
|
||||
XposedBridge.log(TAG + " cached RPC task " + name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
proxy.getClass().getMethod("moneyPacketDetail",
|
||||
XposedHelpers.findClass(
|
||||
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
|
||||
sAppClassLoader));
|
||||
sMoneyPacketRpcTask = proxy;
|
||||
XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从历史/详情请求里偷登录态,供自动拉详情复用 */
|
||||
private static void cacheLoginFromInvokeArgs(Object[] args) {
|
||||
cacheLoginFromRequestObject(extractRpcRequestArg(args));
|
||||
}
|
||||
|
||||
private static void cacheLoginFromRequestObject(Object req) {
|
||||
if (req == null) {
|
||||
return;
|
||||
}
|
||||
String userId = stringField(req, "userId", "getUserId");
|
||||
String sessionId = stringField(req, "sessionId", "getSessionId");
|
||||
String loginId = stringField(req, "loginId", "getLoginId");
|
||||
if (!TextUtils.isEmpty(userId)) {
|
||||
sCachedUserId = userId;
|
||||
}
|
||||
if (!TextUtils.isEmpty(sessionId)) {
|
||||
sCachedSessionId = sessionId;
|
||||
}
|
||||
if (!TextUtils.isEmpty(loginId)) {
|
||||
sCachedLoginId = loginId;
|
||||
}
|
||||
}
|
||||
|
||||
private static String abbreviate(String s) {
|
||||
if (TextUtils.isEmpty(s)) {
|
||||
return "-";
|
||||
}
|
||||
return s.length() <= 8 ? s : s.substring(0, 4) + "…" + s.substring(s.length() - 4);
|
||||
}
|
||||
|
||||
private static Object cloneRequestShallow(Object src) {
|
||||
if (src == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Object copy = src.getClass().getDeclaredConstructor().newInstance();
|
||||
copyAllFields(src, copy);
|
||||
return copy;
|
||||
} catch (Throwable t) {
|
||||
// Kotlin data class 可能无无参构造,直接复用引用(自动拉取在单线程)
|
||||
return src;
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyAllFields(Object src, Object dst) {
|
||||
Class<?> c = src.getClass();
|
||||
while (c != null && c != Object.class) {
|
||||
for (java.lang.reflect.Field f : c.getDeclaredFields()) {
|
||||
if (f.getName().contains("$")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
f.setAccessible(true);
|
||||
f.set(dst, f.get(src));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
c = c.getSuperclass();
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyActivityToRequest(Object req, String activityId, String senderUserId) {
|
||||
try {
|
||||
XposedHelpers.setObjectField(req, "activityId", activityId);
|
||||
} catch (Throwable ignored) {
|
||||
trySet(req, "setActivityId", activityId);
|
||||
}
|
||||
try {
|
||||
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
|
||||
} catch (Throwable ignored) {
|
||||
trySet(req, "setSenderUserId", senderUserId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static Object extractRpcRequestArg(Object[] args) {
|
||||
if (args == null) {
|
||||
return null;
|
||||
}
|
||||
// InvocationHandler.invoke(proxy, method, Object[] methodArgs)
|
||||
if (args.length >= 3 && args[2] instanceof Object[]) {
|
||||
Object[] methodArgs = (Object[]) args[2];
|
||||
if (methodArgs.length > 0 && methodArgs[0] != null) {
|
||||
String n = methodArgs[0].getClass().getName();
|
||||
if (n.contains("Request") || n.contains("moneypacket") || n.contains("MoneyPacket")) {
|
||||
return methodArgs[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Object arg : args) {
|
||||
if (arg == null || arg instanceof Method || arg instanceof String) {
|
||||
continue;
|
||||
}
|
||||
if (arg instanceof Object[]) {
|
||||
continue;
|
||||
}
|
||||
String n = arg.getClass().getName();
|
||||
if (n.contains("Request") && (n.contains("MoneyPacket") || n.contains("moneypacket")
|
||||
|| n.contains("Mmp"))) {
|
||||
return arg;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void scheduleAutoDetailsFromHistory(Object historyResult) {
|
||||
if (historyResult == null) {
|
||||
return;
|
||||
}
|
||||
AUTO_DETAIL_EXEC.execute(() -> {
|
||||
try {
|
||||
List<String[]> jobs = extractHistoryJobs(historyResult);
|
||||
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
|
||||
for (String[] job : jobs) {
|
||||
final String activityId = job[0];
|
||||
final String senderUserId = job[1];
|
||||
long now = System.currentTimeMillis();
|
||||
synchronized (AUTO_DETAIL_AT) {
|
||||
Long last = AUTO_DETAIL_AT.get(activityId);
|
||||
if (last != null && now - last < AUTO_DETAIL_COOLDOWN_MS) {
|
||||
continue;
|
||||
}
|
||||
if (!AUTO_DETAIL_PENDING.add(activityId)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
try {
|
||||
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
|
||||
if (ok) {
|
||||
synchronized (AUTO_DETAIL_AT) {
|
||||
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
synchronized (AUTO_DETAIL_AT) {
|
||||
AUTO_DETAIL_PENDING.remove(activityId);
|
||||
}
|
||||
}
|
||||
try {
|
||||
Thread.sleep(400);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " auto-detail schedule failed: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static List<String[]> extractHistoryJobs(Object historyResult) {
|
||||
List<String[]> jobs = new ArrayList<>();
|
||||
try {
|
||||
Object infos = null;
|
||||
try {
|
||||
infos = XposedHelpers.callMethod(historyResult, "getParticipantInfos");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (infos == null) {
|
||||
try {
|
||||
infos = XposedHelpers.getObjectField(historyResult, "participantInfos");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
if (!(infos instanceof Iterable)) {
|
||||
return jobs;
|
||||
}
|
||||
for (Object info : (Iterable<?>) infos) {
|
||||
if (info == null) {
|
||||
continue;
|
||||
}
|
||||
String activityId = stringField(info, "activityId", "getActivityId");
|
||||
String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
|
||||
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
|
||||
jobs.add(new String[]{activityId, senderUserId});
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
private static String stringField(Object obj, String field, String getter) {
|
||||
try {
|
||||
Object v = XposedHelpers.callMethod(obj, getter);
|
||||
if (v != null) {
|
||||
return String.valueOf(v);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
Object v = XposedHelpers.getObjectField(obj, field);
|
||||
if (v != null) {
|
||||
return String.valueOf(v);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
|
||||
ClassLoader cl = sAppClassLoader;
|
||||
Object task = sMoneyPacketRpcTask;
|
||||
if (cl == null || task == null) {
|
||||
XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Class<?> reqCls = XposedHelpers.findClass(
|
||||
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
|
||||
cl);
|
||||
Object req;
|
||||
if (sTemplateDetailRequest != null
|
||||
&& reqCls.isInstance(sTemplateDetailRequest)) {
|
||||
req = cloneRequestShallow(sTemplateDetailRequest);
|
||||
applyActivityToRequest(req, activityId, senderUserId);
|
||||
XposedBridge.log(TAG + " auto-detail using template request id=" + activityId);
|
||||
} else {
|
||||
req = newDetailRequest(reqCls, activityId, senderUserId);
|
||||
if (req == null) {
|
||||
XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId);
|
||||
return false;
|
||||
}
|
||||
fillLoginOnRequest(req, cl);
|
||||
}
|
||||
cacheLoginFromRequestObject(req);
|
||||
XposedBridge.log(TAG + " auto-detail req id=" + activityId
|
||||
+ " userId=" + stringField(req, "userId", "getUserId")
|
||||
+ " session=" + abbreviate(stringField(req, "sessionId", "getSessionId"))
|
||||
+ " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId"))
|
||||
+ " page=" + stringField(req, "page", "getPage")
|
||||
+ " max=" + stringField(req, "maxResult", "getMaxResult"));
|
||||
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))
|
||||
&& TextUtils.isEmpty(sCachedSessionId)) {
|
||||
XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
|
||||
+ " (先手动点开任意一个红包详情一次以缓存模板)");
|
||||
return false;
|
||||
}
|
||||
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))
|
||||
&& !TextUtils.isEmpty(sCachedSessionId)) {
|
||||
fillLoginOnRequest(req, cl);
|
||||
}
|
||||
Object result = XposedHelpers.callMethod(task, "moneyPacketDetail", req);
|
||||
if (result == null) {
|
||||
XposedBridge.log(TAG + " auto-detail null result id=" + activityId);
|
||||
return false;
|
||||
}
|
||||
String json = buildJsonFromObject(result);
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
json = tryFastjson(result);
|
||||
}
|
||||
if (TextUtils.isEmpty(json)) {
|
||||
XposedBridge.log(TAG + " auto-detail empty json id=" + activityId);
|
||||
return false;
|
||||
}
|
||||
MmpSnapshot probe = parseSnapshot(json);
|
||||
if (probe == null || probe.claims.isEmpty()) {
|
||||
String snippet = json.length() > 240 ? json.substring(0, 240) + "..." : json;
|
||||
XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId
|
||||
+ " body=" + snippet
|
||||
+ " userId=" + stringField(req, "userId", "getUserId"));
|
||||
return false;
|
||||
}
|
||||
forwardParsedJson(json, "auto:" + activityId, "rpc");
|
||||
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
|
||||
+ " claims=" + probe.claims.size());
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) {
|
||||
// 优先: (activityId, senderUserId, loadTime, page, maxResult)
|
||||
try {
|
||||
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 20);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 20,
|
||||
31, null);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
Object req = reqCls.getDeclaredConstructor().newInstance();
|
||||
XposedHelpers.setObjectField(req, "activityId", activityId);
|
||||
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
|
||||
try {
|
||||
XposedHelpers.setIntField(req, "page", 0);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
XposedHelpers.setIntField(req, "maxResult", 20);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return req;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void fillLoginOnRequest(Object req, ClassLoader cl) {
|
||||
// 1) 优先用历史请求里缓存的登录态
|
||||
trySet(req, "setUserId", sCachedUserId);
|
||||
trySet(req, "setSessionId", sCachedSessionId);
|
||||
trySet(req, "setLoginId", sCachedLoginId);
|
||||
try {
|
||||
if (!TextUtils.isEmpty(sCachedUserId)) {
|
||||
XposedHelpers.setObjectField(req, "userId", sCachedUserId);
|
||||
}
|
||||
if (!TextUtils.isEmpty(sCachedSessionId)) {
|
||||
XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
|
||||
}
|
||||
if (!TextUtils.isEmpty(sCachedLoginId)) {
|
||||
XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (!TextUtils.isEmpty(stringField(req, "userId", "getUserId"))
|
||||
&& !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
|
||||
return;
|
||||
}
|
||||
// 2) Hilt EntryPoint 兜底
|
||||
try {
|
||||
Object repo = obtainMoneyPacketRepository(cl);
|
||||
if (repo == null) {
|
||||
return;
|
||||
}
|
||||
Object loginStorage = null;
|
||||
try {
|
||||
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (loginStorage == null) {
|
||||
return;
|
||||
}
|
||||
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
|
||||
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
|
||||
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
|
||||
trySet(req, "setUserId", accountId);
|
||||
trySet(req, "setSessionId", sessionId);
|
||||
trySet(req, "setLoginId", loginId);
|
||||
if (accountId != null) {
|
||||
sCachedUserId = String.valueOf(accountId);
|
||||
}
|
||||
if (sessionId != null) {
|
||||
sCachedSessionId = String.valueOf(sessionId);
|
||||
}
|
||||
if (loginId != null) {
|
||||
sCachedLoginId = String.valueOf(loginId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " fillLogin skip: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void trySet(Object req, String setter, Object value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
XposedHelpers.callMethod(req, setter, value);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
|
||||
try {
|
||||
Context ctx = getContext();
|
||||
if (ctx == null) {
|
||||
return null;
|
||||
}
|
||||
Class<?> entryPoint = Class.forName(
|
||||
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint",
|
||||
false, cl);
|
||||
Class<?> entryPoints = Class.forName("dagger.hilt.EntryPoints", false, cl);
|
||||
Object ep = XposedHelpers.callStaticMethod(
|
||||
entryPoints, "get", ctx.getApplicationContext(), entryPoint);
|
||||
try {
|
||||
return XposedHelpers.callMethod(ep, "repository");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
for (Method m : ep.getClass().getMethods()) {
|
||||
if (m.getParameterTypes().length == 0
|
||||
&& m.getReturnType().getName().contains("MoneyPacketRepository")) {
|
||||
return m.invoke(ep);
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " obtain repo fail: " + t.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extractMoneyPacketRpcOp(Object[] args) {
|
||||
if (args == null) {
|
||||
return null;
|
||||
@@ -279,8 +783,7 @@ public final class TngMoneyPacketHook {
|
||||
if (snapshot == null || snapshot.claims.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String dedupKey = snapshot.dedupKey();
|
||||
if (!remember(dedupKey)) {
|
||||
if (!shouldForwardPacket(snapshot)) {
|
||||
return;
|
||||
}
|
||||
Context context = getContext();
|
||||
@@ -290,9 +793,12 @@ public final class TngMoneyPacketHook {
|
||||
return;
|
||||
}
|
||||
String title = TextUtils.isEmpty(snapshot.title) ? "TNG 红包" : snapshot.title;
|
||||
if (!TextUtils.isEmpty(snapshot.packetId) && snapshot.packetId.length() >= 8) {
|
||||
title = title + " · " + snapshot.packetId.substring(0, 8);
|
||||
}
|
||||
String content = snapshot.formatForForward(channel, sourceHint);
|
||||
HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
|
||||
XposedBridge.log(TAG + " captured packet=" + snapshot.packetId
|
||||
XposedBridge.log(TAG + " captured activityId=" + snapshot.packetId
|
||||
+ " claims=" + snapshot.claims.size() + " via " + channel);
|
||||
}
|
||||
|
||||
@@ -384,14 +890,16 @@ public final class TngMoneyPacketHook {
|
||||
|| "claimers".equals(k)
|
||||
|| "packetreceiverlist".equals(k)
|
||||
|| "receivers".equals(k)
|
||||
|| "activitypoolinfos".equals(k)
|
||||
|| k.contains("receiverlist")
|
||||
|| k.contains("leaderboard");
|
||||
|| k.contains("leaderboard")
|
||||
|| k.contains("activitypool");
|
||||
}
|
||||
|
||||
private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) {
|
||||
// 勿用裸 amount/id:会把领取金额或嵌套 id 误当成红包总额/编号
|
||||
putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "packetCode",
|
||||
"fundOrderId", "orderNo", "bizNo", "bizOrderId", "activityId");
|
||||
// activityId 是红包唯一标识(UUID),优先于其它 id 字段
|
||||
putIfPresent(obj, snapshot, "activityId", "packetId", "mmpId", "moneyPacketId",
|
||||
"packetCode", "fundOrderId", "orderNo", "bizNo", "bizOrderId");
|
||||
putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId");
|
||||
putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName");
|
||||
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
|
||||
@@ -417,11 +925,16 @@ public final class TngMoneyPacketHook {
|
||||
continue;
|
||||
}
|
||||
String keyLower = key.toLowerCase(Locale.US);
|
||||
if (keyLower.contains("packet") || keyLower.contains("mmp")
|
||||
|| keyLower.contains("order") || keyLower.contains("biz")
|
||||
|| keyLower.contains("activity") || keyLower.equals("packetcode")) {
|
||||
if (keyLower.equals("activityid") || keyLower.contains("packet")
|
||||
|| keyLower.contains("mmp") || keyLower.contains("order")
|
||||
|| keyLower.contains("biz") || keyLower.equals("packetcode")) {
|
||||
// 已有 UUID 形态的 activityId 时不覆盖
|
||||
if (TextUtils.isEmpty(snapshot.packetId) && !looksLikeMoneyJson(val)) {
|
||||
snapshot.packetId = val;
|
||||
} else if (!TextUtils.isEmpty(snapshot.packetId)
|
||||
&& keyLower.equals("activityid")
|
||||
&& looksLikeActivityId(val)) {
|
||||
snapshot.packetId = val;
|
||||
}
|
||||
} else if (keyLower.contains("group") || keyLower.contains("chat")
|
||||
|| keyLower.contains("conversation")) {
|
||||
@@ -447,6 +960,14 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean looksLikeActivityId(String val) {
|
||||
if (TextUtils.isEmpty(val)) {
|
||||
return false;
|
||||
}
|
||||
// b8c37a58-bb2c-4aa2-83ef-f15466b9211e
|
||||
return val.length() >= 32 && val.indexOf('-') > 0;
|
||||
}
|
||||
|
||||
private static boolean looksLikeClaimRow(JSONObject obj) {
|
||||
return parseClaimLine(obj) != null;
|
||||
}
|
||||
@@ -683,6 +1204,43 @@ public final class TngMoneyPacketHook {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean shouldForwardPacket(MmpSnapshot snapshot) {
|
||||
String packetId = snapshot.packetId;
|
||||
if (TextUtils.isEmpty(packetId)) {
|
||||
packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
|
||||
snapshot.packetId = packetId;
|
||||
}
|
||||
String claimsFp = snapshot.claimsFingerprint();
|
||||
long now = System.currentTimeMillis();
|
||||
synchronized (RECENT_PACKET_AT) {
|
||||
Long lastAt = RECENT_PACKET_AT.get(packetId);
|
||||
String lastClaims = RECENT_PACKET_CLAIMS.get(packetId);
|
||||
if (lastAt != null && lastClaims != null
|
||||
&& claimsFp.equals(lastClaims)
|
||||
&& now - lastAt < PACKET_DEDUP_MS) {
|
||||
XposedBridge.log(TAG + " skip dup activityId=" + packetId
|
||||
+ " ageMs=" + (now - lastAt));
|
||||
return false;
|
||||
}
|
||||
RECENT_PACKET_AT.put(packetId, now);
|
||||
RECENT_PACKET_CLAIMS.put(packetId, claimsFp);
|
||||
// 顺带清理过期项,防止无限涨
|
||||
if (RECENT_PACKET_AT.size() > DEDUP_SIZE) {
|
||||
java.util.Iterator<java.util.Map.Entry<String, Long>> it =
|
||||
RECENT_PACKET_AT.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
java.util.Map.Entry<String, Long> e = it.next();
|
||||
if (now - e.getValue() > PACKET_DEDUP_MS * 3) {
|
||||
String k = e.getKey();
|
||||
it.remove();
|
||||
RECENT_PACKET_CLAIMS.remove(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean remember(String key) {
|
||||
if (TextUtils.isEmpty(key)) {
|
||||
return false;
|
||||
@@ -734,6 +1292,12 @@ public final class TngMoneyPacketHook {
|
||||
String dedupKey() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(TextUtils.isEmpty(packetId) ? "?" : packetId).append('|');
|
||||
sb.append(claimsFingerprint());
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String claimsFingerprint() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (ClaimLine line : claims) {
|
||||
sb.append(line.nickname).append('=').append(line.amount).append(';');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user