Compare commits

...

2 Commits

Author SHA1 Message Date
mars
1e2021d8fd docs(tng): 补充 Money Packet 领取台说明,并同步自动拉详情 Hook
说明 activityId、手动缓存模板后再自动拉历史详情的流程,以及 /mmp 双通道与去重行为。
2026-08-04 15:39:44 +08:00
mars
88a2b319f8 feat(tng): 红包领取台双通道推送,并修复金额/按包分组
Hook Quake RPC 抓 Money Packet;解析 Money 对象金额;调试台按单个红包展示排行;支持 USB 本机与共享网段双地址转发。
2026-08-04 15:09:28 +08:00
7 changed files with 1454 additions and 119 deletions

View File

@@ -6,6 +6,7 @@ Android 应用,监听通知栏消息并通过 **双通道**(通知监听 + X
> **Telegram 抓消息专文**[docs/Telegram抓消息说明.md](docs/Telegram抓消息说明.md) > **Telegram 抓消息专文**[docs/Telegram抓消息说明.md](docs/Telegram抓消息说明.md)
> **手机部署与银行 bypass 操作**:详见 [docs/手机操作手册.md](docs/手机操作手册.md) > **手机部署与银行 bypass 操作**:详见 [docs/手机操作手册.md](docs/手机操作手册.md)
> **MariBank 风控与 register 载荷**[docs/MariBank风控与载荷说明.md](docs/MariBank风控与载荷说明.md) > **MariBank 风控与 register 载荷**[docs/MariBank风控与载荷说明.md](docs/MariBank风控与载荷说明.md)
> **TNG Money Packet 领取台**[docs/TNG_MoneyPacket领取台.md](docs/TNG_MoneyPacket领取台.md)
## 项目结构 ## 项目结构

View File

@@ -12,11 +12,18 @@ public final class AppConfig {
public static final boolean ENABLE_DEBUG_FORWARD = true; public static final boolean ENABLE_DEBUG_FORWARD = true;
/** /**
* 调试服务地址。 * 调试服务地址(可同时配置 USB 本机 + 局域网)
* USB + adb reverse: http://127.0.0.1:8765 * USB + adb reverse: http://127.0.0.1:8765
* Wi-Fi: http://<电脑局域网IP>: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 加载) */ /** 是否定期唤醒监听列表中的 App保持进程 / Hook 加载) */
public static final boolean ENABLE_MONITORED_APP_KEEP_ALIVE = true; public static final boolean ENABLE_MONITORED_APP_KEEP_ALIVE = true;

View File

@@ -10,6 +10,7 @@ import com.miraclegarden.smsmessage.MessageInfo;
import org.json.JSONObject; import org.json.JSONObject;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import okhttp3.Call; import okhttp3.Call;
import okhttp3.Callback; import okhttp3.Callback;
@@ -21,6 +22,7 @@ import okhttp3.Response;
/** /**
* 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。 * 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。
* 支持同时向本机USB/adb reverse和局域网地址推送。
*/ */
public final class DebugForwarder { public final class DebugForwarder {
@@ -51,6 +53,14 @@ public final class DebugForwarder {
return; return;
} }
String[] urls = AppConfig.DEBUG_SERVER_URLS;
if (urls == null || urls.length == 0) {
if (onComplete != null) {
onComplete.run();
}
return;
}
try { try {
JSONObject json = new JSONObject(); JSONObject json = new JSONObject();
json.put("source", source != null ? source : "unknown"); json.put("source", source != null ? source : "unknown");
@@ -60,23 +70,32 @@ public final class DebugForwarder {
json.put("group", resolveGroup(title, messageInfo)); json.put("group", resolveGroup(title, messageInfo));
json.put("content", content != null ? content : ""); json.put("content", content != null ? content : "");
json.put("timestamp", timestamp); json.put("timestamp", timestamp);
final String body = json.toString();
final AtomicInteger pending = new AtomicInteger(urls.length);
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() Request request = new Request.Builder()
.url(AppConfig.DEBUG_SERVER_URL + "/api/debug/push") .url(target + "/api/debug/push")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.post(RequestBody.create(json.toString(), JSON)) .post(RequestBody.create(body, JSON))
.build(); .build();
Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName() Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
+ " -> " + AppConfig.DEBUG_SERVER_URL); + " -> " + target);
CLIENT.newCall(request).enqueue(new Callback() { CLIENT.newCall(request).enqueue(new Callback() {
@Override @Override
public void onFailure(Call call, java.io.IOException e) { public void onFailure(Call call, java.io.IOException e) {
Log.w(TAG, "forward failed [" + source + "]: " + e.getMessage()); Log.w(TAG, "forward failed [" + source + "] -> " + target
if (onComplete != null) { + ": " + e.getMessage());
onComplete.run(); finishOne(pending, onComplete);
}
} }
@Override @Override
@@ -84,15 +103,16 @@ public final class DebugForwarder {
int code = response.code(); int code = response.code();
response.close(); response.close();
if (code >= 200 && code < 300) { if (code >= 200 && code < 300) {
Log.d(TAG, "forward ok [" + source + "] " + title); Log.d(TAG, "forward ok [" + source + "] -> " + target
+ " " + title);
} else { } else {
Log.w(TAG, "forward http " + code + " [" + source + "] " + title); Log.w(TAG, "forward http " + code + " [" + source + "] -> "
} + target + " " + title);
if (onComplete != null) {
onComplete.run();
} }
finishOne(pending, onComplete);
} }
}); });
}
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "forward build failed: " + e.getMessage()); Log.w(TAG, "forward build failed: " + e.getMessage());
if (onComplete != null) { 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) { private static String resolveGroup(String title, MessageInfo messageInfo) {
if (!TextUtils.isEmpty(title)) { if (!TextUtils.isEmpty(title)) {
return title.trim(); return title.trim();

View File

@@ -3,6 +3,7 @@
import json import json
import threading import threading
import time
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -14,6 +15,7 @@ MAX_MESSAGES = 500
_messages = deque(maxlen=MAX_MESSAGES) _messages = deque(maxlen=MAX_MESSAGES)
_lock = threading.Lock() _lock = threading.Lock()
_last_dedup = {"key": None, "ts": 0.0}
def _now_iso(): def _now_iso():
@@ -32,7 +34,21 @@ def _add_message(payload):
item = dict(payload) item = dict(payload)
item["group"] = _resolve_group(payload) item["group"] = _resolve_group(payload)
item["receivedAt"] = _now_iso() 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: 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) _messages.appendleft(item)
item["id"] = len(_messages) item["id"] = len(_messages)
print("[{0}] [{1}] [{2}] {3} | {4}".format( print("[{0}] [{1}] [{2}] {3} | {4}".format(
@@ -78,6 +94,398 @@ def _group_messages(messages):
return result 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 = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>红包领取台</title>
<style>
* { box-sizing: border-box; }
body { font-family: "Segoe UI", ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b1220; color: #e8eef7; height: 100vh; display: flex; flex-direction: column; }
header { padding: 14px 20px; background: linear-gradient(90deg,#1a1030,#0f1b2d); border-bottom: 1px solid #2a3550; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
h1 { margin: 0; font-size: 18px; letter-spacing: 0.02em; }
.stat { color: #9db0cc; font-size: 13px; }
a.nav { color: #8ec5ff; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
button { background: #c45c26; color: #fff; border: 0; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; }
button.secondary { background: #1c2740; border: 1px solid #314062; }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar { width: 320px; border-right: 1px solid #2a3550; background: #111a2c; overflow-y: auto; flex-shrink: 0; }
.sidebar h2 { margin: 0; padding: 14px 16px 8px; font-size: 12px; color: #8aa0c0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.pkt { padding: 14px 16px; border-bottom: 1px solid #1a2438; cursor: pointer; }
.pkt:hover { background: #162033; }
.pkt.active { background: #1a2744; border-left: 3px solid #ff7a45; padding-left: 13px; }
.pkt-id { font-size: 13px; color: #ffd2a8; word-break: break-all; font-family: ui-monospace, Consolas, monospace; }
.pkt-meta { color: #8aa0c0; font-size: 12px; margin-top: 6px; line-height: 1.45; }
.main { flex: 1; overflow-y: auto; padding: 18px 22px; }
.title { margin: 0 0 6px; font-size: 20px; color: #ffe0c2; }
.sub { color: #9db0cc; font-size: 13px; margin-bottom: 16px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 18px; }
.card { background: #141f35; border: 1px solid #273552; border-radius: 10px; padding: 12px 14px; }
.card .k { color: #8aa0c0; font-size: 11px; }
.card .v { margin-top: 4px; font-size: 18px; color: #fff; font-variant-numeric: tabular-nums; }
table { width: 100%; border-collapse: collapse; background: #111a2c; border-radius: 10px; overflow: hidden; border: 1px solid #273552; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #1e2a42; font-size: 13px; }
th { background: #182338; color: #9db0cc; font-weight: 600; }
tr:last-child td { border-bottom: 0; }
.rank { width: 48px; color: #ff9b5a; font-weight: 700; font-variant-numeric: tabular-nums; }
.amt { font-variant-numeric: tabular-nums; color: #7dffb3; font-weight: 600; }
.empty { padding: 48px 20px; text-align: center; color: #8aa0c0; }
.hint { margin-top: 10px; font-size: 12px; color: #6f84a6; }
.raw { margin-top: 18px; }
.raw summary { cursor: pointer; color: #8ec5ff; font-size: 13px; }
pre { background: #0d1524; border: 1px solid #273552; border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; color: #c9d6ea; white-space: pre-wrap; word-break: break-word; }
</style>
</head>
<body>
<header>
<h1>红包领取台</h1>
<span class="stat" id="pktCount">0 个红包</span>
<span class="stat" id="updated">-</span>
<a class="nav" href="/">← 返回消息台</a>
<button onclick="loadData()">刷新</button>
<button class="secondary" onclick="clearAll()">清空全部</button>
</header>
<div class="layout">
<aside class="sidebar">
<h2>红包列表</h2>
<div id="list"></div>
</aside>
<main class="main">
<div id="empty" class="empty">
暂无红包数据<br/>
<div class="hint">打开 Money Packet 历史页可自动拉取;若没有数据,先手动点开任意一条详情一次(缓存登录模板),再回历史页</div>
</div>
<div id="panel" style="display:none">
<h2 class="title" id="title">-</h2>
<div class="sub" id="sub">-</div>
<div class="cards">
<div class="card"><div class="k">领取人数</div><div class="v" id="cPeople">0</div></div>
<div class="card"><div class="k">领取合计</div><div class="v" id="cSum">0</div></div>
<div class="card"><div class="k">抓取次数</div><div class="v" id="cSnap">0</div></div>
<div class="card"><div class="k">红包总额</div><div class="v" id="cTotal">-</div></div>
</div>
<table>
<thead><tr><th class="rank">排名</th><th>昵称</th><th>领取金额</th><th>领取时间</th></tr></thead>
<tbody id="board"></tbody>
</table>
<div class="raw"><details><summary>查看原始数据</summary><pre id="raw"></pre></details></div>
</div>
</main>
</div>
<script>
let packets = [];
let activeId = null;
async function loadData() {
const res = await fetch('/api/mmp');
packets = await res.json();
document.getElementById('pktCount').textContent = packets.length + ' 个红包';
document.getElementById('updated').textContent = '更新: ' + new Date().toLocaleTimeString();
const list = document.getElementById('list');
list.innerHTML = '';
const empty = document.getElementById('empty');
const panel = document.getElementById('panel');
if (!packets.length) {
empty.style.display = 'block';
panel.style.display = 'none';
activeId = null;
return;
}
empty.style.display = 'none';
panel.style.display = 'block';
if (!activeId || !packets.find(p => p.packetId === activeId)) {
activeId = packets[0].packetId;
}
for (const p of packets) {
const div = document.createElement('div');
div.className = 'pkt' + (p.packetId === activeId ? ' active' : '');
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>`;
list.appendChild(div);
}
renderActive();
}
function shortId(id) {
const s = String(id || '');
if (s.length >= 8 && s.indexOf('-') > 0) return s.slice(0, 8);
return s.slice(0, 16) || '-';
}
function renderActive() {
const p = packets.find(x => x.packetId === activeId);
if (!p) return;
document.getElementById('title').textContent =
(p.sender ? p.sender + ' 的红包' : (p.title || '红包详情'));
const parts = [];
if (p.packetId) parts.push('activityId ' + p.packetId);
if (p.sender) parts.push('发送人 ' + p.sender);
if (p.group) parts.push('群组 ' + p.group);
if (p.via) parts.push('来源 ' + (p.via === 'http' ? '网络' : p.via === 'gson' ? '解析' : p.via === 'rpc' ? 'RPC' : p.via));
document.getElementById('sub').textContent = parts.join(' · ') || '单个红包领取排行';
document.getElementById('cPeople').textContent = p.claimantCount || 0;
document.getElementById('cSum').textContent = Number(p.sumClaimed ?? 0).toFixed(2);
document.getElementById('cSnap').textContent = p.snapshots || 0;
document.getElementById('cTotal').textContent = p.total || '-';
const board = document.getElementById('board');
board.innerHTML = '';
const rows = p.leaderboard || [];
if (!rows.length) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="4" style="color:#8aa0c0;text-align:center">暂无领取记录</td>';
board.appendChild(tr);
}
for (const row of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="rank">${row.rank}</td>
<td>${esc(row.nickname)}</td>
<td class="amt">${esc(row.amountText || Number(row.amount).toFixed(2))}</td>
<td>${esc(row.claimTime || '-')}</td>`;
board.appendChild(tr);
}
const raw = (p.rawMessages || []).map(m =>
'[' + (m.receivedAt || '') + '] ' + (m.source || '') + '\\n' + (m.content || '')
).join('\\n---\\n');
document.getElementById('raw').textContent = raw || '(无)';
}
async function clearAll() {
await fetch('/api/messages', { method: 'DELETE' });
activeId = null;
loadData();
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
loadData();
setInterval(loadData, 2000);
</script>
</body>
</html>"""
return html.encode("utf-8")
def _html_page(): def _html_page():
html = """<!DOCTYPE html> html = """<!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
@@ -91,6 +499,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; } 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; } h1 { margin: 0; font-size: 18px; }
.stat { color: #8b949e; font-size: 13px; } .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 { 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; } button.secondary { background: #21262d; border: 1px solid #30363d; }
.layout { display: flex; flex: 1; min-height: 0; } .layout { display: flex; flex: 1; min-height: 0; }
@@ -117,6 +527,7 @@ def _html_page():
<span class="stat" id="count">0 条</span> <span class="stat" id="count">0 条</span>
<span class="stat" id="groupCount">0 群</span> <span class="stat" id="groupCount">0 群</span>
<span class="stat" id="updated">-</span> <span class="stat" id="updated">-</span>
<a class="nav" href="/mmp">红包领取台 →</a>
<button onclick="loadMessages()">刷新</button> <button onclick="loadMessages()">刷新</button>
<button class="secondary" onclick="clearMessages()">清空</button> <button class="secondary" onclick="clearMessages()">清空</button>
</header> </header>
@@ -237,6 +648,14 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
return 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": if path == "/api/messages":
with _lock: with _lock:
data = list(_messages) data = list(_messages)
@@ -247,6 +666,9 @@ class Handler(BaseHTTPRequestHandler):
data = _group_messages(list(_messages)) data = _group_messages(list(_messages))
_json_response(self, 200, data) _json_response(self, 200, data)
return return
if path == "/api/mmp":
_json_response(self, 200, _mmp_packets())
return
if path == "/health": if path == "/health":
_json_response(self, 200, {"ok": True}) _json_response(self, 200, {"ok": True})
return return
@@ -298,9 +720,11 @@ class Handler(BaseHTTPRequestHandler):
def main(): def main():
server = ThreadingHTTPServer((HOST, PORT), Handler) server = ThreadingHTTPServer((HOST, PORT), Handler)
print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT)) print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT))
print("浏览器打开上述地址即可查看消息") print("通用调试台: http://127.0.0.1:{0}/".format(PORT))
print("手机经 USB 调试时先执行: adb reverse tcp:8765 tcp:8765") print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT))
print("Wi-Fi 调试时将 AppConfig.DEBUG_SERVER_URL 改为 http://<PC局域网IP>:8765") print("手机经 USB: adb reverse tcp:8765 tcp:8765走 127.0.0.1")
print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")
try: try:
server.serve_forever() server.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@@ -0,0 +1,120 @@
# TNG Money Packet 领取台说明
> 更新2026-08-04
> 相关代码:`TngMoneyPacketHook.java`、`debug-server/server.py`、`AppConfig.DEBUG_SERVER_URLS`
## 目标
抓取 TNG eWallet **Money Packet红包** 的领取排行:谁领了、领了多少,并在 PC 调试台按**单个红包**展示。
## 页面与地址
| 入口 | 地址 |
|------|------|
| 本机USB + `adb reverse` | http://127.0.0.1:8765/mmp |
| 局域网 / USB 共享网 | http://&lt;电脑IP&gt;:8765/mmp (当前常见:`http://10.151.104.25:8765/mmp` |
| 通用消息台 | http://127.0.0.1:8765/ |
手机推送会同时尝试:
- `http://127.0.0.1:8765`(需 `adb reverse tcp:8765 tcp:8765`
- `http://10.151.104.25:8765`USB 共享网段;若电脑 IP 变了,改 `AppConfig.DEBUG_SERVER_URLS`
启动调试台:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
```
## 红包唯一标识
正式字段是 **`activityId`**UUID例如
- `b8c37a58-bb2c-4aa2-83ef-f15466b9211e`
- `e5dcf293-4062-4df4-a692-e3bf194d9a37`
领取台左侧按 `activityId` 分条;详情页会显示完整 ID。
转发正文格式示例:
```text
[MMP统计] packet=<activityId> | sender=... | total=1.00 | via=rpc | src=moneyPacketDetail
昵称A -> 0.71
昵称B -> 0.29
```
金额在 App 内多为 Money 对象 `{"amount":"0.71","cent":"71",...}`Hook / 调试台会规范成数字再展示。
## 数据从哪来
| 来源 | Quake op / 方法 | 内容 |
|------|-----------------|------|
| 历史列表 | `moneyPacketHistoryList` / `ap.tngdwallet.moneyPacket.list.retrieve` | 仅摘要:`activityId`、总额、时间等,**无领取排行** |
| 红包详情 | `moneyPacketDetail` / `ap.tngdwallet.moneyPacket.retrieve` | 含 `activityPoolInfos`(领取人 + 金额) |
外部 mitm 因证书 pinning **解不开** TNG API 正文;必须用进程内 Xposed`TngMoneyPacketHook`)。
消费端详情领取列表字段主要是 **`activityPoolInfos`**`receiverName` + `amount`),不是商户版的 `receiverList`
## 推荐操作流程(自动拉详情)
历史页本身没有排行数据。自动拉取需要一次「带登录态的详情请求」作模板:
1. 打开 TNG → **Money Packet**
2. **先手动点开任意一条历史红包详情**(看到排行榜即可)
- 日志:`cached detail request template`(缓存 session / 请求模板)
3. **返回历史列表**,停留几秒
- 日志:`history hit → schedule auto detail`
- 随后:`auto-detail using template` / `auto-detail ok activityId=... claims=N`
4. 刷新领取台http://127.0.0.1:8765/mmp
若跳过第 2 步,自动请求常返回 `totalCount:0`(缺 `sessionId` 等),台上会没有领取人。
也可继续「只点详情」:每进一个详情就会捕获该 `activityId` 的排行。
## 去重与刷新行为
| 规则 | 说明 |
|------|------|
| 标识 | 以 `activityId` 区分红包 |
| 短时去重 | 同一 `activityId` + **相同领取名单**,约 **20 秒内**不重复推送(防双 Hook / 连点) |
| 可刷新 | 超过 20 秒再进详情,或领取名单变化,会再次推送 |
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后约 **60 秒**内不重复自动拉 |
| 调试台 | 同一 `activityId` 只保留最新完整快照做排行;双通道推送有短时内容去重 |
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。
## 排障
```text
adb logcat | findstr TngMmp
```
常见日志:
| 日志 | 含义 |
|------|------|
| `TngMmp installed` | Hook 已加载 |
| `cached RPC task MoneyPacketRpcTask` | 已缓存 RPC 代理 |
| `cached detail request template` | 已缓存手动详情请求(自动拉可用) |
| `history hit → schedule auto detail` | 历史列表触发自动拉 |
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或模板未缓存 |
| `auto-detail ok activityId=... claims=N` | 自动拉成功 |
| `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 |
其它检查:
1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK
2. 强停后再开 TNG使新 Hook 生效
3. 调试台进程在跑USB 时执行过 `adb reverse`
4. notiMessage 监听列表勾选了 TNGHook 转发依赖主 App 接收广播)
5. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App
## 相关文件
| 文件 | 作用 |
|------|------|
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、金额/`activityId` 解析 |
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp` |
| `app/.../AppConfig.java` | `DEBUG_SERVER_URLS` 双地址 |
| `app/.../network/DebugForwarder.java` | 向多个调试地址推送 |
| `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse |

View File

@@ -11,42 +11,25 @@
--- ---
## Money Packet 领取统计 — 网络/明文获取能力 ## Money Packet 领取统计
目标:群红包 Leaderboard 的 **昵称 + 已领金额**(字段预期 `receiverList` / `claimedAmount` 等) **专用说明(操作 / 标识 / 自动拉详情 / 排障):** 见 [TNG_MoneyPacket领取台.md](TNG_MoneyPacket领取台.md)
### 路线对比 ### 能力摘要
| 路线 | 状态 | 说明 | | 路线 | 状态 | 说明 |
|------|------|------| |------|------|------|
| 外部 mitmCharles / mitmproxy | **不可行(当前)** | TNG API`mpaasgw.tngdigital.com.my`)有 **证书 pinning**,只能看到域名,解不开 HTTPS 正文。`reverse/dumps/mitm_mmp/` 里曾落盘的多为官网 HTML/新闻,**不是** 红包 API。 | | 外部 mitm | **不可行** | API 证书 pinning解不开正文 |
| 进程内 Xposed`TngMoneyPacketHook` | **代码已接,运行时未实证** | 在 TLS 之后读明文Hook `OkHttp ResponseBody.string` + `Gson.fromJson`,匹配 `receiverList` / `claimedAmount` / `Mmp*` 模型,经 `HookForwarder` 转发。`MainHook``install` | | Xposed `TngMoneyPacketHook` | **实证** | Hook Quake `moneyPacketDetail` / 历史列表触发自动拉详情;标识为 `activityId` |
| 纯日志/UI 自动化 | 兜底 | 无 API 时可读界面,不稳定,不作主路径。 | | PC 领取台 | **已上线** | http://127.0.0.1:8765/mmp ,按单个红包展示排行 |
### 当前缺口 ### 要点
1. **尚未在真机打开 Money Packet Leaderboard 做过一次捕获验证** → logcat 里暂无 `TngMmp captured …` 1. 历史列表只有摘要;领取排行来自详情 RPC`activityPoolInfos`
2. TNG 主业务多为 **mPaaS / Quake RPC**,若响应不走 `ResponseBody.string()` / 目标 Gson 类名不符,现有 Hook 会漏;需补 **RPC invoke 返回值 / 其它 JSON 入口** 2. **自动拉详情**:先手动点开任意一条详情缓存请求模板(含 session再回历史页即可批量拉
3. Splash 强拉登录不影响「登录后进群点红包」时的抓包,但影响复测效率 3. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推
### 建议验证步骤(下次动手)
1. 保持登录态,进群 → 打开红包详情 / Leaderboard。
2. 看 LSPosed`notiMessageHook/TngMmp installed``captured packet=… claims=N`
3. 若无:对同一次操作抓 logcat 里 URL / 类名,补 Hook 点Quake `RpcInvocationHandler` 等)。
4. 确认 notiMessage / debug-server 是否收到转发内容。
### eKYC「验证您的帐户」强制页2026-08-04 ### eKYC「验证您的帐户」强制页2026-08-04
`HomeEkycVerifyActivity` 挡首页。测试期:`hookHomeEkycVerifySkip` — finish 该页 + 拦 Intent + `canBypassEkyc`/`enforceEkyc` stub。 `HomeEkycVerifyActivity` 挡首页。测试期:`hookHomeEkycVerifySkip` — finish 该页 + 拦 Intent + `canBypassEkyc`/`enforceEkyc` stub。
**注意:** 服务端仍可能在部分功能(转账/红包)二次校验 eKYC首页跳过不等于全功能可用。 **注意:** 服务端仍可能在部分功能(转账/红包)二次校验 eKYC首页跳过不等于全功能可用。
```text
packetId / title
receiverList[]:
- nickname / displayName
- claimedAmount或 amount
```
按昵称聚合 `claimedAmount` 即可做领取排行。

View File

@@ -37,8 +37,32 @@ public final class TngMoneyPacketHook {
private static final ArrayDeque<String> RECENT_KEYS = new ArrayDeque<>(); private static final ArrayDeque<String> RECENT_KEYS = new ArrayDeque<>();
private static final HashSet<String> RECENT_SET = new HashSet<>(); private static final HashSet<String> RECENT_SET = new HashSet<>();
/** activityId → 上次转发时间 */
private static final java.util.HashMap<String, Long> RECENT_PACKET_AT = new java.util.HashMap<>();
/** activityId → 领取指纹 */
private static final java.util.HashMap<String, String> RECENT_PACKET_CLAIMS = new java.util.HashMap<>();
/** 同一 activityId + 相同领取名单20 秒内不重复推(防双 Hook / 连点);超时可刷新 */
private static final long PACKET_DEDUP_MS = 20_000L;
private static final ThreadLocal<String> CURRENT_REQUEST_URL = new ThreadLocal<>(); private static final ThreadLocal<String> CURRENT_REQUEST_URL = new ThreadLocal<>();
/** 历史列表触发的自动拉详情:已排队 / 上次成功时间 */
private static final java.util.HashSet<String> AUTO_DETAIL_PENDING = new java.util.HashSet<>();
private static final java.util.HashMap<String, Long> AUTO_DETAIL_AT = new java.util.HashMap<>();
private static final long AUTO_DETAIL_COOLDOWN_MS = 60_000L;
private static volatile Object sMoneyPacketRpcTask;
private static volatile ClassLoader sAppClassLoader;
private static volatile String sCachedUserId;
private static volatile String sCachedSessionId;
private static volatile String sCachedLoginId;
/** 手动打开详情时抓到的真实请求,自动拉详情时克隆它 */
private static volatile Object sTemplateDetailRequest;
private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "TngMmp-auto-detail");
t.setDaemon(true);
return t;
});
private TngMoneyPacketHook() { private TngMoneyPacketHook() {
} }
@@ -46,9 +70,11 @@ public final class TngMoneyPacketHook {
if (!TngRootBypassHook.isTargetPackage(lpparam.packageName)) { if (!TngRootBypassHook.isTargetPackage(lpparam.packageName)) {
return; return;
} }
sAppClassLoader = lpparam.classLoader;
hookOkHttpUrl(lpparam); hookOkHttpUrl(lpparam);
hookResponseBody(lpparam); hookResponseBody(lpparam);
hookGsonFromJson(lpparam); hookGsonFromJson(lpparam);
hookQuakeRpc(lpparam);
XposedBridge.log(TAG + " installed for " + lpparam.packageName); XposedBridge.log(TAG + " installed for " + lpparam.packageName);
} }
@@ -167,11 +193,589 @@ public final class TngMoneyPacketHook {
if (TextUtils.isEmpty(className)) { if (TextUtils.isEmpty(className)) {
return false; return false;
} }
return className.contains("MmpDetailResult") String n = className;
|| className.contains("MmpClaimQueryResult") return n.contains("MmpDetail")
|| className.contains("MmpClaimResult") || n.contains("MmpClaim")
|| className.contains("MmpReceiver") || n.contains("MmpReceiver")
|| className.contains("MmpDetailLeaderboard"); || 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) {
// 只挂 TNG 封装层,避免与底层 RpcInvocationHandler 双触发导致重复
String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
};
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.hasThrowable() || param.getResult() == null) {
return;
}
cacheMoneyPacketRpcTask(param.args);
cacheLoginFromInvokeArgs(param.args);
String op = extractMoneyPacketRpcOp(param.args);
if (op == null) {
return;
}
Object result = param.getResult();
// 历史列表:无领取排行,但可用 activityId 自动拉详情
if (isHistoryRpcOp(op)) {
XposedBridge.log(TAG + " history hit → schedule auto detail");
scheduleAutoDetailsFromHistory(result);
return;
}
if (!isDetailRpcOp(op)) {
return;
}
String className = result.getClass().getName();
XposedBridge.log(TAG + " rpc hit op=" + op + " type=" + className);
try {
Object detailReq = extractRpcRequestArg(param.args);
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
json = tryFastjson(result);
}
if (TextUtils.isEmpty(json)) {
XposedBridge.log(TAG + " rpc serialize empty op=" + op);
return;
}
MmpSnapshot probe = parseSnapshot(json);
if (probe != null && !probe.claims.isEmpty()) {
if (detailReq != null) {
sTemplateDetailRequest = cloneRequestShallow(detailReq);
cacheLoginFromRequestObject(detailReq);
XposedBridge.log(TAG + " cached detail request template"
+ " userId=" + stringField(detailReq, "userId", "getUserId")
+ " session=" + abbreviate(stringField(detailReq,
"sessionId", "getSessionId"))
+ " page=" + stringField(detailReq, "page", "getPage")
+ " max=" + stringField(detailReq, "maxResult", "getMaxResult"));
}
forwardParsedJson(json, op, "rpc");
} else {
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
}
} 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 boolean isHistoryRpcOp(String op) {
if (TextUtils.isEmpty(op)) {
return false;
}
String lower = op.toLowerCase(Locale.US);
return lower.contains("historylist")
|| lower.contains("history")
|| lower.contains("list.retrieve")
|| lower.equals("moneypackethistorylist");
}
/** 只要详情/领取查询,历史列表走 auto-detail */
private static boolean isDetailRpcOp(String op) {
if (TextUtils.isEmpty(op) || isHistoryRpcOp(op)) {
return false;
}
String lower = op.toLowerCase(Locale.US);
return lower.contains("detail")
|| lower.contains("retrieve")
|| lower.contains("claim.result")
|| lower.contains("leaderboard")
|| lower.contains("moneypacketdetail");
}
/** InvocationHandler.invoke(proxy, method, args) → 缓存 MoneyPacketRpcTask 代理 */
private static void cacheMoneyPacketRpcTask(Object[] args) {
if (args == null || args.length == 0) {
return;
}
try {
Object proxy = args[0];
if (proxy == null || !java.lang.reflect.Proxy.isProxyClass(proxy.getClass())) {
return;
}
if (sMoneyPacketRpcTask != null) {
return;
}
for (Class<?> iface : proxy.getClass().getInterfaces()) {
String name = iface.getName();
if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) {
sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task " + name);
return;
}
}
try {
proxy.getClass().getMethod("moneyPacketDetail",
XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
sAppClassLoader));
sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method");
} catch (Throwable ignored) {
}
} catch (Throwable ignored) {
}
}
/** 从历史/详情请求里偷登录态,供自动拉详情复用 */
private static void cacheLoginFromInvokeArgs(Object[] args) {
cacheLoginFromRequestObject(extractRpcRequestArg(args));
}
private static void cacheLoginFromRequestObject(Object req) {
if (req == null) {
return;
}
String userId = stringField(req, "userId", "getUserId");
String sessionId = stringField(req, "sessionId", "getSessionId");
String loginId = stringField(req, "loginId", "getLoginId");
if (!TextUtils.isEmpty(userId)) {
sCachedUserId = userId;
}
if (!TextUtils.isEmpty(sessionId)) {
sCachedSessionId = sessionId;
}
if (!TextUtils.isEmpty(loginId)) {
sCachedLoginId = loginId;
}
}
private static String abbreviate(String s) {
if (TextUtils.isEmpty(s)) {
return "-";
}
return s.length() <= 8 ? s : s.substring(0, 4) + "" + s.substring(s.length() - 4);
}
private static Object cloneRequestShallow(Object src) {
if (src == null) {
return null;
}
try {
Object copy = src.getClass().getDeclaredConstructor().newInstance();
copyAllFields(src, copy);
return copy;
} catch (Throwable t) {
// Kotlin data class 可能无无参构造,直接复用引用(自动拉取在单线程)
return src;
}
}
private static void copyAllFields(Object src, Object dst) {
Class<?> c = src.getClass();
while (c != null && c != Object.class) {
for (java.lang.reflect.Field f : c.getDeclaredFields()) {
if (f.getName().contains("$")) {
continue;
}
try {
f.setAccessible(true);
f.set(dst, f.get(src));
} catch (Throwable ignored) {
}
}
c = c.getSuperclass();
}
}
private static void applyActivityToRequest(Object req, String activityId, String senderUserId) {
try {
XposedHelpers.setObjectField(req, "activityId", activityId);
} catch (Throwable ignored) {
trySet(req, "setActivityId", activityId);
}
try {
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
} catch (Throwable ignored) {
trySet(req, "setSenderUserId", senderUserId);
}
}
private static Object extractRpcRequestArg(Object[] args) {
if (args == null) {
return null;
}
// InvocationHandler.invoke(proxy, method, Object[] methodArgs)
if (args.length >= 3 && args[2] instanceof Object[]) {
Object[] methodArgs = (Object[]) args[2];
if (methodArgs.length > 0 && methodArgs[0] != null) {
String n = methodArgs[0].getClass().getName();
if (n.contains("Request") || n.contains("moneypacket") || n.contains("MoneyPacket")) {
return methodArgs[0];
}
}
}
for (Object arg : args) {
if (arg == null || arg instanceof Method || arg instanceof String) {
continue;
}
if (arg instanceof Object[]) {
continue;
}
String n = arg.getClass().getName();
if (n.contains("Request") && (n.contains("MoneyPacket") || n.contains("moneypacket")
|| n.contains("Mmp"))) {
return arg;
}
}
return null;
}
private static void scheduleAutoDetailsFromHistory(Object historyResult) {
if (historyResult == null) {
return;
}
AUTO_DETAIL_EXEC.execute(() -> {
try {
List<String[]> jobs = extractHistoryJobs(historyResult);
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
for (String[] job : jobs) {
final String activityId = job[0];
final String senderUserId = job[1];
long now = System.currentTimeMillis();
synchronized (AUTO_DETAIL_AT) {
Long last = AUTO_DETAIL_AT.get(activityId);
if (last != null && now - last < AUTO_DETAIL_COOLDOWN_MS) {
continue;
}
if (!AUTO_DETAIL_PENDING.add(activityId)) {
continue;
}
}
try {
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
if (ok) {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
}
}
} finally {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_PENDING.remove(activityId);
}
}
try {
Thread.sleep(400);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-detail schedule failed: " + t.getMessage());
}
});
}
private static List<String[]> extractHistoryJobs(Object historyResult) {
List<String[]> jobs = new ArrayList<>();
try {
Object infos = null;
try {
infos = XposedHelpers.callMethod(historyResult, "getParticipantInfos");
} catch (Throwable ignored) {
}
if (infos == null) {
try {
infos = XposedHelpers.getObjectField(historyResult, "participantInfos");
} catch (Throwable ignored) {
}
}
if (!(infos instanceof Iterable)) {
return jobs;
}
for (Object info : (Iterable<?>) infos) {
if (info == null) {
continue;
}
String activityId = stringField(info, "activityId", "getActivityId");
String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
jobs.add(new String[]{activityId, senderUserId});
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
}
return jobs;
}
private static String stringField(Object obj, String field, String getter) {
try {
Object v = XposedHelpers.callMethod(obj, getter);
if (v != null) {
return String.valueOf(v);
}
} catch (Throwable ignored) {
}
try {
Object v = XposedHelpers.getObjectField(obj, field);
if (v != null) {
return String.valueOf(v);
}
} catch (Throwable ignored) {
}
return null;
}
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
ClassLoader cl = sAppClassLoader;
Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
return false;
}
try {
Class<?> reqCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
cl);
Object req;
if (sTemplateDetailRequest != null
&& reqCls.isInstance(sTemplateDetailRequest)) {
req = cloneRequestShallow(sTemplateDetailRequest);
applyActivityToRequest(req, activityId, senderUserId);
XposedBridge.log(TAG + " auto-detail using template request id=" + activityId);
} else {
req = newDetailRequest(reqCls, activityId, senderUserId);
if (req == null) {
XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId);
return false;
}
fillLoginOnRequest(req, cl);
}
cacheLoginFromRequestObject(req);
XposedBridge.log(TAG + " auto-detail req id=" + activityId
+ " userId=" + stringField(req, "userId", "getUserId")
+ " session=" + abbreviate(stringField(req, "sessionId", "getSessionId"))
+ " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId"))
+ " page=" + stringField(req, "page", "getPage")
+ " max=" + stringField(req, "maxResult", "getMaxResult"));
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))
&& TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
+ " (先手动点开任意一个红包详情一次以缓存模板)");
return false;
}
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))
&& !TextUtils.isEmpty(sCachedSessionId)) {
fillLoginOnRequest(req, cl);
}
Object result = XposedHelpers.callMethod(task, "moneyPacketDetail", req);
if (result == null) {
XposedBridge.log(TAG + " auto-detail null result id=" + activityId);
return false;
}
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
json = tryFastjson(result);
}
if (TextUtils.isEmpty(json)) {
XposedBridge.log(TAG + " auto-detail empty json id=" + activityId);
return false;
}
MmpSnapshot probe = parseSnapshot(json);
if (probe == null || probe.claims.isEmpty()) {
String snippet = json.length() > 240 ? json.substring(0, 240) + "..." : json;
XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId
+ " body=" + snippet
+ " userId=" + stringField(req, "userId", "getUserId"));
return false;
}
forwardParsedJson(json, "auto:" + activityId, "rpc");
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
+ " claims=" + probe.claims.size());
return true;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
return false;
}
}
private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) {
// 优先: (activityId, senderUserId, loadTime, page, maxResult)
try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 20);
} catch (Throwable ignored) {
}
try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 20,
31, null);
} catch (Throwable ignored) {
}
try {
Object req = reqCls.getDeclaredConstructor().newInstance();
XposedHelpers.setObjectField(req, "activityId", activityId);
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
try {
XposedHelpers.setIntField(req, "page", 0);
} catch (Throwable ignored) {
}
try {
XposedHelpers.setIntField(req, "maxResult", 20);
} catch (Throwable ignored) {
}
return req;
} catch (Throwable t) {
XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage());
return null;
}
}
private static void fillLoginOnRequest(Object req, ClassLoader cl) {
// 1) 优先用历史请求里缓存的登录态
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
try {
if (!TextUtils.isEmpty(sCachedUserId)) {
XposedHelpers.setObjectField(req, "userId", sCachedUserId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
}
if (!TextUtils.isEmpty(sCachedLoginId)) {
XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
}
} catch (Throwable ignored) {
}
if (!TextUtils.isEmpty(stringField(req, "userId", "getUserId"))
&& !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
return;
}
// 2) Hilt EntryPoint 兜底
try {
Object repo = obtainMoneyPacketRepository(cl);
if (repo == null) {
return;
}
Object loginStorage = null;
try {
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
} catch (Throwable ignored) {
}
if (loginStorage == null) {
return;
}
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
trySet(req, "setUserId", accountId);
trySet(req, "setSessionId", sessionId);
trySet(req, "setLoginId", loginId);
if (accountId != null) {
sCachedUserId = String.valueOf(accountId);
}
if (sessionId != null) {
sCachedSessionId = String.valueOf(sessionId);
}
if (loginId != null) {
sCachedLoginId = String.valueOf(loginId);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " fillLogin skip: " + t.getMessage());
}
}
private static void trySet(Object req, String setter, Object value) {
if (value == null) {
return;
}
try {
XposedHelpers.callMethod(req, setter, value);
} catch (Throwable ignored) {
}
}
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
try {
Context ctx = getContext();
if (ctx == null) {
return null;
}
Class<?> entryPoint = Class.forName(
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint",
false, cl);
Class<?> entryPoints = Class.forName("dagger.hilt.EntryPoints", false, cl);
Object ep = XposedHelpers.callStaticMethod(
entryPoints, "get", ctx.getApplicationContext(), entryPoint);
try {
return XposedHelpers.callMethod(ep, "repository");
} catch (Throwable ignored) {
}
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length == 0
&& m.getReturnType().getName().contains("MoneyPacketRepository")) {
return m.invoke(ep);
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " obtain repo fail: " + t.getMessage());
}
return null;
}
private static String extractMoneyPacketRpcOp(Object[] args) {
if (args == null) {
return null;
}
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) { private static void forwardParsedJson(String json, String sourceHint, String channel) {
@@ -179,18 +783,22 @@ public final class TngMoneyPacketHook {
if (snapshot == null || snapshot.claims.isEmpty()) { if (snapshot == null || snapshot.claims.isEmpty()) {
return; return;
} }
String dedupKey = snapshot.dedupKey(); if (!shouldForwardPacket(snapshot)) {
if (!remember(dedupKey)) {
return; return;
} }
Context context = getContext(); Context context = getContext();
if (context == null) { if (context == null) {
XposedBridge.log(TAG + " skip forward: no context, claims="
+ snapshot.claims.size());
return; return;
} }
String title = TextUtils.isEmpty(snapshot.title) ? "TNG Money Packet" : snapshot.title; String title = TextUtils.isEmpty(snapshot.title) ? "TNG 红包" : snapshot.title;
if (!TextUtils.isEmpty(snapshot.packetId) && snapshot.packetId.length() >= 8) {
title = title + " · " + snapshot.packetId.substring(0, 8);
}
String content = snapshot.formatForForward(channel, sourceHint); String content = snapshot.formatForForward(channel, sourceHint);
HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP); HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
XposedBridge.log(TAG + " captured packet=" + snapshot.packetId XposedBridge.log(TAG + " captured activityId=" + snapshot.packetId
+ " claims=" + snapshot.claims.size() + " via " + channel); + " claims=" + snapshot.claims.size() + " via " + channel);
} }
@@ -205,7 +813,11 @@ public final class TngMoneyPacketHook {
private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) { private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) {
MmpSnapshot snapshot = base != null ? base : new MmpSnapshot(); MmpSnapshot snapshot = base != null ? base : new MmpSnapshot();
boolean rootPass = base == null;
// 领取行不再回填 meta其它嵌套节点可补 packetId/sender/total
if (rootPass || parseClaimLine(root) == null) {
fillMeta(root, snapshot); fillMeta(root, snapshot);
}
JSONArray receiverList = findReceiverList(root); JSONArray receiverList = findReceiverList(root);
if (receiverList != null) { if (receiverList != null) {
@@ -233,7 +845,7 @@ public final class TngMoneyPacketHook {
Object elem = arr.opt(i); Object elem = arr.opt(i);
if (elem instanceof JSONObject) { if (elem instanceof JSONObject) {
ClaimLine line = parseClaimLine((JSONObject) elem); ClaimLine line = parseClaimLine((JSONObject) elem);
if (line != null && looksLikeClaimRow((JSONObject) elem)) { if (line != null) {
snapshot.claims.add(line); snapshot.claims.add(line);
} }
parseSnapshot((JSONObject) elem, snapshot); parseSnapshot((JSONObject) elem, snapshot);
@@ -241,6 +853,9 @@ public final class TngMoneyPacketHook {
} }
} }
} }
if (rootPass && TextUtils.isEmpty(snapshot.packetId) && !snapshot.claims.isEmpty()) {
snapshot.packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
}
return snapshot; return snapshot;
} }
@@ -249,7 +864,7 @@ public final class TngMoneyPacketHook {
while (keys.hasNext()) { while (keys.hasNext()) {
String key = keys.next(); String key = keys.next();
Object val = obj.opt(key); Object val = obj.opt(key);
if ("receiverList".equalsIgnoreCase(key) && val instanceof JSONArray) { if (val instanceof JSONArray && isReceiverListKey(key)) {
return (JSONArray) val; return (JSONArray) val;
} }
if (val instanceof JSONObject) { if (val instanceof JSONObject) {
@@ -262,12 +877,34 @@ public final class TngMoneyPacketHook {
return null; 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)
|| "activitypoolinfos".equals(k)
|| k.contains("receiverlist")
|| k.contains("leaderboard")
|| k.contains("activitypool");
}
private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) { private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) {
putIfPresent(obj, snapshot, "packetId", "mmpId", "moneyPacketId", "id"); // activityId 是红包唯一标识UUID优先于其它 id 字段
putIfPresent(obj, snapshot, "activityId", "packetId", "mmpId", "moneyPacketId",
"packetCode", "fundOrderId", "orderNo", "bizNo", "bizOrderId");
putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId"); putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId");
putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName"); putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName");
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName"); putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "amount"); putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
"totalPacketAmount", "originalAmount");
if (TextUtils.isEmpty(snapshot.title)) { if (TextUtils.isEmpty(snapshot.title)) {
String merchant = firstNonEmpty( String merchant = firstNonEmpty(
obj.optString("merchantName", null), obj.optString("merchantName", null),
@@ -283,70 +920,155 @@ public final class TngMoneyPacketHook {
if (!obj.has(key)) { if (!obj.has(key)) {
continue; continue;
} }
String val = obj.optString(key, null); String val = readFieldText(obj, key);
if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) { if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) {
continue; continue;
} }
if (key.toLowerCase(Locale.US).contains("packet") || key.equals("mmpId") || key.equals("id")) { String keyLower = key.toLowerCase(Locale.US);
if (TextUtils.isEmpty(snapshot.packetId)) { if (keyLower.equals("activityid") || keyLower.contains("packet")
|| keyLower.contains("mmp") || keyLower.contains("order")
|| keyLower.contains("biz") || keyLower.equals("packetcode")) {
// 已有 UUID 形态的 activityId 时不覆盖
if (TextUtils.isEmpty(snapshot.packetId) && !looksLikeMoneyJson(val)) {
snapshot.packetId = val;
} else if (!TextUtils.isEmpty(snapshot.packetId)
&& keyLower.equals("activityid")
&& looksLikeActivityId(val)) {
snapshot.packetId = val; snapshot.packetId = val;
} }
} else if (key.toLowerCase(Locale.US).contains("group")) { } else if (keyLower.contains("group") || keyLower.contains("chat")
if (TextUtils.isEmpty(snapshot.groupId)) { || keyLower.contains("conversation")) {
if (keyLower.contains("name")) {
if (TextUtils.isEmpty(snapshot.title)) {
snapshot.title = val;
}
} else if (TextUtils.isEmpty(snapshot.groupId)) {
snapshot.groupId = val; snapshot.groupId = val;
} }
} else if (key.toLowerCase(Locale.US).contains("name") && key.toLowerCase(Locale.US).contains("group")) { } else if (keyLower.contains("sender") || keyLower.contains("operator")
snapshot.title = val; || keyLower.contains("creator")) {
} else if (key.toLowerCase(Locale.US).contains("sender") if (TextUtils.isEmpty(snapshot.senderName) && !looksLikeMoneyJson(val)) {
|| key.toLowerCase(Locale.US).contains("operator")) {
if (TextUtils.isEmpty(snapshot.senderName)) {
snapshot.senderName = val; snapshot.senderName = val;
} }
} else if (key.toLowerCase(Locale.US).contains("total") || key.equals("amount")) { } else if (keyLower.contains("total") || keyLower.contains("packetamount")
if (TextUtils.isEmpty(snapshot.totalAmount)) { || keyLower.contains("original")) {
snapshot.totalAmount = val; String money = normalizeMoneyText(val);
if (TextUtils.isEmpty(snapshot.totalAmount) && !TextUtils.isEmpty(money)) {
snapshot.totalAmount = money;
} }
} }
} }
} }
private static boolean looksLikeActivityId(String val) {
if (TextUtils.isEmpty(val)) {
return false;
}
// b8c37a58-bb2c-4aa2-83ef-f15466b9211e
return val.length() >= 32 && val.indexOf('-') > 0;
}
private static boolean looksLikeClaimRow(JSONObject obj) { private static boolean looksLikeClaimRow(JSONObject obj) {
String name = firstNonEmpty( return parseClaimLine(obj) != null;
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);
} }
private static ClaimLine parseClaimLine(JSONObject obj) { private static ClaimLine parseClaimLine(JSONObject obj) {
String name = firstNonEmpty( String name = firstNonEmpty(
obj.optString("nickName", null), readFieldText(obj, "nickName"),
obj.optString("displayName", null), readFieldText(obj, "nickname"),
obj.optString("userName", null), readFieldText(obj, "displayName"),
obj.optString("receiverName", null), readFieldText(obj, "userName"),
obj.optString("name", null)); readFieldText(obj, "receiverName"),
readFieldText(obj, "receiverAccountName"),
readFieldText(obj, "accountName"),
readFieldText(obj, "participantName"),
readFieldText(obj, "name"));
String amount = firstNonEmpty( String amount = firstNonEmpty(
obj.optString("claimedAmount", null), normalizeMoneyText(readFieldText(obj, "claimedAmount")),
obj.optString("receiveAmount", null), normalizeMoneyText(readFieldText(obj, "receiveAmount")),
obj.optString("amount", null)); normalizeMoneyText(readFieldText(obj, "receiverClaimedAmount")),
normalizeMoneyText(readFieldText(obj, "claimAmount")),
normalizeMoneyText(readFieldText(obj, "amount")));
if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) { if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) {
return null; return null;
} }
if (name.length() > 64 || looksLikeMoneyJson(name)) {
return null;
}
ClaimLine line = new ClaimLine(); ClaimLine line = new ClaimLine();
line.nickname = name; line.nickname = name.trim();
line.amount = amount; line.amount = amount;
line.claimTime = firstNonEmpty( line.claimTime = firstNonEmpty(
obj.optString("claimTime", null), readFieldText(obj, "claimTime"),
obj.optString("claimedTime", null), readFieldText(obj, "claimedTime"),
obj.optString("receiveTime", null)); readFieldText(obj, "receiveTime"),
readFieldText(obj, "gmtClaim"),
readFieldText(obj, "gmtCreate"),
readFieldText(obj, "time"));
return line; 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) { private static String buildJsonFromObject(Object root) {
JSONObject json = objectToJson(root, new HashSet<Integer>(), 0); JSONObject json = objectToJson(root, new HashSet<Integer>(), 0);
return json != null ? json.toString() : null; return json != null ? json.toString() : null;
@@ -482,6 +1204,43 @@ public final class TngMoneyPacketHook {
return false; return false;
} }
private static boolean shouldForwardPacket(MmpSnapshot snapshot) {
String packetId = snapshot.packetId;
if (TextUtils.isEmpty(packetId)) {
packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
snapshot.packetId = packetId;
}
String claimsFp = snapshot.claimsFingerprint();
long now = System.currentTimeMillis();
synchronized (RECENT_PACKET_AT) {
Long lastAt = RECENT_PACKET_AT.get(packetId);
String lastClaims = RECENT_PACKET_CLAIMS.get(packetId);
if (lastAt != null && lastClaims != null
&& claimsFp.equals(lastClaims)
&& now - lastAt < PACKET_DEDUP_MS) {
XposedBridge.log(TAG + " skip dup activityId=" + packetId
+ " ageMs=" + (now - lastAt));
return false;
}
RECENT_PACKET_AT.put(packetId, now);
RECENT_PACKET_CLAIMS.put(packetId, claimsFp);
// 顺带清理过期项,防止无限涨
if (RECENT_PACKET_AT.size() > DEDUP_SIZE) {
java.util.Iterator<java.util.Map.Entry<String, Long>> it =
RECENT_PACKET_AT.entrySet().iterator();
while (it.hasNext()) {
java.util.Map.Entry<String, Long> e = it.next();
if (now - e.getValue() > PACKET_DEDUP_MS * 3) {
String k = e.getKey();
it.remove();
RECENT_PACKET_CLAIMS.remove(k);
}
}
}
}
return true;
}
private static boolean remember(String key) { private static boolean remember(String key) {
if (TextUtils.isEmpty(key)) { if (TextUtils.isEmpty(key)) {
return false; return false;
@@ -533,6 +1292,12 @@ public final class TngMoneyPacketHook {
String dedupKey() { String dedupKey() {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(TextUtils.isEmpty(packetId) ? "?" : packetId).append('|'); sb.append(TextUtils.isEmpty(packetId) ? "?" : packetId).append('|');
sb.append(claimsFingerprint());
return sb.toString();
}
String claimsFingerprint() {
StringBuilder sb = new StringBuilder();
for (ClaimLine line : claims) { for (ClaimLine line : claims) {
sb.append(line.nickname).append('=').append(line.amount).append(';'); sb.append(line.nickname).append('=').append(line.amount).append(';');
} }
@@ -541,23 +1306,24 @@ public final class TngMoneyPacketHook {
String formatForForward(String channel, String sourceHint) { String formatForForward(String channel, String sourceHint) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("[MMP统计] "); sb.append("[MMP统计]");
if (!TextUtils.isEmpty(packetId)) { if (!TextUtils.isEmpty(packetId)) {
sb.append("packet=").append(packetId).append(' '); sb.append(" packet=").append(sanitizeMeta(packetId));
} }
if (!TextUtils.isEmpty(groupId)) { if (!TextUtils.isEmpty(groupId)) {
sb.append("group=").append(groupId).append(' '); sb.append(" | group=").append(sanitizeMeta(groupId));
} }
if (!TextUtils.isEmpty(senderName)) { if (!TextUtils.isEmpty(senderName)) {
sb.append("sender=").append(senderName).append(' '); sb.append(" | sender=").append(sanitizeMeta(senderName));
} }
if (!TextUtils.isEmpty(totalAmount)) { 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)) { if (!TextUtils.isEmpty(sourceHint)) {
sb.append(" src=").append(sourceHint.length() > 120 String src = sourceHint.length() > 120
? sourceHint.substring(0, 120) + "..." : sourceHint); ? sourceHint.substring(0, 120) + "..." : sourceHint;
sb.append(" | src=").append(sanitizeMeta(src));
} }
sb.append("\n"); sb.append("\n");
for (ClaimLine line : claims) { for (ClaimLine line : claims) {
@@ -569,6 +1335,14 @@ public final class TngMoneyPacketHook {
} }
return sb.toString().trim(); return sb.toString().trim();
} }
private static String sanitizeMeta(String value) {
if (value == null) {
return "";
}
// 避免空格拆坏 meta竖线是分隔符
return value.replace('|', '/').trim();
}
} }
private static final class ClaimLine { private static final class ClaimLine {