Compare commits
2 Commits
e80f7f908c
...
1e2021d8fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e2021d8fd | ||
|
|
88a2b319f8 |
@@ -5,7 +5,8 @@ Android 应用,监听通知栏消息并通过 **双通道**(通知监听 + X
|
|||||||
> **Hook 架构与扩展指南**:详见 [docs/Hook指南.md](docs/Hook指南.md)(含 **Xposed / LSPosed**、接入新 App)
|
> **Hook 架构与扩展指南**:详见 [docs/Hook指南.md](docs/Hook指南.md)(含 **Xposed / LSPosed**、接入新 App)
|
||||||
> **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)
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,39 +70,49 @@ 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);
|
||||||
|
|
||||||
Request request = new Request.Builder()
|
for (String baseUrl : urls) {
|
||||||
.url(AppConfig.DEBUG_SERVER_URL + "/api/debug/push")
|
if (TextUtils.isEmpty(baseUrl)) {
|
||||||
.header("Content-Type", "application/json")
|
finishOne(pending, onComplete);
|
||||||
.post(RequestBody.create(json.toString(), JSON))
|
continue;
|
||||||
.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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
|
||||||
public void onResponse(Call call, Response response) {
|
+ " -> " + target);
|
||||||
int code = response.code();
|
|
||||||
response.close();
|
CLIENT.newCall(request).enqueue(new Callback() {
|
||||||
if (code >= 200 && code < 300) {
|
@Override
|
||||||
Log.d(TAG, "forward ok [" + source + "] " + title);
|
public void onFailure(Call call, java.io.IOException e) {
|
||||||
} else {
|
Log.w(TAG, "forward failed [" + source + "] -> " + target
|
||||||
Log.w(TAG, "forward http " + code + " [" + source + "] " + title);
|
+ ": " + 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) {
|
} 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();
|
||||||
|
|||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
|||||||
120
docs/TNG_MoneyPacket领取台.md
Normal file
120
docs/TNG_MoneyPacket领取台.md
Normal 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://<电脑IP>: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 监听列表勾选了 TNG(Hook 转发依赖主 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 |
|
||||||
@@ -11,42 +11,25 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Money Packet 领取统计 — 网络/明文获取能力
|
## Money Packet 领取统计
|
||||||
|
|
||||||
目标:群红包 Leaderboard 的 **昵称 + 已领金额**(字段预期 `receiverList` / `claimedAmount` 等)。
|
**专用说明(操作 / 标识 / 自动拉详情 / 排障):** 见 [TNG_MoneyPacket领取台.md](TNG_MoneyPacket领取台.md)。
|
||||||
|
|
||||||
### 路线对比
|
### 能力摘要
|
||||||
|
|
||||||
| 路线 | 状态 | 说明 |
|
| 路线 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 外部 mitm(Charles / 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` 即可做领取排行。
|
|
||||||
|
|||||||
@@ -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();
|
||||||
fillMeta(root, snapshot);
|
boolean rootPass = base == null;
|
||||||
|
// 领取行不再回填 meta;其它嵌套节点可补 packetId/sender/total
|
||||||
|
if (rootPass || parseClaimLine(root) == null) {
|
||||||
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user