From 88a2b319f8ff178a324b017384d496ff9c73c55d Mon Sep 17 00:00:00 2001
From: mars <3361409208@qq.com>
Date: Tue, 4 Aug 2026 15:09:28 +0800
Subject: [PATCH] =?UTF-8?q?feat(tng):=20=E7=BA=A2=E5=8C=85=E9=A2=86?=
=?UTF-8?q?=E5=8F=96=E5=8F=B0=E5=8F=8C=E9=80=9A=E9=81=93=E6=8E=A8=E9=80=81?=
=?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=E9=87=91=E9=A2=9D/?=
=?UTF-8?q?=E6=8C=89=E5=8C=85=E5=88=86=E7=BB=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Hook Quake RPC 抓 Money Packet;解析 Money 对象金额;调试台按单个红包展示排行;支持 USB 本机与共享网段双地址转发。
---
.../miraclegarden/smsmessage/AppConfig.java | 11 +-
.../smsmessage/network/DebugForwarder.java | 82 ++--
debug-server/server.py | 426 +++++++++++++++++-
.../xposed/hook/TngMoneyPacketHook.java | 320 ++++++++++---
4 files changed, 751 insertions(+), 88 deletions(-)
diff --git a/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java b/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java
index 81b4311..630de4b 100644
--- a/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java
+++ b/app/src/main/java/com/miraclegarden/smsmessage/AppConfig.java
@@ -12,11 +12,18 @@ public final class AppConfig {
public static final boolean ENABLE_DEBUG_FORWARD = true;
/**
- * 调试服务地址。
+ * 调试服务地址(可同时配置 USB 本机 + 局域网)。
* USB + adb reverse: http://127.0.0.1:8765
* Wi-Fi: http://<电脑局域网IP>:8765
+ * 会向列表中每个地址各推送一次;不可达的静默失败。
*/
- public static final String DEBUG_SERVER_URL = "http://127.0.0.1:8765";
+ public static final String[] DEBUG_SERVER_URLS = {
+ "http://127.0.0.1:8765",
+ "http://10.151.104.25:8765",
+ };
+
+ /** 兼容旧引用:取第一个调试地址 */
+ public static final String DEBUG_SERVER_URL = DEBUG_SERVER_URLS[0];
/** 是否定期唤醒监听列表中的 App(保持进程 / Hook 加载) */
public static final boolean ENABLE_MONITORED_APP_KEEP_ALIVE = true;
diff --git a/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java b/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java
index 37af8a6..d5ce864 100644
--- a/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java
+++ b/app/src/main/java/com/miraclegarden/smsmessage/network/DebugForwarder.java
@@ -10,6 +10,7 @@ import com.miraclegarden.smsmessage.MessageInfo;
import org.json.JSONObject;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import okhttp3.Call;
import okhttp3.Callback;
@@ -21,6 +22,7 @@ import okhttp3.Response;
/**
* 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。
+ * 支持同时向本机(USB/adb reverse)和局域网地址推送。
*/
public final class DebugForwarder {
@@ -51,6 +53,14 @@ public final class DebugForwarder {
return;
}
+ String[] urls = AppConfig.DEBUG_SERVER_URLS;
+ if (urls == null || urls.length == 0) {
+ if (onComplete != null) {
+ onComplete.run();
+ }
+ return;
+ }
+
try {
JSONObject json = new JSONObject();
json.put("source", source != null ? source : "unknown");
@@ -60,39 +70,49 @@ public final class DebugForwarder {
json.put("group", resolveGroup(title, messageInfo));
json.put("content", content != null ? content : "");
json.put("timestamp", timestamp);
+ final String body = json.toString();
+ final AtomicInteger pending = new AtomicInteger(urls.length);
- Request request = new Request.Builder()
- .url(AppConfig.DEBUG_SERVER_URL + "/api/debug/push")
- .header("Content-Type", "application/json")
- .post(RequestBody.create(json.toString(), JSON))
- .build();
-
- Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
- + " -> " + AppConfig.DEBUG_SERVER_URL);
-
- CLIENT.newCall(request).enqueue(new Callback() {
- @Override
- public void onFailure(Call call, java.io.IOException e) {
- Log.w(TAG, "forward failed [" + source + "]: " + e.getMessage());
- if (onComplete != null) {
- onComplete.run();
- }
+ for (String baseUrl : urls) {
+ if (TextUtils.isEmpty(baseUrl)) {
+ finishOne(pending, onComplete);
+ continue;
}
+ final String target = baseUrl.endsWith("/")
+ ? baseUrl.substring(0, baseUrl.length() - 1)
+ : baseUrl;
+ Request request = new Request.Builder()
+ .url(target + "/api/debug/push")
+ .header("Content-Type", "application/json")
+ .post(RequestBody.create(body, JSON))
+ .build();
- @Override
- public void onResponse(Call call, Response response) {
- int code = response.code();
- response.close();
- if (code >= 200 && code < 300) {
- Log.d(TAG, "forward ok [" + source + "] " + title);
- } else {
- Log.w(TAG, "forward http " + code + " [" + source + "] " + title);
+ Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
+ + " -> " + target);
+
+ CLIENT.newCall(request).enqueue(new Callback() {
+ @Override
+ public void onFailure(Call call, java.io.IOException e) {
+ Log.w(TAG, "forward failed [" + source + "] -> " + target
+ + ": " + e.getMessage());
+ finishOne(pending, onComplete);
}
- if (onComplete != null) {
- onComplete.run();
+
+ @Override
+ public void onResponse(Call call, Response response) {
+ int code = response.code();
+ response.close();
+ if (code >= 200 && code < 300) {
+ Log.d(TAG, "forward ok [" + source + "] -> " + target
+ + " " + title);
+ } else {
+ Log.w(TAG, "forward http " + code + " [" + source + "] -> "
+ + target + " " + title);
+ }
+ finishOne(pending, onComplete);
}
- }
- });
+ });
+ }
} catch (Exception e) {
Log.w(TAG, "forward build failed: " + e.getMessage());
if (onComplete != null) {
@@ -101,6 +121,12 @@ public final class DebugForwarder {
}
}
+ private static void finishOne(AtomicInteger pending, Runnable onComplete) {
+ if (pending.decrementAndGet() == 0 && onComplete != null) {
+ onComplete.run();
+ }
+ }
+
private static String resolveGroup(String title, MessageInfo messageInfo) {
if (!TextUtils.isEmpty(title)) {
return title.trim();
diff --git a/debug-server/server.py b/debug-server/server.py
index 3b26b14..9d85368 100644
--- a/debug-server/server.py
+++ b/debug-server/server.py
@@ -3,6 +3,7 @@
import json
import threading
+import time
from collections import deque
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -14,6 +15,7 @@ MAX_MESSAGES = 500
_messages = deque(maxlen=MAX_MESSAGES)
_lock = threading.Lock()
+_last_dedup = {"key": None, "ts": 0.0}
def _now_iso():
@@ -32,7 +34,21 @@ def _add_message(payload):
item = dict(payload)
item["group"] = _resolve_group(payload)
item["receivedAt"] = _now_iso()
+ # 本机 + 局域网双推时可能各成功一次,3 秒内同内容去重
+ dedup_key = "|".join([
+ str(payload.get("source") or ""),
+ str(payload.get("packageName") or ""),
+ str(payload.get("title") or ""),
+ str(payload.get("content") or ""),
+ ])
+ now = time.time()
with _lock:
+ if (_last_dedup["key"] == dedup_key
+ and now - float(_last_dedup["ts"]) < 3.0):
+ if _messages:
+ return _messages[0]
+ _last_dedup["key"] = dedup_key
+ _last_dedup["ts"] = now
_messages.appendleft(item)
item["id"] = len(_messages)
print("[{0}] [{1}] [{2}] {3} | {4}".format(
@@ -78,6 +94,394 @@ def _group_messages(messages):
return result
+def _is_mmp_message(msg):
+ source = (msg.get("source") or "").lower()
+ content = msg.get("content") or ""
+ title = msg.get("title") or ""
+ if "tng_mmp" in source or "xposed_tng_mmp" in source:
+ return True
+ if "[MMP统计]" in content or "Money Packet" in title:
+ return True
+ lower = content.lower()
+ return "receiverlist" in lower or ("claimedamount" in lower and "mmp" in lower)
+
+
+def _normalize_money(value):
+ """把 1.23 / RM1.23 / {"amount":"0.71","cent":"71"} 统一成 float 或 None。"""
+ if value is None:
+ return None
+ text = str(value).strip()
+ if not text or text.lower() == "null":
+ return None
+ text = text.replace("RM", "").replace("rm", "").strip()
+ if text.startswith("{") and "amount" in text:
+ try:
+ obj = json.loads(text)
+ if isinstance(obj, dict):
+ if obj.get("amount") not in (None, ""):
+ return float(str(obj["amount"]).replace(",", ""))
+ if obj.get("cent") not in (None, ""):
+ return int(str(obj["cent"])) / 100.0
+ except Exception:
+ return None
+ try:
+ return float(text.replace(",", ""))
+ except ValueError:
+ return None
+
+
+def _parse_mmp_content(content):
+ """解析 TngMoneyPacketHook 转发文本 → {meta, claims[]}。"""
+ text = (content or "").strip()
+ meta = {}
+ claims = []
+ if not text:
+ return meta, claims
+ lines = text.splitlines()
+ head = lines[0] if lines else ""
+ if head.startswith("[MMP统计]"):
+ head = head[len("[MMP统计]"):].strip()
+ # 新格式用 " | " 分隔;旧格式兼容空格拆 token
+ if " | " in head:
+ parts = [p.strip() for p in head.split(" | ") if p.strip()]
+ else:
+ parts = []
+ buf = ""
+ for part in head.split():
+ if buf:
+ buf += " " + part
+ if buf.count("{") <= buf.count("}"):
+ parts.append(buf)
+ buf = ""
+ continue
+ if "=" in part and part.split("=", 1)[1].startswith("{") and part.count("{") > part.count("}"):
+ buf = part
+ continue
+ parts.append(part)
+ if buf:
+ parts.append(buf)
+ for part in parts:
+ if "=" not in part:
+ continue
+ k, v = part.split("=", 1)
+ meta[k.strip()] = v.strip()
+ # 旧报文 sender=LIAO RUICHAO 被空格截断时,尝试从原文还原
+ if meta.get("sender") and "sender=" in head:
+ raw_sender = head.split("sender=", 1)[1]
+ for stop in (" | ", " total=", " via=", " group=", " packet=", " src="):
+ if stop in raw_sender:
+ raw_sender = raw_sender.split(stop, 1)[0]
+ break
+ raw_sender = raw_sender.strip()
+ if raw_sender and len(raw_sender) > len(meta.get("sender") or ""):
+ meta["sender"] = raw_sender
+ 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"])
+ for line in lines[1:]:
+ line = line.strip()
+ if not line or "->" not in line:
+ continue
+ left, right = line.split("->", 1)
+ nick = left.strip()
+ right = right.strip()
+ claim_time = ""
+ if right.endswith(")") and "(" in right and not right.startswith("{"):
+ amt, _, rest = right.partition("(")
+ amount_raw = amt.strip()
+ claim_time = rest.rstrip(")").strip()
+ else:
+ amount_raw = right
+ amount_num = _normalize_money(amount_raw)
+ if nick:
+ claims.append({
+ "nickname": nick,
+ "amount": ("{0:.2f}".format(amount_num) if amount_num is not None else "0.00"),
+ "amountValue": amount_num if amount_num is not None else 0.0,
+ "claimTime": claim_time,
+ })
+ return meta, claims
+
+
+def _claims_fingerprint(claims):
+ rows = []
+ for c in claims:
+ rows.append("{0}={1}".format(c.get("nickname") or "?", c.get("amount") or "0"))
+ rows.sort()
+ return "|".join(rows)
+
+
+def _aggregate_claims(claims):
+ """同一快照内同昵称合并;跨快照不应再混加(外层只取最新快照)。"""
+ buckets = {}
+ order = []
+ for c in claims:
+ nick = c.get("nickname") or "?"
+ if nick not in buckets:
+ buckets[nick] = {"nickname": nick, "amount": 0.0, "count": 0, "claimTime": ""}
+ order.append(nick)
+ 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"]:
+ b["claimTime"] = c.get("claimTime") or ""
+ result = []
+ for nick in order:
+ b = buckets[nick]
+ result.append({
+ "nickname": nick,
+ "amount": round(b["amount"], 4),
+ "amountText": "{0:.2f}".format(b["amount"]),
+ "claimCount": b["count"],
+ "claimTime": b.get("claimTime") or "",
+ })
+ result.sort(key=lambda x: x["amount"], reverse=True)
+ for i, row in enumerate(result, 1):
+ row["rank"] = i
+ return result
+
+
+def _mmp_packets():
+ with _lock:
+ msgs = [m for m in _messages if _is_mmp_message(m)]
+ # 每个红包只保留「最新一次完整领取榜」快照,避免多个红包因缺 packetId 被揉在一起
+ packets = {}
+ for msg in msgs:
+ meta, claims = _parse_mmp_content(msg.get("content") or "")
+ if not claims:
+ continue
+ packet_id = meta.get("packet") or meta.get("packetId") or ""
+ if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"):
+ # 用领取名单指纹区分不同红包
+ packet_id = "红包-" + _claims_fingerprint(claims)[:48]
+ key = str(packet_id)
+ item = {
+ "packetId": key,
+ "title": msg.get("title") or "TNG 红包",
+ "sender": meta.get("sender") or "",
+ "group": meta.get("group") or "",
+ "total": meta.get("total") or "",
+ "via": meta.get("via") or "",
+ "latestAt": msg.get("receivedAt") or "",
+ "snapshots": 1,
+ "claims": claims,
+ "rawMessages": [{
+ "id": msg.get("id"),
+ "receivedAt": msg.get("receivedAt"),
+ "content": msg.get("content"),
+ "source": msg.get("source"),
+ }],
+ }
+ existing = packets.get(key)
+ if existing is None or (item["latestAt"] or "") >= (existing.get("latestAt") or ""):
+ if existing is not None:
+ item["snapshots"] = int(existing.get("snapshots") or 1) + 1
+ # 保留历史原始报文,但排行只用最新 claims
+ item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"]
+ packets[key] = item
+ else:
+ existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
+ existing["rawMessages"].append(item["rawMessages"][0])
+ result = []
+ for p in packets.values():
+ ranked = _aggregate_claims(p.get("claims") or [])
+ p["leaderboard"] = ranked
+ p["claimantCount"] = len(ranked)
+ p["claimEventCount"] = len(p.get("claims") or [])
+ try:
+ p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4)
+ except Exception:
+ p["sumClaimed"] = 0
+ # 总额缺失或只抓到单笔金额时,用领取合计兜底
+ total_num = _normalize_money(p.get("total"))
+ if total_num is None or (p["sumClaimed"] > 0 and abs(total_num - p["sumClaimed"]) > 0.001
+ and total_num <= max((x["amount"] for x in ranked), default=0) + 1e-9):
+ p["total"] = "{0:.2f}".format(p["sumClaimed"])
+ elif total_num is not None:
+ p["total"] = "{0:.2f}".format(total_num)
+ del p["claims"]
+ result.append(p)
+ result.sort(key=lambda x: x.get("latestAt") or "", reverse=True)
+ return result
+
+
+def _mmp_html_page():
+ html = """
+
+
+
+
+ 红包领取台
+
+
+
+
+ 红包领取台
+ 0 个红包
+ -
+ ← 返回消息台
+
+
+
+
+
+
+
+ 暂无红包数据
+
在手机打开 TNG「红包」详情或排行榜后,领取列表会显示在这里
+
+
+
+
+
+
+"""
+ return html.encode("utf-8")
+
+
def _html_page():
html = """
@@ -91,6 +495,8 @@ def _html_page():
header { padding: 14px 20px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
h1 { margin: 0; font-size: 18px; }
.stat { color: #8b949e; font-size: 13px; }
+ a.nav { color: #58a6ff; text-decoration: none; font-size: 13px; }
+ a.nav:hover { text-decoration: underline; }
button { background: #238636; color: #fff; border: 0; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; }
button.secondary { background: #21262d; border: 1px solid #30363d; }
.layout { display: flex; flex: 1; min-height: 0; }
@@ -117,6 +523,7 @@ def _html_page():
0 条
0 群
-
+ 红包领取台 →
@@ -237,6 +644,14 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
return
+ if path == "/mmp":
+ body = _mmp_html_page()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
if path == "/api/messages":
with _lock:
data = list(_messages)
@@ -247,6 +662,9 @@ class Handler(BaseHTTPRequestHandler):
data = _group_messages(list(_messages))
_json_response(self, 200, data)
return
+ if path == "/api/mmp":
+ _json_response(self, 200, _mmp_packets())
+ return
if path == "/health":
_json_response(self, 200, {"ok": True})
return
@@ -298,9 +716,11 @@ class Handler(BaseHTTPRequestHandler):
def main():
server = ThreadingHTTPServer((HOST, PORT), Handler)
print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT))
- print("浏览器打开上述地址即可查看消息")
- print("手机经 USB 调试时先执行: adb reverse tcp:8765 tcp:8765")
- print("Wi-Fi 调试时将 AppConfig.DEBUG_SERVER_URL 改为 http://:8765")
+ print("通用调试台: http://127.0.0.1:{0}/".format(PORT))
+ print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT))
+ print("手机经 USB: adb reverse tcp:8765 tcp:8765(走 127.0.0.1)")
+ print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
+ print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")
try:
server.serve_forever()
except KeyboardInterrupt:
diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java
index 240dd19..3ad60b0 100644
--- a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java
+++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java
@@ -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(), 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 {