feat(tng): 自动拉红包历史/详情,并展示发放与领取时间
从 ILoginStorage 与官方历史页缓存请求模板,级联拉详情;领取台列表用发放时间而非拉取时间。
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
@@ -178,6 +179,11 @@ def _parse_mmp_content(content):
|
||||
if "total" in meta:
|
||||
total_num = _normalize_money(meta["total"])
|
||||
meta["total"] = ("{0:.2f}".format(total_num) if total_num is not None else meta["total"])
|
||||
# 发放时间:issued= / createTime= / issuedAt=
|
||||
for key in ("issued", "createTime", "issuedAt", "create"):
|
||||
if meta.get(key):
|
||||
meta["issued"] = meta[key]
|
||||
break
|
||||
for line in lines[1:]:
|
||||
line = line.strip()
|
||||
if not line or "->" not in line:
|
||||
@@ -212,22 +218,28 @@ def _claims_fingerprint(claims):
|
||||
|
||||
|
||||
def _aggregate_claims(claims):
|
||||
"""同一快照内同昵称合并;跨快照不应再混加(外层只取最新快照)。"""
|
||||
"""同一快照内同昵称只保留一笔(取较大金额),红包每人只领一次,禁止累加导致翻倍。"""
|
||||
buckets = {}
|
||||
order = []
|
||||
for c in claims:
|
||||
nick = c.get("nickname") or "?"
|
||||
try:
|
||||
amt = float(c.get("amountValue") if c.get("amountValue") is not None
|
||||
else _normalize_money(c.get("amount")) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
amt = 0.0
|
||||
if nick not in buckets:
|
||||
buckets[nick] = {"nickname": nick, "amount": 0.0, "count": 0, "claimTime": ""}
|
||||
buckets[nick] = {"nickname": nick, "amount": amt, "count": 1,
|
||||
"claimTime": c.get("claimTime") or ""}
|
||||
order.append(nick)
|
||||
continue
|
||||
b = buckets[nick]
|
||||
b["count"] += 1
|
||||
try:
|
||||
b["amount"] += float(c.get("amountValue") if c.get("amountValue") is not None
|
||||
else _normalize_money(c.get("amount")) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if c.get("claimTime") and not b["claimTime"]:
|
||||
if amt >= b["amount"]:
|
||||
b["amount"] = amt
|
||||
if c.get("claimTime"):
|
||||
b["claimTime"] = c.get("claimTime") or b["claimTime"]
|
||||
elif c.get("claimTime") and not b["claimTime"]:
|
||||
b["claimTime"] = c.get("claimTime") or ""
|
||||
result = []
|
||||
for nick in order:
|
||||
@@ -259,6 +271,7 @@ def _mmp_packets():
|
||||
# 用领取名单指纹区分不同红包
|
||||
packet_id = "红包-" + _claims_fingerprint(claims)[:48]
|
||||
key = str(packet_id)
|
||||
issued = meta.get("issued") or ""
|
||||
item = {
|
||||
"packetId": key,
|
||||
"title": msg.get("title") or "TNG 红包",
|
||||
@@ -266,7 +279,9 @@ def _mmp_packets():
|
||||
"group": meta.get("group") or "",
|
||||
"total": meta.get("total") or "",
|
||||
"via": meta.get("via") or "",
|
||||
"latestAt": msg.get("receivedAt") or "",
|
||||
"issuedAt": issued,
|
||||
"fetchedAt": msg.get("receivedAt") or "",
|
||||
"latestAt": issued or (msg.get("receivedAt") or ""),
|
||||
"snapshots": 1,
|
||||
"claims": claims,
|
||||
"rawMessages": [{
|
||||
@@ -277,15 +292,27 @@ def _mmp_packets():
|
||||
}],
|
||||
}
|
||||
existing = packets.get(key)
|
||||
if existing is None or (item["latestAt"] or "") >= (existing.get("latestAt") or ""):
|
||||
# 优先保留「有发放时间 + 领取更全」的快照;时间戳用发放时间排序
|
||||
def _score(it):
|
||||
return (
|
||||
1 if it.get("issuedAt") else 0,
|
||||
len(it.get("claims") or []),
|
||||
it.get("fetchedAt") or "",
|
||||
)
|
||||
if existing is None or _score(item) >= _score(existing):
|
||||
if existing is not None:
|
||||
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
|
||||
# 保留历史原始报文,但排行只用最新 claims
|
||||
item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"]
|
||||
if not item.get("issuedAt") and existing.get("issuedAt"):
|
||||
item["issuedAt"] = existing["issuedAt"]
|
||||
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
|
||||
packets[key] = item
|
||||
else:
|
||||
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
|
||||
existing["rawMessages"].append(item["rawMessages"][0])
|
||||
if not existing.get("issuedAt") and issued:
|
||||
existing["issuedAt"] = issued
|
||||
existing["latestAt"] = issued
|
||||
result = []
|
||||
for p in packets.values():
|
||||
ranked = _aggregate_claims(p.get("claims") or [])
|
||||
@@ -305,10 +332,19 @@ def _mmp_packets():
|
||||
p["total"] = "{0:.2f}".format(total_num)
|
||||
del p["claims"]
|
||||
result.append(p)
|
||||
result.sort(key=lambda x: x.get("latestAt") or "", reverse=True)
|
||||
result.sort(key=lambda x: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def _mmp_time_sort_key(text):
|
||||
"""把 13/07/2026 11:54:44 / ISO 等统一成可比较字符串。"""
|
||||
s = (text or "").strip()
|
||||
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}:\d{2}(?::\d{2})?))?", s)
|
||||
if m:
|
||||
return "{0}-{1}-{2} {3}".format(m.group(3), m.group(2), m.group(1), m.group(4) or "")
|
||||
return s
|
||||
|
||||
|
||||
def _mmp_html_page():
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
@@ -371,7 +407,7 @@ def _mmp_html_page():
|
||||
<main class="main">
|
||||
<div id="empty" class="empty">
|
||||
暂无红包数据<br/>
|
||||
<div class="hint">打开 Money Packet 历史页可自动拉取;若没有数据,先手动点开任意一条详情一次(缓存登录模板),再回历史页</div>
|
||||
<div class="hint">登录 TNG 后停留任意页面即可自动拉历史并拉详情;也可打开 Money Packet 历史页加速。无需再手动点详情。</div>
|
||||
</div>
|
||||
<div id="panel" style="display:none">
|
||||
<h2 class="title" id="title">-</h2>
|
||||
@@ -420,7 +456,7 @@ def _mmp_html_page():
|
||||
div.onclick = () => { activeId = p.packetId; loadData(); };
|
||||
div.innerHTML = `
|
||||
<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>`;
|
||||
<div class="pkt-meta">${p.claimantCount || 0} 人领取 · ${esc(p.issuedAt ? ('发放 ' + p.issuedAt) : '发放时间未知')}</div>`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
renderActive();
|
||||
@@ -440,6 +476,7 @@ def _mmp_html_page():
|
||||
const parts = [];
|
||||
if (p.packetId) parts.push('activityId ' + p.packetId);
|
||||
if (p.sender) parts.push('发送人 ' + p.sender);
|
||||
if (p.issuedAt) parts.push('发放 ' + p.issuedAt);
|
||||
if (p.group) parts.push('群组 ' + p.group);
|
||||
if (p.via) parts.push('来源 ' + (p.via === 'http' ? '网络' : p.via === 'gson' ? '解析' : p.via === 'rpc' ? 'RPC' : p.via));
|
||||
document.getElementById('sub').textContent = parts.join(' · ') || '单个红包领取排行';
|
||||
|
||||
@@ -55,31 +55,35 @@ powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
|
||||
|
||||
消费端详情领取列表字段主要是 **`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
|
||||
1. 打开 TNG 并完成登录 / PIN(任意前台页面即可)
|
||||
2. Hook 从 `ILoginStorage` / 历史请求自动取 `sessionId`;`Activity.onResume` 约每 **45 秒**主动调一次 `moneyPacketHistoryList`(主线程)
|
||||
3. 历史返回后自动按 `activityId` 在主线程串行拉详情(间隔约 200ms),推送到领取台
|
||||
4. 仅打开 **Money Packet 历史页**时,App 自己的列表响应也会立刻触发步骤 3
|
||||
5. 打开 http://127.0.0.1:8765/mmp 刷新查看
|
||||
|
||||
若跳过第 2 步,自动请求常返回 `totalCount:0`(缺 `sessionId` 等),台上会没有领取人。
|
||||
日志期望:
|
||||
|
||||
也可继续「只点详情」:每进一个详情就会捕获该 `activityId` 的排行。
|
||||
- `hooked ILoginStorage getters` / `login from storage session=…` / `cached history request template`
|
||||
- `history hit → schedule auto detail` 或 `auto-history ok → schedule details`
|
||||
- `auto-detail ok activityId=… claims=N`
|
||||
|
||||
若仍无数据:确认已安装最新 Xposed 模块并强停重启 TNG;看是否出现 `auto-detail empty claims` / `no sessionId`。
|
||||
|
||||
## 去重与刷新行为
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 标识 | 以 `activityId` 区分红包 |
|
||||
| 短时去重 | 同一 `activityId` + **相同领取名单**,约 **20 秒内**不重复推送(防双 Hook / 连点) |
|
||||
| 可刷新 | 超过 20 秒再进详情,或领取名单变化,会再次推送 |
|
||||
| sessionId | 自动:`ILoginStorage` / Repository.loginStorage;有真实请求时也会缓存 |
|
||||
| 自动历史 | 前台 `onResume` 触发,约 **45 秒**冷却;也可由首次缓存到 RpcTask 触发 |
|
||||
| 短时去重 | 同一 `activityId` + **相同领取名单**,约 **20 秒内**不重复推送 |
|
||||
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后约 **60 秒**内不重复自动拉 |
|
||||
| 调试台 | 同一 `activityId` 只保留最新完整快照做排行;双通道推送有短时内容去重 |
|
||||
| 包间间隔 | 自动拉详情串行,约 **150ms**/条 |
|
||||
| 调试台 | 同一 `activityId` 只保留最新完整快照;双通道短时内容去重 |
|
||||
|
||||
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。
|
||||
|
||||
@@ -95,10 +99,11 @@ 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` | 自动拉成功 |
|
||||
| `cached detail request template` | 已缓存手动详情请求(可选加速) |
|
||||
| `login from storage session=…` | 已从 ILoginStorage 自动取到 session |
|
||||
| `auto-history ok → schedule details` | 已自动拉历史列表 |
|
||||
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或服务端空结果 |
|
||||
| `auto-detail ok activityId=... claims=N` | 自动拉详情成功 |
|
||||
| `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 |
|
||||
|
||||
其它检查:
|
||||
|
||||
@@ -26,8 +26,9 @@
|
||||
### 要点
|
||||
|
||||
1. 历史列表只有摘要;领取排行来自详情 RPC(`activityPoolInfos`)。
|
||||
2. **自动拉详情**:先手动点开任意一条详情缓存请求模板(含 session),再回历史页即可批量拉。
|
||||
3. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。
|
||||
2. **sessionId** 从 `ILoginStorage` 自动读取,一般不必再手动点详情。
|
||||
3. **自动拉历史**:前台 Activity resume(约 45s 冷却)主动调 `moneyPacketHistoryList`,再级联拉详情。
|
||||
4. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。
|
||||
|
||||
### eKYC「验证您的帐户」强制页(2026-08-04)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.miraclegarden.smsmessage.xposed.hook;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.miraclegarden.smsmessage.xposed.HookBridge;
|
||||
@@ -49,13 +51,27 @@ public final class TngMoneyPacketHook {
|
||||
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;
|
||||
/** activityId → 历史列表里的发放时间 createTime */
|
||||
private static final java.util.HashMap<String, String> ACTIVITY_ISSUE_TIME =
|
||||
new java.util.HashMap<>();
|
||||
/** 自动拉历史列表冷却(无需打开历史页) */
|
||||
private static final long AUTO_HISTORY_COOLDOWN_MS = 45_000L;
|
||||
private static volatile long sLastAutoHistoryAt;
|
||||
private static volatile boolean sAutoHistoryRunning;
|
||||
/** 本线程正在主动拉历史,避免 Quake afterHook 再 schedule 一遍 */
|
||||
private static final ThreadLocal<Boolean> AUTO_HISTORY_SELF = new ThreadLocal<>();
|
||||
private static volatile Object sMoneyPacketRepository;
|
||||
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;
|
||||
/** 手动打开详情时抓到的真实请求,自动拉详情时克隆它 */
|
||||
/** 真实详情/历史请求模板(有则克隆;无则用 ILoginStorage 自造) */
|
||||
private static volatile Object sTemplateDetailRequest;
|
||||
private static volatile Object sTemplateHistoryRequest;
|
||||
/** 最近一次带登录态的请求(历史/详情),用于给自造详情请求补 session */
|
||||
private static volatile Object sLoginDonorRequest;
|
||||
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
|
||||
private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
|
||||
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "TngMmp-auto-detail");
|
||||
@@ -75,6 +91,8 @@ public final class TngMoneyPacketHook {
|
||||
hookResponseBody(lpparam);
|
||||
hookGsonFromJson(lpparam);
|
||||
hookQuakeRpc(lpparam);
|
||||
hookLoginStorage(lpparam);
|
||||
hookActivityResumeForAutoHistory(lpparam);
|
||||
XposedBridge.log(TAG + " installed for " + lpparam.packageName);
|
||||
}
|
||||
|
||||
@@ -209,9 +227,10 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
|
||||
private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
|
||||
// 只挂 TNG 封装层,避免与底层 RpcInvocationHandler 双触发导致重复
|
||||
// TNG 封装 + Quake 底层都挂,避免历史/详情只走其中一条链路时漏抓
|
||||
String[] rpcClasses = {
|
||||
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
|
||||
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
|
||||
};
|
||||
XC_MethodHook hook = new XC_MethodHook() {
|
||||
@Override
|
||||
@@ -225,9 +244,25 @@ public final class TngMoneyPacketHook {
|
||||
if (op == null) {
|
||||
return;
|
||||
}
|
||||
XposedBridge.log(TAG + " rpc op=" + op
|
||||
+ " via=" + param.thisObject.getClass().getSimpleName());
|
||||
Object result = param.getResult();
|
||||
// 历史列表:无领取排行,但可用 activityId 自动拉详情
|
||||
if (isHistoryRpcOp(op)) {
|
||||
Object historyReq = extractRpcRequestArg(param.args);
|
||||
if (historyReq != null) {
|
||||
sTemplateHistoryRequest = cloneRequestShallow(historyReq);
|
||||
sLoginDonorRequest = historyReq;
|
||||
cacheLoginFromRequestObject(historyReq);
|
||||
XposedBridge.log(TAG + " cached history request template"
|
||||
+ " session=" + abbreviate(stringField(historyReq,
|
||||
"sessionId", "getSessionId")));
|
||||
}
|
||||
// 无论是否自己触发的历史请求,都先缓存发放时间
|
||||
cacheIssueTimesFromHistory(result);
|
||||
if (Boolean.TRUE.equals(AUTO_HISTORY_SELF.get())) {
|
||||
return;
|
||||
}
|
||||
XposedBridge.log(TAG + " history hit → schedule auto detail");
|
||||
scheduleAutoDetailsFromHistory(result);
|
||||
return;
|
||||
@@ -248,9 +283,11 @@ public final class TngMoneyPacketHook {
|
||||
return;
|
||||
}
|
||||
MmpSnapshot probe = parseSnapshot(json);
|
||||
enrichSnapshotFromJavaResult(result, probe);
|
||||
if (probe != null && !probe.claims.isEmpty()) {
|
||||
if (detailReq != null) {
|
||||
sTemplateDetailRequest = cloneRequestShallow(detailReq);
|
||||
sLoginDonorRequest = detailReq;
|
||||
cacheLoginFromRequestObject(detailReq);
|
||||
XposedBridge.log(TAG + " cached detail request template"
|
||||
+ " userId=" + stringField(detailReq, "userId", "getUserId")
|
||||
@@ -259,7 +296,7 @@ public final class TngMoneyPacketHook {
|
||||
+ " page=" + stringField(detailReq, "page", "getPage")
|
||||
+ " max=" + stringField(detailReq, "maxResult", "getMaxResult"));
|
||||
}
|
||||
forwardParsedJson(json, op, "rpc");
|
||||
forwardSnapshot(probe, op, "rpc");
|
||||
} else {
|
||||
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
|
||||
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
|
||||
@@ -287,6 +324,105 @@ public final class TngMoneyPacketHook {
|
||||
XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage());
|
||||
}
|
||||
}
|
||||
hookMoneyPacketRepository(lpparam);
|
||||
}
|
||||
|
||||
/** DI 创建 Repository 时立刻缓存 task + login,不必等用户进红包页 */
|
||||
private static void hookMoneyPacketRepository(XC_LoadPackage.LoadPackageParam lpparam) {
|
||||
try {
|
||||
Class<?> repoCls = XposedHelpers.findClass(
|
||||
"my.com.tngdigital.funding.moneypacket.common.data.repo.MoneyPacketRepositoryImpl",
|
||||
lpparam.classLoader);
|
||||
XposedBridge.hookAllConstructors(repoCls, new XC_MethodHook() {
|
||||
@Override
|
||||
protected void afterHookedMethod(MethodHookParam param) {
|
||||
captureRepoInstance(param.thisObject, "ctor");
|
||||
}
|
||||
});
|
||||
// Dagger 可能先无参构造再注入字段;在业务方法入口再捕获一次
|
||||
for (String methodName : new String[]{
|
||||
"getMoneyPacketList", "getMoneyPacketDetail", "doClaim", "preClaim"}) {
|
||||
try {
|
||||
for (Method m : repoCls.getDeclaredMethods()) {
|
||||
if (!methodName.equals(m.getName())) {
|
||||
continue;
|
||||
}
|
||||
XposedBridge.hookMethod(m, new XC_MethodHook() {
|
||||
@Override
|
||||
protected void beforeHookedMethod(MethodHookParam param) {
|
||||
captureRepoInstance(param.thisObject, "method:" + methodName);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
XposedBridge.log(TAG + " hooked MoneyPacketRepositoryImpl");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " repo hook skip: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void captureRepoInstance(Object repo, String reason) {
|
||||
if (repo == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sMoneyPacketRepository = repo;
|
||||
Object task = null;
|
||||
try {
|
||||
task = XposedHelpers.getObjectField(repo, "task");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (task != null && sMoneyPacketRpcTask == null) {
|
||||
sMoneyPacketRpcTask = task;
|
||||
XposedBridge.log(TAG + " cached RPC task from repo (" + reason + ")");
|
||||
}
|
||||
Object loginStorage = null;
|
||||
try {
|
||||
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
applyLoginStorage(loginStorage);
|
||||
if (sMoneyPacketRpcTask != null && !TextUtils.isEmpty(sCachedSessionId)) {
|
||||
scheduleAutoHistory("repo-" + reason);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " captureRepo fail: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 与 App 一致:发 RPC 前走 Repository.addNetworkHandle,否则易 RpcApiException / 空结果 */
|
||||
private static void prepareRpcRequest(Object req) {
|
||||
if (req == null) {
|
||||
return;
|
||||
}
|
||||
Object repo = sMoneyPacketRepository;
|
||||
if (repo == null) {
|
||||
repo = obtainMoneyPacketRepository(sAppClassLoader);
|
||||
if (repo != null) {
|
||||
sMoneyPacketRepository = repo;
|
||||
}
|
||||
}
|
||||
if (repo == null) {
|
||||
XposedBridge.log(TAG + " prepareRpc skip: no repository");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// addNetworkHandle$default(repo, request, processor=null, onError=null, mask=3, marker=null)
|
||||
XposedHelpers.callStaticMethod(repo.getClass(), "addNetworkHandle$default",
|
||||
repo, req, null, null, 3, null);
|
||||
XposedBridge.log(TAG + " prepareRpc addNetworkHandle$default ok");
|
||||
return;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " addNetworkHandle$default fail: " + t.getMessage());
|
||||
}
|
||||
try {
|
||||
XposedHelpers.callMethod(repo, "addNetworkHandle", req, null, null);
|
||||
XposedBridge.log(TAG + " prepareRpc addNetworkHandle ok");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " addNetworkHandle fail: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isHistoryRpcOp(String op) {
|
||||
@@ -331,6 +467,7 @@ public final class TngMoneyPacketHook {
|
||||
if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) {
|
||||
sMoneyPacketRpcTask = proxy;
|
||||
XposedBridge.log(TAG + " cached RPC task " + name);
|
||||
scheduleAutoHistory("rpc-task-cached");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -341,6 +478,7 @@ public final class TngMoneyPacketHook {
|
||||
sAppClassLoader));
|
||||
sMoneyPacketRpcTask = proxy;
|
||||
XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method");
|
||||
scheduleAutoHistory("rpc-task-cached");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
@@ -456,46 +594,60 @@ public final class TngMoneyPacketHook {
|
||||
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;
|
||||
}
|
||||
// 解析放调用线程;真正 RPC 必须丢到主线程(Quake/登录态常绑主线程)
|
||||
final List<String[]> jobs;
|
||||
try {
|
||||
jobs = extractHistoryJobs(historyResult);
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
|
||||
return;
|
||||
}
|
||||
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
|
||||
if (jobs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long delayMs = 0L;
|
||||
for (String[] job : jobs) {
|
||||
final String activityId = job[0];
|
||||
final String senderUserId = job[1];
|
||||
final String createTime = job.length > 2 ? job[2] : null;
|
||||
if (!TextUtils.isEmpty(createTime)) {
|
||||
synchronized (ACTIVITY_ISSUE_TIME) {
|
||||
ACTIVITY_ISSUE_TIME.put(activityId, createTime);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
final long thisDelay = delayMs;
|
||||
delayMs += 250L;
|
||||
AUTO_DETAIL_EXEC.execute(() -> {
|
||||
try {
|
||||
if (thisDelay > 0) {
|
||||
Thread.sleep(thisDelay);
|
||||
}
|
||||
try {
|
||||
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
|
||||
if (ok) {
|
||||
synchronized (AUTO_DETAIL_AT) {
|
||||
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
|
||||
if (ok) {
|
||||
synchronized (AUTO_DETAIL_AT) {
|
||||
AUTO_DETAIL_PENDING.remove(activityId);
|
||||
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
try {
|
||||
Thread.sleep(400);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
synchronized (AUTO_DETAIL_AT) {
|
||||
AUTO_DETAIL_PENDING.remove(activityId);
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " auto-detail schedule failed: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String[]> extractHistoryJobs(Object historyResult) {
|
||||
@@ -521,8 +673,10 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
String activityId = stringField(info, "activityId", "getActivityId");
|
||||
String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
|
||||
String createTime = stringField(info, "createTime", "getCreateTime");
|
||||
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
|
||||
jobs.add(new String[]{activityId, senderUserId});
|
||||
jobs.add(new String[]{activityId, senderUserId,
|
||||
TextUtils.isEmpty(createTime) ? "" : createTime});
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
@@ -551,6 +705,7 @@ public final class TngMoneyPacketHook {
|
||||
|
||||
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
|
||||
ClassLoader cl = sAppClassLoader;
|
||||
ensureRpcTaskAndLogin(cl);
|
||||
Object task = sMoneyPacketRpcTask;
|
||||
if (cl == null || task == null) {
|
||||
XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
|
||||
@@ -572,25 +727,20 @@ public final class TngMoneyPacketHook {
|
||||
XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId);
|
||||
return false;
|
||||
}
|
||||
fillLoginOnRequest(req, cl);
|
||||
}
|
||||
cacheLoginFromRequestObject(req);
|
||||
fillLoginOnRequest(req, cl);
|
||||
prepareRpcRequest(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)) {
|
||||
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
|
||||
XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
|
||||
+ " (先手动点开任意一个红包详情一次以缓存模板)");
|
||||
+ " (ILoginStorage 尚未就绪,等登录后 Activity resume 会重试)");
|
||||
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);
|
||||
@@ -605,6 +755,7 @@ public final class TngMoneyPacketHook {
|
||||
return false;
|
||||
}
|
||||
MmpSnapshot probe = parseSnapshot(json);
|
||||
enrichSnapshotFromJavaResult(result, probe);
|
||||
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
|
||||
@@ -612,9 +763,10 @@ public final class TngMoneyPacketHook {
|
||||
+ " userId=" + stringField(req, "userId", "getUserId"));
|
||||
return false;
|
||||
}
|
||||
forwardParsedJson(json, "auto:" + activityId, "rpc");
|
||||
forwardSnapshot(probe, "auto:" + activityId, "rpc");
|
||||
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
|
||||
+ " claims=" + probe.claims.size());
|
||||
+ " claims=" + probe.claims.size()
|
||||
+ " issued=" + probe.issueTime);
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
|
||||
@@ -653,7 +805,20 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
|
||||
private static void fillLoginOnRequest(Object req, ClassLoader cl) {
|
||||
// 1) 优先用历史请求里缓存的登录态
|
||||
ensureRpcTaskAndLogin(cl);
|
||||
// 0) 从最近真实请求拷贝登录相关字段(历史页打开后即可,无需点详情)
|
||||
Object donor = sLoginDonorRequest;
|
||||
if (donor == null) {
|
||||
donor = sTemplateHistoryRequest;
|
||||
}
|
||||
if (donor == null) {
|
||||
donor = sTemplateDetailRequest;
|
||||
}
|
||||
if (donor != null && donor != req) {
|
||||
copyLoginFields(donor, req);
|
||||
cacheLoginFromRequestObject(donor);
|
||||
}
|
||||
// 1) 再用缓存的登录态覆盖
|
||||
trySet(req, "setUserId", sCachedUserId);
|
||||
trySet(req, "setSessionId", sCachedSessionId);
|
||||
trySet(req, "setLoginId", sCachedLoginId);
|
||||
@@ -673,37 +838,60 @@ public final class TngMoneyPacketHook {
|
||||
&& !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
|
||||
return;
|
||||
}
|
||||
// 2) Hilt EntryPoint 兜底
|
||||
// 2) 从 Repository.loginStorage / ILoginStorage 再读一遍
|
||||
Object loginStorage = obtainLoginStorage(cl);
|
||||
if (loginStorage == null) {
|
||||
return;
|
||||
}
|
||||
applyLoginStorage(loginStorage);
|
||||
trySet(req, "setUserId", sCachedUserId);
|
||||
trySet(req, "setSessionId", sCachedSessionId);
|
||||
trySet(req, "setLoginId", sCachedLoginId);
|
||||
try {
|
||||
Object repo = obtainMoneyPacketRepository(cl);
|
||||
if (repo == null) {
|
||||
return;
|
||||
if (!TextUtils.isEmpty(sCachedUserId)) {
|
||||
XposedHelpers.setObjectField(req, "userId", sCachedUserId);
|
||||
}
|
||||
Object loginStorage = null;
|
||||
if (!TextUtils.isEmpty(sCachedSessionId)) {
|
||||
XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
|
||||
}
|
||||
if (!TextUtils.isEmpty(sCachedLoginId)) {
|
||||
XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyLoginFields(Object src, Object dst) {
|
||||
if (src == null || dst == null) {
|
||||
return;
|
||||
}
|
||||
String[] keys = {"userId", "sessionId", "loginId", "securityId", "apiName"};
|
||||
for (String key : keys) {
|
||||
try {
|
||||
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
|
||||
Object v = null;
|
||||
try {
|
||||
v = XposedHelpers.getObjectField(src, key);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (v == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
XposedHelpers.setObjectField(dst, key, v);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
String setter = "set" + Character.toUpperCase(key.charAt(0)) + key.substring(1);
|
||||
trySet(dst, setter, v);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (loginStorage == null) {
|
||||
return;
|
||||
}
|
||||
// extParams 等 Map 也尽量带上
|
||||
try {
|
||||
Object ext = XposedHelpers.getObjectField(src, "extParams");
|
||||
if (ext != null) {
|
||||
XposedHelpers.setObjectField(dst, "extParams", ext);
|
||||
}
|
||||
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());
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -717,30 +905,344 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
}
|
||||
|
||||
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
|
||||
/** 被动缓存:App 任何地方读 session 时同步到本 Hook */
|
||||
private static void hookLoginStorage(XC_LoadPackage.LoadPackageParam lpparam) {
|
||||
// 不能 hook 接口抽象方法;只挂实现类
|
||||
try {
|
||||
Class<?> impl = XposedHelpers.findClass(
|
||||
"my.com.tngdigital.user.model.CacheLoginStorageImpl", lpparam.classLoader);
|
||||
XC_MethodHook cacheHook = new XC_MethodHook() {
|
||||
@Override
|
||||
protected void afterHookedMethod(MethodHookParam param) {
|
||||
Object v = param.getResult();
|
||||
if (v == null || TextUtils.isEmpty(String.valueOf(v))) {
|
||||
return;
|
||||
}
|
||||
String name = param.method.getName();
|
||||
if ("getSessionId".equals(name)) {
|
||||
sCachedSessionId = String.valueOf(v);
|
||||
} else if ("getAccountId".equals(name)) {
|
||||
sCachedUserId = String.valueOf(v);
|
||||
} else if ("getLoginId".equals(name)) {
|
||||
sCachedLoginId = String.valueOf(v);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (String m : new String[]{"getSessionId", "getAccountId", "getLoginId"}) {
|
||||
XposedBridge.hookAllMethods(impl, m, cacheHook);
|
||||
}
|
||||
XposedBridge.log(TAG + " hooked CacheLoginStorageImpl getters");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " CacheLoginStorageImpl hook skip: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 前台 Activity 恢复时自动拉历史列表(再级联详情),无需手动进红包历史页 */
|
||||
private static void hookActivityResumeForAutoHistory(XC_LoadPackage.LoadPackageParam lpparam) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
"android.app.Activity",
|
||||
lpparam.classLoader,
|
||||
"onResume",
|
||||
new XC_MethodHook() {
|
||||
@Override
|
||||
protected void afterHookedMethod(MethodHookParam param) {
|
||||
try {
|
||||
Object act = param.thisObject;
|
||||
if (act == null) {
|
||||
return;
|
||||
}
|
||||
String pkg = String.valueOf(
|
||||
XposedHelpers.callMethod(act, "getPackageName"));
|
||||
if (!PACKAGE.equals(pkg)) {
|
||||
return;
|
||||
}
|
||||
scheduleAutoHistory("activity-resume");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
});
|
||||
XposedBridge.log(TAG + " hooked Activity.onResume for auto history");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " Activity.onResume hook skip: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void scheduleAutoHistory(String reason) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (sAutoHistoryRunning) {
|
||||
return;
|
||||
}
|
||||
if (now - sLastAutoHistoryAt < AUTO_HISTORY_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
sAutoHistoryRunning = true;
|
||||
final String why = reason;
|
||||
AUTO_DETAIL_EXEC.execute(() -> {
|
||||
try {
|
||||
// 等登录/Hilt/Network 就绪;不要在主线程阻塞等 RPC
|
||||
Thread.sleep(why != null && why.startsWith("repo") ? 1500L : 800L);
|
||||
boolean ok = fetchHistoryListAuto(why);
|
||||
if (ok) {
|
||||
sLastAutoHistoryAt = System.currentTimeMillis();
|
||||
}
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
} finally {
|
||||
sAutoHistoryRunning = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 主动调 moneyPacketHistoryList,结果走现有 scheduleAutoDetailsFromHistory */
|
||||
private static boolean fetchHistoryListAuto(String reason) {
|
||||
ClassLoader cl = sAppClassLoader;
|
||||
ensureRpcTaskAndLogin(cl);
|
||||
Object task = sMoneyPacketRpcTask;
|
||||
if (cl == null || task == null) {
|
||||
XposedBridge.log(TAG + " auto-history skip: no rpc task (" + reason + ")");
|
||||
return false;
|
||||
}
|
||||
if (TextUtils.isEmpty(sCachedSessionId)) {
|
||||
Object ls = obtainLoginStorage(cl);
|
||||
if (ls != null) {
|
||||
applyLoginStorage(ls);
|
||||
}
|
||||
}
|
||||
if (TextUtils.isEmpty(sCachedSessionId)) {
|
||||
XposedBridge.log(TAG + " auto-history skip: no sessionId yet (" + reason + ")");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Class<?> reqCls = XposedHelpers.findClass(
|
||||
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketHistoryListRequest",
|
||||
cl);
|
||||
// 1) 已有官方请求模板(首次打开历史页后会缓存)→ 静默复用
|
||||
if (sTemplateHistoryRequest != null
|
||||
&& reqCls.isInstance(sTemplateHistoryRequest)) {
|
||||
Object req = cloneRequestShallow(sTemplateHistoryRequest);
|
||||
XposedBridge.log(TAG + " auto-history using template (" + reason + ")");
|
||||
fillLoginOnRequest(req, cl);
|
||||
prepareRpcRequest(req);
|
||||
AUTO_HISTORY_SELF.set(Boolean.TRUE);
|
||||
Object result;
|
||||
try {
|
||||
result = callHistoryListRpc(task, req);
|
||||
} finally {
|
||||
AUTO_HISTORY_SELF.remove();
|
||||
}
|
||||
if (result != null && historyHasJobs(result)) {
|
||||
XposedBridge.log(TAG + " auto-history ok(template) → schedule details");
|
||||
scheduleAutoDetailsFromHistory(result);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 2) 无模板时:自造请求易「非法参数」,直接拉起官方历史页让 App 发正确 RPC
|
||||
XposedBridge.log(TAG + " auto-history no template → open HistoryActivity (" + reason + ")");
|
||||
openMoneyPacketHistoryActivity();
|
||||
return false;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " auto-history fail: " + t.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean historyHasJobs(Object historyResult) {
|
||||
return !extractHistoryJobs(historyResult).isEmpty();
|
||||
}
|
||||
|
||||
/** 拉起官方历史页;App 自己的 moneyPacketHistoryList 成功后 Hook 会缓存模板并级联拉详情 */
|
||||
private static void openMoneyPacketHistoryActivity() {
|
||||
try {
|
||||
Context ctx = getContext();
|
||||
if (ctx == null) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
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);
|
||||
android.content.Intent intent = new android.content.Intent();
|
||||
intent.setClassName(PACKAGE,
|
||||
"my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION);
|
||||
ctx.startActivity(intent);
|
||||
XposedBridge.log(TAG + " started MoneyPacketHistoryActivity");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " start HistoryActivity fail: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Object callHistoryListRpc(Object task, Object req) {
|
||||
try {
|
||||
return XposedHelpers.callMethod(task, "moneyPacketHistoryList", req);
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " history rpc fail: " + t.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureRpcTaskAndLogin(ClassLoader cl) {
|
||||
if (cl == null) {
|
||||
return;
|
||||
}
|
||||
if (sMoneyPacketRpcTask == null || TextUtils.isEmpty(sCachedSessionId)) {
|
||||
Object repo = obtainMoneyPacketRepository(cl);
|
||||
if (repo != null) {
|
||||
if (sMoneyPacketRpcTask == null) {
|
||||
try {
|
||||
Object task = XposedHelpers.getObjectField(repo, "task");
|
||||
if (task != null) {
|
||||
sMoneyPacketRpcTask = task;
|
||||
XposedBridge.log(TAG + " cached RPC task from repository");
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
Object loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
|
||||
applyLoginStorage(loginStorage);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (TextUtils.isEmpty(sCachedSessionId)) {
|
||||
applyLoginStorage(obtainLoginStorage(cl));
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyLoginStorage(Object loginStorage) {
|
||||
if (loginStorage == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
|
||||
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
|
||||
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
|
||||
if (accountId != null && !TextUtils.isEmpty(String.valueOf(accountId))) {
|
||||
sCachedUserId = String.valueOf(accountId);
|
||||
}
|
||||
if (sessionId != null && !TextUtils.isEmpty(String.valueOf(sessionId))) {
|
||||
sCachedSessionId = String.valueOf(sessionId);
|
||||
}
|
||||
if (loginId != null && !TextUtils.isEmpty(String.valueOf(loginId))) {
|
||||
sCachedLoginId = String.valueOf(loginId);
|
||||
}
|
||||
if (!TextUtils.isEmpty(sCachedSessionId)) {
|
||||
XposedBridge.log(TAG + " login from storage session="
|
||||
+ abbreviate(sCachedSessionId)
|
||||
+ " userId=" + sCachedUserId);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " obtain repo fail: " + t.getMessage());
|
||||
XposedBridge.log(TAG + " applyLoginStorage skip: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static Object obtainLoginStorage(ClassLoader cl) {
|
||||
try {
|
||||
Object repo = obtainMoneyPacketRepository(cl);
|
||||
if (repo != null) {
|
||||
try {
|
||||
Object ls = XposedHelpers.getObjectField(repo, "loginStorage");
|
||||
if (ls != null) {
|
||||
return ls;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
// Hilt:尝试常见 EntryPoint 上返回 ILoginStorage 的无参方法
|
||||
try {
|
||||
Context ctx = getContext();
|
||||
if (ctx == null || cl == null) {
|
||||
return null;
|
||||
}
|
||||
String[] entryPoints = {
|
||||
"my.com.tngdigital.member.di.MemberEntryPoint",
|
||||
"my.com.tngdigital.user.di.UserEntryPoint",
|
||||
"my.com.tngdigital.common.di.CommonEntryPoint",
|
||||
};
|
||||
Class<?> entryPointsCls = Class.forName("dagger.hilt.EntryPoints", false, cl);
|
||||
for (String epName : entryPoints) {
|
||||
try {
|
||||
Class<?> epCls = Class.forName(epName, false, cl);
|
||||
Object ep = XposedHelpers.callStaticMethod(
|
||||
entryPointsCls, "get", ctx.getApplicationContext(), epCls);
|
||||
for (Method m : ep.getClass().getMethods()) {
|
||||
if (m.getParameterTypes().length != 0) {
|
||||
continue;
|
||||
}
|
||||
if (m.getReturnType().getName().contains("ILoginStorage")) {
|
||||
Object ls = m.invoke(ep);
|
||||
if (ls != null) {
|
||||
return ls;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
|
||||
Context ctx = getContext();
|
||||
if (ctx == null || cl == null) {
|
||||
return null;
|
||||
}
|
||||
Context appCtx = ctx.getApplicationContext() != null ? ctx.getApplicationContext() : ctx;
|
||||
Class<?> entryPoint;
|
||||
try {
|
||||
entryPoint = XposedHelpers.findClass(
|
||||
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint", cl);
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " MoneyPacketEntryPoint missing: " + t.getMessage());
|
||||
return null;
|
||||
}
|
||||
// 1) EntryPointAccessors.fromApplication(Hilt Android 推荐)
|
||||
try {
|
||||
Class<?> accessors = XposedHelpers.findClass(
|
||||
"dagger.hilt.android.EntryPointAccessors", cl);
|
||||
Object ep = XposedHelpers.callStaticMethod(
|
||||
accessors, "fromApplication", appCtx, entryPoint);
|
||||
Object repo = invokeRepository(ep);
|
||||
if (repo != null) {
|
||||
return repo;
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " EntryPointAccessors fail: " + t.getMessage());
|
||||
}
|
||||
// 2) EntryPoints.get
|
||||
try {
|
||||
Class<?> entryPoints = XposedHelpers.findClass("dagger.hilt.EntryPoints", cl);
|
||||
Object ep = XposedHelpers.callStaticMethod(
|
||||
entryPoints, "get", appCtx, entryPoint);
|
||||
Object repo = invokeRepository(ep);
|
||||
if (repo != null) {
|
||||
return repo;
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " EntryPoints.get fail: " + t.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Object invokeRepository(Object ep) {
|
||||
if (ep == null) {
|
||||
return null;
|
||||
}
|
||||
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")) {
|
||||
try {
|
||||
return m.invoke(ep);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -783,6 +1285,15 @@ public final class TngMoneyPacketHook {
|
||||
if (snapshot == null || snapshot.claims.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
forwardSnapshot(snapshot, sourceHint, channel);
|
||||
}
|
||||
|
||||
private static void forwardSnapshot(MmpSnapshot snapshot, String sourceHint, String channel) {
|
||||
if (snapshot == null || snapshot.claims.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
dedupeClaims(snapshot);
|
||||
snapshot.applyCachedIssueTime();
|
||||
if (!shouldForwardPacket(snapshot)) {
|
||||
return;
|
||||
}
|
||||
@@ -799,7 +1310,158 @@ public final class TngMoneyPacketHook {
|
||||
String content = snapshot.formatForForward(channel, sourceHint);
|
||||
HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
|
||||
XposedBridge.log(TAG + " captured activityId=" + snapshot.packetId
|
||||
+ " claims=" + snapshot.claims.size() + " via " + channel);
|
||||
+ " claims=" + snapshot.claims.size()
|
||||
+ " issued=" + snapshot.issueTime
|
||||
+ " via " + channel);
|
||||
}
|
||||
|
||||
/** 直接从 Java 结果对象读 gmtModified / createTime,不依赖 JSON 字段是否序列化成功 */
|
||||
private static void enrichSnapshotFromJavaResult(Object result, MmpSnapshot snapshot) {
|
||||
if (result == null || snapshot == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Object pools = null;
|
||||
try {
|
||||
pools = XposedHelpers.callMethod(result, "getActivityPoolInfos");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (pools == null) {
|
||||
try {
|
||||
pools = XposedHelpers.getObjectField(result, "activityPoolInfos");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
if (pools instanceof Iterable) {
|
||||
for (Object info : (Iterable<?>) pools) {
|
||||
if (info == null) {
|
||||
continue;
|
||||
}
|
||||
String name = stringField(info, "receiverName", "getReceiverName");
|
||||
String claimTime = firstNonEmpty(
|
||||
stringField(info, "gmtModified", "getGmtModified"),
|
||||
stringField(info, "gmtCreate", "getGmtCreate"),
|
||||
stringField(info, "createTime", "getCreateTime"));
|
||||
String amount = null;
|
||||
try {
|
||||
Object money = XposedHelpers.callMethod(info, "getAmount");
|
||||
if (money != null) {
|
||||
amount = normalizeMoneyText(String.valueOf(
|
||||
XposedHelpers.callMethod(money, "getAmount")));
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (TextUtils.isEmpty(amount)) {
|
||||
amount = normalizeMoneyText(stringField(info, "amount", "getAmount"));
|
||||
}
|
||||
if (TextUtils.isEmpty(name)) {
|
||||
continue;
|
||||
}
|
||||
boolean matched = false;
|
||||
for (ClaimLine line : snapshot.claims) {
|
||||
if (name.equals(line.nickname)) {
|
||||
if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(claimTime)) {
|
||||
line.claimTime = claimTime;
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (!matched && !TextUtils.isEmpty(amount)) {
|
||||
ClaimLine line = new ClaimLine();
|
||||
line.nickname = name;
|
||||
line.amount = amount;
|
||||
line.claimTime = claimTime;
|
||||
snapshot.claims.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
Object summary = null;
|
||||
try {
|
||||
summary = XposedHelpers.callMethod(result, "getSummaryInfo");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (summary == null) {
|
||||
try {
|
||||
summary = XposedHelpers.getObjectField(result, "summaryInfo");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
if (summary != null) {
|
||||
if (TextUtils.isEmpty(snapshot.senderName)) {
|
||||
snapshot.senderName = stringField(summary, "senderName", "getSenderName");
|
||||
}
|
||||
if (TextUtils.isEmpty(snapshot.packetId)) {
|
||||
snapshot.packetId = stringField(summary, "activityId", "getActivityId");
|
||||
}
|
||||
if (TextUtils.isEmpty(snapshot.totalAmount)) {
|
||||
try {
|
||||
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
|
||||
if (money != null) {
|
||||
snapshot.totalAmount = normalizeMoneyText(String.valueOf(
|
||||
XposedHelpers.callMethod(money, "getAmount")));
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshot.applyCachedIssueTime();
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " enrich times failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void cacheIssueTimesFromHistory(Object historyResult) {
|
||||
if (historyResult == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (String[] job : extractHistoryJobs(historyResult)) {
|
||||
if (job.length > 2 && looksLikeActivityId(job[0]) && !TextUtils.isEmpty(job[2])) {
|
||||
synchronized (ACTIVITY_ISSUE_TIME) {
|
||||
ACTIVITY_ISSUE_TIME.put(job[0], job[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
XposedBridge.log(TAG + " cached issue times size=" + ACTIVITY_ISSUE_TIME.size());
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " cache issue times failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void dedupeClaims(MmpSnapshot snapshot) {
|
||||
if (snapshot == null || snapshot.claims.size() <= 1) {
|
||||
return;
|
||||
}
|
||||
java.util.LinkedHashMap<String, ClaimLine> map = new java.util.LinkedHashMap<>();
|
||||
for (ClaimLine line : snapshot.claims) {
|
||||
if (line == null || TextUtils.isEmpty(line.nickname)) {
|
||||
continue;
|
||||
}
|
||||
ClaimLine old = map.get(line.nickname);
|
||||
if (old == null) {
|
||||
map.put(line.nickname, line);
|
||||
continue;
|
||||
}
|
||||
// 同昵称保留金额更大的;时间有则补上
|
||||
try {
|
||||
double a = Double.parseDouble(old.amount);
|
||||
double b = Double.parseDouble(line.amount);
|
||||
if (b > a) {
|
||||
if (TextUtils.isEmpty(line.claimTime)) {
|
||||
line.claimTime = old.claimTime;
|
||||
}
|
||||
map.put(line.nickname, line);
|
||||
} else if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
|
||||
old.claimTime = line.claimTime;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
|
||||
old.claimTime = line.claimTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshot.claims.clear();
|
||||
snapshot.claims.addAll(map.values());
|
||||
}
|
||||
|
||||
private static MmpSnapshot parseSnapshot(String json) {
|
||||
@@ -905,6 +1567,8 @@ public final class TngMoneyPacketHook {
|
||||
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
|
||||
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
|
||||
"totalPacketAmount", "originalAmount");
|
||||
putIfPresent(obj, snapshot, "createTime", "createdTime", "gmtCreate", "issueTime",
|
||||
"issuedTime", "sendTime", "packetCreateTime");
|
||||
if (TextUtils.isEmpty(snapshot.title)) {
|
||||
String merchant = firstNonEmpty(
|
||||
obj.optString("merchantName", null),
|
||||
@@ -925,7 +1589,13 @@ public final class TngMoneyPacketHook {
|
||||
continue;
|
||||
}
|
||||
String keyLower = key.toLowerCase(Locale.US);
|
||||
if (keyLower.equals("activityid") || keyLower.contains("packet")
|
||||
if (keyLower.contains("createtime") || keyLower.contains("created")
|
||||
|| keyLower.contains("gmtcreate") || keyLower.contains("issue")
|
||||
|| keyLower.contains("sendtime")) {
|
||||
if (TextUtils.isEmpty(snapshot.issueTime) && !looksLikeMoneyJson(val)) {
|
||||
snapshot.issueTime = val;
|
||||
}
|
||||
} else if (keyLower.equals("activityid") || keyLower.contains("packet")
|
||||
|| keyLower.contains("mmp") || keyLower.contains("order")
|
||||
|| keyLower.contains("biz") || keyLower.equals("packetcode")) {
|
||||
// 已有 UUID 形态的 activityId 时不覆盖
|
||||
@@ -1003,7 +1673,10 @@ public final class TngMoneyPacketHook {
|
||||
readFieldText(obj, "claimedTime"),
|
||||
readFieldText(obj, "receiveTime"),
|
||||
readFieldText(obj, "gmtClaim"),
|
||||
readFieldText(obj, "gmtModified"),
|
||||
readFieldText(obj, "gmtModify"),
|
||||
readFieldText(obj, "gmtCreate"),
|
||||
readFieldText(obj, "createTime"),
|
||||
readFieldText(obj, "time"));
|
||||
return line;
|
||||
}
|
||||
@@ -1287,6 +1960,8 @@ public final class TngMoneyPacketHook {
|
||||
String title;
|
||||
String senderName;
|
||||
String totalAmount;
|
||||
/** 发放时间(历史 createTime 或详情里的 createTime) */
|
||||
String issueTime;
|
||||
final List<ClaimLine> claims = new ArrayList<>();
|
||||
|
||||
String dedupKey() {
|
||||
@@ -1304,7 +1979,31 @@ public final class TngMoneyPacketHook {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
void applyCachedIssueTime() {
|
||||
if (!TextUtils.isEmpty(issueTime) || TextUtils.isEmpty(packetId)) {
|
||||
return;
|
||||
}
|
||||
synchronized (ACTIVITY_ISSUE_TIME) {
|
||||
String cached = ACTIVITY_ISSUE_TIME.get(packetId);
|
||||
if (!TextUtils.isEmpty(cached)) {
|
||||
issueTime = cached;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String formatForForward(String channel, String sourceHint) {
|
||||
applyCachedIssueTime();
|
||||
// 详情无发放时间时,用最早领取时间兜底(仍优于拉取时间)
|
||||
if (TextUtils.isEmpty(issueTime)) {
|
||||
for (ClaimLine line : claims) {
|
||||
if (!TextUtils.isEmpty(line.claimTime)) {
|
||||
if (TextUtils.isEmpty(issueTime)
|
||||
|| line.claimTime.compareTo(issueTime) < 0) {
|
||||
issueTime = line.claimTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("[MMP统计]");
|
||||
if (!TextUtils.isEmpty(packetId)) {
|
||||
@@ -1319,6 +2018,9 @@ public final class TngMoneyPacketHook {
|
||||
if (!TextUtils.isEmpty(totalAmount)) {
|
||||
sb.append(" | total=").append(sanitizeMeta(totalAmount));
|
||||
}
|
||||
if (!TextUtils.isEmpty(issueTime)) {
|
||||
sb.append(" | issued=").append(sanitizeMeta(issueTime));
|
||||
}
|
||||
sb.append(" | via=").append(sanitizeMeta(channel));
|
||||
if (!TextUtils.isEmpty(sourceHint)) {
|
||||
String src = sourceHint.length() > 120
|
||||
|
||||
Reference in New Issue
Block a user