feat(tng): 红包领取台双通道推送,并修复金额/按包分组

Hook Quake RPC 抓 Money Packet;解析 Money 对象金额;调试台按单个红包展示排行;支持 USB 本机与共享网段双地址转发。
This commit is contained in:
mars
2026-08-04 15:09:28 +08:00
parent e80f7f908c
commit 88a2b319f8
4 changed files with 751 additions and 88 deletions

View File

@@ -49,6 +49,7 @@ public final class TngMoneyPacketHook {
hookOkHttpUrl(lpparam);
hookResponseBody(lpparam);
hookGsonFromJson(lpparam);
hookQuakeRpc(lpparam);
XposedBridge.log(TAG + " installed for " + lpparam.packageName);
}
@@ -167,11 +168,110 @@ public final class TngMoneyPacketHook {
if (TextUtils.isEmpty(className)) {
return false;
}
return className.contains("MmpDetailResult")
|| className.contains("MmpClaimQueryResult")
|| className.contains("MmpClaimResult")
|| className.contains("MmpReceiver")
|| className.contains("MmpDetailLeaderboard");
String n = className;
return n.contains("MmpDetail")
|| n.contains("MmpClaim")
|| n.contains("MmpReceiver")
|| n.contains("MmpLeaderboard")
|| n.contains("MoneyPacketDetail")
|| n.contains("MoneyPacketClaim")
|| n.contains("MoneyPacketLeader")
|| n.contains("MoneyPacketDoClaim")
|| n.contains("ClaimResultDto")
|| n.contains("ClaimDto")
|| n.contains("ReceiverList")
|| n.contains("packetReceiver");
}
private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
};
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.hasThrowable() || param.getResult() == null) {
return;
}
String op = extractMoneyPacketRpcOp(param.args);
if (op == null) {
return;
}
Object result = param.getResult();
String className = result.getClass().getName();
XposedBridge.log(TAG + " rpc hit op=" + op + " type=" + className);
try {
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
json = tryFastjson(result);
}
if (TextUtils.isEmpty(json)) {
XposedBridge.log(TAG + " rpc serialize empty op=" + op);
return;
}
forwardParsedJson(json, op, "rpc");
MmpSnapshot probe = parseSnapshot(json);
if (probe == null || probe.claims.isEmpty()) {
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " rpc capture failed: " + t.getMessage());
}
}
};
for (String className : rpcClasses) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!"invoke".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, hook);
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked RPC " + className + " invoke=" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage());
}
}
}
private static String extractMoneyPacketRpcOp(Object[] args) {
if (args == null) {
return null;
}
for (Object arg : args) {
if (arg instanceof Method) {
String name = ((Method) arg).getName().toLowerCase(Locale.US);
if (name.contains("moneypacket") || name.contains("mmp")) {
return ((Method) arg).getName();
}
} else if (arg instanceof String) {
String text = (String) arg;
String lower = text.toLowerCase(Locale.US);
if (lower.contains("moneypacket") || lower.contains(".mmp")
|| lower.contains("tngdwallet.money")) {
return text;
}
}
}
return null;
}
private static String tryFastjson(Object obj) {
try {
Class<?> json = Class.forName("com.alibaba.fastjson.JSON", false,
obj.getClass().getClassLoader());
Object out = XposedHelpers.callStaticMethod(json, "toJSONString", obj);
return out != null ? String.valueOf(out) : null;
} catch (Throwable ignored) {
return null;
}
}
private static void forwardParsedJson(String json, String sourceHint, String channel) {
@@ -185,9 +285,11 @@ public final class TngMoneyPacketHook {
}
Context context = getContext();
if (context == null) {
XposedBridge.log(TAG + " skip forward: no context, claims="
+ snapshot.claims.size());
return;
}
String title = TextUtils.isEmpty(snapshot.title) ? "TNG Money Packet" : snapshot.title;
String title = TextUtils.isEmpty(snapshot.title) ? "TNG 红包" : snapshot.title;
String content = snapshot.formatForForward(channel, sourceHint);
HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
XposedBridge.log(TAG + " captured packet=" + snapshot.packetId
@@ -205,7 +307,11 @@ public final class TngMoneyPacketHook {
private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) {
MmpSnapshot snapshot = base != null ? base : new MmpSnapshot();
fillMeta(root, snapshot);
boolean rootPass = base == null;
// 领取行不再回填 meta其它嵌套节点可补 packetId/sender/total
if (rootPass || parseClaimLine(root) == null) {
fillMeta(root, snapshot);
}
JSONArray receiverList = findReceiverList(root);
if (receiverList != null) {
@@ -233,7 +339,7 @@ public final class TngMoneyPacketHook {
Object elem = arr.opt(i);
if (elem instanceof JSONObject) {
ClaimLine line = parseClaimLine((JSONObject) elem);
if (line != null && looksLikeClaimRow((JSONObject) elem)) {
if (line != null) {
snapshot.claims.add(line);
}
parseSnapshot((JSONObject) elem, snapshot);
@@ -241,6 +347,9 @@ public final class TngMoneyPacketHook {
}
}
}
if (rootPass && TextUtils.isEmpty(snapshot.packetId) && !snapshot.claims.isEmpty()) {
snapshot.packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
}
return snapshot;
}
@@ -249,7 +358,7 @@ public final class TngMoneyPacketHook {
while (keys.hasNext()) {
String key = keys.next();
Object val = obj.opt(key);
if ("receiverList".equalsIgnoreCase(key) && val instanceof JSONArray) {
if (val instanceof JSONArray && isReceiverListKey(key)) {
return (JSONArray) val;
}
if (val instanceof JSONObject) {
@@ -262,12 +371,32 @@ public final class TngMoneyPacketHook {
return null;
}
private static boolean isReceiverListKey(String key) {
if (TextUtils.isEmpty(key)) {
return false;
}
String k = key.toLowerCase(Locale.US);
return "receiverlist".equals(k)
|| "leaderboardlist".equals(k)
|| "leaderboard".equals(k)
|| "rankinglist".equals(k)
|| "claimlist".equals(k)
|| "claimers".equals(k)
|| "packetreceiverlist".equals(k)
|| "receivers".equals(k)
|| k.contains("receiverlist")
|| k.contains("leaderboard");
}
private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) {
putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "id");
// 勿用裸 amount/id会把领取金额或嵌套 id 误当成红包总额/编号
putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "packetCode",
"fundOrderId", "orderNo", "bizNo", "bizOrderId", "activityId");
putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId");
putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName");
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName");
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "amount");
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
"totalPacketAmount", "originalAmount");
if (TextUtils.isEmpty(snapshot.title)) {
String merchant = firstNonEmpty(
obj.optString("merchantName", null),
@@ -283,70 +412,142 @@ public final class TngMoneyPacketHook {
if (!obj.has(key)) {
continue;
}
String val = obj.optString(key, null);
String val = readFieldText(obj, key);
if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) {
continue;
}
if (key.toLowerCase(Locale.US).contains("packet") || key.equals("mmpId") || key.equals("id")) {
if (TextUtils.isEmpty(snapshot.packetId)) {
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 (TextUtils.isEmpty(snapshot.packetId) && !looksLikeMoneyJson(val)) {
snapshot.packetId = val;
}
} else if (key.toLowerCase(Locale.US).contains("group")) {
if (TextUtils.isEmpty(snapshot.groupId)) {
} else if (keyLower.contains("group") || keyLower.contains("chat")
|| keyLower.contains("conversation")) {
if (keyLower.contains("name")) {
if (TextUtils.isEmpty(snapshot.title)) {
snapshot.title = val;
}
} else if (TextUtils.isEmpty(snapshot.groupId)) {
snapshot.groupId = val;
}
} else if (key.toLowerCase(Locale.US).contains("name") && key.toLowerCase(Locale.US).contains("group")) {
snapshot.title = val;
} else if (key.toLowerCase(Locale.US).contains("sender")
|| key.toLowerCase(Locale.US).contains("operator")) {
if (TextUtils.isEmpty(snapshot.senderName)) {
} else if (keyLower.contains("sender") || keyLower.contains("operator")
|| keyLower.contains("creator")) {
if (TextUtils.isEmpty(snapshot.senderName) && !looksLikeMoneyJson(val)) {
snapshot.senderName = val;
}
} else if (key.toLowerCase(Locale.US).contains("total") || key.equals("amount")) {
if (TextUtils.isEmpty(snapshot.totalAmount)) {
snapshot.totalAmount = val;
} else if (keyLower.contains("total") || keyLower.contains("packetamount")
|| keyLower.contains("original")) {
String money = normalizeMoneyText(val);
if (TextUtils.isEmpty(snapshot.totalAmount) && !TextUtils.isEmpty(money)) {
snapshot.totalAmount = money;
}
}
}
}
private static boolean looksLikeClaimRow(JSONObject obj) {
String name = firstNonEmpty(
obj.optString("nickName", null),
obj.optString("displayName", null),
obj.optString("userName", null),
obj.optString("receiverName", null));
String amount = firstNonEmpty(
obj.optString("claimedAmount", null),
obj.optString("receiveAmount", null),
obj.optString("amount", null));
return !TextUtils.isEmpty(name) && !TextUtils.isEmpty(amount);
return parseClaimLine(obj) != null;
}
private static ClaimLine parseClaimLine(JSONObject obj) {
String name = firstNonEmpty(
obj.optString("nickName", null),
obj.optString("displayName", null),
obj.optString("userName", null),
obj.optString("receiverName", null),
obj.optString("name", null));
readFieldText(obj, "nickName"),
readFieldText(obj, "nickname"),
readFieldText(obj, "displayName"),
readFieldText(obj, "userName"),
readFieldText(obj, "receiverName"),
readFieldText(obj, "receiverAccountName"),
readFieldText(obj, "accountName"),
readFieldText(obj, "participantName"),
readFieldText(obj, "name"));
String amount = firstNonEmpty(
obj.optString("claimedAmount", null),
obj.optString("receiveAmount", null),
obj.optString("amount", null));
normalizeMoneyText(readFieldText(obj, "claimedAmount")),
normalizeMoneyText(readFieldText(obj, "receiveAmount")),
normalizeMoneyText(readFieldText(obj, "receiverClaimedAmount")),
normalizeMoneyText(readFieldText(obj, "claimAmount")),
normalizeMoneyText(readFieldText(obj, "amount")));
if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) {
return null;
}
if (name.length() > 64 || looksLikeMoneyJson(name)) {
return null;
}
ClaimLine line = new ClaimLine();
line.nickname = name;
line.nickname = name.trim();
line.amount = amount;
line.claimTime = firstNonEmpty(
obj.optString("claimTime", null),
obj.optString("claimedTime", null),
obj.optString("receiveTime", null));
readFieldText(obj, "claimTime"),
readFieldText(obj, "claimedTime"),
readFieldText(obj, "receiveTime"),
readFieldText(obj, "gmtClaim"),
readFieldText(obj, "gmtCreate"),
readFieldText(obj, "time"));
return line;
}
/** 读取字段:兼容 Money 对象 / 嵌套 JSON / 普通字符串。 */
private static String readFieldText(JSONObject obj, String key) {
if (obj == null || TextUtils.isEmpty(key) || !obj.has(key)) {
return null;
}
Object raw = obj.opt(key);
if (raw == null || raw == JSONObject.NULL) {
return null;
}
if (raw instanceof JSONObject) {
return normalizeMoneyText(((JSONObject) raw).toString());
}
String text = String.valueOf(raw).trim();
if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
return null;
}
if (looksLikeMoneyJson(text)) {
return normalizeMoneyText(text);
}
return text;
}
private static boolean looksLikeMoneyJson(String text) {
if (TextUtils.isEmpty(text)) {
return false;
}
String t = text.trim();
return t.startsWith("{") && t.contains("\"amount\"");
}
private static String normalizeMoneyText(String text) {
if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
return null;
}
String t = text.trim().replace("RM", "").trim();
if (looksLikeMoneyJson(t)) {
try {
JSONObject money = new JSONObject(t);
String amount = money.optString("amount", null);
if (!TextUtils.isEmpty(amount) && !"null".equalsIgnoreCase(amount)) {
return amount.trim();
}
String cent = money.optString("cent", null);
if (!TextUtils.isEmpty(cent) && !"null".equalsIgnoreCase(cent)) {
try {
return String.format(Locale.US, "%.2f", Integer.parseInt(cent) / 100.0);
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
// 纯数字或 1.00
try {
return String.format(Locale.US, "%.2f", Double.parseDouble(t.replace(",", "")));
} catch (Throwable ignored) {
return t;
}
}
private static String buildJsonFromObject(Object root) {
JSONObject json = objectToJson(root, new HashSet<Integer>(), 0);
return json != null ? json.toString() : null;
@@ -541,23 +742,24 @@ public final class TngMoneyPacketHook {
String formatForForward(String channel, String sourceHint) {
StringBuilder sb = new StringBuilder();
sb.append("[MMP统计] ");
sb.append("[MMP统计]");
if (!TextUtils.isEmpty(packetId)) {
sb.append("packet=").append(packetId).append(' ');
sb.append(" packet=").append(sanitizeMeta(packetId));
}
if (!TextUtils.isEmpty(groupId)) {
sb.append("group=").append(groupId).append(' ');
sb.append(" | group=").append(sanitizeMeta(groupId));
}
if (!TextUtils.isEmpty(senderName)) {
sb.append("sender=").append(senderName).append(' ');
sb.append(" | sender=").append(sanitizeMeta(senderName));
}
if (!TextUtils.isEmpty(totalAmount)) {
sb.append("total=").append(totalAmount).append(' ');
sb.append(" | total=").append(sanitizeMeta(totalAmount));
}
sb.append("via=").append(channel);
sb.append(" | via=").append(sanitizeMeta(channel));
if (!TextUtils.isEmpty(sourceHint)) {
sb.append(" src=").append(sourceHint.length() > 120
? sourceHint.substring(0, 120) + "..." : sourceHint);
String src = sourceHint.length() > 120
? sourceHint.substring(0, 120) + "..." : sourceHint;
sb.append(" | src=").append(sanitizeMeta(src));
}
sb.append("\n");
for (ClaimLine line : claims) {
@@ -569,6 +771,14 @@ public final class TngMoneyPacketHook {
}
return sb.toString().trim();
}
private static String sanitizeMeta(String value) {
if (value == null) {
return "";
}
// 避免空格拆坏 meta竖线是分隔符
return value.replace('|', '/').trim();
}
}
private static final class ClaimLine {