feat(tng): 自动拉红包历史/详情,并展示发放与领取时间

从 ILoginStorage 与官方历史页缓存请求模板,级联拉详情;领取台列表用发放时间而非拉取时间。
This commit is contained in:
mars
2026-08-04 16:20:13 +08:00
parent 1e2021d8fd
commit 1517ff4671
4 changed files with 873 additions and 128 deletions

View File

@@ -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.fromApplicationHilt 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