Compare commits

..

10 Commits

Author SHA1 Message Date
mars
3fe84a8dd3 fix(mmp): 停止反复强跳红包历史页,避免打断前台使用
领取台恢复改为仅拉起 TNG 前台;Hook 侧限次/冷却,已在历史页不再重复打开。
2026-08-06 16:45:53 +08:00
mars
ae65351d8e feat(mmp): Hook 无心跳时自动唤醒或强停重开 TNG
领取台检测开启时,进程在跑但心跳过期会先进历史页唤醒,仍无效则 force-stop 重建 Hook。
2026-08-05 17:29:36 +08:00
mars
abf41b9ab6 fix(mmp): 状态条标明调试台可选,避免误解为故障
调试台未连接不参与爆红判定,文案改为可选提示。
2026-08-05 17:18:16 +08:00
mars
b3f2b2b3c2 feat(mmp): 领取进度/过期状态,并展示红包与领取人ID
状态优先显示已全部领取或已领取N/M,未领完才附加已过期;列表与最高最低补全 activityId、receiverId。
2026-08-05 15:45:39 +08:00
mars
293979122d feat(mmp): 领取人ID/模块版本检测,并修正状态条误报红
展示 receiverId 与 activityPoolId;检测 Hook 是否最新;按真实时间排序;有心跳与定时拉取时不再因久无新数据爆红。
2026-08-05 15:09:51 +08:00
mars
a4aed01b29 docs(mmp): 功能说明补充 TNG 闪退与强制停止排障
说明多点重试、强停、清缓存、更新 Hook 后强停及作用域排查步骤。
2026-08-04 17:49:22 +08:00
mars
c8cbfdff82 feat(mmp): 独立红包领取台(手机+PC)、同步、筛选与功能说明
隔离通用消息台;支持设置/手气摘要/电脑同步;忽略临时逆向脚本与运行时 JSON。
2026-08-04 17:39:29 +08:00
mars
1517ff4671 feat(tng): 自动拉红包历史/详情,并展示发放与领取时间
从 ILoginStorage 与官方历史页缓存请求模板,级联拉详情;领取台列表用发放时间而非拉取时间。
2026-08-04 16:20:13 +08:00
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
34 changed files with 8471 additions and 127 deletions

9
.gitignore vendored
View File

@@ -28,8 +28,17 @@ reverse/frida/logcat_capture.txt
reverse/frida/*.out reverse/frida/*.out
reverse/frida/*.err reverse/frida/*.err
debug-server/__pycache__/ debug-server/__pycache__/
# 调试台运行时落盘(设置/红包镜像,本机生成)
debug-server/mmp_packets.json
debug-server/mmp_settings.json
magisk-modules/tng_exit_guard/obj/ magisk-modules/tng_exit_guard/obj/
magisk-modules/tng_exit_guard/libs/ magisk-modules/tng_exit_guard/libs/
# 调试抓包/截图/ANR/APK 产物,不入库 # 调试抓包/截图/ANR/APK 产物,不入库
reverse/dumps/ reverse/dumps/
reverse/scripts/__pycache__/ reverse/scripts/__pycache__/
# 临时 MMP 逆向探测脚本(一次性 dump/scan不入库
reverse/scripts/_dump_mmp_*.py
reverse/scripts/_scan_mmp_*.py
reverse/scripts/_verify_mmp_*.py
reverse/scripts/_find_mmp_*.py
reverse/scripts/_dump_rpc_request.py

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

@@ -74,6 +74,18 @@
<activity <activity
android:name=".Activity.BankListActivity" android:name=".Activity.BankListActivity"
android:exported="false" /> android:exported="false" />
<activity
android:name=".Activity.MmpClaimActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity
android:name=".Activity.MmpDetailActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity
android:name=".Activity.MmpHelpActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity <activity
android:name=".Activity.PermissionActivity" android:name=".Activity.PermissionActivity"
android:exported="false" /> android:exported="false" />
@@ -112,6 +124,7 @@
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="com.miraclegarden.smsmessage.action.HOOK_MESSAGE" /> <action android:name="com.miraclegarden.smsmessage.action.HOOK_MESSAGE" />
<action android:name="com.miraclegarden.smsmessage.action.HOOK_STATUS" />
</intent-filter> </intent-filter>
</receiver> </receiver>

View File

@@ -72,6 +72,10 @@ public class MainActivity extends MiracleGardenActivity<ActivityMainBinding> {
startActivity(new Intent(this, BankListActivity.class)); startActivity(new Intent(this, BankListActivity.class));
}); });
binding.btnMmpClaim.setOnClickListener(v -> {
startActivity(new Intent(this, MmpClaimActivity.class));
});
binding.permissionNotificationAccess.setOnClickListener(v -> { binding.permissionNotificationAccess.setOnClickListener(v -> {
startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)); startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS));
}); });

View File

@@ -0,0 +1,661 @@
package com.miraclegarden.smsmessage.Activity;
import android.app.DatePickerDialog;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.miraclegarden.library.app.MiracleGardenActivity;
import com.miraclegarden.smsmessage.R;
import com.miraclegarden.smsmessage.comm.CommonAdapter;
import com.miraclegarden.smsmessage.comm.ViewHolder;
import com.miraclegarden.smsmessage.databinding.ActivityMmpClaimBinding;
import com.miraclegarden.smsmessage.mmp.MmpHookStatus;
import com.miraclegarden.smsmessage.mmp.MmpLocalSettings;
import com.miraclegarden.smsmessage.mmp.MmpModuleInfo;
import com.miraclegarden.smsmessage.mmp.MmpPacket;
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
import com.miraclegarden.smsmessage.mmp.MmpSettingsClient;
import com.miraclegarden.smsmessage.mmp.MmpSyncClient;
import com.miraclegarden.smsmessage.mmp.TngProcessHelper;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.Locale;
/**
* 手机端红包领取台(独立于通用消息/情况监听)。
*/
public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBinding>
implements MmpPacketStore.Listener {
private final List<MmpPacket> allPackets = new ArrayList<>();
private final List<MmpPacket> packets = new ArrayList<>();
private CommonAdapter<MmpPacket> adapter;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable refreshRunnable = this::reload;
/** 0全部 1领取中 2已领完 */
private int statusFilter = 0;
/** 0全部时间 1近1天 2近3天 3近7天 4自定义 */
private int timeMode = 0;
private long customFromMs;
private long customToMs;
private String keyword = "";
private boolean settingsOpen;
private boolean autoWatchWhileOpen = true;
private boolean autoLaunchTngIfKilled = true;
private long lastTngLaunchAt;
/** 0=未处理 1=已唤醒/进历史 2=已强停重开 */
private int hookRecoverStep;
private long lastHookRecoverAt;
private final Runnable watchRunnable = this::watchTngOnce;
/** null=检测中 true=通 false=不通 */
private Boolean debugServerOk;
private long lastDebugPingAt;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
JSONObject local = MmpLocalSettings.load(this);
autoWatchWhileOpen = local.optBoolean("autoWatchWhileOpen", true);
autoLaunchTngIfKilled = local.optBoolean("autoLaunchTngIfKilled", true);
initView();
reload();
loadSettingsQuiet();
}
@Override
protected void onResume() {
super.onResume();
MmpPacketStore.addListener(this);
reload();
MmpSyncClient.syncIfStale(this, 5000L);
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
scheduleWatch();
updateConnStatus();
pingDebugServer();
}
@Override
protected void onPause() {
super.onPause();
MmpPacketStore.removeListener(this);
handler.removeCallbacks(refreshRunnable);
handler.removeCallbacks(watchRunnable);
}
@Override
public void onMmpPacketsChanged() {
runOnUiThread(() -> {
reload();
updateConnStatus();
});
}
private void initView() {
binding.backIv.setOnClickListener(v -> finish());
binding.btnClear.setOnClickListener(v -> confirmClear());
binding.btnHelp.setOnClickListener(v ->
startActivity(new android.content.Intent(this, MmpHelpActivity.class)));
binding.btnSettings.setOnClickListener(v -> toggleSettings());
binding.btnCloseSettings.setOnClickListener(v -> {
settingsOpen = false;
binding.settingsPanel.setVisibility(View.GONE);
});
binding.btnSync.setOnClickListener(v -> syncNow());
binding.btnRefresh.setOnClickListener(v -> manualRefresh());
binding.chipAll.setOnClickListener(v -> setStatusFilter(0));
binding.chipOpen.setOnClickListener(v -> setStatusFilter(1));
binding.chipDone.setOnClickListener(v -> setStatusFilter(2));
binding.chipTimeAll.setOnClickListener(v -> setTimeMode(0));
binding.chipTime1.setOnClickListener(v -> setTimeMode(1));
binding.chipTime3.setOnClickListener(v -> setTimeMode(2));
binding.chipTime7.setOnClickListener(v -> setTimeMode(3));
binding.chipTimeCustom.setOnClickListener(v -> setTimeMode(4));
binding.btnDateFrom.setOnClickListener(v -> pickDate(true));
binding.btnDateTo.setOnClickListener(v -> pickDate(false));
binding.etFilter.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
keyword = s != null ? s.toString() : "";
applyFilter();
}
@Override
public void afterTextChanged(Editable s) {
}
});
binding.btnPresetFast.setOnClickListener(v -> applyPreset("fast"));
binding.btnPresetNormal.setOnClickListener(v -> applyPreset("normal"));
binding.btnPresetSlow.setOnClickListener(v -> applyPreset("slow"));
binding.btnSaveSettings.setOnClickListener(v -> saveSettings());
binding.recyclerview.setLayoutManager(new LinearLayoutManager(this));
adapter = new CommonAdapter<MmpPacket>(this, R.layout.item_mmp_packet, packets) {
@Override
public void convert(ViewHolder holder, MmpPacket packet, int index) {
holder.setText(R.id.tv_title, packet.displayTitle());
holder.setText(R.id.tv_sum, formatMoney(packet.sumClaimed > 0
? packet.sumClaimed : parseDouble(packet.total)));
TextView idView = holder.getView(R.id.tv_id);
if (!TextUtils.isEmpty(packet.packetId)) {
idView.setVisibility(View.VISIBLE);
idView.setText("ID " + packet.packetId);
} else {
idView.setVisibility(View.GONE);
idView.setText("");
}
android.widget.TextView status = holder.getView(R.id.tv_status);
status.setText(packet.statusLabel());
if (packet.isFullyClaimed()) {
status.setTextColor(Color.parseColor("#047857"));
status.setBackgroundColor(Color.parseColor("#ECFDF5"));
} else if (packet.isExpired()) {
status.setTextColor(Color.parseColor("#6B7280"));
status.setBackgroundColor(Color.parseColor("#F3F4F6"));
} else {
status.setTextColor(Color.parseColor("#B45309"));
status.setBackgroundColor(Color.parseColor("#FFFBEB"));
}
String meta;
if (packet.effectiveTotalCount() > 0) {
meta = packet.effectiveClaimedCount() + "/" + packet.effectiveTotalCount() + "";
} else {
meta = packet.claimantCount + "";
}
if (packet.issuedAt != null && !packet.issuedAt.isEmpty()) {
meta += " · 发放 " + packet.issuedAt;
}
if (packet.expiresAt != null && !packet.expiresAt.isEmpty()) {
meta += " · 到期 " + packet.expiresAt;
}
holder.setText(R.id.tv_meta, meta);
String hi = packet.finished ? "最佳" : "最高";
String lo = packet.finished ? "最差" : "最低";
com.miraclegarden.smsmessage.mmp.MmpClaim best = packet.bestClaim();
com.miraclegarden.smsmessage.mmp.MmpClaim worst = packet.worstClaim();
holder.setText(R.id.tv_best, formatHiLoLine(hi, best));
if (worst != null && packet.claimantCount > 1) {
holder.setText(R.id.tv_worst, formatHiLoLine(lo, worst));
} else {
holder.setText(R.id.tv_worst, lo + " -");
}
holder.itemView.setOnClickListener(v -> openDetail(packet.packetId));
}
};
binding.recyclerview.setAdapter(adapter);
updateChipUi();
updateTimeChipUi();
}
private void reload() {
allPackets.clear();
allPackets.addAll(MmpPacketStore.getPackets(this));
applyFilter();
updateConnStatus();
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
}
private void applyFilter() {
packets.clear();
for (MmpPacket p : allPackets) {
if (p.matchesFilter(keyword, statusFilter, timeMode, customFromMs, customToMs)) {
packets.add(p);
}
}
adapter.notifyDataSetChanged();
binding.tvCount.setText(packets.size() + "/" + allPackets.size() + "");
binding.emptyLy.setVisibility(packets.isEmpty() ? View.VISIBLE : View.GONE);
}
private void setStatusFilter(int filter) {
statusFilter = filter;
updateChipUi();
applyFilter();
}
private void setTimeMode(int mode) {
timeMode = mode;
binding.customRangeLy.setVisibility(mode == 4 ? View.VISIBLE : View.GONE);
updateTimeChipUi();
applyFilter();
}
private void pickDate(boolean from) {
Calendar cal = Calendar.getInstance();
long base = from ? customFromMs : customToMs;
if (base > 0) {
cal.setTimeInMillis(base);
}
new DatePickerDialog(this, (view, y, m, d) -> {
Calendar c = Calendar.getInstance();
if (from) {
c.set(y, m, d, 0, 0, 0);
c.set(Calendar.MILLISECOND, 0);
customFromMs = c.getTimeInMillis();
binding.btnDateFrom.setText(String.format(Locale.US, "%04d-%02d-%02d", y, m + 1, d));
} else {
c.set(y, m, d, 23, 59, 59);
c.set(Calendar.MILLISECOND, 999);
customToMs = c.getTimeInMillis();
binding.btnDateTo.setText(String.format(Locale.US, "%04d-%02d-%02d", y, m + 1, d));
}
if (timeMode != 4) {
setTimeMode(4);
} else {
applyFilter();
}
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show();
}
private void updateChipUi() {
styleChip(binding.chipAll, statusFilter == 0, true);
styleChip(binding.chipOpen, statusFilter == 1, true);
styleChip(binding.chipDone, statusFilter == 2, true);
}
private void updateTimeChipUi() {
styleChip(binding.chipTimeAll, timeMode == 0, false);
styleChip(binding.chipTime1, timeMode == 1, false);
styleChip(binding.chipTime3, timeMode == 2, false);
styleChip(binding.chipTime7, timeMode == 3, false);
styleChip(binding.chipTimeCustom, timeMode == 4, false);
}
private static void styleChip(View chip, boolean active, boolean brand) {
if (!(chip instanceof android.widget.TextView)) {
return;
}
android.widget.TextView tv = (android.widget.TextView) chip;
if (active) {
tv.setBackgroundColor(Color.parseColor(brand ? "#C45C26" : "#1C1410"));
tv.setTextColor(Color.WHITE);
} else {
tv.setBackgroundColor(Color.parseColor("#EEEEEE"));
tv.setTextColor(Color.parseColor("#333333"));
}
}
private void syncNow() {
Toast.makeText(this, "正在同步到电脑…", Toast.LENGTH_SHORT).show();
MmpSyncClient.syncToPc(this, (ok, message) -> runOnUiThread(() ->
Toast.makeText(this, message != null ? message : (ok ? "同步成功" : "同步失败"),
Toast.LENGTH_SHORT).show()));
}
private void manualRefresh() {
reload();
MmpSyncClient.syncIfStale(this, 0L);
boolean alive = TngProcessHelper.isRunning(this);
String tip = "已刷新,当前 " + packets.size() + "/" + allPackets.size() + "";
if (!alive) {
tip += "TNG 未运行";
if (autoLaunchTngIfKilled) {
if (TngProcessHelper.launch(this)) {
lastTngLaunchAt = System.currentTimeMillis();
tip += ",已尝试打开";
} else {
tip += ",打开失败";
}
}
} else {
tip += "TNG 在跑";
}
Toast.makeText(this, tip, Toast.LENGTH_SHORT).show();
updateConnStatus();
pingDebugServer();
scheduleWatch();
}
private void scheduleWatch() {
handler.removeCallbacks(watchRunnable);
handler.postDelayed(watchRunnable, 3_000L);
}
private void watchTngOnce() {
if (isFinishing()) {
return;
}
boolean alive = TngProcessHelper.isRunning(this);
if (autoWatchWhileOpen && autoLaunchTngIfKilled) {
if (!alive) {
long now = System.currentTimeMillis();
if (now - lastTngLaunchAt > 60_000L) {
if (TngProcessHelper.launch(this)) {
lastTngLaunchAt = now;
Toast.makeText(this, "检测到 TNG 已退出,正在重新打开…", Toast.LENGTH_SHORT).show();
}
}
} else {
maybeRecoverStaleHook();
}
}
updateConnStatus();
if (System.currentTimeMillis() - lastDebugPingAt > 20_000L) {
pingDebugServer();
}
handler.removeCallbacks(watchRunnable);
handler.postDelayed(watchRunnable, 12_000L);
}
/**
* TNG 还在跑但 Hook 长时间无心跳:仅把 TNG 拉到前台(不强进历史页),
* 仍无效再强停重开。历史页跳转只由 Xposed 短 bounce会自动 finish避免一直把人拽进历史页。
*/
private void maybeRecoverStaleHook() {
long hookAt = MmpHookStatus.lastAliveAt(this);
long age = hookAt > 0 ? System.currentTimeMillis() - hookAt : Long.MAX_VALUE;
if (age <= 3 * 60_000L) {
hookRecoverStep = 0;
return;
}
long now = System.currentTimeMillis();
if (hookRecoverStep == 0) {
if (now - lastHookRecoverAt < 90_000L) {
return;
}
lastHookRecoverAt = now;
hookRecoverStep = 1;
// 不强进 MoneyPacketHistoryActivity用户已在 App 内时会被反复打断
boolean ok = TngProcessHelper.launch(this);
if (ok) {
Toast.makeText(this, "Hook 无心跳,已尝试唤醒 TNG 前台…", Toast.LENGTH_SHORT).show();
}
return;
}
if (hookRecoverStep == 1) {
if (now - lastHookRecoverAt < 2 * 60_000L) {
return;
}
// 唤醒后仍无心跳 → 强停重建
lastHookRecoverAt = now;
hookRecoverStep = 2;
Toast.makeText(this, "仍无心跳,强制重启 TNG…", Toast.LENGTH_SHORT).show();
new Thread(() -> {
boolean ok = TngProcessHelper.forceStopAndLaunch(this);
runOnUiThread(() -> {
if (ok) {
lastTngLaunchAt = System.currentTimeMillis();
} else {
Toast.makeText(this, "强制重启 TNG 失败", Toast.LENGTH_SHORT).show();
hookRecoverStep = 0;
}
});
}, "mmp-hook-recover").start();
return;
}
// step 2 后隔 3 分钟仍无心跳,再重试一轮
if (now - lastHookRecoverAt > 3 * 60_000L) {
hookRecoverStep = 0;
}
}
private void pingDebugServer() {
lastDebugPingAt = System.currentTimeMillis();
MmpSettingsClient.pingHealth(ok -> runOnUiThread(() -> {
debugServerOk = ok;
updateConnStatus();
}));
}
private void updateConnStatus() {
if (binding.tvConnStatus == null) {
return;
}
boolean tngAlive = TngProcessHelper.isRunning(this);
long lastIngest = MmpPacketStore.lastIngestAt(this);
long ageMs = lastIngest > 0 ? System.currentTimeMillis() - lastIngest : -1L;
long hookAge = MmpHookStatus.lastAliveAt(this) > 0
? System.currentTimeMillis() - MmpHookStatus.lastAliveAt(this) : -1L;
// 有近期心跳 = Hook 活着(定时拉取会报活);久无入库不等于故障
boolean hookAlive = hookAge >= 0 && hookAge <= 3 * 60_000L;
boolean modLatest = MmpHookStatus.liveVersionCode(this) >= 0
&& MmpHookStatus.isLiveLatest(this);
boolean modKnownOld = hookAlive
&& MmpHookStatus.liveVersionCode(this) > 0
&& MmpHookStatus.liveVersionCode(this) < MmpModuleInfo.EXPECTED_VERSION_CODE;
String tngPart = tngAlive ? "TNG运行中" : "TNG未运行";
String hookPart;
if (!tngAlive) {
hookPart = "Hook未连接TNG 未开)";
} else if (hookAlive && ageMs >= 0 && ageMs <= 120_000L) {
hookPart = "Hook正常" + formatAge(ageMs) + "前有数据)";
} else if (hookAlive) {
if (ageMs >= 0) {
hookPart = "Hook正常定时拉取中上次数据 " + formatAge(ageMs) + "前)";
} else {
hookPart = "Hook正常定时拉取中尚无入库";
}
} else if (ageMs >= 0 && ageMs <= 10 * 60_000L) {
hookPart = "Hook待确认" + formatAge(ageMs) + "前有数据,心跳偏旧)";
} else if (ageMs < 0) {
hookPart = "Hook待确认无心跳尚无入库";
} else {
hookPart = "Hook异常无心跳" + formatAge(ageMs) + "前曾有数据)";
}
String watchPart = autoWatchWhileOpen
? (autoLaunchTngIfKilled
? "检测:开(退出/无心跳会拉起)"
: "检测:开(不自动拉起)")
: "检测:关";
String debugPart;
if (debugServerOk == null) {
debugPart = "调试台:检测中(可选)";
} else if (debugServerOk) {
debugPart = "调试台:已连接(可选)";
} else {
debugPart = "调试台:未连(可选,不影响领取)";
}
String text = tngPart + " · " + hookPart + "\n"
+ watchPart + " · " + debugPart + "\n"
+ MmpHookStatus.describe(this);
binding.tvConnStatus.setText(text);
// 颜色只看 TNG / Hook 心跳 / 模块版本;调试台可有可无,绝不因此爆红
boolean okAll = tngAlive && hookAlive && modLatest;
boolean bad = !tngAlive || !hookAlive || modKnownOld;
if (okAll) {
binding.tvConnStatus.setBackgroundColor(Color.parseColor("#064E3B"));
binding.tvConnStatus.setTextColor(Color.parseColor("#D1FAE5"));
} else if (bad) {
binding.tvConnStatus.setBackgroundColor(Color.parseColor("#7F1D1D"));
binding.tvConnStatus.setTextColor(Color.parseColor("#FEE2E2"));
} else {
binding.tvConnStatus.setBackgroundColor(Color.parseColor("#78350F"));
binding.tvConnStatus.setTextColor(Color.parseColor("#FEF3C7"));
}
}
private static String formatAge(long ageMs) {
if (ageMs < 60_000L) {
return Math.max(1, ageMs / 1000) + "";
}
if (ageMs < 3600_000L) {
return (ageMs / 60_000L) + "分钟";
}
return (ageMs / 3600_000L) + "小时";
}
private void toggleSettings() {
settingsOpen = !settingsOpen;
binding.settingsPanel.setVisibility(settingsOpen ? View.VISIBLE : View.GONE);
if (settingsOpen) {
loadSettingsQuiet();
}
}
private void loadSettingsQuiet() {
MmpSettingsClient.load(this, (json, error) -> {
if (json != null) {
fillSettingsForm(json);
if (error != null && !error.isEmpty()) {
binding.tvSettingsStatus.setText(error);
} else {
binding.tvSettingsStatus.setText("设置已加载(本地优先,可离线保存)");
}
} else {
fillSettingsForm(MmpLocalSettings.load(this));
binding.tvSettingsStatus.setText(error != null ? error : "已用本地默认设置");
}
});
}
private void fillSettingsForm(JSONObject json) {
binding.cfgHistory.setText(String.valueOf(json.optInt("historyCooldownSec", 8)));
binding.cfgDetail.setText(String.valueOf(json.optInt("detailCooldownSec", 8)));
binding.cfgDedup.setText(String.valueOf(json.optInt("dedupSec", 3)));
binding.cfgGap.setText(String.valueOf(json.optInt("detailGapMs", 80)));
binding.cfgOpenHistory.setChecked(json.optBoolean("openHistoryIfNoTemplate", true));
binding.cfgBounceHistory.setChecked(json.optBoolean("autoBounceHistoryOnDisconnect", true));
binding.cfgAutoWatch.setChecked(json.optBoolean("autoWatchWhileOpen", true));
binding.cfgAutoLaunch.setChecked(json.optBoolean("autoLaunchTngIfKilled", true));
autoWatchWhileOpen = binding.cfgAutoWatch.isChecked();
autoLaunchTngIfKilled = binding.cfgAutoLaunch.isChecked();
}
private void applyPreset(String name) {
try {
JSONObject o = MmpLocalSettings.defaults();
if ("fast".equals(name)) {
o.put("historyCooldownSec", 5);
o.put("detailCooldownSec", 5);
o.put("dedupSec", 2);
o.put("detailGapMs", 50);
o.put("pagePollMs", 800);
} else if ("slow".equals(name)) {
o.put("historyCooldownSec", 30);
o.put("detailCooldownSec", 45);
o.put("dedupSec", 15);
o.put("detailGapMs", 200);
o.put("pagePollMs", 3000);
}
o.put("openHistoryIfNoTemplate", true);
o.put("autoBounceHistoryOnDisconnect", true);
o.put("autoWatchWhileOpen", true);
o.put("autoLaunchTngIfKilled", true);
fillSettingsForm(o);
binding.tvSettingsStatus.setText("已套用预设,点保存生效");
} catch (Exception e) {
binding.tvSettingsStatus.setText("预设失败");
}
}
private void saveSettings() {
try {
JSONObject body = new JSONObject();
body.put("historyCooldownSec", parseIntSafe(binding.cfgHistory.getText().toString(), 8));
body.put("detailCooldownSec", parseIntSafe(binding.cfgDetail.getText().toString(), 8));
body.put("dedupSec", parseIntSafe(binding.cfgDedup.getText().toString(), 3));
body.put("detailGapMs", parseIntSafe(binding.cfgGap.getText().toString(), 80));
body.put("pagePollMs", 1000);
body.put("openHistoryIfNoTemplate", binding.cfgOpenHistory.isChecked());
body.put("autoBounceHistoryOnDisconnect", binding.cfgBounceHistory.isChecked());
body.put("autoWatchWhileOpen", binding.cfgAutoWatch.isChecked());
body.put("autoLaunchTngIfKilled", binding.cfgAutoLaunch.isChecked());
autoWatchWhileOpen = binding.cfgAutoWatch.isChecked();
autoLaunchTngIfKilled = binding.cfgAutoLaunch.isChecked();
binding.tvSettingsStatus.setText("保存中…");
MmpSettingsClient.save(this, body, (json, error) -> {
if (json != null) {
fillSettingsForm(json);
String msg = error != null ? error : "已保存Hook 参数约 10 秒内生效(需调试台)";
binding.tvSettingsStatus.setText(msg);
Toast.makeText(this, "设置已保存", Toast.LENGTH_SHORT).show();
scheduleWatch();
} else {
binding.tvSettingsStatus.setText("保存失败:" + error);
Toast.makeText(this, "保存失败", Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
binding.tvSettingsStatus.setText("保存异常");
}
}
private void openDetail(String packetId) {
Intent intent = new Intent(this, MmpDetailActivity.class);
intent.putExtra(MmpDetailActivity.EXTRA_PACKET_ID, packetId);
startActivity(intent);
}
private static String formatHiLoLine(String prefix,
com.miraclegarden.smsmessage.mmp.MmpClaim claim) {
if (claim == null) {
return prefix + " -";
}
String nick = claim.nickname != null ? claim.nickname : "?";
String amt = claim.amountText != null ? claim.amountText : formatMoney(claim.amount);
if (!TextUtils.isEmpty(claim.userId)) {
return prefix + " " + nick + " " + amt + "\nID " + claim.userId;
}
return prefix + " " + nick + " " + amt;
}
private void confirmClear() {
new AlertDialog.Builder(this)
.setTitle("清空红包台")
.setMessage("仅清空本机红包领取记录,不影响通用消息台。")
.setPositiveButton("清空", (d, w) -> {
MmpPacketStore.clear(this);
Toast.makeText(this, "已清空", Toast.LENGTH_SHORT).show();
reload();
})
.setNegativeButton("取消", null)
.show();
}
private static String formatMoney(double v) {
return String.format(Locale.US, "%.2f", v);
}
private static double parseDouble(String s) {
try {
return s == null || s.isEmpty() ? 0 : Double.parseDouble(s);
} catch (NumberFormatException e) {
return 0;
}
}
private static int parseIntSafe(String s, int def) {
try {
return Integer.parseInt(s.trim());
} catch (Exception e) {
return def;
}
}
}

View File

@@ -0,0 +1,233 @@
package com.miraclegarden.smsmessage.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.miraclegarden.library.app.MiracleGardenActivity;
import com.miraclegarden.smsmessage.R;
import com.miraclegarden.smsmessage.comm.CommonAdapter;
import com.miraclegarden.smsmessage.comm.ViewHolder;
import com.miraclegarden.smsmessage.databinding.ActivityMmpDetailBinding;
import com.miraclegarden.smsmessage.mmp.MmpClaim;
import com.miraclegarden.smsmessage.mmp.MmpPacket;
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* 单个红包领取排行(数据优先展示)。
*/
public class MmpDetailActivity extends MiracleGardenActivity<ActivityMmpDetailBinding>
implements MmpPacketStore.Listener {
public static final String EXTRA_PACKET_ID = "packetId";
private String packetId;
private final List<MmpClaim> claims = new ArrayList<>();
private CommonAdapter<MmpClaim> adapter;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable refreshRunnable = this::reload;
private double bestAmount = -1;
private double worstAmount = -1;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
packetId = getIntent().getStringExtra(EXTRA_PACKET_ID);
if (TextUtils.isEmpty(packetId)) {
finish();
return;
}
initView();
reload();
}
@Override
protected void onResume() {
super.onResume();
MmpPacketStore.addListener(this);
reload();
}
@Override
protected void onPause() {
super.onPause();
MmpPacketStore.removeListener(this);
handler.removeCallbacks(refreshRunnable);
}
@Override
public void onMmpPacketsChanged() {
runOnUiThread(this::reload);
}
private void initView() {
binding.backIv.setOnClickListener(v -> finish());
binding.recyclerview.setLayoutManager(new LinearLayoutManager(this));
adapter = new CommonAdapter<MmpClaim>(this, R.layout.item_mmp_claim, claims) {
@Override
public void convert(ViewHolder holder, MmpClaim claim, int index) {
holder.setText(R.id.tv_rank, String.valueOf(claim.rank > 0 ? claim.rank : index + 1));
holder.setText(R.id.tv_nickname, claim.nickname != null ? claim.nickname : "?");
TextView userIdView = holder.getView(R.id.tv_user_id);
if (!TextUtils.isEmpty(claim.userId)) {
userIdView.setVisibility(View.VISIBLE);
userIdView.setText("用户ID " + claim.userId);
} else {
userIdView.setVisibility(View.GONE);
userIdView.setText("");
}
TextView poolIdView = holder.getView(R.id.tv_pool_id);
if (!TextUtils.isEmpty(claim.poolId)) {
poolIdView.setVisibility(View.VISIBLE);
poolIdView.setText("领取ID " + claim.poolId);
} else {
poolIdView.setVisibility(View.GONE);
poolIdView.setText("");
}
holder.setText(R.id.tv_amount, claim.amountText != null
? claim.amountText
: String.format(Locale.US, "%.2f", claim.amount));
holder.setText(R.id.tv_time,
TextUtils.isEmpty(claim.claimTime) ? "-" : claim.claimTime);
TextView tag = holder.getView(R.id.tv_tag);
boolean isBest = claims.size() > 1 && bestAmount >= 0
&& Math.abs(claim.amount - bestAmount) < 0.0001;
boolean isWorst = claims.size() > 1 && worstAmount >= 0
&& Math.abs(claim.amount - worstAmount) < 0.0001
&& Math.abs(bestAmount - worstAmount) > 0.0001;
if (isBest) {
tag.setVisibility(View.VISIBLE);
tag.setText("最高");
tag.setTextColor(Color.parseColor("#047857"));
holder.itemView.setBackgroundColor(Color.parseColor("#ECFDF5"));
} else if (isWorst) {
tag.setVisibility(View.VISIBLE);
tag.setText("最低");
tag.setTextColor(Color.parseColor("#B45309"));
holder.itemView.setBackgroundColor(Color.parseColor("#FFFBEB"));
} else {
tag.setVisibility(View.GONE);
holder.itemView.setBackgroundColor(Color.WHITE);
}
}
};
binding.recyclerview.setAdapter(adapter);
}
private void reload() {
MmpPacket packet = MmpPacketStore.getPacket(this, packetId);
if (packet == null) {
binding.tvTitle.setText("红包详情");
binding.tvHeaderMoney.setText("-");
binding.tvSub.setText("记录不存在或已清空");
binding.tvBestName.setText("-");
binding.tvBestAmt.setText("-");
binding.tvWorstName.setText("-");
binding.tvWorstAmt.setText("-");
claims.clear();
adapter.notifyDataSetChanged();
binding.emptyLy.setVisibility(View.VISIBLE);
binding.recyclerview.setVisibility(View.GONE);
return;
}
binding.tvTitle.setText(packet.displayTitle());
binding.tvHeaderMoney.setText(String.format(Locale.US, "%.2f",
packet.sumClaimed > 0 ? packet.sumClaimed : parseDouble(packet.total)));
StringBuilder sub = new StringBuilder();
if (!TextUtils.isEmpty(packet.packetId)) {
sub.append("ID ").append(packet.packetId);
}
if (!TextUtils.isEmpty(packet.issuedAt)) {
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append("发放 ").append(packet.issuedAt);
}
if (!TextUtils.isEmpty(packet.expiresAt)) {
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append("到期 ").append(packet.expiresAt);
}
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append(packet.statusLabel());
binding.tvSub.setText(sub.toString());
binding.tvStatPeople.setText(String.valueOf(packet.claimantCount));
binding.tvStatSum.setText(String.format(Locale.US, "%.2f", packet.sumClaimed));
binding.tvStatTotal.setText(TextUtils.isEmpty(packet.total) ? "-" : packet.total);
String hi = packet.finished ? "手气最佳" : "目前最高";
String lo = packet.finished ? "手气最差" : "目前最低";
binding.tvBestLab.setText(hi);
binding.tvWorstLab.setText(lo);
MmpClaim best = packet.bestClaim();
MmpClaim worst = packet.worstClaim();
if (best != null) {
bestAmount = best.amount;
binding.tvBestName.setText(formatClaimName(best));
binding.tvBestAmt.setText(best.amountText != null ? best.amountText
: String.format(Locale.US, "%.2f", best.amount));
} else {
bestAmount = -1;
binding.tvBestName.setText("-");
binding.tvBestAmt.setText("-");
}
if (worst != null && packet.claimantCount > 1) {
worstAmount = worst.amount;
binding.tvWorstName.setText(formatClaimName(worst));
binding.tvWorstAmt.setText(worst.amountText != null ? worst.amountText
: String.format(Locale.US, "%.2f", worst.amount));
} else {
worstAmount = -1;
binding.tvWorstName.setText("-");
binding.tvWorstAmt.setText("-");
}
claims.clear();
if (packet.leaderboard != null) {
claims.addAll(packet.leaderboard);
}
adapter.notifyDataSetChanged();
boolean empty = claims.isEmpty();
binding.emptyLy.setVisibility(empty ? View.VISIBLE : View.GONE);
binding.recyclerview.setVisibility(empty ? View.GONE : View.VISIBLE);
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
}
private static String formatClaimName(MmpClaim claim) {
if (claim == null) {
return "-";
}
String nick = claim.nickname != null ? claim.nickname : "?";
if (!TextUtils.isEmpty(claim.userId)) {
return nick + "\nID " + claim.userId;
}
return nick;
}
private static double parseDouble(String s) {
try {
return s == null || s.isEmpty() ? 0 : Double.parseDouble(s);
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

@@ -0,0 +1,20 @@
package com.miraclegarden.smsmessage.Activity;
import android.os.Bundle;
import androidx.annotation.Nullable;
import com.miraclegarden.library.app.MiracleGardenActivity;
import com.miraclegarden.smsmessage.databinding.ActivityMmpHelpBinding;
/**
* 红包领取台功能说明页。
*/
public class MmpHelpActivity extends MiracleGardenActivity<ActivityMmpHelpBinding> {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding.backIv.setOnClickListener(v -> finish());
}
}

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://&lt;电脑局域网IP&gt;:8765 * Wi-Fi: http://&lt;电脑局域网IP&gt;: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

@@ -0,0 +1,33 @@
package com.miraclegarden.smsmessage.mmp;
/**
* 单个红包领取人。
*/
public class MmpClaim {
public String nickname;
/** TNG receiverId重名时用来区分 */
public String userId;
/** activityPoolId单次领取池条目 */
public String poolId;
public String amountText;
public double amount;
public String claimTime;
public int rank;
public MmpClaim() {
}
public MmpClaim(String nickname, double amount, String claimTime) {
this.nickname = nickname;
this.amount = amount;
this.amountText = String.format(java.util.Locale.US, "%.2f", amount);
this.claimTime = claimTime != null ? claimTime : "";
}
public String shortUserId() {
if (userId == null || userId.isEmpty()) {
return "";
}
return userId.length() <= 10 ? userId : userId.substring(0, 8) + "";
}
}

View File

@@ -0,0 +1,136 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.text.TextUtils;
/**
* 记录 TNG 进程内 Hook 心跳,用于判断是否挂上最新模块。
*/
public final class MmpHookStatus {
private static final String PREF = "mmp_hook_status";
private static volatile int sLiveCode;
private static volatile String sLiveName = "";
private static volatile long sLiveAt;
private static volatile String sHostPkg = "";
private MmpHookStatus() {
}
public static void noteAlive(Context context, int versionCode, String versionName,
String hostPackage) {
sLiveCode = versionCode;
sLiveName = versionName != null ? versionName : "";
sLiveAt = System.currentTimeMillis();
sHostPkg = hostPackage != null ? hostPackage : "";
if (context == null) {
return;
}
prefs(context).edit()
.putInt("live_code", sLiveCode)
.putString("live_name", sLiveName)
.putLong("live_at", sLiveAt)
.putString("host_pkg", sHostPkg)
.apply();
}
public static void ensureLoaded(Context context) {
if (sLiveAt > 0 || context == null) {
return;
}
SharedPreferences sp = prefs(context);
sLiveCode = sp.getInt("live_code", 0);
sLiveName = sp.getString("live_name", "");
sLiveAt = sp.getLong("live_at", 0L);
sHostPkg = sp.getString("host_pkg", "");
}
public static long lastAliveAt(Context context) {
ensureLoaded(context);
return sLiveAt;
}
public static int liveVersionCode(Context context) {
ensureLoaded(context);
return sLiveCode;
}
public static String liveVersionName(Context context) {
ensureLoaded(context);
return sLiveName != null ? sLiveName : "";
}
public static int installedVersionCode(Context context) {
if (context == null) {
return 0;
}
try {
PackageInfo pi = context.getPackageManager()
.getPackageInfo(MmpModuleInfo.XPOSED_PACKAGE, 0);
return pi != null ? pi.versionCode : 0;
} catch (PackageManager.NameNotFoundException e) {
return 0;
}
}
public static String installedVersionName(Context context) {
if (context == null) {
return "";
}
try {
PackageInfo pi = context.getPackageManager()
.getPackageInfo(MmpModuleInfo.XPOSED_PACKAGE, 0);
return pi != null && pi.versionName != null ? pi.versionName : "";
} catch (PackageManager.NameNotFoundException e) {
return "";
}
}
/**
* @return 人类可读模块状态
*/
public static String describe(Context context) {
ensureLoaded(context);
int installed = installedVersionCode(context);
String installedName = installedVersionName(context);
if (installed <= 0) {
return "模块APK未安装";
}
String apkPart = "模块APK" + (TextUtils.isEmpty(installedName)
? String.valueOf(installed) : installedName);
boolean apkLatest = installed >= MmpModuleInfo.EXPECTED_VERSION_CODE;
if (!apkLatest) {
apkPart += "(旧,期望" + MmpModuleInfo.EXPECTED_VERSION_NAME + ")";
}
long age = sLiveAt > 0 ? System.currentTimeMillis() - sLiveAt : -1L;
String livePart;
if (age < 0) {
livePart = "运行中:未检测到(请强停再开 TNG";
} else if (age > 3 * 60_000L) {
livePart = "运行中:心跳偏旧(" + (age / 60_000L) + "分钟前 "
+ (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + ")";
} else if (sLiveCode >= MmpModuleInfo.EXPECTED_VERSION_CODE) {
livePart = "运行中:最新(" + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + ")";
} else if (sLiveCode > 0) {
livePart = "运行中:旧版(" + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName)
+ ",期望" + MmpModuleInfo.EXPECTED_VERSION_NAME + ")";
} else {
livePart = "运行中:未知";
}
return apkPart + " · " + livePart;
}
public static boolean isLiveLatest(Context context) {
ensureLoaded(context);
long age = sLiveAt > 0 ? System.currentTimeMillis() - sLiveAt : -1L;
return age >= 0 && age <= 3 * 60_000L && sLiveCode >= MmpModuleInfo.EXPECTED_VERSION_CODE;
}
private static SharedPreferences prefs(Context context) {
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
}
}

View File

@@ -0,0 +1,85 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import org.json.JSONObject;
/**
* 领取台本地设置(不依赖电脑调试台也能读写)。
*/
public final class MmpLocalSettings {
private static final String PREF = "mmp_settings_local";
private MmpLocalSettings() {
}
public static JSONObject defaults() {
JSONObject o = new JSONObject();
try {
o.put("historyCooldownSec", 8);
o.put("detailCooldownSec", 8);
o.put("dedupSec", 3);
o.put("detailGapMs", 80);
o.put("pagePollMs", 1000);
o.put("openHistoryIfNoTemplate", true);
o.put("autoBounceHistoryOnDisconnect", true);
o.put("autoLaunchTngIfKilled", true);
o.put("autoWatchWhileOpen", true);
} catch (Exception ignored) {
}
return o;
}
public static JSONObject load(Context context) {
JSONObject out = defaults();
if (context == null) {
return out;
}
SharedPreferences sp = prefs(context);
try {
String raw = sp.getString("json", null);
if (!TextUtils.isEmpty(raw)) {
JSONObject saved = new JSONObject(raw);
mergeInto(out, saved);
}
} catch (Exception ignored) {
}
return out;
}
public static void save(Context context, JSONObject json) {
if (context == null || json == null) {
return;
}
JSONObject merged = defaults();
mergeInto(merged, json);
prefs(context).edit().putString("json", merged.toString()).apply();
}
public static void mergeInto(JSONObject target, JSONObject src) {
if (target == null || src == null) {
return;
}
String[] keys = {
"historyCooldownSec", "detailCooldownSec", "dedupSec", "detailGapMs", "pagePollMs",
"openHistoryIfNoTemplate", "autoBounceHistoryOnDisconnect",
"autoLaunchTngIfKilled", "autoWatchWhileOpen"
};
for (String k : keys) {
if (!src.has(k)) {
continue;
}
try {
target.put(k, src.get(k));
} catch (Exception ignored) {
}
}
}
private static SharedPreferences prefs(Context context) {
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
}
}

View File

@@ -0,0 +1,13 @@
package com.miraclegarden.smsmessage.mmp;
/**
* Xposed 模块版本约定(与 xposed-module/build.gradle 同步)。
*/
public final class MmpModuleInfo {
public static final String XPOSED_PACKAGE = "com.miraclegarden.smsmessage.xposed";
public static final int EXPECTED_VERSION_CODE = 5;
public static final String EXPECTED_VERSION_NAME = "1.2.2";
private MmpModuleInfo() {
}
}

View File

@@ -0,0 +1,292 @@
package com.miraclegarden.smsmessage.mmp;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* 按 activityId 聚合的红包快照。
*/
public class MmpPacket {
public String packetId;
public String title;
public String sender;
public String group;
public String total;
public String via;
public String issuedAt;
public String expiresAt;
/** TNG activityStatusACTIVE / FINISHED / ENDED / CLOSED / CANCELLED … */
public String status;
public String updatedAt;
public boolean finished;
public int snapshots;
/** 已领人数(来自 TNG claimedCount缺省用名单人数 */
public int claimedCount;
/** 红包总份数TNG totalCount */
public int totalCount;
public int claimantCount;
public double sumClaimed;
public List<MmpClaim> leaderboard = new ArrayList<>();
public MmpPacket() {
}
public String displayTitle() {
if (sender != null && !sender.isEmpty()) {
return sender + " 的红包";
}
if (title != null && !title.isEmpty()) {
return title;
}
return "红包";
}
public String shortId() {
if (packetId == null || packetId.isEmpty()) {
return "-";
}
if (packetId.length() >= 8 && packetId.indexOf('-') > 0) {
return packetId.substring(0, 8);
}
return packetId.length() > 16 ? packetId.substring(0, 16) : packetId;
}
/** 手气最佳(金额最大);排行已按金额降序时取首位。 */
public MmpClaim bestClaim() {
if (leaderboard == null || leaderboard.isEmpty()) {
return null;
}
MmpClaim best = leaderboard.get(0);
for (MmpClaim c : leaderboard) {
if (c != null && c.amount > best.amount) {
best = c;
}
}
return best;
}
/** 手气最差(金额最小)。 */
public MmpClaim worstClaim() {
if (leaderboard == null || leaderboard.isEmpty()) {
return null;
}
MmpClaim worst = leaderboard.get(0);
for (MmpClaim c : leaderboard) {
if (c != null && c.amount < worst.amount) {
worst = c;
}
}
return worst;
}
public String luckSummaryLine() {
refreshClosedState();
MmpClaim best = bestClaim();
MmpClaim worst = worstClaim();
boolean closed = finished;
String hi = closed ? "手气最佳" : "目前最高";
String lo = closed ? "手气最差" : "目前最低";
if (best == null) {
return hi + "- " + lo + "-";
}
String bn = best.nickname != null ? best.nickname : "?";
String ba = best.amountText != null ? best.amountText
: String.format(Locale.US, "%.2f", best.amount);
if (worst == null || leaderboard.size() == 1) {
return hi + "" + bn + "" + ba + " " + lo + "-";
}
String wn = worst.nickname != null ? worst.nickname : "?";
String wa = worst.amountText != null ? worst.amountText
: String.format(Locale.US, "%.2f", worst.amount);
return hi + "" + bn + "" + ba + " " + lo + "" + wn + "" + wa + "";
}
/** 根据 expire/status/人数 刷新 finished过期未领完也会标结束 */
public void refreshClosedState() {
if (isFullyClaimed()) {
finished = true;
return;
}
if (finished) {
return;
}
if (isTerminalStatus(status) || isExpirePassed()) {
finished = true;
}
}
public boolean isExpired() {
if (isExpirePassed()) {
return true;
}
if (status == null) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("EXPIRE") || s.contains("ENDED") || s.contains("CLOSED")
|| s.contains("CANCEL");
}
public boolean isFullyClaimed() {
int claimed = effectiveClaimedCount();
int total = effectiveTotalCount();
return total > 0 && claimed >= total;
}
public int effectiveClaimedCount() {
return Math.max(claimedCount, claimantCount);
}
public int effectiveTotalCount() {
return Math.max(0, totalCount);
}
/**
* 优先展示领取进度,过期放后面:
* 已全部领取 / 已领取 N/M 人 · 已过期
*/
public String statusLabel() {
refreshClosedState();
String progress;
if (isFullyClaimed()) {
progress = "已全部领取";
} else {
int claimed = effectiveClaimedCount();
int total = effectiveTotalCount();
if (total > 0) {
progress = "已领取 " + claimed + "/" + total + "";
} else if (claimed > 0) {
progress = "已领取 " + claimed + "";
} else {
progress = finished ? "未领取" : "领取中";
}
}
if (isExpired() && !isFullyClaimed()) {
return progress + " · 已过期";
}
return progress;
}
private boolean isExpirePassed() {
long ms = parseIssueMs(expiresAt);
return ms > 0 && System.currentTimeMillis() >= ms;
}
private static boolean isTerminalStatus(String status) {
if (status == null || status.isEmpty()) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE");
}
public boolean matchesFilter(String keyword, int statusFilter) {
return matchesFilter(keyword, statusFilter, 0, 0L, 0L);
}
/**
* @param timeMode 0全部 1近1天 2近3天 3近7天 4自定义
* @param customFromMs / customToMs 自定义起止(含当天),未用传 0
*/
public boolean matchesFilter(String keyword, int statusFilter,
int timeMode, long customFromMs, long customToMs) {
refreshClosedState();
if (statusFilter == 1 && finished) {
return false;
}
if (statusFilter == 2 && !finished) {
return false;
}
if (!matchesTime(timeMode, customFromMs, customToMs)) {
return false;
}
if (keyword == null || keyword.trim().isEmpty()) {
return true;
}
String q = keyword.trim().toLowerCase(Locale.US);
if (containsIgnoreCase(packetId, q) || containsIgnoreCase(sender, q)
|| containsIgnoreCase(title, q) || containsIgnoreCase(group, q)
|| containsIgnoreCase(total, q)) {
return true;
}
if (leaderboard != null) {
for (MmpClaim c : leaderboard) {
if (c != null && (containsIgnoreCase(c.nickname, q)
|| containsIgnoreCase(c.userId, q)
|| containsIgnoreCase(c.poolId, q))) {
return true;
}
}
}
return false;
}
private boolean matchesTime(int timeMode, long customFromMs, long customToMs) {
if (timeMode <= 0) {
return true;
}
long ms = parseIssueMs(issuedAt);
if (ms <= 0) {
ms = parseIssueMs(updatedAt);
}
if (ms <= 0) {
return false;
}
long now = System.currentTimeMillis();
if (timeMode == 1) {
return ms >= now - 1L * 24 * 3600 * 1000;
}
if (timeMode == 2) {
return ms >= now - 3L * 24 * 3600 * 1000;
}
if (timeMode == 3) {
return ms >= now - 7L * 24 * 3600 * 1000;
}
if (timeMode == 4) {
if (customFromMs > 0 && ms < customFromMs) {
return false;
}
if (customToMs > 0 && ms > customToMs) {
return false;
}
return true;
}
return true;
}
/** 解析发放时间13/07/2026 11:39:15 或 yyyy-MM-dd… */
public static long parseIssueMs(String text) {
if (text == null || text.trim().isEmpty()) {
return 0;
}
String s = text.trim();
try {
java.text.SimpleDateFormat[] formats = new java.text.SimpleDateFormat[]{
new java.text.SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale.US),
new java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US),
new java.text.SimpleDateFormat("dd/MM/yyyy", Locale.US),
new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US),
new java.text.SimpleDateFormat("yyyy-MM-dd", Locale.US),
};
for (java.text.SimpleDateFormat f : formats) {
f.setLenient(true);
try {
java.util.Date d = f.parse(s);
if (d != null) {
return d.getTime();
}
} catch (Exception ignored) {
}
}
} catch (Exception ignored) {
}
return 0;
}
private static boolean containsIgnoreCase(String hay, String needle) {
return hay != null && hay.toLowerCase(Locale.US).contains(needle);
}
}

View File

@@ -0,0 +1,805 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 本地红包领取台数据:解析 Hook 的 [MMP统计] 文本,按 activityId 持久化。
*/
public final class MmpPacketStore {
private static final String TAG = "MmpPacketStore";
private static final String PREF = "mmp_packets";
private static final String KEY_JSON = "packets_json";
private static final String KEY_LAST_INGEST = "last_ingest_at";
private static final int MAX_PACKETS = 200;
private static final Pattern AMOUNT_JSON = Pattern.compile(
"\"amount\"\\s*:\\s*\"?([0-9]+(?:\\.[0-9]+)?)\"?");
private static final Pattern AMOUNT_PLAIN = Pattern.compile("([0-9]+(?:\\.[0-9]+)?)");
public interface Listener {
void onMmpPacketsChanged();
}
private static final CopyOnWriteArrayList<Listener> LISTENERS = new CopyOnWriteArrayList<>();
private static volatile long sLastIngestAt;
private MmpPacketStore() {
}
public static void addListener(Listener listener) {
if (listener != null) {
LISTENERS.add(listener);
}
}
public static void removeListener(Listener listener) {
LISTENERS.remove(listener);
}
public static boolean isMmpContent(String content, String source) {
if (source != null && source.toLowerCase(Locale.US).contains("tng_mmp")) {
return true;
}
return content != null && content.contains("[MMP统计]");
}
/** 解析并写入;非 MMP 或无领取人则忽略。 */
public static boolean ingest(Context context, String title, String content, String source) {
if (context == null || TextUtils.isEmpty(content) || !isMmpContent(content, source)) {
return false;
}
ParseResult parsed = parse(content);
noteModuleFromMeta(context, parsed.meta);
String packetId = parsed.meta.get("packet");
if (TextUtils.isEmpty(packetId)) {
packetId = parsed.meta.get("packetId");
}
boolean hasRealPacketId = !TextUtils.isEmpty(packetId)
&& !"unknown".equals(packetId)
&& !"TNG 红包".equals(packetId)
&& !"TNG Money Packet".equals(packetId);
// 允许无人领取的新红包入库(只有 packet/metaclaims 为空)
if (parsed.claims.isEmpty() && !hasRealPacketId) {
return false;
}
if (!hasRealPacketId) {
packetId = "红包-" + claimsFingerprint(parsed.claims);
if (packetId.length() > 56) {
packetId = packetId.substring(0, 56);
}
}
MmpPacket incoming = new MmpPacket();
incoming.packetId = packetId;
incoming.title = TextUtils.isEmpty(title) ? "TNG 红包" : title;
incoming.sender = nullToEmpty(parsed.meta.get("sender"));
incoming.group = nullToEmpty(parsed.meta.get("group"));
incoming.total = nullToEmpty(parsed.meta.get("total"));
incoming.via = nullToEmpty(parsed.meta.get("via"));
incoming.issuedAt = firstNonEmpty(parsed.meta, "issued", "createTime", "issuedAt", "create");
incoming.expiresAt = firstNonEmpty(parsed.meta, "expire", "expireTime", "expiry", "expiresAt");
incoming.status = firstNonEmpty(parsed.meta, "status", "activityStatus");
parseCountsMeta(incoming, parsed.meta.get("counts"));
if (incoming.claimedCount <= 0) {
tryParseIntField(incoming, parsed.meta, "claimed", "claimedCount");
}
if (incoming.totalCount <= 0) {
tryParseIntField(incoming, parsed.meta, "slots", "totalCount", "quantity");
}
incoming.finished = "1".equals(parsed.meta.get("done"))
|| "true".equalsIgnoreCase(parsed.meta.get("done"))
|| isTerminalStatusMeta(incoming.status);
incoming.refreshClosedState();
incoming.updatedAt = nowText();
incoming.snapshots = 1;
List<MmpClaim> ranked = aggregateClaims(parsed.claims);
incoming.leaderboard = ranked;
incoming.claimantCount = ranked.size();
if (incoming.claimedCount <= 0) {
incoming.claimedCount = ranked.size();
}
if (incoming.totalCount > 0 && incoming.claimedCount >= incoming.totalCount) {
incoming.finished = true;
}
double sum = 0;
for (MmpClaim c : ranked) {
sum += c.amount;
}
incoming.sumClaimed = Math.round(sum * 10000.0) / 10000.0;
Double totalNum = normalizeMoney(incoming.total);
if (totalNum == null || (incoming.sumClaimed > 0
&& Math.abs(totalNum - incoming.sumClaimed) > 0.001
&& totalNum <= maxClaimAmount(ranked) + 1e-9)) {
incoming.total = String.format(Locale.US, "%.2f", incoming.sumClaimed);
} else {
incoming.total = String.format(Locale.US, "%.2f", totalNum);
}
synchronized (MmpPacketStore.class) {
Map<String, MmpPacket> map = loadMap(context);
MmpPacket existing = map.get(packetId);
if (existing != null) {
if (score(incoming) >= score(existing)) {
incoming.snapshots = existing.snapshots + 1;
if (TextUtils.isEmpty(incoming.issuedAt) && !TextUtils.isEmpty(existing.issuedAt)) {
incoming.issuedAt = existing.issuedAt;
}
if (TextUtils.isEmpty(incoming.expiresAt) && !TextUtils.isEmpty(existing.expiresAt)) {
incoming.expiresAt = existing.expiresAt;
}
if (TextUtils.isEmpty(incoming.status) && !TextUtils.isEmpty(existing.status)) {
incoming.status = existing.status;
}
if (incoming.totalCount <= 0 && existing.totalCount > 0) {
incoming.totalCount = existing.totalCount;
}
if (incoming.claimedCount < existing.claimedCount) {
// 保留更大的已领人数
incoming.claimedCount = existing.claimedCount;
}
if (!incoming.finished && existing.finished) {
incoming.finished = true;
}
incoming.refreshClosedState();
map.put(packetId, incoming);
} else {
existing.snapshots = existing.snapshots + 1;
if (TextUtils.isEmpty(existing.issuedAt) && !TextUtils.isEmpty(incoming.issuedAt)) {
existing.issuedAt = incoming.issuedAt;
}
existing.updatedAt = nowText();
}
} else {
map.put(packetId, incoming);
}
trimMap(map);
saveMap(context, map);
}
notifyListeners();
sLastIngestAt = System.currentTimeMillis();
try {
prefs(context).edit().putLong(KEY_LAST_INGEST, sLastIngestAt).apply();
} catch (Exception ignored) {
}
Log.i(TAG, "ingest packet=" + packetId + " claims=" + ranked.size());
try {
MmpSyncClient.syncIfStale(context.getApplicationContext(), 3000L);
} catch (Exception e) {
Log.w(TAG, "auto sync skip", e);
}
return true;
}
/** Hook 最近一次成功写入本地的时间戳0 表示尚无 */
public static long lastIngestAt(Context context) {
if (sLastIngestAt > 0) {
return sLastIngestAt;
}
if (context == null) {
return 0L;
}
try {
long v = prefs(context).getLong(KEY_LAST_INGEST, 0L);
if (v > 0) {
sLastIngestAt = v;
}
return v;
} catch (Exception e) {
return 0L;
}
}
public static List<MmpPacket> getPackets(Context context) {
synchronized (MmpPacketStore.class) {
List<MmpPacket> list = new ArrayList<>(loadMap(context).values());
Collections.sort(list, new Comparator<MmpPacket>() {
@Override
public int compare(MmpPacket a, MmpPacket b) {
// DD/MM/YYYY 不能按字符串比;按真实时间降序,最新在前
long ta = sortTimeMs(a);
long tb = sortTimeMs(b);
if (tb != ta) {
return Long.compare(tb, ta);
}
return nullToEmpty(b.packetId).compareTo(nullToEmpty(a.packetId));
}
});
for (MmpPacket p : list) {
if (p != null) {
p.refreshClosedState();
}
}
return list;
}
}
private static long sortTimeMs(MmpPacket p) {
if (p == null) {
return 0L;
}
long ms = MmpPacket.parseIssueMs(p.issuedAt);
if (ms <= 0) {
ms = MmpPacket.parseIssueMs(p.updatedAt);
}
return ms;
}
public static MmpPacket getPacket(Context context, String packetId) {
if (TextUtils.isEmpty(packetId)) {
return null;
}
synchronized (MmpPacketStore.class) {
MmpPacket p = loadMap(context).get(packetId);
if (p != null) {
p.refreshClosedState();
}
return p;
}
}
public static void clear(Context context) {
synchronized (MmpPacketStore.class) {
prefs(context).edit().remove(KEY_JSON).apply();
}
notifyListeners();
}
/** 导出给 PC /api/mmp/sync 的全量 JSON。 */
public static JSONArray exportJsonArray(Context context) {
JSONArray arr = new JSONArray();
synchronized (MmpPacketStore.class) {
for (MmpPacket p : loadMap(context).values()) {
try {
arr.put(toJson(p));
} catch (Exception e) {
Log.e(TAG, "export packet failed", e);
}
}
}
return arr;
}
// --- parse ---
/** 从 header 的 mod=1.2.0/3 更新 Hook 运行版本(兜底) */
private static void noteModuleFromMeta(Context context, Map<String, String> meta) {
if (context == null || meta == null) {
return;
}
String mod = meta.get("mod");
if (TextUtils.isEmpty(mod)) {
return;
}
int code = 0;
String name = mod.trim();
int slash = name.lastIndexOf('/');
if (slash >= 0 && slash + 1 < name.length()) {
String codePart = name.substring(slash + 1).trim();
name = name.substring(0, slash).trim();
try {
code = Integer.parseInt(codePart);
} catch (NumberFormatException ignored) {
code = 0;
}
}
MmpHookStatus.noteAlive(context.getApplicationContext(), code, name, "my.com.tngdigital.ewallet");
}
private static final class ParseResult {
final Map<String, String> meta = new LinkedHashMap<>();
final List<RawClaim> claims = new ArrayList<>();
}
private static final class RawClaim {
String nickname;
String userId;
String poolId;
double amount;
String claimTime;
}
private static ParseResult parse(String content) {
ParseResult out = new ParseResult();
String text = content.trim();
String[] lines = text.split("\\r?\\n");
if (lines.length == 0) {
return out;
}
String head = lines[0].trim();
if (head.startsWith("[MMP统计]")) {
head = head.substring("[MMP统计]".length()).trim();
}
List<String> parts;
if (head.contains(" | ")) {
parts = splitPipe(head);
} else {
parts = splitLegacy(head);
}
for (String part : parts) {
int eq = part.indexOf('=');
if (eq <= 0) {
continue;
}
out.meta.put(part.substring(0, eq).trim(), part.substring(eq + 1).trim());
}
// sender 被空格截断时尽量还原
String sender = out.meta.get("sender");
if (sender != null && head.contains("sender=")) {
String raw = head.substring(head.indexOf("sender=") + "sender=".length());
for (String stop : new String[]{" | ", " total=", " via=", " group=", " packet=", " src=", " issued="}) {
int i = raw.indexOf(stop);
if (i >= 0) {
raw = raw.substring(0, i);
break;
}
}
raw = raw.trim();
if (raw.length() > sender.length()) {
out.meta.put("sender", raw);
}
}
Double totalNum = normalizeMoney(out.meta.get("total"));
if (totalNum != null) {
out.meta.put("total", String.format(Locale.US, "%.2f", totalNum));
}
for (int i = 1; i < lines.length; i++) {
String line = lines[i].trim();
if (line.isEmpty() || !line.contains("->")) {
continue;
}
int arrow = line.indexOf("->");
String nick = line.substring(0, arrow).trim();
String right = line.substring(arrow + 2).trim();
String userId = "";
String poolId = "";
int ridAt = right.indexOf(" #rid=");
if (ridAt >= 0) {
String rest = right.substring(ridAt + 6).trim();
right = right.substring(0, ridAt).trim();
int sp = rest.indexOf(' ');
if (sp > 0) {
userId = rest.substring(0, sp).trim();
String more = rest.substring(sp + 1).trim();
if (more.startsWith("#pool=")) {
poolId = more.substring(6).trim();
}
} else {
userId = rest;
}
}
int poolAt = right.indexOf(" #pool=");
if (poolAt >= 0 && TextUtils.isEmpty(poolId)) {
poolId = right.substring(poolAt + 7).trim();
right = right.substring(0, poolAt).trim();
}
// 兼容 #rid= 后仍有 #pool=
int poolInId = userId.indexOf("#pool=");
if (poolInId >= 0) {
poolId = userId.substring(poolInId + 6).trim();
userId = userId.substring(0, poolInId).trim();
}
String claimTime = "";
String amountRaw = right;
if (right.endsWith(")") && right.contains("(") && !right.startsWith("{")) {
int lp = right.lastIndexOf('(');
amountRaw = right.substring(0, lp).trim();
claimTime = right.substring(lp + 1, right.length() - 1).trim();
}
Double amt = normalizeMoney(amountRaw);
if (!TextUtils.isEmpty(nick) || !TextUtils.isEmpty(userId)) {
RawClaim c = new RawClaim();
c.nickname = TextUtils.isEmpty(nick) ? "?" : nick;
c.userId = userId;
c.poolId = poolId;
c.amount = amt != null ? amt : 0;
c.claimTime = claimTime;
out.claims.add(c);
}
}
return out;
}
private static List<String> splitPipe(String head) {
String[] arr = head.split(" \\| ");
List<String> parts = new ArrayList<>();
for (String p : arr) {
if (!TextUtils.isEmpty(p.trim())) {
parts.add(p.trim());
}
}
return parts;
}
private static List<String> splitLegacy(String head) {
List<String> parts = new ArrayList<>();
String buf = "";
for (String part : head.split(" ")) {
if (!buf.isEmpty()) {
buf += " " + part;
if (countChar(buf, '{') <= countChar(buf, '}')) {
parts.add(buf);
buf = "";
}
continue;
}
if (part.contains("=")) {
String v = part.substring(part.indexOf('=') + 1);
if (v.startsWith("{") && countChar(part, '{') > countChar(part, '}')) {
buf = part;
continue;
}
}
parts.add(part);
}
if (!buf.isEmpty()) {
parts.add(buf);
}
return parts;
}
private static int countChar(String s, char c) {
int n = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == c) {
n++;
}
}
return n;
}
private static List<MmpClaim> aggregateClaims(List<RawClaim> claims) {
LinkedHashMap<String, MmpClaim> buckets = new LinkedHashMap<>();
for (RawClaim c : claims) {
String nick = c.nickname != null ? c.nickname : "?";
String key = !TextUtils.isEmpty(c.userId) ? ("id:" + c.userId) : ("n:" + nick);
MmpClaim b = buckets.get(key);
if (b == null) {
MmpClaim created = new MmpClaim(nick, c.amount, c.claimTime);
created.userId = c.userId;
created.poolId = c.poolId;
buckets.put(key, created);
continue;
}
if (c.amount >= b.amount) {
b.amount = c.amount;
b.amountText = String.format(Locale.US, "%.2f", c.amount);
if (!TextUtils.isEmpty(c.claimTime)) {
b.claimTime = c.claimTime;
}
if (!TextUtils.isEmpty(c.userId)) {
b.userId = c.userId;
}
if (!TextUtils.isEmpty(c.poolId)) {
b.poolId = c.poolId;
}
if (!TextUtils.isEmpty(nick) && !"?".equals(nick)) {
b.nickname = nick;
}
} else {
if (TextUtils.isEmpty(b.claimTime) && !TextUtils.isEmpty(c.claimTime)) {
b.claimTime = c.claimTime;
}
if (TextUtils.isEmpty(b.userId) && !TextUtils.isEmpty(c.userId)) {
b.userId = c.userId;
}
if (TextUtils.isEmpty(b.poolId) && !TextUtils.isEmpty(c.poolId)) {
b.poolId = c.poolId;
}
}
}
List<MmpClaim> result = new ArrayList<>(buckets.values());
Collections.sort(result, new Comparator<MmpClaim>() {
@Override
public int compare(MmpClaim a, MmpClaim b) {
return Double.compare(b.amount, a.amount);
}
});
for (int i = 0; i < result.size(); i++) {
result.get(i).rank = i + 1;
}
return result;
}
private static String claimsFingerprint(List<RawClaim> claims) {
List<String> rows = new ArrayList<>();
for (RawClaim c : claims) {
String id = !TextUtils.isEmpty(c.userId) ? c.userId : (c.nickname != null ? c.nickname : "?");
rows.add(id + "=" + String.format(Locale.US, "%.2f", c.amount));
}
Collections.sort(rows);
StringBuilder sb = new StringBuilder();
for (String r : rows) {
if (sb.length() > 0) {
sb.append('|');
}
sb.append(r);
}
return sb.toString();
}
static Double normalizeMoney(String raw) {
if (TextUtils.isEmpty(raw)) {
return null;
}
String s = raw.trim();
if (s.startsWith("{")) {
Matcher m = AMOUNT_JSON.matcher(s);
if (m.find()) {
try {
return Double.parseDouble(m.group(1));
} catch (NumberFormatException ignored) {
}
}
}
Matcher m = AMOUNT_PLAIN.matcher(s.replace(",", ""));
if (m.find()) {
try {
return Double.parseDouble(m.group(1));
} catch (NumberFormatException ignored) {
}
}
return null;
}
private static double maxClaimAmount(List<MmpClaim> ranked) {
double max = 0;
for (MmpClaim c : ranked) {
if (c.amount > max) {
max = c.amount;
}
}
return max;
}
private static int score(MmpPacket p) {
int issued = TextUtils.isEmpty(p.issuedAt) ? 0 : 1;
int claims = p.leaderboard != null ? p.leaderboard.size() : 0;
return issued * 1000 + claims;
}
private static void trimMap(Map<String, MmpPacket> map) {
if (map.size() <= MAX_PACKETS) {
return;
}
List<MmpPacket> list = new ArrayList<>(map.values());
Collections.sort(list, new Comparator<MmpPacket>() {
@Override
public int compare(MmpPacket a, MmpPacket b) {
return nullToEmpty(a.updatedAt).compareTo(nullToEmpty(b.updatedAt));
}
});
int remove = map.size() - MAX_PACKETS;
for (int i = 0; i < remove; i++) {
map.remove(list.get(i).packetId);
}
}
// --- persistence ---
private static SharedPreferences prefs(Context context) {
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
}
private static Map<String, MmpPacket> loadMap(Context context) {
Map<String, MmpPacket> map = new HashMap<>();
String raw = prefs(context).getString(KEY_JSON, null);
if (TextUtils.isEmpty(raw)) {
return map;
}
try {
JSONArray arr = new JSONArray(raw);
for (int i = 0; i < arr.length(); i++) {
MmpPacket p = fromJson(arr.getJSONObject(i));
if (p != null && !TextUtils.isEmpty(p.packetId)) {
map.put(p.packetId, p);
}
}
} catch (Exception e) {
Log.e(TAG, "load failed", e);
}
return map;
}
private static void saveMap(Context context, Map<String, MmpPacket> map) {
try {
JSONArray arr = new JSONArray();
for (MmpPacket p : map.values()) {
arr.put(toJson(p));
}
prefs(context).edit().putString(KEY_JSON, arr.toString()).apply();
} catch (Exception e) {
Log.e(TAG, "save failed", e);
}
}
private static JSONObject toJson(MmpPacket p) throws Exception {
JSONObject o = new JSONObject();
o.put("packetId", nullToEmpty(p.packetId));
o.put("title", nullToEmpty(p.title));
o.put("sender", nullToEmpty(p.sender));
o.put("group", nullToEmpty(p.group));
o.put("total", nullToEmpty(p.total));
o.put("via", nullToEmpty(p.via));
o.put("issuedAt", nullToEmpty(p.issuedAt));
o.put("expiresAt", nullToEmpty(p.expiresAt));
o.put("status", nullToEmpty(p.status));
o.put("updatedAt", nullToEmpty(p.updatedAt));
o.put("finished", p.finished);
o.put("snapshots", p.snapshots);
o.put("claimedCount", p.claimedCount);
o.put("totalCount", p.totalCount);
o.put("claimantCount", p.claimantCount);
o.put("sumClaimed", p.sumClaimed);
JSONArray board = new JSONArray();
if (p.leaderboard != null) {
for (MmpClaim c : p.leaderboard) {
JSONObject row = new JSONObject();
row.put("nickname", nullToEmpty(c.nickname));
row.put("userId", nullToEmpty(c.userId));
row.put("poolId", nullToEmpty(c.poolId));
row.put("amountText", nullToEmpty(c.amountText));
row.put("amount", c.amount);
row.put("claimTime", nullToEmpty(c.claimTime));
row.put("rank", c.rank);
board.put(row);
}
}
o.put("leaderboard", board);
return o;
}
private static MmpPacket fromJson(JSONObject o) throws Exception {
MmpPacket p = new MmpPacket();
p.packetId = o.optString("packetId", "");
p.title = o.optString("title", "");
p.sender = o.optString("sender", "");
p.group = o.optString("group", "");
p.total = o.optString("total", "");
p.via = o.optString("via", "");
p.issuedAt = o.optString("issuedAt", "");
p.expiresAt = o.optString("expiresAt", "");
p.status = o.optString("status", "");
p.updatedAt = o.optString("updatedAt", "");
p.finished = o.optBoolean("finished", false);
p.snapshots = o.optInt("snapshots", 1);
p.claimedCount = o.optInt("claimedCount", 0);
p.totalCount = o.optInt("totalCount", 0);
p.claimantCount = o.optInt("claimantCount", 0);
p.refreshClosedState();
p.snapshots = o.optInt("snapshots", 1);
p.claimantCount = o.optInt("claimantCount", 0);
p.sumClaimed = o.optDouble("sumClaimed", 0);
JSONArray board = o.optJSONArray("leaderboard");
if (board != null) {
for (int i = 0; i < board.length(); i++) {
JSONObject row = board.getJSONObject(i);
MmpClaim c = new MmpClaim();
c.nickname = row.optString("nickname", "");
c.userId = row.optString("userId", "");
c.poolId = row.optString("poolId", "");
c.amount = row.optDouble("amount", 0);
c.amountText = row.optString("amountText",
String.format(Locale.US, "%.2f", c.amount));
c.claimTime = row.optString("claimTime", "");
c.rank = row.optInt("rank", i + 1);
p.leaderboard.add(c);
}
}
if (p.claimantCount <= 0) {
p.claimantCount = p.leaderboard.size();
}
if (p.claimedCount <= 0) {
p.claimedCount = p.claimantCount;
}
p.refreshClosedState();
return p;
}
private static void notifyListeners() {
for (Listener l : LISTENERS) {
try {
l.onMmpPacketsChanged();
} catch (Exception e) {
Log.e(TAG, "listener error", e);
}
}
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}
private static String firstNonEmpty(Map<String, String> meta, String... keys) {
for (String k : keys) {
String v = meta.get(k);
if (!TextUtils.isEmpty(v)) {
return v;
}
}
return "";
}
/** counts=1/5 */
private static void parseCountsMeta(MmpPacket packet, String counts) {
if (packet == null || TextUtils.isEmpty(counts)) {
return;
}
String raw = counts.trim();
int slash = raw.indexOf('/');
try {
if (slash >= 0) {
String left = raw.substring(0, slash).trim();
String right = raw.substring(slash + 1).trim();
if (!left.isEmpty()) {
packet.claimedCount = Integer.parseInt(left);
}
if (!right.isEmpty()) {
packet.totalCount = Integer.parseInt(right);
}
} else {
packet.claimedCount = Integer.parseInt(raw);
}
} catch (NumberFormatException ignored) {
}
}
private static void tryParseIntField(MmpPacket packet, Map<String, String> meta,
String... keys) {
if (packet == null || meta == null) {
return;
}
for (String k : keys) {
String v = meta.get(k);
if (TextUtils.isEmpty(v)) {
continue;
}
try {
int n = Integer.parseInt(v.trim());
if (n < 0) {
continue;
}
if ("claimed".equals(k) || "claimedCount".equals(k)) {
packet.claimedCount = n;
} else {
packet.totalCount = n;
}
return;
} catch (NumberFormatException ignored) {
}
}
}
private static boolean isTerminalStatusMeta(String status) {
if (TextUtils.isEmpty(status)) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE");
}
private static String nowText() {
return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
.format(new java.util.Date());
}
}

View File

@@ -0,0 +1,178 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.miraclegarden.smsmessage.AppConfig;
import org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* 领取台设置:本地 SharedPreferences 优先,能连上调试台时再同步 Hook 侧参数。
*/
public final class MmpSettingsClient {
private static final String TAG = "MmpSettingsClient";
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
.connectTimeout(4, TimeUnit.SECONDS)
.readTimeout(4, TimeUnit.SECONDS)
.writeTimeout(4, TimeUnit.SECONDS)
.build();
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor();
private static final Handler MAIN = new Handler(Looper.getMainLooper());
public interface CallbackJson {
void onResult(JSONObject json, String error);
}
private MmpSettingsClient() {
}
/** 先本地再尝试合并调试台Hook 冷却等) */
public static void load(Context context, CallbackJson cb) {
final Context app = context != null ? context.getApplicationContext() : null;
EXEC.execute(() -> {
JSONObject local = MmpLocalSettings.load(app);
Exception last = null;
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (base == null || base.isEmpty()) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/settings")
.get()
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
if (resp.isSuccessful() && resp.body() != null) {
JSONObject remote = new JSONObject(resp.body().string());
// 远程覆盖 Hook 相关;手机专属开关保留本地
boolean launch = local.optBoolean("autoLaunchTngIfKilled", true);
boolean watch = local.optBoolean("autoWatchWhileOpen", true);
MmpLocalSettings.mergeInto(local, remote);
local.put("autoLaunchTngIfKilled", launch);
local.put("autoWatchWhileOpen", watch);
MmpLocalSettings.save(app, local);
JSONObject out = local;
MAIN.post(() -> cb.onResult(out, null));
return;
}
}
} catch (Exception e) {
last = e;
}
}
JSONObject out = local;
String tip = last != null
? ("已用本地设置(调试台未连上:" + last.getMessage() + "")
: null;
MAIN.post(() -> cb.onResult(out, tip));
});
}
/** 始终写本地;能连调试台则同步 Hook 参数 */
public static void save(Context context, JSONObject body, CallbackJson cb) {
final Context app = context != null ? context.getApplicationContext() : null;
EXEC.execute(() -> {
MmpLocalSettings.save(app, body);
JSONObject saved = MmpLocalSettings.load(app);
Exception last = null;
boolean remoteOk = false;
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (base == null || base.isEmpty()) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/settings")
.post(RequestBody.create(saved.toString(), JSON))
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
if (resp.isSuccessful() && resp.body() != null) {
JSONObject remote = new JSONObject(resp.body().string());
boolean launch = saved.optBoolean("autoLaunchTngIfKilled", true);
boolean watch = saved.optBoolean("autoWatchWhileOpen", true);
MmpLocalSettings.mergeInto(saved, remote);
saved.put("autoLaunchTngIfKilled", launch);
saved.put("autoWatchWhileOpen", watch);
MmpLocalSettings.save(app, saved);
remoteOk = true;
break;
}
}
} catch (Exception e) {
last = e;
}
}
final boolean okRemote = remoteOk;
final JSONObject out = saved;
final Exception err = last;
MAIN.post(() -> {
if (okRemote) {
cb.onResult(out, null);
} else {
// 本地已保存成功
cb.onResult(out, err != null
? ("已保存到手机;调试台未同步:" + err.getMessage())
: "已保存到手机");
}
});
});
}
public static void pingAsync() {
load(null, (json, error) -> {
if (error != null) {
Log.w(TAG, "settings ping: " + error);
}
});
}
public interface HealthCallback {
void onResult(boolean ok);
}
/** 探测调试台 /health 是否可达 */
public static void pingHealth(HealthCallback cb) {
EXEC.execute(() -> {
boolean ok = false;
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (base == null || base.isEmpty()) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/health")
.get()
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
if (resp.isSuccessful()) {
ok = true;
break;
}
}
} catch (Exception ignored) {
}
}
boolean result = ok;
MAIN.post(() -> {
if (cb != null) {
cb.onResult(result);
}
});
});
}
}

View File

@@ -0,0 +1,134 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
import com.miraclegarden.smsmessage.AppConfig;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* 把手机本地红包台全量同步到 PC 调试台,方便电脑打开 /mmp 查看。
*/
public final class MmpSyncClient {
private static final String TAG = "MmpSyncClient";
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build();
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor();
private static final AtomicBoolean IN_FLIGHT = new AtomicBoolean(false);
private static volatile long sLastSyncAt;
public interface Callback {
void onDone(boolean ok, String message);
}
private MmpSyncClient() {
}
public static void syncToPc(Context context) {
syncToPc(context, null);
}
public static void syncToPc(Context context, Callback callback) {
if (context == null || !AppConfig.ENABLE_DEBUG_FORWARD) {
if (callback != null) {
callback.onDone(false, "调试转发未开启");
}
return;
}
final Context app = context.getApplicationContext();
EXEC.execute(() -> {
if (!IN_FLIGHT.compareAndSet(false, true)) {
if (callback != null) {
callback.onDone(false, "同步进行中");
}
return;
}
try {
JSONArray packets = MmpPacketStore.exportJsonArray(app);
JSONObject body = new JSONObject();
body.put("packets", packets);
body.put("device", "android");
body.put("syncedAt", System.currentTimeMillis());
JSONObject hook = new JSONObject();
MmpHookStatus.ensureLoaded(app);
hook.put("liveCode", MmpHookStatus.liveVersionCode(app));
hook.put("liveName", MmpHookStatus.liveVersionName(app));
hook.put("liveAt", MmpHookStatus.lastAliveAt(app));
hook.put("installedCode", MmpHookStatus.installedVersionCode(app));
hook.put("installedName", MmpHookStatus.installedVersionName(app));
body.put("hookStatus", hook);
String payload = body.toString();
boolean anyOk = false;
String lastErr = "调试台不可达";
String lastOkMsg = "";
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (TextUtils.isEmpty(base)) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/sync")
.post(RequestBody.create(payload, JSON))
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
String respBody = resp.body() != null ? resp.body().string() : "";
if (resp.isSuccessful()) {
anyOk = true;
lastOkMsg = "已同步 " + packets.length() + " 个红包到电脑";
Log.i(TAG, "sync ok -> " + base + " " + respBody);
} else {
lastErr = "HTTP " + resp.code();
}
}
} catch (Exception e) {
lastErr = e.getMessage();
Log.w(TAG, "sync fail " + base + ": " + lastErr);
}
}
sLastSyncAt = System.currentTimeMillis();
if (callback != null) {
if (anyOk) {
callback.onDone(true, lastOkMsg);
} else {
callback.onDone(false, lastErr != null ? lastErr : "同步失败");
}
}
} catch (Exception e) {
Log.e(TAG, "sync error", e);
if (callback != null) {
callback.onDone(false, e.getMessage());
}
} finally {
IN_FLIGHT.set(false);
}
});
}
/** 距上次同步超过 cooldownMs 才再同步。 */
public static void syncIfStale(Context context, long cooldownMs) {
if (System.currentTimeMillis() - sLastSyncAt < cooldownMs) {
return;
}
syncToPc(context, null);
}
}

View File

@@ -0,0 +1,155 @@
package com.miraclegarden.smsmessage.mmp;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.text.TextUtils;
import android.util.Log;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 检测 / 拉起 TNG供领取台「自动检测」「杀掉后自动打开」使用。
*/
public final class TngProcessHelper {
private static final String TAG = "TngProcessHelper";
public static final String TNG_PACKAGE = "my.com.tngdigital.ewallet";
private TngProcessHelper() {
}
public static boolean isRunning(Context context) {
if (context == null) {
return false;
}
if (pidofAlive()) {
return true;
}
try {
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) {
return false;
}
List<ActivityManager.RunningAppProcessInfo> list = am.getRunningAppProcesses();
if (list == null) {
return false;
}
for (ActivityManager.RunningAppProcessInfo info : list) {
if (info == null || TextUtils.isEmpty(info.processName)) {
continue;
}
if (info.processName.equals(TNG_PACKAGE)
|| info.processName.startsWith(TNG_PACKAGE + ":")) {
return true;
}
}
} catch (Exception e) {
Log.w(TAG, "running check fail", e);
}
return false;
}
private static boolean pidofAlive() {
String out = shell("pidof " + TNG_PACKAGE);
if (TextUtils.isEmpty(out)) {
out = shell("su -c pidof " + TNG_PACKAGE);
}
return !TextUtils.isEmpty(out) && out.trim().matches(".*\\d.*");
}
private static String shell(String cmd) {
Process p = null;
try {
p = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd});
if (!p.waitFor(2, TimeUnit.SECONDS)) {
p.destroy();
return "";
}
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
sb.append(line).append('\n');
}
}
return sb.toString().trim();
} catch (Exception e) {
return "";
} finally {
if (p != null) {
p.destroy();
}
}
}
/** @return true 已发出打开 Intent */
public static boolean launch(Context context) {
if (context == null) {
return false;
}
try {
PackageManager pm = context.getPackageManager();
Intent intent = pm.getLaunchIntentForPackage(TNG_PACKAGE);
if (intent == null) {
Log.w(TAG, "no launch intent for TNG");
return false;
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
context.startActivity(intent);
Log.i(TAG, "launched TNG");
return true;
} catch (Exception e) {
Log.e(TAG, "launch TNG fail", e);
return false;
}
}
/** 打开红包历史页,触发 Hook onResume / 自动拉取 */
public static boolean openMoneyPacketHistory(Context context) {
if (context == null) {
return false;
}
try {
Intent intent = new Intent();
intent.setClassName(TNG_PACKAGE,
"my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_NO_ANIMATION);
context.startActivity(intent);
Log.i(TAG, "opened MoneyPacketHistoryActivity");
return true;
} catch (Exception e) {
Log.w(TAG, "open history fail, fallback launch: " + e.getMessage());
return launch(context);
}
}
/** 强制停止 TNG优先 su */
public static boolean forceStop(Context context) {
String out = shell("su -c am force-stop " + TNG_PACKAGE);
if (TextUtils.isEmpty(out)) {
out = shell("am force-stop " + TNG_PACKAGE);
}
Log.i(TAG, "force-stop TNG done");
return true;
}
/** 强停后再打开,用于 Hook 假死重建 */
public static boolean forceStopAndLaunch(Context context) {
forceStop(context);
try {
Thread.sleep(900L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return launch(context);
}
}

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

@@ -10,6 +10,8 @@ import com.miraclegarden.smsmessage.Activity.NotificationActivity;
import com.miraclegarden.smsmessage.App; import com.miraclegarden.smsmessage.App;
import com.miraclegarden.smsmessage.AppConfig; import com.miraclegarden.smsmessage.AppConfig;
import com.miraclegarden.smsmessage.MessageInfo; import com.miraclegarden.smsmessage.MessageInfo;
import com.miraclegarden.smsmessage.mmp.MmpHookStatus;
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
import com.miraclegarden.smsmessage.network.DebugForwarder; import com.miraclegarden.smsmessage.network.DebugForwarder;
/** /**
@@ -21,15 +23,30 @@ public class HookMessageReceiver extends BroadcastReceiver {
private static final String TAG = "HookMessageReceiver"; private static final String TAG = "HookMessageReceiver";
public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE"; public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE";
public static final String ACTION_HOOK_STATUS = "com.miraclegarden.smsmessage.action.HOOK_STATUS";
public static final String EXTRA_PACKAGE_NAME = "packageName"; public static final String EXTRA_PACKAGE_NAME = "packageName";
public static final String EXTRA_TITLE = "title"; public static final String EXTRA_TITLE = "title";
public static final String EXTRA_CONTENT = "content"; public static final String EXTRA_CONTENT = "content";
public static final String EXTRA_TIMESTAMP = "timestamp"; public static final String EXTRA_TIMESTAMP = "timestamp";
public static final String EXTRA_SOURCE = "source"; public static final String EXTRA_SOURCE = "source";
public static final String EXTRA_MODULE_VERSION_CODE = "moduleVersionCode";
public static final String EXTRA_MODULE_VERSION_NAME = "moduleVersionName";
public static final String EXTRA_HOST_PACKAGE = "hostPackage";
@Override @Override
public void onReceive(Context context, Intent intent) { public void onReceive(Context context, Intent intent) {
if (intent == null || !ACTION_HOOK_MESSAGE.equals(intent.getAction())) { if (intent == null || intent.getAction() == null) {
return;
}
if (ACTION_HOOK_STATUS.equals(intent.getAction())) {
int code = intent.getIntExtra(EXTRA_MODULE_VERSION_CODE, 0);
String name = intent.getStringExtra(EXTRA_MODULE_VERSION_NAME);
String host = intent.getStringExtra(EXTRA_HOST_PACKAGE);
MmpHookStatus.noteAlive(context.getApplicationContext(), code, name, host);
Log.i(TAG, "hook status host=" + host + " mod=" + name + "/" + code);
return;
}
if (!ACTION_HOOK_MESSAGE.equals(intent.getAction())) {
return; return;
} }
@@ -58,6 +75,26 @@ public class HookMessageReceiver extends BroadcastReceiver {
Log.i(TAG, "hook received: pkg=" + packageName + " source=" + source + " title=" + title); Log.i(TAG, "hook received: pkg=" + packageName + " source=" + source + " title=" + title);
final String finalTitle = title; final String finalTitle = title;
final boolean isMmp = MmpPacketStore.isMmpContent(content, source);
// 红包统计:只进领取台,不混入「情况」监听台 / 正式上传
if (isMmp) {
try {
MmpPacketStore.ingest(context.getApplicationContext(), finalTitle, content, source);
} catch (Exception e) {
Log.e(TAG, "mmp ingest failed", e);
}
Context appContext = context.getApplicationContext();
PendingResult pendingResult = goAsync();
Runnable finish = pendingResult::finish;
if (AppConfig.ENABLE_DEBUG_FORWARD) {
DebugForwarder.forward(appContext, messageInfo, finalTitle, content, timestamp, source, finish);
} else {
finish.run();
}
return;
}
NotificationActivity.sendMessage("[Hook/" + source + "] " + finalTitle + " " + content); NotificationActivity.sendMessage("[Hook/" + source + "] " + finalTitle + " " + content);
Context appContext = context.getApplicationContext(); Context appContext = context.getApplicationContext();

View File

@@ -92,6 +92,16 @@
android:backgroundTint="#FF3700B3" /> android:backgroundTint="#FF3700B3" />
</LinearLayout> </LinearLayout>
<Button
android:id="@+id/btn_mmp_claim"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="12dp"
android:text="红包领取台(独立)"
android:textSize="16sp"
android:textColor="@color/white"
android:backgroundTint="#C45C26" />
<!-- 权限设置 --> <!-- 权限设置 -->
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -0,0 +1,633 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F6F1EA">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#111827"
android:orientation="vertical"
android:paddingBottom="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="44dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<ImageView
android:id="@+id/back_iv"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="返回"
android:src="@drawable/ic_action_back" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="红包领取台"
android:textColor="#FFFFFF"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:maxLines="1"
android:text="0 个"
android:textColor="#FDBA74"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp">
<TextView
android:id="@+id/btn_refresh"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="刷新"
android:textColor="#FFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/btn_sync"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="同步"
android:textColor="#FFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/btn_help"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="说明"
android:textColor="#FDBA74"
android:textSize="13sp" />
<TextView
android:id="@+id/btn_settings"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="设置"
android:textColor="#FFFFFF"
android:textSize="13sp" />
<TextView
android:id="@+id/btn_clear"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="清空"
android:textColor="#FECACA"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/tv_conn_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#1F2937"
android:lineSpacingExtra="2dp"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp"
android:text="连接检测中…"
android:textColor="#E5E7EB"
android:textSize="12sp" />
<!-- 筛选 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp">
<EditText
android:id="@+id/et_filter"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="#F0F0F0"
android:hint="筛选:昵称 / 发送人 / ID"
android:imeOptions="actionSearch"
android:inputType="text"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:textColor="#222222"
android:textColorHint="#666666"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/chip_all"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_weight="1"
android:background="#C45C26"
android:gravity="center"
android:text="全部"
android:textColor="@color/white"
android:textSize="13sp" />
<TextView
android:id="@+id/chip_open"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#EEEEEE"
android:gravity="center"
android:text="领取中"
android:textColor="#333333"
android:textSize="13sp" />
<TextView
android:id="@+id/chip_done"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#EEEEEE"
android:gravity="center"
android:text="已领完"
android:textColor="#333333"
android:textSize="13sp" />
</LinearLayout>
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/chip_time_all"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:background="#1C1410"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="全部时间"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_1"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近1天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_3"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近3天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_7"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近7天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_custom"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="自定义"
android:textColor="#333333"
android:textSize="12sp" />
</LinearLayout>
</HorizontalScrollView>
<LinearLayout
android:id="@+id/custom_range_ly"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
android:visibility="gone">
<Button
android:id="@+id/btn_date_from"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:backgroundTint="#FFF3E0"
android:text="起始日期"
android:textColor="#4E342E"
android:textSize="12sp" />
<Button
android:id="@+id/btn_date_to"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:backgroundTint="#FFF3E0"
android:text="结束日期"
android:textColor="#4E342E"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/empty_ly"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂无红包数据"
android:textColor="#999999"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:text="确认监听列表已勾选 TNG打开 TNG 登录后等待自动拉取全部历史与详情"
android:textColor="#FF9800"
android:textSize="13sp" />
</LinearLayout>
<!-- 设置全屏可滚动层:盖在列表之上,可滚到底,避免底部裁切 -->
<ScrollView
android:id="@+id/settings_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="84dp"
android:background="#FFF8F1"
android:clickable="true"
android:elevation="12dp"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
android:paddingBottom="48dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="拉取设置"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/btn_close_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:text="关闭"
android:textColor="#C2410C"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="控制 TNG 里多久自动扫一次红包列表/详情。改完点保存,约 10 秒后生效。不懂就用下面「标准」。"
android:textColor="#5D4037"
android:textSize="12sp" />
<!-- 历史冷却 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="① 历史列表冷却(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="多久再自动拉一次「红包历史列表」。越小越勤,越费电。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_history"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 8"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 详情冷却 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="② 详情刷新冷却(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="还在领的红包,多久再拉一次领取名单。已领完的会自动停刷。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_detail"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 8"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 去重 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="③ 相同名单去重(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="领取名单没变化时,多少秒内不重复推送到领取台(防刷屏)。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_dedup"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 3"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 间隔 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="④ 详情请求间隔(毫秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="连续拉多个红包详情时每个之间停顿多久。1000 毫秒 = 1 秒。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_gap"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 80"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<CheckBox
android:id="@+id/cfg_open_history"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:buttonTint="#C45C26"
android:text="无请求模板时,自动打开 TNG 红包历史页(方便首次抓取)"
android:textColor="#222222"
android:textSize="13sp" />
<CheckBox
android:id="@+id/cfg_bounce_history"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:buttonTint="#C45C26"
android:checked="true"
android:text="Hook 断线时短进历史页再建连(约 2 秒后自动返回;已在历史页不会重复打开)"
android:textColor="#222222"
android:textSize="13sp" />
<CheckBox
android:id="@+id/cfg_auto_watch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:buttonTint="#C45C26"
android:checked="true"
android:text="领取台打开时自动检测 TNG 是否在跑"
android:textColor="#222222"
android:textSize="13sp" />
<CheckBox
android:id="@+id/cfg_auto_launch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:buttonTint="#C45C26"
android:checked="true"
android:text="检测到 TNG 被完全杀掉时,自动重新打开 TNG"
android:textColor="#222222"
android:textSize="13sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="快捷预设"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="极速=更勤拉(费电) 标准=日常推荐 省流=少拉省电"
android:textColor="#6D4C41"
android:textSize="11sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<Button
android:id="@+id/btn_preset_fast"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="极速"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_preset_normal"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="标准"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_preset_slow"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="省流"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_save_settings"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#C45C26"
android:text="保存"
android:textColor="#FFFFFF"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tv_settings_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="#4E342E"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>
</FrameLayout>

View File

@@ -0,0 +1,324 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F3F4F6"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="#111827"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<ImageView
android:id="@+id/back_iv"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="返回"
android:src="@drawable/ic_action_back" />
<TextView
android:id="@+id/tv_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="红包详情"
android:textColor="#FFFFFF"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_header_money"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.00"
android:textColor="#FDBA74"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/tv_sub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#6B7280"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#F9FAFB"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="人数"
android:textColor="#6B7280"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_people"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="0"
android:textColor="#111827"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFF7ED"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="合计"
android:textColor="#9A3412"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_sum"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFF7ED"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="总额"
android:textColor="#9A3412"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_total"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#C2410C"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#ECFDF5"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tv_best_lab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="手气最佳"
android:textColor="#047857"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_best_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:text="-"
android:textColor="#047857"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_best_amt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#047857"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFFBEB"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tv_worst_lab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="手气最差"
android:textColor="#B45309"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:text="-"
android:textColor="#B45309"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst_amt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#B45309"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<!-- keep old tv_luck hidden for binding safety if referenced - remove from activity instead -->
</LinearLayout>
</ScrollView>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="14dp"
android:paddingTop="10dp"
android:paddingEnd="14dp"
android:paddingBottom="6dp"
android:text="领取排行"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#F9FAFB"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="8dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="#"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="昵称"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="金额"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="96dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="时间"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="#FFFFFF" />
<LinearLayout
android:id="@+id/empty_ly"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂无领取记录"
android:textColor="#9CA3AF"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F6F1EA"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="#111827"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<ImageView
android:id="@+id/back_iv"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="返回"
android:src="@drawable/ic_action_back" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:text="功能说明"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:paddingBottom="32dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="红包领取台做什么"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="3dp"
android:text="独立统计 TNG Money Packet红包谁发的、谁领了、领了多少。数据只进本台不会进通用「情况」监听台也不会上传正式服务器。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="怎么用"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="1. 主 App 监听列表勾选 TNG\n2. LSPosed 勾选本 Xposed 模块,作用域含 TNG\n3. 强制停止后再打开 TNG若闪退见下方「打开 TNG 闪退怎么办」\n4. 登录 / 输 PIN 后回到本页等待自动拉取(一般不必再进红包历史页)\n5. 要电脑看:电脑开调试台 + adb reverse再点「同步电脑」或打开本页自动同步"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="列表怎么看"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="• 右上角橙色数字:已领合计金额\n• 领取中 / 已领完:红包是否还在领\n• 最高 / 最低(领取中)或 最佳 / 最差(已领完):当前手气两端\n• 点进卡片:看完整领取排行\n• 清空:只清本台红包数据,不影响通用消息"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="筛选"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="• 关键词:昵称 / 发送人 / 红包 ID\n• 状态:全部、领取中、已领完\n• 时间:近 1 / 3 / 7 天,或自定义起止日期"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="同步电脑"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="手机是数据源。点「同步电脑」会把本地红包全量推到调试台(落盘),电脑打开 http://127.0.0.1:8765/mmp 即可看。手机浏览器也可访问USB reverse 或局域网 IP。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="设置里各项含义"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="① 历史列表冷却:多久再扫一次红包历史列表\n② 详情刷新冷却:领取中的包多久再拉一次名单\n③ 相同名单去重:名单没变时,多少秒内不重复推送\n④ 详情请求间隔:连拉多个包之间停顿(毫秒)\n\n已领完的包会停刷详情。不懂就用预设「标准」再保存约 10 秒后对 Hook 生效。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="断线自动进历史页"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="设置里可勾选:\n• 检测不到 Hook 连接 → 自动进历史页再返回\n• 领取台打开时自动检测 TNG 是否在跑\n• TNG 被完全杀掉时自动重新打开\n\n这些设置保存在手机本地不依赖电脑调试台。顶栏「刷新」可立刻重载列表并检测 TNG。\n\n注意自动打开 TNG 需要领取台在前台(或刚打开);完全退出本 App 后不会后台常驻拉起。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="打开 TNG 闪退怎么办"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="装了 Xposed / 防护相关模块后TNG 偶发一打开就闪退,属常见现象,可按下面顺序试:\n\n1. 多点几次图标再打开(有时前几次会崩,后面就能进)\n2. 仍不行:系统设置 → 应用 → TNG → 强制停止,再重新打开\n3. 还不行:强制停止后清一下「缓存」(尽量别清「数据」,否则要重新登录)\n4. 推送/更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪\n5. 连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开\n\n能进首页并登录 / 输 PIN 后,再回本领取台等自动拉数据。"
android:textColor="#4E342E"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="没数据时先查这些"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:lineSpacingExtra="4dp"
android:text="• 监听列表是否勾选 TNG\n• Xposed 模块是否启用且作用域含 TNG\n• 是否按上面做过「强制停止再开」并完成登录\n• 电脑同步失败调试台是否在跑、8765 是否多开、adb reverse 是否做好\n\n更细的技术说明见仓库 docs/TNG_MoneyPacket领取台.md"
android:textColor="#4E342E"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="12dp"
android:paddingEnd="14dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/tv_rank"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="1"
android:textColor="#EA580C"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_nickname"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="昵称"
android:textColor="#111827"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_user_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
android:text=""
android:textColor="#9CA3AF"
android:textSize="11sp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_pool_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
android:text=""
android:textColor="#9CA3AF"
android:textSize="11sp"
android:visibility="gone" />
<TextView
android:id="@+id/tv_tag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text=""
android:textSize="11sp"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_amount"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_time"
android:layout_width="96dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="-"
android:textColor="#9CA3AF"
android:textSize="11sp" />
</LinearLayout>

View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:background="#FFFFFF"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="10dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="红包"
android:textColor="#111827"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
android:text=""
android:textColor="#9CA3AF"
android:textSize="11sp"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_sum"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#ECFDF5"
android:paddingStart="5dp"
android:paddingTop="1dp"
android:paddingEnd="5dp"
android:paddingBottom="1dp"
android:text="已领完"
android:textColor="#047857"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_meta"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="0 人"
android:textColor="#6B7280"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_best"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#ECFDF5"
android:ellipsize="end"
android:maxLines="2"
android:paddingStart="6dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"
android:paddingBottom="4dp"
android:text="最高 -"
android:textColor="#047857"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:background="#FFFBEB"
android:ellipsize="end"
android:maxLines="2"
android:paddingStart="6dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"
android:paddingBottom="4dp"
android:text="最低 -"
android:textColor="#B45309"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

147
debug-server/mmp_help.html Normal file
View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>红包领取台 · 功能说明</title>
<style>
:root {
--bg: #f6f1ea; --panel: #fff; --ink: #1c1410; --muted: #5d4037;
--line: #e7e0d8; --accent: #c45c26; --nav: #111827;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--ink); line-height: 1.55;
}
header {
position: sticky; top: 0; z-index: 2;
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 12px 16px; background: var(--nav); color: #fff;
}
header h1 { margin: 0; font-size: 17px; font-weight: 700; }
a.nav { color: #fdba74; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
main { max-width: 720px; margin: 0 auto; padding: 20px 16px 48px; }
section {
background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 16px 18px; margin-bottom: 12px;
}
h2 { margin: 0 0 8px; font-size: 16px; }
p, li { margin: 0; color: var(--muted); font-size: 14px; }
p + p { margin-top: 8px; }
ol, ul { margin: 6px 0 0; padding-left: 1.25em; color: var(--muted); font-size: 14px; }
li + li { margin-top: 4px; }
code {
font-family: ui-monospace, Consolas, monospace; font-size: 12px;
background: #f3eee6; padding: 1px 5px; border-radius: 4px; color: #1c1410;
}
.tip {
margin-top: 10px; padding: 10px 12px; border-radius: 8px;
background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; font-size: 13px;
}
</style>
</head>
<body>
<header>
<h1>功能说明</h1>
<a class="nav" href="/mmp">← 返回领取台</a>
<a class="nav" href="/">通用消息台</a>
</header>
<main>
<section>
<h2>红包领取台做什么</h2>
<p>独立统计 TNG Money Packet红包谁发的、谁领了、领了多少。</p>
<p>数据只进本台,不进入通用「情况」监听台,也不走正式上传。</p>
</section>
<section>
<h2>怎么用</h2>
<ol>
<li>主 App 监听列表勾选 TNG</li>
<li>LSPosed 勾选本 Xposed 模块,作用域含 TNG</li>
<li>强制停止后再打开 TNG若闪退见下方「打开 TNG 闪退怎么办」</li>
<li>登录 / 输 PIN 后,打开手机「红包领取台」等待自动拉取(一般不必再进红包历史页)</li>
<li>电脑本页看数据:调试台运行 + <code>adb reverse tcp:8765 tcp:8765</code>,手机点「同步电脑」或打开领取台自动同步</li>
</ol>
</section>
<section>
<h2>断线自动进历史页</h2>
<p>设置可勾选「检测不到连接时:自动进历史页再返回」。</p>
<ul>
<li>session 丢失、历史 RPC 失败、或还没有请求模板时触发</li>
<li>短暂打开 TNG 红包历史页约 2 秒后自动返回,用来重建连接</li>
<li>约 45 秒最多一次,避免刷屏</li>
<li>仍需要 TNG 进程在跑;完全杀掉后不会自己拉起</li>
</ul>
</section>
<section>
<h2>打开 TNG 闪退怎么办</h2>
<p>装了 Xposed / 防护相关模块后TNG 偶发一打开就闪退,属常见现象,可按顺序试:</p>
<ol>
<li>多点几次图标再打开(有时前几次会崩,后面就能进)</li>
<li>仍不行:系统设置 → 应用 → TNG → <strong>强制停止</strong>,再重新打开</li>
<li>还不行:强制停止后清一下「缓存」(尽量别清「数据」,否则要重新登录)</li>
<li>推送 / 更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪</li>
<li>连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开</li>
</ol>
<div class="tip">能进首页并登录 / 输 PIN 后,再回领取台等自动拉数据。</div>
</section>
<section>
<h2>列表怎么看</h2>
<ul>
<li>右侧橙色金额:已领合计</li>
<li>领取中 / 已领完:红包是否还在领</li>
<li>最高/最低(领取中)或 最佳/最差(已领完):手气两端</li>
<li>点选左侧条目:右侧看完整领取排行</li>
<li>清空:只清本台红包数据</li>
</ul>
</section>
<section>
<h2>筛选</h2>
<ul>
<li>关键词:昵称 / 发送人 / 红包 ID</li>
<li>状态:全部、领取中、已领完</li>
<li>时间:近 1 / 3 / 7 天,或自定义起止</li>
</ul>
</section>
<section>
<h2>同步与电脑访问</h2>
<p>手机是数据源。同步后落盘到调试台,刷新本页即可。</p>
<ul>
<li>本机:<code>http://127.0.0.1:8765/mmp</code></li>
<li>局域网:<code>http://&lt;电脑IP&gt;:8765/mmp</code></li>
</ul>
<div class="tip">8765 不要多开多个调试台进程,否则同步会挂。</div>
</section>
<section>
<h2>设置含义</h2>
<ul>
<li>① 历史列表冷却:多久再扫一次红包历史列表</li>
<li>② 详情刷新冷却:领取中的包多久再拉名单</li>
<li>③ 相同名单去重:名单没变时防重复推送</li>
<li>④ 详情请求间隔:连拉多个包之间的停顿(毫秒)</li>
<li>页面轮询:电脑页多久向服务器拉一次列表</li>
</ul>
<p style="margin-top:8px">已领完的包会停刷详情。不懂就用「标准」预设再保存,约 10 秒后对手机 Hook 生效。</p>
</section>
<section>
<h2>没数据时先查</h2>
<ul>
<li>监听列表是否勾选 TNG</li>
<li>Xposed 是否启用且作用域含 TNG</li>
<li>是否按「打开 TNG 闪退怎么办」做过强制停止再开,并完成登录</li>
<li>同步失败调试台是否在跑、端口是否冲突、adb reverse 是否做好</li>
</ul>
<p style="margin-top:8px">更细的技术说明见仓库 <code>docs/TNG_MoneyPacket领取台.md</code></p>
</section>
</main>
</body>
</html>

542
debug-server/mmp_page.html Normal file
View File

@@ -0,0 +1,542 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>红包领取台</title>
<style>
:root {
--bg: #f3f4f6;
--panel: #ffffff;
--ink: #111827;
--muted: #6b7280;
--line: #e5e7eb;
--money: #c2410c;
--best: #047857;
--best-bg: #ecfdf5;
--worst: #b45309;
--worst-bg: #fffbeb;
--done: #047857;
--open: #b45309;
--rank: #ea580c;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--ink);
display: flex; flex-direction: column;
}
button, input { font: inherit; }
header {
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
flex-shrink: 0;
}
h1 { margin: 0; font-size: 18px; font-weight: 700; margin-right: auto; }
.stat { color: var(--muted); font-size: 13px; }
a.nav { color: #2563eb; text-decoration: none; font-size: 13px; }
.btn {
border: 1px solid var(--line); background: #fff; color: var(--ink);
padding: 7px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.btn.primary { background: #ea580c; border-color: #ea580c; color: #fff; font-weight: 600; }
.conn-bar {
background: #1f2937; color: #e5e7eb; padding: 8px 16px; font-size: 12px;
line-height: 1.45; flex-shrink: 0; border-bottom: 1px solid #111827;
}
.conn-bar.ok { background: #064e3b; color: #d1fae5; }
.conn-bar.bad { background: #7f1d1d; color: #fee2e2; }
.conn-bar.warn { background: #78350f; color: #fef3c7; }
.uid { display: block; font-size: 11px; color: var(--muted); font-weight: 500; margin-top: 2px; }
.toolbar {
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 10px 16px; display: grid; gap: 8px; flex-shrink: 0;
}
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.search {
flex: 1; min-width: 180px; border: 1px solid var(--line); border-radius: 6px;
padding: 8px 10px; background: #fff;
}
.chip {
border: 1px solid var(--line); background: #fff; color: var(--muted);
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.chip.active { background: #111827; color: #fff; border-color: #111827; }
.chip.tone.active { background: #ea580c; border-color: #ea580c; }
.range { display: none; gap: 8px; align-items: center; flex-wrap: wrap; }
.range.open { display: flex; }
.range input { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }
.settings-panel {
display: none; background: #fff; border-bottom: 1px solid var(--line);
padding: 12px 16px; flex-shrink: 0;
}
.settings-panel.open { display: block; }
.settings-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;
}
.settings-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.settings-grid input[type=number] { border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
.check { flex-direction: row !important; align-items: center; gap: 8px !important; }
.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; align-items: center; }
.settings-hint { color: var(--muted); font-size: 12px; margin-top: 8px; }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar {
width: 340px; background: var(--panel); border-right: 1px solid var(--line);
overflow-y: auto; flex-shrink: 0;
}
.sidebar h2 {
margin: 0; padding: 10px 14px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--line); background: #fafafa; font-weight: 600;
}
.pkt {
padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer;
}
.pkt:hover { background: #f9fafb; }
.pkt.active { background: #fff7ed; box-shadow: inset 3px 0 0 #ea580c; }
.pkt-row { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
.pkt-name { font-size: 13px; font-weight: 700; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pkt-uid { font-size: 11px; color: #9ca3af; font-weight: 500; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, Consolas, monospace; }
.pkt-money {
font-size: 15px; font-weight: 800; color: var(--money);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.pkt-meta { margin-top: 3px; font-size: 11px; color: var(--muted); display: flex; gap: 5px; flex-wrap: nowrap; align-items: center; overflow: hidden; }
.pkt-meta span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge {
display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 10px; font-weight: 700; flex-shrink: 0;
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.badge.done { background: var(--best-bg); color: var(--done); }
.badge.open { background: var(--worst-bg); color: var(--open); }
.badge.expired { background: #f3f4f6; color: #6b7280; }
.pkt-luck {
margin-top: 4px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
}
.mini {
border-radius: 4px; padding: 3px 6px; font-size: 11px; line-height: 1.25;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700;
}
.mini.best { background: var(--best-bg); color: var(--best); }
.mini.worst { background: var(--worst-bg); color: var(--worst); }
.pkt-id { margin-left: auto; flex-shrink: 0; font-size: 10px; color: #9ca3af; font-family: ui-monospace, Consolas, monospace; }
.main { flex: 1; overflow-y: auto; padding: 16px 18px; }
.empty { padding: 60px 20px; text-align: center; color: var(--muted); }
.title-row { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 6px; }
.title { margin: 0; font-size: 22px; font-weight: 800; }
.title-money {
font-size: 28px; font-weight: 900; color: var(--money);
font-variant-numeric: tabular-nums; line-height: 1;
}
.sub { color: var(--muted); font-size: 12px; margin-bottom: 14px; word-break: break-all; }
.kpi {
display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; margin-bottom: 12px;
}
.kpi .box {
background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px;
}
.kpi .k { font-size: 12px; color: var(--muted); font-weight: 600; }
.kpi .v {
margin-top: 4px; font-size: 22px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
.kpi .v.money { color: var(--money); }
.luck-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px;
}
.luck-card {
border-radius: 8px; padding: 14px 16px; border: 1px solid transparent;
}
.luck-card.best { background: var(--best-bg); border-color: #a7f3d0; }
.luck-card.worst { background: var(--worst-bg); border-color: #fde68a; }
.luck-card .lab { font-size: 12px; font-weight: 700; opacity: .9; }
.luck-card .name { margin-top: 4px; font-size: 18px; font-weight: 800; }
.luck-card .amt {
margin-top: 2px; font-size: 24px; font-weight: 900;
font-variant-numeric: tabular-nums;
}
.luck-card.best .lab, .luck-card.best .name, .luck-card.best .amt { color: var(--best); }
.luck-card.worst .lab, .luck-card.worst .name, .luck-card.worst .amt { color: var(--worst); }
.section { font-size: 13px; font-weight: 700; color: var(--muted); margin: 0 0 8px; }
table {
width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
}
th, td { padding: 11px 12px; text-align: left; border-bottom: 1px solid var(--line); font-size: 13px; }
th { background: #f9fafb; color: var(--muted); font-weight: 700; font-size: 12px; }
tr:last-child td { border-bottom: 0; }
tr.row-best { background: #ecfdf5; }
tr.row-worst { background: #fffbeb; }
.rank { width: 48px; color: var(--rank); font-weight: 800; font-variant-numeric: tabular-nums; }
.nick { font-weight: 700; }
.amt { color: var(--money); font-weight: 800; font-variant-numeric: tabular-nums; font-size: 15px; }
.tag-inline {
margin-left: 6px; font-size: 11px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
}
.tag-inline.best { background: #d1fae5; color: var(--best); }
.tag-inline.worst { background: #fef3c7; color: var(--worst); }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar { width: 100%; max-height: 36vh; border-right: 0; border-bottom: 1px solid var(--line); }
.kpi, .luck-grid { grid-template-columns: 1fr 1fr; }
.title-money { font-size: 22px; }
}
</style>
</head>
<body>
<header>
<h1>红包领取台</h1>
<span class="stat" id="pktCount">0 个</span>
<span class="stat" id="updated">-</span>
<span class="stat" id="cfgHint">刷新: -</span>
<a class="nav" href="/mmp/help">功能说明</a>
<a class="nav" href="/">通用消息台</a>
<button class="btn" onclick="toggleSettings()">设置</button>
<button class="btn" onclick="loadData()">刷新</button>
<button class="btn primary" onclick="clearAll()">清空</button>
</header>
<div class="conn-bar warn" id="connBar">模块检测中…</div>
<div class="toolbar">
<div class="row">
<input class="search" type="search" id="filterQ" placeholder="搜索昵称 / 发送人 / ID" oninput="onFilterChange()" />
<button type="button" class="chip tone active" id="fAll" onclick="setStatus('all')">全部</button>
<button type="button" class="chip" id="fOpen" onclick="setStatus('open')">领取中</button>
<button type="button" class="chip" id="fDone" onclick="setStatus('done')">已领完</button>
</div>
<div class="row">
<button type="button" class="chip active" id="tAll" onclick="setTime('all')">全部时间</button>
<button type="button" class="chip" id="t1" onclick="setTime('1')">近1天</button>
<button type="button" class="chip" id="t3" onclick="setTime('3')">近3天</button>
<button type="button" class="chip" id="t7" onclick="setTime('7')">近7天</button>
<button type="button" class="chip" id="tCustom" onclick="setTime('custom')">自定义</button>
<div class="range" id="customRange">
<label><input type="date" id="dateFrom" onchange="onFilterChange()" /></label>
<label><input type="date" id="dateTo" onchange="onFilterChange()" /></label>
</div>
</div>
</div>
<div class="settings-panel" id="settingsPanel">
<div class="settings-grid">
<label>① 历史列表冷却(秒)— 多久扫一次红包列表
<input type="number" id="cfgHistory" min="1" max="300" step="1" />
</label>
<label>② 详情刷新冷却(秒)— 领取中的包多久再拉名单
<input type="number" id="cfgDetail" min="1" max="300" step="1" />
</label>
<label>③ 相同名单去重(秒)— 名单没变时防重复推送
<input type="number" id="cfgDedup" min="0" max="120" step="1" />
</label>
<label>④ 详情请求间隔(毫秒)— 连拉多个包之间的停顿
<input type="number" id="cfgGap" min="0" max="5000" step="10" />
</label>
<label>页面轮询(毫秒)
<input type="number" id="cfgPagePoll" min="500" max="30000" step="100" />
</label>
<label class="check"><input type="checkbox" id="cfgOpenHistory" /> 无模板时自动打开历史页</label>
<label class="check"><input type="checkbox" id="cfgBounceHistory" /> 检测不到连接时:自动进历史页再返回(重建连接)</label>
</div>
<div class="settings-actions">
<button type="button" class="chip" onclick="applyPreset('fast')">极速</button>
<button type="button" class="chip" onclick="applyPreset('normal')">标准</button>
<button type="button" class="chip" onclick="applyPreset('slow')">省流</button>
<button type="button" class="btn primary" onclick="saveSettings()">保存</button>
<span class="stat" id="cfgStatus"></span>
</div>
<div class="settings-hint">不懂就选「标准」再保存。约 10 秒后对手机 Hook 生效。</div>
</div>
<div class="layout">
<aside class="sidebar">
<h2>红包列表</h2>
<div id="list"></div>
</aside>
<main class="main">
<div id="empty" class="empty">暂无数据。打开手机红包领取台会自动同步到这里。</div>
<div id="panel" style="display:none">
<div class="title-row">
<h2 class="title" id="title">-</h2>
<div class="title-money" id="titleMoney">RM 0.00</div>
</div>
<div class="sub" id="sub">-</div>
<div class="kpi">
<div class="box"><div class="k">领取人数</div><div class="v" id="cPeople">0</div></div>
<div class="box"><div class="k">领取合计</div><div class="v money" id="cSum">0.00</div></div>
<div class="box"><div class="k">红包总额</div><div class="v money" id="cTotal">-</div></div>
<div class="box"><div class="k">状态</div><div class="v" id="cStatus">-</div></div>
</div>
<div class="luck-grid">
<div class="luck-card best">
<div class="lab" id="luckBestLab">手气最佳</div>
<div class="name" id="luckBestName">-</div>
<div class="amt" id="luckBestAmt">-</div>
</div>
<div class="luck-card worst">
<div class="lab" id="luckWorstLab">手气最差</div>
<div class="name" id="luckWorstName">-</div>
<div class="amt" id="luckWorstAmt">-</div>
</div>
</div>
<div class="section">领取排行</div>
<table>
<thead><tr><th class="rank">#</th><th>昵称 / ID</th><th>金额</th><th>时间</th></tr></thead>
<tbody id="board"></tbody>
</table>
</div>
</main>
</div>
<script>
let packets = [], activeId = null, pagePollTimer = null;
let statusFilter = 'all', timeFilter = 'all', keyword = '';
let settings = { historyCooldownSec: 8, detailCooldownSec: 8, dedupSec: 3, detailGapMs: 80, pagePollMs: 1000, openHistoryIfNoTemplate: true, autoBounceHistoryOnDisconnect: true };
function toggleSettings(){ document.getElementById('settingsPanel').classList.toggle('open'); }
function fillSettingsForm(){
cfgHistory.value = settings.historyCooldownSec; cfgDetail.value = settings.detailCooldownSec;
cfgDedup.value = settings.dedupSec; cfgGap.value = settings.detailGapMs;
cfgPagePoll.value = settings.pagePollMs; cfgOpenHistory.checked = !!settings.openHistoryIfNoTemplate;
cfgBounceHistory.checked = settings.autoBounceHistoryOnDisconnect !== false;
cfgHint.textContent = '刷新: 历史' + settings.historyCooldownSec + 's / 详情' + settings.detailCooldownSec + 's';
}
function readSettingsForm(){
return {
historyCooldownSec: Number(cfgHistory.value), detailCooldownSec: Number(cfgDetail.value),
dedupSec: Number(cfgDedup.value), detailGapMs: Number(cfgGap.value),
pagePollMs: Number(cfgPagePoll.value), openHistoryIfNoTemplate: cfgOpenHistory.checked,
autoBounceHistoryOnDisconnect: cfgBounceHistory.checked
};
}
function applyPreset(name){
if (name==='fast') settings={historyCooldownSec:5,detailCooldownSec:5,dedupSec:2,detailGapMs:50,pagePollMs:800,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
else if (name==='slow') settings={historyCooldownSec:30,detailCooldownSec:45,dedupSec:15,detailGapMs:200,pagePollMs:3000,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
else settings={historyCooldownSec:8,detailCooldownSec:8,dedupSec:3,detailGapMs:80,pagePollMs:1000,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
fillSettingsForm();
}
async function loadSettings(){ settings = await (await fetch('/api/mmp/settings')).json(); fillSettingsForm(); restartPagePoll(); }
async function saveSettings(){
settings = await (await fetch('/api/mmp/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(readSettingsForm())})).json();
fillSettingsForm(); restartPagePoll(); cfgStatus.textContent = '已保存 ' + new Date().toLocaleTimeString();
}
function restartPagePoll(){ if(pagePollTimer) clearInterval(pagePollTimer); pagePollTimer=setInterval(loadData, Math.max(500, Number(settings.pagePollMs)||1000)); }
function setStatus(s){
statusFilter=s;
['fAll','fOpen','fDone'].forEach(id=>document.getElementById(id).classList.remove('active'));
document.getElementById(s==='all'?'fAll':s==='open'?'fOpen':'fDone').classList.add('active');
renderList();
}
function setTime(t){
timeFilter=t;
['tAll','t1','t3','t7','tCustom'].forEach(id=>document.getElementById(id).classList.remove('active'));
document.getElementById({all:'tAll','1':'t1','3':'t3','7':'t7',custom:'tCustom'}[t]).classList.add('active');
customRange.classList.toggle('open', t==='custom');
renderList();
}
function onFilterChange(){ keyword=(filterQ.value||'').trim().toLowerCase(); renderList(); }
function parseIssueMs(text){
const s=(text||'').trim(); if(!s) return null;
let m=s.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?/);
if(m) return new Date(+m[3],+m[2]-1,+m[1],+(m[4]||0),+(m[5]||0),+(m[6]||0)).getTime();
m=s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if(m) return new Date(+m[1],+m[2]-1,+m[3]).getTime();
const t=Date.parse(s); return Number.isNaN(t)?null:t;
}
function inTimeRange(p){
if(timeFilter==='all') return true;
const ms=parseIssueMs(p.issuedAt||p.latestAt||p.updatedAt||p.fetchedAt||'');
if(ms==null) return false;
const now=Date.now();
if(timeFilter==='1'||timeFilter==='3'||timeFilter==='7') return ms>=now-Number(timeFilter)*86400000;
if(timeFilter==='custom'){
const from=dateFrom.value, to=dateTo.value;
if(from){ const fromMs=new Date(from+'T00:00:00').getTime(); if(ms<fromMs) return false; }
if(to){ const toMs=new Date(to+'T00:00:00').getTime()+86400000-1; if(ms>toMs) return false; }
}
return true;
}
function filteredPackets(){
return packets.filter(p=>{
if(statusFilter==='open'&&p.finished) return false;
if(statusFilter==='done'&&!p.finished) return false;
if(!inTimeRange(p)) return false;
if(!keyword) return true;
const blob=[p.packetId,p.sender,p.title,p.group,p.total,p.bestNick,p.worstNick,...(p.leaderboard||[]).map(r=>[r.nickname,r.userId,r.poolId].join(' '))].join(' ').toLowerCase();
return blob.indexOf(keyword)>=0;
});
}
function money(v){ const n=Number(v); return Number.isFinite(n)?n.toFixed(2):String(v??'-'); }
function shortId(id){ const s=String(id||''); return (s.length>=8&&s.indexOf('-')>0)?s.slice(0,8):(s.slice(0,16)||'-'); }
function formatHiLo(prefix, row){
if(!row) return prefix + ' -';
const nick = row.nickname || row.bestNick || '-';
const amt = row.amountText || row.bestAmount || (row.amount!=null ? money(row.amount) : '');
const uid = row.userId || '';
if(uid) return `${prefix} ${esc(nick)} ${esc(amt)}<div class="pkt-uid">ID ${esc(uid)}</div>`;
return `${prefix} ${esc(nick)} ${esc(amt)}`.trim();
}
function hiLo(p){
return {
hi: p&&p.finished?'手气最佳':'目前最高',
lo: p&&p.finished?'手气最差':'目前最低'
};
}
async function loadData(){
const [pktRes, stRes] = await Promise.all([
fetch('/api/mmp'),
fetch('/api/mmp/status')
]);
packets = await pktRes.json();
updated.textContent = '更新 ' + new Date().toLocaleTimeString();
try {
const st = await stRes.json();
renderConnBar(st);
} catch (e) {
renderConnBar({ text: '模块状态不可用', ok: false });
}
renderList();
}
function renderConnBar(st){
const el = document.getElementById('connBar');
if (!el) return;
el.textContent = (st && st.text) ? st.text : '模块状态未知';
el.className = 'conn-bar ' + (st && st.ok ? 'ok' : (st && st.liveAt ? 'bad' : 'warn'));
}
function renderList(){
const view=filteredPackets();
pktCount.textContent = view.length + '/' + packets.length + ' 个';
list.innerHTML='';
if(!view.length){ empty.style.display='block'; panel.style.display='none'; activeId=null; return; }
empty.style.display='none'; panel.style.display='block';
if(!activeId || !view.find(p=>p.packetId===activeId)) activeId=view[0].packetId;
for(const p of view){
const div=document.createElement('div');
div.className='pkt'+(p.packetId===activeId?' active':'');
div.onclick=()=>{ activeId=p.packetId; renderList(); };
const label = p.statusLabel || (p.finished ? '已领完' : '领取中');
const fully = label.indexOf('已全部领取') >= 0;
const expired = label.indexOf('已过期') >= 0;
const badgeClass = fully ? 'done' : (expired ? 'expired' : 'open');
const badge=`<span class="badge ${badgeClass}">${esc(label)}</span>`;
const hiShort = p.finished || fully ? '最佳' : '最高';
const loShort = p.finished || fully ? '最差' : '最低';
const best = (p.leaderboard && p.leaderboard[0]) || null;
const worst = (p.leaderboard && p.leaderboard.length > 1)
? p.leaderboard[p.leaderboard.length - 1] : null;
const bestLine = formatHiLo(hiShort, best || {nickname:p.bestNick, amountText:p.bestAmount, userId:p.bestUserId});
const worstLine = (p.claimantCount||0)<=1
? `${loShort} -`
: formatHiLo(loShort, worst || {nickname:p.worstNick, amountText:p.worstAmount, userId:p.worstUserId});
const timeLine = p.expiresAt
? `${esc(p.issuedAt||'时间未知')} · 到期 ${esc(p.expiresAt)}`
: esc(p.issuedAt||'时间未知');
const countLine = (p.totalCount > 0)
? `${p.claimedCount||p.claimantCount||0}/${p.totalCount}`
: `${p.claimantCount||0}`;
div.innerHTML=`
<div class="pkt-row">
<div style="min-width:0;flex:1">
<div class="pkt-name">${esc(p.sender||p.title||'红包')}</div>
${p.packetId ? `<div class="pkt-uid">ID ${esc(p.packetId)}</div>` : ''}
</div>
<div class="pkt-money">${money(p.sumClaimed??p.total??0)}</div>
</div>
<div class="pkt-meta">${badge}<span>${countLine} · ${timeLine}</span></div>
<div class="pkt-luck">
<div class="mini best">${bestLine}</div>
<div class="mini worst">${worstLine}</div>
</div>`;
list.appendChild(div);
}
renderActive();
}
function renderActive(){
const p=packets.find(x=>x.packetId===activeId); if(!p) return;
const labels=hiLo(p);
title.textContent = p.sender ? p.sender + ' 的红包' : (p.title || '红包详情');
titleMoney.textContent = 'RM ' + money(p.sumClaimed ?? p.total ?? 0);
const parts=[];
if(p.packetId) parts.push('ID '+p.packetId);
if(p.issuedAt) parts.push('发放 '+p.issuedAt);
if(p.expiresAt) parts.push('到期 '+p.expiresAt);
if(p.via) parts.push(p.via);
sub.textContent = parts.join(' · ');
cPeople.textContent = p.claimantCount || 0;
cSum.textContent = money(p.sumClaimed ?? 0);
cTotal.textContent = p.total || '-';
cStatus.innerHTML = (()=>{
const label = p.statusLabel || (p.finished ? '已领完' : '领取中');
const fully = label.indexOf('已全部领取') >= 0;
const expired = label.indexOf('已过期') >= 0;
const cls = fully ? 'done' : (expired ? 'expired' : 'open');
return `<span class="badge ${cls}">${esc(label)}</span>`;
})();
luckBestLab.textContent = labels.hi;
luckWorstLab.textContent = labels.lo;
const best = (p.leaderboard && p.leaderboard[0]) || null;
const worst = (p.leaderboard && p.leaderboard.length > 1)
? p.leaderboard[p.leaderboard.length - 1] : null;
if(best){
luckBestName.innerHTML = best.userId
? `${esc(best.nickname||'-')}<div class="pkt-uid">ID ${esc(best.userId)}</div>`
: esc(best.nickname||p.bestNick||'-');
luckBestAmt.textContent = best.amountText ? ('RM ' + best.amountText)
: (p.bestAmount ? ('RM ' + p.bestAmount) : '-');
} else {
luckBestName.textContent = p.bestNick || '-';
luckBestAmt.textContent = p.bestAmount ? ('RM ' + p.bestAmount) : '-';
}
if(worst && (p.claimantCount||0)>1){
luckWorstName.innerHTML = worst.userId
? `${esc(worst.nickname||'-')}<div class="pkt-uid">ID ${esc(worst.userId)}</div>`
: esc(worst.nickname||p.worstNick||'-');
luckWorstAmt.textContent = worst.amountText ? ('RM ' + worst.amountText)
: (p.worstAmount ? ('RM ' + p.worstAmount) : '-');
} else {
luckWorstName.textContent='-'; luckWorstAmt.textContent='-';
}
const rows=p.leaderboard||[];
board.innerHTML='';
if(!rows.length){
board.innerHTML='<tr><td colspan="4" style="text-align:center;color:#6b7280">暂无领取记录</td></tr>';
return;
}
const maxAmt = Math.max(...rows.map(r=>Number(r.amount)||0));
const minAmt = Math.min(...rows.map(r=>Number(r.amount)||0));
for(const row of rows){
const amt=Number(row.amount)||0;
const isBest=rows.length>1 && amt===maxAmt;
const isWorst=rows.length>1 && amt===minAmt && maxAmt!==minAmt;
const tr=document.createElement('tr');
if(isBest) tr.className='row-best';
if(isWorst) tr.className='row-worst';
let nick=esc(row.nickname);
if(isBest) nick += '<span class="tag-inline best">最高</span>';
if(isWorst) nick += '<span class="tag-inline worst">最低</span>';
let nickHtml=`<div>${nick}</div>`;
if(row.userId) nickHtml += `<span class="uid">用户ID ${esc(row.userId)}</span>`;
if(row.poolId) nickHtml += `<span class="uid">领取ID ${esc(row.poolId)}</span>`;
tr.innerHTML=`
<td class="rank">${row.rank}</td>
<td class="nick">${nickHtml}</td>
<td class="amt">${esc(row.amountText || money(row.amount))}</td>
<td>${esc(row.claimTime || '-')}</td>`;
board.appendChild(tr);
}
}
async function clearAll(){
if(!confirm('仅清空红包领取台数据?')) return;
await fetch('/api/mmp',{method:'DELETE'}); activeId=null; loadData();
}
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
loadSettings().then(loadData);
</script>
</body>
</html>

View File

@@ -2,7 +2,10 @@
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。""" """notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
import json import json
import os
import re
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
@@ -11,9 +14,96 @@ from urllib.parse import urlparse
HOST = "0.0.0.0" HOST = "0.0.0.0"
PORT = 8765 PORT = 8765
MAX_MESSAGES = 500 MAX_MESSAGES = 500
SETTINGS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_settings.json")
PACKETS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_packets.json")
HOOK_STATUS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_hook_status.json")
# 与 xposed-module / App MmpModuleInfo 同步
EXPECTED_MODULE_CODE = 5
EXPECTED_MODULE_NAME = "1.2.2"
_messages = deque(maxlen=MAX_MESSAGES) _messages = deque(maxlen=MAX_MESSAGES)
_mmp_messages = deque(maxlen=MAX_MESSAGES)
_mmp_synced = {} # packetId -> packet dict手机全量同步落盘
_mmp_hook_status = {
"liveCode": 0,
"liveName": "",
"liveAt": 0, # epoch ms
"installedCode": 0,
"installedName": "",
"source": "",
}
_lock = threading.Lock() _lock = threading.Lock()
_last_dedup = {"key": None, "ts": 0.0}
_mmp_last_dedup = {"key": None, "ts": 0.0}
_mmp_settings_lock = threading.Lock()
_DEFAULT_MMP_SETTINGS = {
"historyCooldownSec": 8,
"detailCooldownSec": 8,
"dedupSec": 3,
"detailGapMs": 80,
"pagePollMs": 1000,
"openHistoryIfNoTemplate": True,
"autoBounceHistoryOnDisconnect": True,
}
_mmp_settings = dict(_DEFAULT_MMP_SETTINGS)
def _load_mmp_settings():
global _mmp_settings
try:
if os.path.isfile(SETTINGS_PATH):
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
merged = dict(_DEFAULT_MMP_SETTINGS)
merged.update(data)
_mmp_settings = _normalize_mmp_settings(merged)
except Exception as e:
print("load mmp settings failed:", e)
def _save_mmp_settings():
try:
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
json.dump(_mmp_settings, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp settings failed:", e)
def _normalize_mmp_settings(raw):
out = dict(_DEFAULT_MMP_SETTINGS)
if not isinstance(raw, dict):
return out
try:
out["historyCooldownSec"] = max(1, min(300, int(raw.get("historyCooldownSec", out["historyCooldownSec"]))))
out["detailCooldownSec"] = max(1, min(300, int(raw.get("detailCooldownSec", out["detailCooldownSec"]))))
out["dedupSec"] = max(0, min(120, int(raw.get("dedupSec", out["dedupSec"]))))
out["detailGapMs"] = max(0, min(5000, int(raw.get("detailGapMs", out["detailGapMs"]))))
out["pagePollMs"] = max(500, min(30000, int(raw.get("pagePollMs", out["pagePollMs"]))))
out["openHistoryIfNoTemplate"] = bool(raw.get(
"openHistoryIfNoTemplate", out["openHistoryIfNoTemplate"]))
out["autoBounceHistoryOnDisconnect"] = bool(raw.get(
"autoBounceHistoryOnDisconnect", out["autoBounceHistoryOnDisconnect"]))
except (TypeError, ValueError):
pass
return out
def _get_mmp_settings():
with _mmp_settings_lock:
return dict(_mmp_settings)
def _set_mmp_settings(raw):
global _mmp_settings
with _mmp_settings_lock:
_mmp_settings = _normalize_mmp_settings(raw)
_save_mmp_settings()
return dict(_mmp_settings)
_load_mmp_settings()
def _now_iso(): def _now_iso():
@@ -32,11 +122,42 @@ 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()
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()
is_mmp = _is_mmp_message(item)
content_for_mod = ""
with _lock: with _lock:
if is_mmp:
if (_mmp_last_dedup["key"] == dedup_key
and now - float(_mmp_last_dedup["ts"]) < 3.0):
if _mmp_messages:
return _mmp_messages[0]
_mmp_last_dedup["key"] = dedup_key
_mmp_last_dedup["ts"] = now
_mmp_messages.appendleft(item)
item["id"] = len(_mmp_messages)
channel = "MMP"
content_for_mod = item.get("content") or ""
else:
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( channel = "MSG"
if content_for_mod:
_note_module_from_content(content_for_mod)
print("[{0}] [{1}] [{2}] [{3}] {4} | {5}".format(
_now_iso(), _now_iso(),
channel,
item["group"], item["group"],
payload.get("source", "?"), payload.get("source", "?"),
payload.get("appName", payload.get("packageName", "")), payload.get("appName", payload.get("packageName", "")),
@@ -45,6 +166,16 @@ def _add_message(payload):
return item return item
def _clear_mmp_messages():
with _lock:
_mmp_messages.clear()
_mmp_synced.clear()
# 兼容:顺带清掉旧版混入通用队列的 MMP
keep = [m for m in _messages if not _is_mmp_message(m)]
_messages.clear()
_messages.extend(keep)
_save_mmp_synced()
def _json_response(handler, status, data): def _json_response(handler, status, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8") body = json.dumps(data, ensure_ascii=False).encode("utf-8")
handler.send_response(status) handler.send_response(status)
@@ -58,6 +189,8 @@ def _json_response(handler, status, data):
def _group_messages(messages): def _group_messages(messages):
groups = {} groups = {}
for msg in messages: for msg in messages:
if _is_mmp_message(msg):
continue
key = msg.get("group") or _resolve_group(msg) key = msg.get("group") or _resolve_group(msg)
if key not in groups: if key not in groups:
groups[key] = { groups[key] = {
@@ -78,6 +211,751 @@ 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"])
# 发放时间issued= / createTime= / issuedAt=
for key in ("issued", "createTime", "issuedAt", "create"):
if meta.get(key):
meta["issued"] = meta[key]
break
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()
user_id = ""
pool_id = ""
if " #rid=" in right:
before, _, rest = right.partition(" #rid=")
right = before.strip()
rid_part = rest.strip()
if " #pool=" in rid_part:
user_id, _, pool_rest = rid_part.partition(" #pool=")
user_id = user_id.strip()
pool_id = pool_rest.strip()
elif " " in rid_part:
user_id = rid_part.split(" ", 1)[0].strip()
else:
user_id = rid_part
if "#pool=" in user_id:
user_id, _, pool_id = user_id.partition("#pool=")
user_id = user_id.strip()
pool_id = pool_id.strip()
if " #pool=" in right and not pool_id:
before, _, pool_rest = right.partition(" #pool=")
right = before.strip()
pool_id = pool_rest.strip()
claim_time = ""
amount_raw = right
if "(" in right and ")" in right and not right.startswith("{"):
amt, _, rest = right.partition("(")
amount_raw = amt.strip()
claim_time = rest.split(")", 1)[0].strip()
amount_num = _normalize_money(amount_raw)
if nick or user_id:
claims.append({
"nickname": nick or "?",
"userId": user_id,
"poolId": pool_id,
"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:
key = c.get("userId") or c.get("nickname") or "?"
rows.append("{0}={1}".format(key, c.get("amount") or "0"))
rows.sort()
return "|".join(rows)
def _aggregate_claims(claims):
"""同一快照内按 userId无则昵称去重取较大金额禁止同人累加翻倍。"""
buckets = {}
order = []
for c in claims:
nick = c.get("nickname") or "?"
uid = (c.get("userId") or "").strip()
key = ("id:" + uid) if uid else ("n:" + nick)
try:
amt = float(c.get("amountValue") if c.get("amountValue") is not None
else _normalize_money(c.get("amount")) or 0.0)
except (TypeError, ValueError):
amt = 0.0
if key not in buckets:
buckets[key] = {
"nickname": nick,
"userId": uid,
"poolId": (c.get("poolId") or "").strip(),
"amount": amt,
"count": 1,
"claimTime": c.get("claimTime") or "",
}
order.append(key)
continue
b = buckets[key]
b["count"] += 1
if uid and not b.get("userId"):
b["userId"] = uid
if c.get("poolId") and not b.get("poolId"):
b["poolId"] = c.get("poolId") or ""
if amt >= b["amount"]:
b["amount"] = amt
if nick and nick != "?":
b["nickname"] = nick
if c.get("claimTime"):
b["claimTime"] = c.get("claimTime") or b["claimTime"]
if uid:
b["userId"] = uid
if c.get("poolId"):
b["poolId"] = c.get("poolId") or b.get("poolId") or ""
elif c.get("claimTime") and not b["claimTime"]:
b["claimTime"] = c.get("claimTime") or ""
result = []
for key in order:
b = buckets[key]
result.append({
"nickname": b["nickname"],
"userId": b.get("userId") or "",
"poolId": b.get("poolId") or "",
"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 _parse_mod_token(mod):
"""mod=1.2.0/3 → (name, code)"""
text = (mod or "").strip()
if not text:
return "", 0
name = text
code = 0
if "/" in text:
name, _, code_part = text.rpartition("/")
name = name.strip()
try:
code = int(code_part.strip())
except (TypeError, ValueError):
code = 0
return name, code
def _note_module_from_content(content):
if not content or "[MMP统计]" not in content:
return
head = content.strip().split("\n", 1)[0]
if "mod=" not in head:
return
raw = head.split("mod=", 1)[1]
for stop in (" | ", " src=", " "):
if stop in raw:
raw = raw.split(stop, 1)[0]
break
name, code = _parse_mod_token(raw)
_update_hook_status({
"liveCode": code,
"liveName": name,
"liveAt": int(time.time() * 1000),
"source": "message",
})
def _load_hook_status():
global _mmp_hook_status
try:
if not os.path.isfile(HOOK_STATUS_PATH):
return
with open(HOOK_STATUS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
with _lock:
_mmp_hook_status.update({
"liveCode": int(data.get("liveCode") or 0),
"liveName": str(data.get("liveName") or ""),
"liveAt": int(data.get("liveAt") or 0),
"installedCode": int(data.get("installedCode") or 0),
"installedName": str(data.get("installedName") or ""),
"source": str(data.get("source") or ""),
})
except Exception as e:
print("load mmp hook status failed:", e)
def _save_hook_status():
try:
with _lock:
data = dict(_mmp_hook_status)
with open(HOOK_STATUS_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp hook status failed:", e)
def _update_hook_status(patch):
if not isinstance(patch, dict):
return
with _lock:
try:
incoming_live_at = int(patch.get("liveAt") or 0)
except (TypeError, ValueError):
incoming_live_at = 0
stale_live = (incoming_live_at > 0
and int(_mmp_hook_status.get("liveAt") or 0) > 0
and incoming_live_at < int(_mmp_hook_status.get("liveAt") or 0))
for k in ("liveCode", "liveName", "liveAt", "installedCode", "installedName", "source"):
if k not in patch:
continue
if stale_live and k in ("liveCode", "liveName", "liveAt", "source"):
continue
# 无心跳时不要用 0 覆盖已有运行版本
if incoming_live_at <= 0 and k in ("liveCode", "liveName", "liveAt"):
continue
val = patch[k]
if k in ("liveCode", "liveAt", "installedCode"):
try:
val = int(val or 0)
except (TypeError, ValueError):
val = 0
else:
val = str(val or "")
if k in ("installedCode",) and val <= 0:
continue
if k in ("installedName",) and not val:
continue
_mmp_hook_status[k] = val
_save_hook_status()
def _describe_hook_status():
with _lock:
st = dict(_mmp_hook_status)
installed = int(st.get("installedCode") or 0)
installed_name = st.get("installedName") or ""
live_code = int(st.get("liveCode") or 0)
live_name = st.get("liveName") or ""
live_at = int(st.get("liveAt") or 0)
now_ms = int(time.time() * 1000)
if installed > 0:
apk = "模块APK" + (installed_name or str(installed))
if installed < EXPECTED_MODULE_CODE:
apk += "(旧,期望{0})".format(EXPECTED_MODULE_NAME)
else:
apk = "模块APK未知等手机同步"
age = (now_ms - live_at) if live_at > 0 else -1
if age < 0:
live = "运行中:未检测到(请强停再开 TNG"
ok = False
elif age > 3 * 60 * 1000:
live = "运行中:心跳偏旧({0}分钟前 {1})".format(
age // 60000, live_name or live_code or "?")
ok = False
elif live_code >= EXPECTED_MODULE_CODE:
live = "运行中:最新({0})".format(live_name or live_code)
ok = True
elif live_code > 0:
live = "运行中:旧版({0},期望{1})".format(
live_name or live_code, EXPECTED_MODULE_NAME)
ok = False
else:
live = "运行中:未知"
ok = False
return {
"ok": ok,
"text": apk + " · " + live,
"expectedCode": EXPECTED_MODULE_CODE,
"expectedName": EXPECTED_MODULE_NAME,
"liveCode": live_code,
"liveName": live_name,
"liveAt": live_at,
"installedCode": installed,
"installedName": installed_name,
"ageMs": age,
}
def _packet_score(it):
board = it.get("leaderboard") or it.get("claims") or []
return (
1 if it.get("finished") else 0,
1 if it.get("issuedAt") else 0,
len(board),
it.get("updatedAt") or it.get("fetchedAt") or it.get("latestAt") or "",
)
def _normalize_synced_packet(raw):
if not isinstance(raw, dict):
return None
pid = str(raw.get("packetId") or raw.get("packet") or "").strip()
if not pid:
return None
board = raw.get("leaderboard") or []
if not isinstance(board, list):
board = []
norm_board = []
for i, row in enumerate(board):
if not isinstance(row, dict):
continue
amt = row.get("amount")
if amt is None:
amt = _normalize_money(row.get("amountText"))
try:
amt = float(amt or 0)
except (TypeError, ValueError):
amt = 0.0
nick = (row.get("nickname") or "?").strip() or "?"
norm_board.append({
"nickname": nick,
"userId": (row.get("userId") or "").strip(),
"poolId": (row.get("poolId") or "").strip(),
"amount": round(amt, 4),
"amountText": row.get("amountText") or "{0:.2f}".format(amt),
"claimTime": row.get("claimTime") or "",
"rank": int(row.get("rank") or (i + 1)),
})
norm_board.sort(key=lambda x: x["amount"], reverse=True)
for i, row in enumerate(norm_board, 1):
row["rank"] = i
best = norm_board[0] if norm_board else None
worst = norm_board[-1] if norm_board else None
sum_claimed = raw.get("sumClaimed")
try:
sum_claimed = float(sum_claimed) if sum_claimed is not None else round(
sum(x["amount"] for x in norm_board), 4)
except (TypeError, ValueError):
sum_claimed = round(sum(x["amount"] for x in norm_board), 4)
total = raw.get("total") or ""
total_num = _normalize_money(total)
if total_num is not None:
total = "{0:.2f}".format(total_num)
elif sum_claimed:
total = "{0:.2f}".format(sum_claimed)
issued = raw.get("issuedAt") or raw.get("issued") or ""
expires = raw.get("expiresAt") or raw.get("expire") or raw.get("expireTime") or ""
status = raw.get("status") or raw.get("activityStatus") or ""
finished = bool(raw.get("finished")) or _is_terminal_status(status) or _is_expire_passed(expires)
return {
"packetId": pid,
"title": raw.get("title") or "TNG 红包",
"sender": raw.get("sender") or "",
"group": raw.get("group") or "",
"total": total,
"via": raw.get("via") or "phone-sync",
"issuedAt": issued,
"expiresAt": expires,
"status": status,
"updatedAt": raw.get("updatedAt") or "",
"fetchedAt": raw.get("updatedAt") or raw.get("fetchedAt") or _now_iso(),
"latestAt": issued or raw.get("updatedAt") or "",
"finished": finished,
"snapshots": int(raw.get("snapshots") or 1),
"claimedCount": int(raw.get("claimedCount") or 0),
"totalCount": int(raw.get("totalCount") or 0),
"leaderboard": norm_board,
"claimantCount": len(norm_board) if norm_board else int(raw.get("claimantCount") or 0),
"sumClaimed": sum_claimed,
"bestNick": (best or {}).get("nickname") or "",
"bestAmount": (best or {}).get("amountText") or "",
"worstNick": (worst or {}).get("nickname") or "",
"worstAmount": (worst or {}).get("amountText") or "",
"fromSync": True,
}
def _load_mmp_synced():
global _mmp_synced
try:
if not os.path.isfile(PACKETS_PATH):
return
with open(PACKETS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
items = data.get("packets") if isinstance(data, dict) else data
if not isinstance(items, list):
return
synced = {}
for raw in items:
p = _normalize_synced_packet(raw)
if p:
synced[p["packetId"]] = p
with _lock:
_mmp_synced = synced
print("loaded mmp synced packets:", len(synced))
except Exception as e:
print("load mmp packets failed:", e)
def _save_mmp_synced():
try:
with _lock:
packets = list(_mmp_synced.values())
with open(PACKETS_PATH, "w", encoding="utf-8") as f:
json.dump({"packets": packets, "savedAt": _now_iso()}, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp packets failed:", e)
def _sync_mmp_packets(payload):
"""手机全量同步:合并进落盘镜像,电脑刷新 /mmp 即可看到。"""
items = []
if isinstance(payload, dict):
items = payload.get("packets") or []
hook = payload.get("hookStatus")
if isinstance(hook, dict):
_update_hook_status({
"liveCode": hook.get("liveCode") or hook.get("moduleVersionCode") or 0,
"liveName": hook.get("liveName") or hook.get("moduleVersionName") or "",
"liveAt": hook.get("liveAt") or hook.get("updatedAt") or int(time.time() * 1000),
"installedCode": hook.get("installedCode") or 0,
"installedName": hook.get("installedName") or "",
"source": "phone-sync",
})
elif isinstance(payload, list):
items = payload
if not isinstance(items, list):
return {"ok": False, "error": "packets must be list", "count": 0}
merged = 0
with _lock:
for raw in items:
p = _normalize_synced_packet(raw)
if not p:
continue
key = p["packetId"]
old = _mmp_synced.get(key)
if old is None or _packet_score(p) >= _packet_score(old):
if old and old.get("finished"):
p["finished"] = True
if old and not p.get("issuedAt") and old.get("issuedAt"):
p["issuedAt"] = old["issuedAt"]
p["latestAt"] = p["issuedAt"] or p.get("latestAt") or ""
_mmp_synced[key] = p
merged += 1
total = len(_mmp_synced)
_save_mmp_synced()
return {"ok": True, "count": merged, "total": total}
def _mmp_packets():
with _lock:
# 独立队列 + 兼容旧版混入通用消息的 MMP
msgs = list(_mmp_messages) + [m for m in _messages if _is_mmp_message(m)]
# 每个红包只保留「最新一次完整领取榜」快照
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)
issued = meta.get("issued") or ""
finished = str(meta.get("done") or "").lower() in ("1", "true") \
or _is_terminal_status(meta.get("status") or "") \
or _is_expire_passed(meta.get("expire") or meta.get("expireTime") or "")
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 "",
"issuedAt": issued,
"expiresAt": meta.get("expire") or meta.get("expireTime") or "",
"status": meta.get("status") or "",
"fetchedAt": msg.get("receivedAt") or "",
"latestAt": issued or (msg.get("receivedAt") or ""),
"finished": finished,
"snapshots": 1,
"claims": claims,
}
_apply_counts_from_meta(item, meta)
existing = packets.get(key)
def _score(it):
return (
1 if it.get("issuedAt") else 0,
len(it.get("claims") or []),
it.get("fetchedAt") or "",
)
if existing is None or _score(item) >= _score(existing):
if existing is not None:
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
if not item.get("issuedAt") and existing.get("issuedAt"):
item["issuedAt"] = existing["issuedAt"]
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
if existing.get("finished"):
item["finished"] = True
packets[key] = item
else:
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
if not existing.get("issuedAt") and issued:
existing["issuedAt"] = issued
existing["latestAt"] = issued
if finished:
existing["finished"] = True
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)
best = ranked[0] if ranked else None
worst = ranked[-1] if ranked else None
p["bestNick"] = (best or {}).get("nickname") or ""
p["bestAmount"] = (best or {}).get("amountText") or ""
p["worstNick"] = (worst or {}).get("nickname") or ""
p["worstAmount"] = (worst or {}).get("amountText") or ""
if not p.get("claimedCount"):
p["claimedCount"] = len(ranked)
if p.get("totalCount") and int(p.get("claimedCount") or 0) >= int(p.get("totalCount") or 0):
p["finished"] = True
del p["claims"]
result.append(p)
# 合并手机全量同步落盘数据(电脑重启后仍可看)
with _lock:
synced_items = list(_mmp_synced.values())
by_id = {p["packetId"]: p for p in result}
for sp in synced_items:
key = sp.get("packetId")
if not key:
continue
cur = by_id.get(key)
if cur is None or _packet_score(sp) >= _packet_score(cur):
merged = dict(sp)
if cur and cur.get("finished"):
merged["finished"] = True
if cur and not merged.get("issuedAt") and cur.get("issuedAt"):
merged["issuedAt"] = cur["issuedAt"]
merged["latestAt"] = merged["issuedAt"]
by_id[key] = merged
result = list(by_id.values())
for p in result:
_refresh_packet_closed(p)
p["statusLabel"] = _packet_status_label(p)
result.sort(key=lambda x: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
return result
def _mmp_time_sort_key(text):
"""把 13/07/2026 11:54:44 / ISO 等统一成可比较字符串。"""
s = (text or "").strip()
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}:\d{2}(?::\d{2})?))?", s)
if m:
return "{0}-{1}-{2} {3}".format(m.group(3), m.group(2), m.group(1), m.group(4) or "")
return s
def _parse_mmp_time_ms(text):
s = (text or "").strip()
if not s:
return 0
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?", s)
if m:
try:
dt = datetime(
int(m.group(3)), int(m.group(2)), int(m.group(1)),
int(m.group(4) or 23), int(m.group(5) or 59), int(m.group(6) or 59))
return int(dt.timestamp() * 1000)
except ValueError:
return 0
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?", s)
if m:
try:
dt = datetime(
int(m.group(1)), int(m.group(2)), int(m.group(3)),
int(m.group(4) or 0), int(m.group(5) or 0), int(m.group(6) or 0))
return int(dt.timestamp() * 1000)
except ValueError:
return 0
if re.match(r"^\d{13}$", s):
return int(s)
if re.match(r"^\d{10}$", s):
return int(s) * 1000
return 0
def _is_terminal_status(status):
s = (status or "").upper()
if not s:
return False
return any(k in s for k in (
"FINISH", "COMPLETE", "EXPIRE", "ENDED", "CLOSED", "CANCEL", "DONE"))
def _is_expire_passed(expire_text):
ms = _parse_mmp_time_ms(expire_text)
return ms > 0 and int(time.time() * 1000) >= ms
def _refresh_packet_closed(p):
if not isinstance(p, dict):
return p
if p.get("finished"):
return p
if _is_terminal_status(p.get("status") or "") or _is_expire_passed(
p.get("expiresAt") or p.get("expire") or ""):
p["finished"] = True
return p
def _packet_status_label(p):
_refresh_packet_closed(p)
claimed = int(p.get("claimedCount") or 0)
total = int(p.get("totalCount") or 0)
board_n = len(p.get("leaderboard") or []) if isinstance(p.get("leaderboard"), list) else 0
claimed = max(claimed, board_n, int(p.get("claimantCount") or 0))
fully = total > 0 and claimed >= total
if fully:
progress = "已全部领取"
elif total > 0:
progress = "已领取 {0}/{1}".format(claimed, total)
elif claimed > 0:
progress = "已领取 {0}".format(claimed)
else:
progress = "未领取" if p.get("finished") else "领取中"
expired = _is_expire_passed(p.get("expiresAt") or "") or any(
k in (p.get("status") or "").upper() for k in ("EXPIRE", "ENDED", "CLOSED", "CANCEL"))
if expired and not fully:
return progress + " · 已过期"
return progress
def _apply_counts_from_meta(item, meta):
counts = (meta.get("counts") or "").strip()
if counts and "/" in counts:
left, right = counts.split("/", 1)
try:
item["claimedCount"] = int(left.strip() or 0)
except ValueError:
pass
try:
item["totalCount"] = int(right.strip() or 0)
except ValueError:
pass
board = item.get("claims") or item.get("leaderboard") or []
if not item.get("claimedCount") and isinstance(board, list):
item["claimedCount"] = len(board)
if item.get("totalCount") and item.get("claimedCount", 0) >= item["totalCount"]:
item["finished"] = True
def _mmp_html_page(filename="mmp_page.html"):
page = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
try:
with open(page, "r", encoding="utf-8") as f:
return f.read().encode("utf-8")
except Exception as e:
body = "<h1>%s missing</h1><pre>%s</pre>" % (filename, e)
return body.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 +969,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 +997,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 +1118,22 @@ 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("mmp_page.html")
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 == "/mmp/help":
body = _mmp_html_page("mmp_help.html")
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 +1144,15 @@ 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 == "/api/mmp/status":
_json_response(self, 200, _describe_hook_status())
return
if path == "/api/mmp/settings":
_json_response(self, 200, _get_mmp_settings())
return
if path == "/health": if path == "/health":
_json_response(self, 200, {"ok": True}) _json_response(self, 200, {"ok": True})
return return
@@ -254,6 +1160,28 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
path = urlparse(self.path).path path = urlparse(self.path).path
if path == "/api/mmp/settings":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except ValueError:
_json_response(self, 400, {"error": "invalid json"})
return
_json_response(self, 200, _set_mmp_settings(payload))
return
if path == "/api/mmp/sync":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except ValueError:
_json_response(self, 400, {"error": "invalid json"})
return
result = _sync_mmp_packets(payload)
print("[{0}] MMP sync from phone: {1}".format(_now_iso(), result))
_json_response(self, 200 if result.get("ok") else 400, result)
return
if path not in ("/api/messages", "/api/debug/push", "/api/bills/app-upload"): if path not in ("/api/messages", "/api/debug/push", "/api/bills/app-upload"):
_json_response(self, 404, {"error": "not found"}) _json_response(self, 404, {"error": "not found"})
return return
@@ -287,20 +1215,36 @@ class Handler(BaseHTTPRequestHandler):
def do_DELETE(self): def do_DELETE(self):
path = urlparse(self.path).path path = urlparse(self.path).path
if path == "/api/mmp":
_clear_mmp_messages()
_json_response(self, 200, {"ok": True})
return
if path != "/api/messages": if path != "/api/messages":
_json_response(self, 404, {"error": "not found"}) _json_response(self, 404, {"error": "not found"})
return return
with _lock: with _lock:
# 只清通用消息,保留红包独立队列
keep = [m for m in _messages if _is_mmp_message(m)]
_messages.clear() _messages.clear()
# 旧数据里的 MMP 迁入独立队列
for m in keep:
_mmp_messages.appendleft(m)
_json_response(self, 200, {"ok": True}) _json_response(self, 200, {"ok": True})
_load_mmp_synced()
_load_hook_status()
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("手机同步: POST /api/mmp/sync → 落盘 mmp_packets.json电脑打开 /mmp 即可查看")
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,171 @@
# TNG Money Packet 领取台说明
> 更新2026-08-04
> 相关代码:`TngMoneyPacketHook.java`、`debug-server/server.py`、`AppConfig.DEBUG_SERVER_URLS`
## 目标
抓取 TNG eWallet **Money Packet红包** 的领取排行:谁领了、领了多少,并在 PC 调试台按**单个红包**展示。
## 手机端领取台
主 App 首页有 **红包领取台(独立)** 入口(`MmpClaimActivity`
- Hook 收到 `[MMP统计]` 后写入本地 `MmpPacketStore`**不进入**通用「情况」监听台、也不走正式上传
- 手机数据可同步到 PC打开领取台会自动同步也可点「同步电脑」PC 打开 http://127.0.0.1:8765/mmp 即可看(落盘 `mmp_packets.json`,重启调试台不丢)
- PC 调试台红包数据走独立队列,与 `/` 通用消息台隔离
- 列表支持筛选(全部/领取中/已领完 + 关键词)、详情/卡片显示手气(领完)或目前最高/最低(领取中)
- 点进看领取排行;支持清空(仅红包)
- 需监听列表勾选 TNG且 Xposed 模块生效
- **功能说明页**:手机顶栏「说明」→ `MmpHelpActivity`;电脑 http://127.0.0.1:8765/mmp/help
PC 页 http://127.0.0.1:8765/mmp 手机浏览器也可访问USB reverse 或局域网 IP
---
## 页面与地址
| 入口 | 地址 |
|------|------|
| 本机USB + `adb reverse` | http://127.0.0.1:8765/mmp |
| 功能说明 | http://127.0.0.1:8765/mmp/help |
| 局域网 / 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 并完成登录 / PIN任意前台页面即可
2. Hook 从 `ILoginStorage` / 历史请求自动取 `sessionId``Activity.onResume` 按设置冷却主动调 `moneyPacketHistoryList`
3. 历史返回后自动按 `activityId` 串行拉详情(间隔见设置),推送到领取台
4. 仅打开 **Money Packet 历史页**时App 自己的列表响应也会立刻触发步骤 3
5. 打开 http://127.0.0.1:8765/mmp ;点 **设置** 可调刷新速度,保存后约 10 秒内生效
日志期望:
- `hooked ILoginStorage getters` / `login from storage session=…` / `cached history request template`
- `history hit → schedule auto detail``auto-history ok → schedule details`
- `auto-detail ok activityId=… claims=N`
若仍无数据:确认已安装最新 Xposed 模块并强停重启 TNG看是否出现 `auto-detail empty claims` / `no sessionId`
## 去重与刷新行为
冷却时间可在领取台 **设置** 面板调整(`/mmp` → 设置),保存后约 **10 秒内**手机 Hook 生效。默认偏快(历史/详情约 8 秒)。
| 规则 | 说明 |
|------|------|
| 标识 | 以 `activityId` 区分红包 |
| sessionId | 自动:`ILoginStorage` / Repository.loginStorage有真实请求时也会缓存 |
| 自动历史 | 前台 `onResume` 触发;冷却见设置「历史列表冷却」 |
| 短时去重 | 同一 `activityId` + **相同领取名单**,「相同名单去重」秒内不重复推送 |
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后,「详情刷新冷却」内不重复自动拉 |
| 包间间隔 | 自动拉详情串行,「详情请求间隔」毫秒/条 |
| 无模板 | 「无模板时自动打开历史页」可关,避免突然跳转 |
| 调试台 | 同一 `activityId` 只保留最新完整快照;双通道短时内容去重 |
预设:**极速**(约 5s/ **标准**(约 8s/ **省流**(约 3045s
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。
`settings applied hist=…` 表示 Hook 已拉到新配置。
| 已领完停刷 | `remainingCount<=0` / `claimed>=total` / status FINISH·COMPLETE·EXPIRE / 金额凑齐,且**已有领取名单**后不再自动拉该包详情 |
| 断线重连 | session 丢失或 RPC 鉴权/网络失败 → 标记断开session 恢复或 Activity resume 时**强制**重拉历史 |
日志:`packet DONE stop-refresh``session lost``session recovered → force re-fetch`
---
## 排障
### 打开 TNG 闪退
装了 Xposed / 防护相关模块后TNG 偶发一打开就闪退,属常见现象:
1. **多点几次**图标再打开(前几次崩、后面能进的情况不少见)
2. 仍不行:系统设置 → 应用 → TNG → **强制停止**,再重新打开
3. 还不行:强制停止后清「缓存」(尽量别清「数据」,否则要重新登录)
4. 推送 / 更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪
5. 连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开
手机 / PC 功能说明页也写了同样步骤(「说明」或 `/mmp/help`)。
```text
adb logcat | findstr TngMmp
```
常见日志:
| 日志 | 含义 |
|------|------|
| `TngMmp installed` | Hook 已加载 |
| `cached RPC task MoneyPacketRpcTask` | 已缓存 RPC 代理 |
| `cached detail request template` | 已缓存手动详情请求(可选加速) |
| `login from storage session=…` | 已从 ILoginStorage 自动取到 session |
| `auto-history ok → schedule details` | 已自动拉历史列表 |
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或服务端空结果 |
| `auto-detail ok activityId=... claims=N` | 自动拉详情成功 |
| `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 |
其它检查:
1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK期望 **1.2.0 / versionCode 3**
2. 强停后再开 TNG使新 Hook 生效;领取台状态栏应显示「运行中:最新」
3. 领取人重名时看详情里的 `ID``receiverId`),不要只看昵称
4. 调试台进程在跑USB 时执行过 `adb reverse`
5. notiMessage 监听列表勾选了 TNGHook 转发依赖主 App 接收广播)
6. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App
## 相关文件
| 文件 | 作用 |
|------|------|
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、轮询领取台设置 |
| `app/.../mmp/MmpHookStatus.java` | 检测 TNG 内 Hook 是否最新模块 |
| `app/.../mmp/MmpClaim.java` | 领取人含 `userId`receiverId |
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp``/api/mmp/settings` |
| `debug-server/mmp_settings.json` | 领取台刷新设置(自动生成) |
| `app/.../AppConfig.java` | `DEBUG_SERVER_URLS` 双地址 |
| `app/.../network/DebugForwarder.java` | 向多个调试地址推送 |
| `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse |

View File

@@ -11,42 +11,26 @@
--- ---
## 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. **sessionId**`ILoginStorage` 自动读取,一般不必再手动点详情
3. Splash 强拉登录不影响「登录后进群点红包」时的抓包,但影响复测效率。 3. **自动拉历史**:前台 Activity resume约 45s 冷却)主动调 `moneyPacketHistoryList`,再级联拉详情。
4. 去重:同一 `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

@@ -10,8 +10,8 @@ android {
applicationId "com.miraclegarden.smsmessage.xposed" applicationId "com.miraclegarden.smsmessage.xposed"
minSdk 24 minSdk 24
targetSdk 34 targetSdk 34
versionCode 2 versionCode 5
versionName "1.1.0" versionName "1.2.2"
} }
buildTypes { buildTypes {

View File

@@ -4,11 +4,18 @@ public final class HookBridge {
public static final String TARGET_APP_PACKAGE = "com.miraclegarden.smsmessage"; public static final String TARGET_APP_PACKAGE = "com.miraclegarden.smsmessage";
public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE"; public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE";
public static final String ACTION_HOOK_STATUS = "com.miraclegarden.smsmessage.action.HOOK_STATUS";
public static final String EXTRA_PACKAGE_NAME = "packageName"; public static final String EXTRA_PACKAGE_NAME = "packageName";
public static final String EXTRA_TITLE = "title"; public static final String EXTRA_TITLE = "title";
public static final String EXTRA_CONTENT = "content"; public static final String EXTRA_CONTENT = "content";
public static final String EXTRA_TIMESTAMP = "timestamp"; public static final String EXTRA_TIMESTAMP = "timestamp";
public static final String EXTRA_SOURCE = "source"; public static final String EXTRA_SOURCE = "source";
public static final String EXTRA_MODULE_VERSION_CODE = "moduleVersionCode";
public static final String EXTRA_MODULE_VERSION_NAME = "moduleVersionName";
public static final String EXTRA_HOST_PACKAGE = "hostPackage";
/** 与 xposed-module/build.gradle 保持同步 */
public static final int MODULE_VERSION_CODE = 5;
public static final String MODULE_VERSION_NAME = "1.2.2";
public static final String SOURCE_XPOSED_SQLITE = "xposed_sqlite"; public static final String SOURCE_XPOSED_SQLITE = "xposed_sqlite";
public static final String SOURCE_XPOSED_WECHAT = "xposed_wechat"; public static final String SOURCE_XPOSED_WECHAT = "xposed_wechat";
public static final String SOURCE_XPOSED_TELEGRAM = "xposed_telegram"; public static final String SOURCE_XPOSED_TELEGRAM = "xposed_telegram";

View File

@@ -33,4 +33,23 @@ public final class HookForwarder {
XposedBridge.log(TAG + " sendBroadcast failed: " + t.getMessage()); XposedBridge.log(TAG + " sendBroadcast failed: " + t.getMessage());
} }
} }
/** 向主 App 汇报当前进程内 Hook 模块版本(用于检测是否最新) */
public static void reportStatus(Context context, String hostPackage, String source) {
if (context == null) {
return;
}
try {
Intent intent = new Intent(HookBridge.ACTION_HOOK_STATUS);
intent.setPackage(HookBridge.TARGET_APP_PACKAGE);
intent.putExtra(HookBridge.EXTRA_HOST_PACKAGE, hostPackage);
intent.putExtra(HookBridge.EXTRA_SOURCE, source);
intent.putExtra(HookBridge.EXTRA_MODULE_VERSION_CODE, HookBridge.MODULE_VERSION_CODE);
intent.putExtra(HookBridge.EXTRA_MODULE_VERSION_NAME, HookBridge.MODULE_VERSION_NAME);
intent.putExtra(HookBridge.EXTRA_TIMESTAMP, System.currentTimeMillis());
context.sendBroadcast(intent);
} catch (Throwable t) {
XposedBridge.log(TAG + " reportStatus failed: " + t.getMessage());
}
}
} }

View File

@@ -1,6 +1,8 @@
package com.miraclegarden.smsmessage.xposed.hook; package com.miraclegarden.smsmessage.xposed.hook;
import android.content.Context; import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils; import android.text.TextUtils;
import com.miraclegarden.smsmessage.xposed.HookBridge; import com.miraclegarden.smsmessage.xposed.HookBridge;
@@ -9,9 +11,14 @@ import com.miraclegarden.smsmessage.xposed.HookForwarder;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.lang.reflect.Array; import java.lang.reflect.Array;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque; import java.util.ArrayDeque;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
@@ -37,8 +44,77 @@ 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 + 相同领取名单短时不重复推(可由领取台设置覆盖) */
private static final long DEFAULT_PACKET_DEDUP_MS = 3_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 DEFAULT_DETAIL_COOLDOWN_MS = 8_000L;
/** activityId → 历史列表里的发放时间 createTime */
private static final java.util.HashMap<String, String> ACTIVITY_ISSUE_TIME =
new java.util.HashMap<>();
/** 已成功拿到领取名单的 activityId */
private static final java.util.HashSet<String> PACKETS_WITH_DATA = new java.util.HashSet<>();
/** 已领完且已有数据 → 不再自动刷详情 */
private static final java.util.HashSet<String> COMPLETED_PACKETS = new java.util.HashSet<>();
/** 自动拉历史列表冷却(无需打开历史页;可由领取台设置覆盖) */
private static final long DEFAULT_HISTORY_COOLDOWN_MS = 8_000L;
private static final long DEFAULT_DETAIL_GAP_MS = 80L;
private static final long SETTINGS_POLL_MS = 10_000L;
/** 与 AppConfig.DEBUG_SERVER_URLS 对齐 */
private static final String[] SETTINGS_BASE_URLS = {
"http://127.0.0.1:8765",
"http://10.151.104.25:8765",
};
private static volatile long sHistoryCooldownMs = DEFAULT_HISTORY_COOLDOWN_MS;
private static volatile long sDetailCooldownMs = DEFAULT_DETAIL_COOLDOWN_MS;
private static volatile long sPacketDedupMs = DEFAULT_PACKET_DEDUP_MS;
private static volatile long sDetailGapMs = DEFAULT_DETAIL_GAP_MS;
private static volatile boolean sOpenHistoryIfNoTemplate = true;
/** 断线/拉历史失败时:自动进一下历史页再返回,重建模板与连接 */
private static volatile boolean sAutoBounceHistoryOnDisconnect = true;
private static volatile long sLastSettingsPollAt;
private static volatile long sLastAutoHistoryAt;
private static volatile boolean sAutoHistoryRunning;
private static volatile long sLastHistoryBounceAt;
private static volatile boolean sBounceHistoryPending;
private static volatile long sLastHookStatusAt;
private static final long HISTORY_BOUNCE_COOLDOWN_MS = 120_000L;
private static final long HISTORY_BOUNCE_FINISH_DELAY_MS = 2_200L;
/** 无模板时最多主动打开历史页次数(进程内),避免一直把用户拽进历史页 */
private static volatile int sNoTemplateOpenCount;
private static final int MAX_NO_TEMPLATE_OPEN = 2;
private static final long HOOK_STATUS_COOLDOWN_MS = 20_000L;
/** 曾拿到过 session之后变空 / RPC 鉴权失败 → 视为断线,恢复后强制重拉 */
private static volatile boolean sHadSession;
private static volatile boolean sSessionDisconnected;
/** 本线程正在主动拉历史,避免 Quake afterHook 再 schedule 一遍 */
private static final ThreadLocal<Boolean> AUTO_HISTORY_SELF = new ThreadLocal<>();
private static volatile Object sMoneyPacketRepository;
private static volatile Object sMoneyPacketRpcTask;
private static volatile ClassLoader sAppClassLoader;
private static volatile String sCachedUserId;
private static volatile String sCachedSessionId;
private static volatile String sCachedLoginId;
/** 真实详情/历史请求模板(有则克隆;无则用 ILoginStorage 自造) */
private static volatile Object sTemplateDetailRequest;
private static volatile Object sTemplateHistoryRequest;
/** 最近一次带登录态的请求(历史/详情),用于给自造详情请求补 session */
private static volatile Object sLoginDonorRequest;
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "TngMmp-auto-detail");
t.setDaemon(true);
return t;
});
private TngMoneyPacketHook() { private TngMoneyPacketHook() {
} }
@@ -46,10 +122,139 @@ 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);
XposedBridge.log(TAG + " installed for " + lpparam.packageName); hookQuakeRpc(lpparam);
hookLoginStorage(lpparam);
hookActivityResumeForAutoHistory(lpparam);
startSettingsPoller();
startAutoHistoryPoller();
reportHookAlive("install");
XposedBridge.log(TAG + " installed for " + lpparam.packageName
+ " mod=" + HookBridge.MODULE_VERSION_NAME
+ "/" + HookBridge.MODULE_VERSION_CODE);
}
/** 按历史冷却定时拉列表 + 心跳,避免无新红包时状态条误报「久无数据」 */
private static void startAutoHistoryPoller() {
Thread t = new Thread(() -> {
while (true) {
try {
reportHookAlive("poll");
scheduleAutoHistory("poll");
long sleepMs = Math.max(5_000L, sHistoryCooldownMs);
Thread.sleep(sleepMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
} catch (Throwable ignored) {
try {
Thread.sleep(Math.max(5_000L, sHistoryCooldownMs));
} catch (InterruptedException ie2) {
Thread.currentThread().interrupt();
return;
}
}
}
}, "TngMmp-auto-hist-poll");
t.setDaemon(true);
t.start();
}
/** 后台轮询领取台 /api/mmp/settings约 10 秒内生效 */
private static void startSettingsPoller() {
Thread t = new Thread(() -> {
while (true) {
try {
pollMmpSettings(true);
Thread.sleep(SETTINGS_POLL_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
} catch (Throwable ignored) {
try {
Thread.sleep(SETTINGS_POLL_MS);
} catch (InterruptedException ie2) {
Thread.currentThread().interrupt();
return;
}
}
}
}, "TngMmp-settings");
t.setDaemon(true);
t.start();
}
private static void pollMmpSettings(boolean force) {
long now = System.currentTimeMillis();
if (!force && now - sLastSettingsPollAt < SETTINGS_POLL_MS) {
return;
}
sLastSettingsPollAt = now;
for (String base : SETTINGS_BASE_URLS) {
if (tryApplySettingsFromUrl(base + "/api/mmp/settings")) {
return;
}
}
}
private static boolean tryApplySettingsFromUrl(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(2500);
conn.setReadTimeout(2500);
conn.setRequestMethod("GET");
conn.setUseCaches(false);
int code = conn.getResponseCode();
if (code != 200) {
return false;
}
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
}
JSONObject json = new JSONObject(sb.toString());
long historyMs = Math.max(1, json.optInt("historyCooldownSec", 8)) * 1000L;
long detailMs = Math.max(1, json.optInt("detailCooldownSec", 8)) * 1000L;
long dedupMs = Math.max(0, json.optInt("dedupSec", 3)) * 1000L;
long gapMs = Math.max(0, json.optInt("detailGapMs", 80));
boolean openHistory = json.optBoolean("openHistoryIfNoTemplate", true);
boolean bounceHistory = json.optBoolean("autoBounceHistoryOnDisconnect", true);
boolean changed = historyMs != sHistoryCooldownMs
|| detailMs != sDetailCooldownMs
|| dedupMs != sPacketDedupMs
|| gapMs != sDetailGapMs
|| openHistory != sOpenHistoryIfNoTemplate
|| bounceHistory != sAutoBounceHistoryOnDisconnect;
sHistoryCooldownMs = historyMs;
sDetailCooldownMs = detailMs;
sPacketDedupMs = dedupMs;
sDetailGapMs = gapMs;
sOpenHistoryIfNoTemplate = openHistory;
sAutoBounceHistoryOnDisconnect = bounceHistory;
if (changed) {
XposedBridge.log(TAG + " settings applied hist=" + (historyMs / 1000)
+ "s detail=" + (detailMs / 1000) + "s dedup=" + (dedupMs / 1000)
+ "s gap=" + gapMs + "ms openHist=" + openHistory
+ " bounceHist=" + bounceHistory
+ " from " + urlStr);
}
return true;
} catch (Throwable ignored) {
return false;
} finally {
if (conn != null) {
conn.disconnect();
}
}
} }
private static void hookOkHttpUrl(XC_LoadPackage.LoadPackageParam lpparam) { private static void hookOkHttpUrl(XC_LoadPackage.LoadPackageParam lpparam) {
@@ -167,11 +372,1591 @@ 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 封装 + Quake 底层都挂,避免历史/详情只走其中一条链路时漏抓
String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
};
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.hasThrowable() || param.getResult() == null) {
return;
}
cacheMoneyPacketRpcTask(param.args);
cacheLoginFromInvokeArgs(param.args);
String op = extractMoneyPacketRpcOp(param.args);
if (op == null) {
return;
}
XposedBridge.log(TAG + " rpc op=" + op
+ " via=" + param.thisObject.getClass().getSimpleName());
Object result = param.getResult();
// 历史列表:无领取排行,但可用 activityId 自动拉详情
if (isHistoryRpcOp(op)) {
Object historyReq = extractRpcRequestArg(param.args);
if (historyReq != null) {
sTemplateHistoryRequest = cloneRequestShallow(historyReq);
sLoginDonorRequest = historyReq;
cacheLoginFromRequestObject(historyReq);
sNoTemplateOpenCount = 0;
XposedBridge.log(TAG + " cached history request template"
+ " session=" + abbreviate(stringField(historyReq,
"sessionId", "getSessionId")));
}
// 无论是否自己触发的历史请求,都先缓存发放时间
cacheIssueTimesFromHistory(result);
if (Boolean.TRUE.equals(AUTO_HISTORY_SELF.get())) {
return;
}
XposedBridge.log(TAG + " history hit → schedule auto detail");
scheduleAutoDetailsFromHistory(result);
return;
}
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);
enrichSnapshotFromJavaResult(result, probe);
if (probe != null && (!probe.claims.isEmpty() || snapshotHasPacketMeta(probe))) {
if (detailReq != null) {
sTemplateDetailRequest = cloneRequestShallow(detailReq);
sLoginDonorRequest = 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"));
}
forwardSnapshot(probe, op, "rpc");
} else {
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
}
} 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());
}
}
hookMoneyPacketRepository(lpparam);
}
/** DI 创建 Repository 时立刻缓存 task + login不必等用户进红包页 */
private static void hookMoneyPacketRepository(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> repoCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.repo.MoneyPacketRepositoryImpl",
lpparam.classLoader);
XposedBridge.hookAllConstructors(repoCls, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
captureRepoInstance(param.thisObject, "ctor");
}
});
// Dagger 可能先无参构造再注入字段;在业务方法入口再捕获一次
for (String methodName : new String[]{
"getMoneyPacketList", "getMoneyPacketDetail", "doClaim", "preClaim"}) {
try {
for (Method m : repoCls.getDeclaredMethods()) {
if (!methodName.equals(m.getName())) {
continue;
}
XposedBridge.hookMethod(m, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
captureRepoInstance(param.thisObject, "method:" + methodName);
}
});
}
} catch (Throwable ignored) {
}
}
XposedBridge.log(TAG + " hooked MoneyPacketRepositoryImpl");
} catch (Throwable t) {
XposedBridge.log(TAG + " repo hook skip: " + t.getMessage());
}
}
private static void captureRepoInstance(Object repo, String reason) {
if (repo == null) {
return;
}
try {
sMoneyPacketRepository = repo;
Object task = null;
try {
task = XposedHelpers.getObjectField(repo, "task");
} catch (Throwable ignored) {
}
if (task != null && sMoneyPacketRpcTask == null) {
sMoneyPacketRpcTask = task;
XposedBridge.log(TAG + " cached RPC task from repo (" + reason + ")");
}
Object loginStorage = null;
try {
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
} catch (Throwable ignored) {
}
applyLoginStorage(loginStorage);
if (sMoneyPacketRpcTask != null && !TextUtils.isEmpty(sCachedSessionId)) {
scheduleAutoHistory("repo-" + reason);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " captureRepo fail: " + t.getMessage());
}
}
/** 与 App 一致:发 RPC 前走 Repository.addNetworkHandle否则易 RpcApiException / 空结果 */
private static void prepareRpcRequest(Object req) {
if (req == null) {
return;
}
Object repo = sMoneyPacketRepository;
if (repo == null) {
repo = obtainMoneyPacketRepository(sAppClassLoader);
if (repo != null) {
sMoneyPacketRepository = repo;
}
}
if (repo == null) {
XposedBridge.log(TAG + " prepareRpc skip: no repository");
return;
}
try {
// addNetworkHandle$default(repo, request, processor=null, onError=null, mask=3, marker=null)
XposedHelpers.callStaticMethod(repo.getClass(), "addNetworkHandle$default",
repo, req, null, null, 3, null);
XposedBridge.log(TAG + " prepareRpc addNetworkHandle$default ok");
return;
} catch (Throwable t) {
XposedBridge.log(TAG + " addNetworkHandle$default fail: " + t.getMessage());
}
try {
XposedHelpers.callMethod(repo, "addNetworkHandle", req, null, null);
XposedBridge.log(TAG + " prepareRpc addNetworkHandle ok");
} catch (Throwable t) {
XposedBridge.log(TAG + " addNetworkHandle fail: " + t.getMessage());
}
}
private static boolean isHistoryRpcOp(String op) {
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);
scheduleAutoHistory("rpc-task-cached");
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");
scheduleAutoHistory("rpc-task-cached");
} 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);
}
// 尽量一次拉全领取名单(避免默认 20 条截断)
bumpDetailPageSize(req);
}
private static void bumpDetailPageSize(Object req) {
if (req == null) {
return;
}
try {
XposedHelpers.setIntField(req, "page", 0);
} catch (Throwable ignored) {
}
try {
XposedHelpers.setIntField(req, "maxResult", 200);
} catch (Throwable ignored) {
try {
XposedHelpers.setObjectField(req, "maxResult", 200);
} catch (Throwable ignored2) {
}
}
}
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;
}
pollMmpSettings(false);
// 解析放调用线程;真正 RPC 必须丢到主线程Quake/登录态常绑主线程)
final List<String[]> jobs;
try {
jobs = extractHistoryJobs(historyResult);
} catch (Throwable t) {
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
return;
}
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
if (jobs.isEmpty()) {
return;
}
long delayMs = 0L;
for (String[] job : jobs) {
final String activityId = job[0];
final String senderUserId = job[1];
final String createTime = job.length > 2 ? job[2] : null;
if (!TextUtils.isEmpty(createTime)) {
synchronized (ACTIVITY_ISSUE_TIME) {
ACTIVITY_ISSUE_TIME.put(activityId, createTime);
}
}
long now = System.currentTimeMillis();
long detailCooldown = sDetailCooldownMs;
if (COMPLETED_PACKETS.contains(activityId)) {
continue;
}
// job[3]=remainingCount, job[4]=claimedCount/totalCount hint, job[5]=status
String remainHint = job.length > 3 ? job[3] : null;
String countHint = job.length > 4 ? job[4] : null;
String statusHint = job.length > 5 ? job[5] : null;
if (isFinishedFlags(remainHint, countHint, statusHint)
&& PACKETS_WITH_DATA.contains(activityId)) {
COMPLETED_PACKETS.add(activityId);
XposedBridge.log(TAG + " skip completed(history) id=" + activityId);
continue;
}
synchronized (AUTO_DETAIL_AT) {
Long last = AUTO_DETAIL_AT.get(activityId);
if (last != null && now - last < detailCooldown) {
continue;
}
if (!AUTO_DETAIL_PENDING.add(activityId)) {
continue;
}
}
final long thisDelay = delayMs;
delayMs += Math.max(0L, sDetailGapMs);
AUTO_DETAIL_EXEC.execute(() -> {
try {
if (thisDelay > 0) {
Thread.sleep(thisDelay);
}
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
if (ok) {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
}
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_PENDING.remove(activityId);
}
}
});
}
}
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");
String createTime = stringField(info, "createTime", "getCreateTime");
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
String remain = firstNonEmpty(
stringField(info, "remainingCount", "getRemainingCount"),
stringField(info, "remainCount", "getRemainCount"));
String claimed = stringField(info, "claimedCount", "getClaimedCount");
String totalCnt = stringField(info, "totalCount", "getTotalCount");
String countHint = "";
if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
countHint = nullToEmpty(claimed) + "/" + nullToEmpty(totalCnt);
}
String status = firstNonEmpty(
stringField(info, "activityStatus", "getActivityStatus"),
stringField(info, "participantStatus", "getParticipantStatus"),
stringField(info, "status", "getStatus"),
boolFieldText(info, "isFinished", "isFinished"));
jobs.add(new String[]{
activityId,
senderUserId,
TextUtils.isEmpty(createTime) ? "" : createTime,
nullToEmpty(remain),
countHint,
nullToEmpty(status)
});
}
}
} 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 String boolFieldText(Object obj, String field, String getter) {
try {
Object v = XposedHelpers.callMethod(obj, getter);
if (v instanceof Boolean) {
return Boolean.TRUE.equals(v) ? "FINISHED" : null;
}
} catch (Throwable ignored) {
}
try {
Object v = XposedHelpers.getObjectField(obj, field);
if (v instanceof Boolean && Boolean.TRUE.equals(v)) {
return "FINISHED";
}
} catch (Throwable ignored) {
}
return null;
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}
/** remaining / claimed/total / status 任一表明已领完或已结束 */
private static boolean isFinishedFlags(String remain, String claimedSlashTotal, String status) {
if (!TextUtils.isEmpty(remain)) {
try {
if (Integer.parseInt(remain.trim()) <= 0) {
return true;
}
} catch (NumberFormatException ignored) {
}
}
if (!TextUtils.isEmpty(claimedSlashTotal) && claimedSlashTotal.contains("/")) {
String[] parts = claimedSlashTotal.split("/", 2);
try {
int claimed = Integer.parseInt(parts[0].trim());
int total = Integer.parseInt(parts[1].trim());
if (total > 0 && claimed >= total) {
return true;
}
} catch (NumberFormatException ignored) {
}
}
if (!TextUtils.isEmpty(status)) {
String s = status.toUpperCase(Locale.US);
// TNG ActivityStatus: ACTIVE/INIT/FINISHED/ENDED/CLOSED/CANCELLED
if (s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE") || "TRUE".equals(s)) {
return true;
}
}
return false;
}
private static boolean evaluateFinished(MmpSnapshot snapshot) {
if (snapshot == null) {
return false;
}
if (isFinishedFlags(snapshot.remainingCount,
buildCountHint(snapshot.claimedCount, snapshot.totalCount),
snapshot.activityStatus)) {
return true;
}
if (isExpireTimePassed(snapshot.expireTime)) {
return true;
}
// 已领金额 ≈ 总额
Double claimedAmt = parseMoneyDouble(snapshot.claimedAmountText);
Double totalAmt = parseMoneyDouble(snapshot.totalAmount);
if (claimedAmt == null && snapshot.claims != null && !snapshot.claims.isEmpty()) {
double sum = 0;
for (ClaimLine line : snapshot.claims) {
Double a = parseMoneyDouble(line.amount);
if (a != null) {
sum += a;
}
}
claimedAmt = sum;
}
if (claimedAmt != null && totalAmt != null && totalAmt > 0
&& Math.abs(claimedAmt - totalAmt) < 0.02) {
return true;
}
return false;
}
/** expireTime 形如 05/08/2026 14:06:56 或 ISO已过则视为结束 */
private static boolean isExpireTimePassed(String expireTime) {
long ms = parseIssueTimeMs(expireTime);
return ms > 0 && System.currentTimeMillis() >= ms;
}
private static long parseIssueTimeMs(String text) {
if (TextUtils.isEmpty(text)) {
return 0L;
}
String s = text.trim();
try {
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("^(\\d{2})/(\\d{2})/(\\d{4})(?:\\s+(\\d{2}):(\\d{2})(?::(\\d{2}))?)?")
.matcher(s);
if (m.find()) {
java.util.Calendar c = java.util.Calendar.getInstance();
c.set(Integer.parseInt(m.group(3)),
Integer.parseInt(m.group(2)) - 1,
Integer.parseInt(m.group(1)),
m.group(4) != null ? Integer.parseInt(m.group(4)) : 23,
m.group(5) != null ? Integer.parseInt(m.group(5)) : 59,
m.group(6) != null ? Integer.parseInt(m.group(6)) : 59);
c.set(java.util.Calendar.MILLISECOND, 999);
return c.getTimeInMillis();
}
if (s.matches("^\\d{13}$")) {
return Long.parseLong(s);
}
if (s.matches("^\\d{10}$")) {
return Long.parseLong(s) * 1000L;
}
// 2026-08-05T14:06:56 / 2026-08-05 14:06:56
java.util.regex.Matcher iso = java.util.regex.Pattern
.compile("^(\\d{4})-(\\d{2})-(\\d{2})[ T](\\d{2}):(\\d{2})(?::(\\d{2}))?")
.matcher(s);
if (iso.find()) {
java.util.Calendar c = java.util.Calendar.getInstance();
c.set(Integer.parseInt(iso.group(1)),
Integer.parseInt(iso.group(2)) - 1,
Integer.parseInt(iso.group(3)),
Integer.parseInt(iso.group(4)),
Integer.parseInt(iso.group(5)),
iso.group(6) != null ? Integer.parseInt(iso.group(6)) : 0);
c.set(java.util.Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
} catch (Throwable ignored) {
}
return 0L;
}
private static String buildCountHint(String claimed, String total) {
if (TextUtils.isEmpty(claimed) && TextUtils.isEmpty(total)) {
return null;
}
return nullToEmpty(claimed) + "/" + nullToEmpty(total);
}
private static Double parseMoneyDouble(String raw) {
if (TextUtils.isEmpty(raw)) {
return null;
}
try {
String n = normalizeMoneyText(raw);
if (TextUtils.isEmpty(n)) {
return null;
}
return Double.parseDouble(n);
} catch (Throwable ignored) {
return null;
}
}
private static void rememberPacketData(String packetId, MmpSnapshot snapshot, Object result) {
if (snapshot == null) {
return;
}
boolean hasClaims = snapshot.claims != null && !snapshot.claims.isEmpty();
if (!hasClaims && !snapshotHasPacketMeta(snapshot)) {
return;
}
String id = !TextUtils.isEmpty(packetId) ? packetId : snapshot.packetId;
if (TextUtils.isEmpty(id) || !looksLikeActivityId(id)) {
// 指纹 id 也记,避免反复刷
if (!TextUtils.isEmpty(id) && hasClaims) {
PACKETS_WITH_DATA.add(id);
}
} else if (hasClaims) {
PACKETS_WITH_DATA.add(id);
}
if (result != null
&& TextUtils.isEmpty(snapshot.remainingCount)
&& TextUtils.isEmpty(snapshot.claimedCount)
&& TextUtils.isEmpty(snapshot.activityStatus)) {
enrichSnapshotFromJavaResult(result, snapshot);
}
if (!snapshot.finished) {
snapshot.finished = evaluateFinished(snapshot);
}
if (snapshot.finished && hasClaims && !TextUtils.isEmpty(id)) {
if (COMPLETED_PACKETS.add(id)) {
XposedBridge.log(TAG + " packet DONE stop-refresh id=" + id
+ " claims=" + snapshot.claims.size()
+ " remain=" + snapshot.remainingCount
+ " status=" + snapshot.activityStatus);
}
}
}
private static void onSessionObserved(String sid) {
boolean has = !TextUtils.isEmpty(sid) && !"null".equalsIgnoreCase(sid);
if (has) {
sCachedSessionId = sid;
if (sSessionDisconnected) {
sSessionDisconnected = false;
XposedBridge.log(TAG + " session recovered → force re-fetch");
scheduleAutoHistoryForced("session-recovered");
}
sHadSession = true;
} else if (sHadSession) {
if (!sSessionDisconnected) {
sSessionDisconnected = true;
XposedBridge.log(TAG + " session lost / disconnected");
}
}
}
private static void noteSessionAlive() {
if (!TextUtils.isEmpty(sCachedSessionId)) {
sHadSession = true;
if (sSessionDisconnected) {
sSessionDisconnected = false;
XposedBridge.log(TAG + " connection alive again");
}
}
}
private static void tryRefreshLogin(ClassLoader cl) {
try {
Object ls = obtainLoginStorage(cl);
if (ls != null) {
applyLoginStorage(ls);
}
} catch (Throwable ignored) {
}
}
private static void markMaybeDisconnected(Throwable t, String where) {
String msg = t == null ? "" : String.valueOf(t.getMessage());
if (TextUtils.isEmpty(msg) && t != null) {
msg = t.getClass().getSimpleName();
}
String low = msg.toLowerCase(Locale.US);
if (low.contains("session") || low.contains("login") || low.contains("auth")
|| low.contains("unauthorized") || low.contains("timeout")
|| low.contains("unable to resolve") || low.contains("failed to connect")
|| low.contains("network") || low.contains("disconnect")
|| low.contains("socket") || low.contains("ssl")
|| msg.contains("未登录") || msg.contains("非法") || msg.contains("登录")
|| msg.contains("过期")) {
sSessionDisconnected = true;
XposedBridge.log(TAG + " disconnect suspected @" + where + ": " + msg);
tryRefreshLogin(sAppClassLoader);
}
}
private static void notePossibleDisconnectFromEmpty(Object result, String activityId) {
// 新发红包无人领取时 activityPoolInfos 为空、totalCount/claimedCount 也常为 0不是断线
if (looksLikeValidEmptyPacket(result, activityId)) {
XposedBridge.log(TAG + " empty claims but valid summary id=" + activityId
+ " → keep (0 claimants)");
return;
}
// totalCount:0 + 无领取人 且无有效摘要时,才怀疑缺登录态
String totalCnt = stringField(result, "totalCount", "getTotalCount");
if ("0".equals(totalCnt) && sHadSession) {
XposedBridge.log(TAG + " empty claims+totalCount0 id=" + activityId
+ " → treat as possible disconnect");
sSessionDisconnected = true;
tryRefreshLogin(sAppClassLoader);
}
}
/** 详情有 summaryACTIVE / 有总额)但领取名单为空:刚发出去、还没人领 */
private static boolean looksLikeValidEmptyPacket(Object result, String activityId) {
if (result == null) {
return false;
}
try {
Object summary = null;
try {
summary = XposedHelpers.callMethod(result, "getSummaryInfo");
} catch (Throwable ignored) {
}
if (summary == null) {
try {
summary = XposedHelpers.getObjectField(result, "summaryInfo");
} catch (Throwable ignored) {
}
}
if (summary == null) {
return false;
}
String id = firstNonEmpty(
stringField(summary, "activityId", "getActivityId"),
activityId);
if (!looksLikeActivityId(id)) {
return false;
}
String status = stringField(summary, "activityStatus", "getActivityStatus");
if (!TextUtils.isEmpty(status)) {
String up = status.toUpperCase(Locale.US);
if (up.contains("ACTIVE") || up.contains("OPEN") || up.contains("PROGRESS")
|| up.contains("FINISH") || up.contains("COMPLETE")
|| up.contains("EXPIRE")) {
return true;
}
}
String total = null;
try {
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
if (money != null) {
total = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
return !TextUtils.isEmpty(total);
} catch (Throwable ignored) {
return false;
}
}
private static boolean snapshotHasPacketMeta(MmpSnapshot snapshot) {
return snapshot != null
&& looksLikeActivityId(snapshot.packetId)
&& (!TextUtils.isEmpty(snapshot.totalAmount)
|| !TextUtils.isEmpty(snapshot.activityStatus)
|| !TextUtils.isEmpty(snapshot.senderName));
}
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
ClassLoader cl = sAppClassLoader;
ensureRpcTaskAndLogin(cl);
Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
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);
prepareRpcRequest(req);
XposedBridge.log(TAG + " auto-detail req id=" + activityId
+ " userId=" + stringField(req, "userId", "getUserId")
+ " session=" + abbreviate(stringField(req, "sessionId", "getSessionId"))
+ " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId"))
+ " page=" + stringField(req, "page", "getPage")
+ " max=" + stringField(req, "maxResult", "getMaxResult"));
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
+ " (ILoginStorage 尚未就绪,等登录后 Activity resume 会重试)");
return false;
}
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);
enrichSnapshotFromJavaResult(result, probe);
if (probe == null) {
XposedBridge.log(TAG + " auto-detail parse null id=" + activityId);
return false;
}
if (TextUtils.isEmpty(probe.packetId)) {
probe.packetId = activityId;
}
if (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"));
if (!snapshotHasPacketMeta(probe) && !looksLikeValidEmptyPacket(result, activityId)) {
notePossibleDisconnectFromEmpty(result, activityId);
return false;
}
// 刚发出、还没人领:照样入库,方便列表看到「领取中 0人」
rememberPacketData(probe.packetId, probe, result);
forwardSnapshot(probe, "auto:" + activityId, "rpc");
XposedBridge.log(TAG + " auto-detail ok(empty) activityId=" + activityId
+ " status=" + probe.activityStatus
+ " total=" + probe.totalAmount
+ " issued=" + probe.issueTime);
noteSessionAlive();
return true;
}
rememberPacketData(probe.packetId != null ? probe.packetId : activityId, probe, result);
forwardSnapshot(probe, "auto:" + activityId, "rpc");
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
+ " claims=" + probe.claims.size()
+ " issued=" + probe.issueTime
+ (COMPLETED_PACKETS.contains(activityId) ? " DONE" : ""));
noteSessionAlive();
return true;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
markMaybeDisconnected(t, "auto-detail");
return false;
}
}
private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) {
// 优先: (activityId, senderUserId, loadTime, page, maxResult) — maxResult 拉大以尽量一次取全
try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200);
} catch (Throwable ignored) {
}
try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200,
31, null);
} catch (Throwable ignored) {
}
try {
Object req = reqCls.getDeclaredConstructor().newInstance();
XposedHelpers.setObjectField(req, "activityId", activityId);
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
bumpDetailPageSize(req);
return req;
} catch (Throwable t) {
XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage());
return null;
}
}
private static void fillLoginOnRequest(Object req, ClassLoader cl) {
ensureRpcTaskAndLogin(cl);
// 0) 从最近真实请求拷贝登录相关字段(历史页打开后即可,无需点详情)
Object donor = sLoginDonorRequest;
if (donor == null) {
donor = sTemplateHistoryRequest;
}
if (donor == null) {
donor = sTemplateDetailRequest;
}
if (donor != null && donor != req) {
copyLoginFields(donor, req);
cacheLoginFromRequestObject(donor);
}
// 1) 再用缓存的登录态覆盖
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
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) 从 Repository.loginStorage / ILoginStorage 再读一遍
Object loginStorage = obtainLoginStorage(cl);
if (loginStorage == null) {
return;
}
applyLoginStorage(loginStorage);
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
try {
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) {
}
}
private static void copyLoginFields(Object src, Object dst) {
if (src == null || dst == null) {
return;
}
String[] keys = {"userId", "sessionId", "loginId", "securityId", "apiName"};
for (String key : keys) {
try {
Object v = null;
try {
v = XposedHelpers.getObjectField(src, key);
} catch (Throwable ignored) {
}
if (v == null) {
continue;
}
try {
XposedHelpers.setObjectField(dst, key, v);
} catch (Throwable ignored) {
}
String setter = "set" + Character.toUpperCase(key.charAt(0)) + key.substring(1);
trySet(dst, setter, v);
} catch (Throwable ignored) {
}
}
// extParams 等 Map 也尽量带上
try {
Object ext = XposedHelpers.getObjectField(src, "extParams");
if (ext != null) {
XposedHelpers.setObjectField(dst, "extParams", ext);
}
} catch (Throwable ignored) {
}
}
private static void trySet(Object req, String setter, Object value) {
if (value == null) {
return;
}
try {
XposedHelpers.callMethod(req, setter, value);
} catch (Throwable ignored) {
}
}
/** 被动缓存App 任何地方读 session 时同步到本 Hook */
private static void hookLoginStorage(XC_LoadPackage.LoadPackageParam lpparam) {
// 不能 hook 接口抽象方法;只挂实现类
try {
Class<?> impl = XposedHelpers.findClass(
"my.com.tngdigital.user.model.CacheLoginStorageImpl", lpparam.classLoader);
XC_MethodHook cacheHook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object v = param.getResult();
String name = param.method.getName();
if ("getSessionId".equals(name)) {
onSessionObserved(v == null ? "" : String.valueOf(v));
} else if (v == null || TextUtils.isEmpty(String.valueOf(v))) {
return;
} else if ("getAccountId".equals(name)) {
sCachedUserId = String.valueOf(v);
} else if ("getLoginId".equals(name)) {
sCachedLoginId = String.valueOf(v);
}
}
};
for (String m : new String[]{"getSessionId", "getAccountId", "getLoginId"}) {
XposedBridge.hookAllMethods(impl, m, cacheHook);
}
XposedBridge.log(TAG + " hooked CacheLoginStorageImpl getters");
} catch (Throwable t) {
XposedBridge.log(TAG + " CacheLoginStorageImpl hook skip: " + t.getMessage());
}
}
/** 前台 Activity 恢复时自动拉历史列表(再级联详情),无需手动进红包历史页 */
private static void hookActivityResumeForAutoHistory(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"android.app.Activity",
lpparam.classLoader,
"onResume",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
try {
Object act = param.thisObject;
if (act == null) {
return;
}
String pkg = String.valueOf(
XposedHelpers.callMethod(act, "getPackageName"));
if (!PACKAGE.equals(pkg)) {
return;
}
String actName = act.getClass().getName();
if (actName.contains("MoneyPacketHistoryActivity")
&& sBounceHistoryPending) {
scheduleHistoryBounceFinish(act);
}
reportHookAlive("activity-resume");
if (sSessionDisconnected) {
tryRefreshLogin(sAppClassLoader);
scheduleAutoHistoryForced("reconnect-resume");
} else {
scheduleAutoHistory("activity-resume");
}
} catch (Throwable ignored) {
}
}
});
XposedBridge.log(TAG + " hooked Activity.onResume for auto history");
} catch (Throwable t) {
XposedBridge.log(TAG + " Activity.onResume hook skip: " + t.getMessage());
}
}
private static void scheduleAutoHistory(String reason) {
scheduleAutoHistoryInternal(reason, false);
}
/** 断线恢复后忽略冷却,立刻重拉历史/详情 */
private static void scheduleAutoHistoryForced(String reason) {
scheduleAutoHistoryInternal(reason, true);
}
private static void scheduleAutoHistoryInternal(String reason, boolean force) {
pollMmpSettings(false);
long now = System.currentTimeMillis();
if (sAutoHistoryRunning) {
return;
}
if (!force && now - sLastAutoHistoryAt < sHistoryCooldownMs) {
return;
}
sAutoHistoryRunning = true;
final String why = reason;
AUTO_DETAIL_EXEC.execute(() -> {
try {
// 等登录/Hilt/Network 就绪;不要在主线程阻塞等 RPC
Thread.sleep(why != null && why.startsWith("repo") ? 1500L : 800L);
boolean ok = fetchHistoryListAuto(why);
if (ok) {
sLastAutoHistoryAt = System.currentTimeMillis();
noteSessionAlive();
reportHookAlive("auto-history-ok");
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
sAutoHistoryRunning = false;
}
});
}
/** 主动调 moneyPacketHistoryList结果走现有 scheduleAutoDetailsFromHistory */
private static boolean fetchHistoryListAuto(String reason) {
ClassLoader cl = sAppClassLoader;
ensureRpcTaskAndLogin(cl);
Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-history skip: no rpc task (" + reason + ")");
maybeBounceHistoryPage("no-rpc-task");
return false;
}
if (TextUtils.isEmpty(sCachedSessionId)) {
Object ls = obtainLoginStorage(cl);
if (ls != null) {
applyLoginStorage(ls);
}
}
if (TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " auto-history skip: no sessionId yet (" + reason + ")");
if (sHadSession) {
sSessionDisconnected = true;
}
maybeBounceHistoryPage("no-session");
return false;
}
try {
Class<?> reqCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketHistoryListRequest",
cl);
// 1) 已有官方请求模板(首次打开历史页后会缓存)→ 静默复用
if (sTemplateHistoryRequest != null
&& reqCls.isInstance(sTemplateHistoryRequest)) {
Object req = cloneRequestShallow(sTemplateHistoryRequest);
XposedBridge.log(TAG + " auto-history using template (" + reason + ")");
fillLoginOnRequest(req, cl);
prepareRpcRequest(req);
AUTO_HISTORY_SELF.set(Boolean.TRUE);
Object result;
try {
result = callHistoryListRpc(task, req);
} finally {
AUTO_HISTORY_SELF.remove();
}
if (result != null) {
if (historyHasJobs(result)) {
XposedBridge.log(TAG + " auto-history ok(template) → schedule details");
scheduleAutoDetailsFromHistory(result);
} else {
XposedBridge.log(TAG + " auto-history ok(template) but empty list");
}
return true;
}
// 有模板但 RPC 失败:不要误当成「无模板」反复乱跳;按断线处理并短进历史页
XposedBridge.log(TAG + " auto-history template rpc failed (" + reason + ")");
maybeBounceHistoryPage("template-rpc-fail");
return false;
}
// 2) 无模板时:自造请求易「非法参数」,短进官方历史页让 App 发正确 RPC限次 + 冷却)
if (!sOpenHistoryIfNoTemplate && !sAutoBounceHistoryOnDisconnect) {
XposedBridge.log(TAG + " auto-history no template, openHistory disabled ("
+ reason + ")");
return false;
}
if (sNoTemplateOpenCount >= MAX_NO_TEMPLATE_OPEN) {
XposedBridge.log(TAG + " auto-history no template, open capped ("
+ reason + " count=" + sNoTemplateOpenCount + ")");
return false;
}
sNoTemplateOpenCount++;
XposedBridge.log(TAG + " auto-history no template → open HistoryActivity ("
+ reason + " #" + sNoTemplateOpenCount + ")");
openMoneyPacketHistoryActivity(true);
return false;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-history fail: " + t.getMessage());
markMaybeDisconnected(t, "auto-history");
maybeBounceHistoryPage("history-exception");
return false;
}
}
private static boolean historyHasJobs(Object historyResult) {
return !extractHistoryJobs(historyResult).isEmpty();
}
/** 断线/失败时短进历史页再建连(有冷却,避免刷屏) */
private static void maybeBounceHistoryPage(String reason) {
if (!sAutoBounceHistoryOnDisconnect) {
XposedBridge.log(TAG + " bounce history disabled (" + reason + ")");
return;
}
openMoneyPacketHistoryActivity(true);
XposedBridge.log(TAG + " bounce history for " + reason);
}
/** 拉起官方历史页bounceBack=true 时约 2 秒后自动 finish 返回 */
private static void openMoneyPacketHistoryActivity(boolean bounceBack) {
try {
long now = System.currentTimeMillis();
if (bounceBack) {
if (now - sLastHistoryBounceAt < HISTORY_BOUNCE_COOLDOWN_MS) {
XposedBridge.log(TAG + " bounce history cooldown skip ageMs="
+ (now - sLastHistoryBounceAt));
return;
}
}
// 已在历史页则不再 startActivity避免反复跳转
try {
Context ctx0 = getContext();
if (ctx0 instanceof android.app.Activity) {
String cur = ((android.app.Activity) ctx0).getClass().getName();
if (cur.contains("MoneyPacketHistoryActivity")) {
XposedBridge.log(TAG + " already on HistoryActivity, skip open");
if (bounceBack) {
sLastHistoryBounceAt = now;
sBounceHistoryPending = true;
scheduleHistoryBounceFinish(ctx0);
}
return;
}
}
} catch (Throwable ignored) {
}
if (bounceBack) {
sLastHistoryBounceAt = now;
sBounceHistoryPending = true;
}
Context ctx = getContext();
if (ctx == null) {
sBounceHistoryPending = false;
return;
}
android.content.Intent intent = new android.content.Intent();
intent.setClassName(PACKAGE,
"my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
| android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION);
ctx.startActivity(intent);
XposedBridge.log(TAG + " started MoneyPacketHistoryActivity bounce=" + bounceBack);
} catch (Throwable t) {
sBounceHistoryPending = false;
XposedBridge.log(TAG + " start HistoryActivity fail: " + t.getMessage());
}
}
private static void scheduleHistoryBounceFinish(final Object activity) {
if (!(activity instanceof android.app.Activity)) {
return;
}
final android.app.Activity act = (android.app.Activity) activity;
MAIN_HANDLER.postDelayed(() -> {
try {
if (!sBounceHistoryPending) {
return;
}
if (act.isFinishing()) {
sBounceHistoryPending = false;
return;
}
act.finish();
XposedBridge.log(TAG + " bounce history finished → back");
} catch (Throwable t) {
XposedBridge.log(TAG + " bounce history finish fail: " + t.getMessage());
} finally {
sBounceHistoryPending = false;
}
}, HISTORY_BOUNCE_FINISH_DELAY_MS);
}
private static Object callHistoryListRpc(Object task, Object req) {
try {
return XposedHelpers.callMethod(task, "moneyPacketHistoryList", req);
} catch (Throwable t) {
XposedBridge.log(TAG + " history rpc fail: " + t.getMessage());
markMaybeDisconnected(t, "history-rpc");
return null;
}
}
private static void ensureRpcTaskAndLogin(ClassLoader cl) {
if (cl == null) {
return;
}
if (sMoneyPacketRpcTask == null || TextUtils.isEmpty(sCachedSessionId)) {
Object repo = obtainMoneyPacketRepository(cl);
if (repo != null) {
if (sMoneyPacketRpcTask == null) {
try {
Object task = XposedHelpers.getObjectField(repo, "task");
if (task != null) {
sMoneyPacketRpcTask = task;
XposedBridge.log(TAG + " cached RPC task from repository");
}
} catch (Throwable ignored) {
}
}
try {
Object loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
applyLoginStorage(loginStorage);
} catch (Throwable ignored) {
}
}
}
if (TextUtils.isEmpty(sCachedSessionId)) {
applyLoginStorage(obtainLoginStorage(cl));
}
}
private static void applyLoginStorage(Object loginStorage) {
if (loginStorage == null) {
return;
}
try {
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
if (accountId != null && !TextUtils.isEmpty(String.valueOf(accountId))) {
sCachedUserId = String.valueOf(accountId);
}
if (sessionId != null && !TextUtils.isEmpty(String.valueOf(sessionId))) {
onSessionObserved(String.valueOf(sessionId));
} else if (sHadSession) {
onSessionObserved("");
}
if (loginId != null && !TextUtils.isEmpty(String.valueOf(loginId))) {
sCachedLoginId = String.valueOf(loginId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " login from storage session="
+ abbreviate(sCachedSessionId)
+ " userId=" + sCachedUserId);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " applyLoginStorage skip: " + t.getMessage());
}
}
private static Object obtainLoginStorage(ClassLoader cl) {
try {
Object repo = obtainMoneyPacketRepository(cl);
if (repo != null) {
try {
Object ls = XposedHelpers.getObjectField(repo, "loginStorage");
if (ls != null) {
return ls;
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
// Hilt尝试常见 EntryPoint 上返回 ILoginStorage 的无参方法
try {
Context ctx = getContext();
if (ctx == null || cl == null) {
return null;
}
String[] entryPoints = {
"my.com.tngdigital.member.di.MemberEntryPoint",
"my.com.tngdigital.user.di.UserEntryPoint",
"my.com.tngdigital.common.di.CommonEntryPoint",
};
Class<?> entryPointsCls = Class.forName("dagger.hilt.EntryPoints", false, cl);
for (String epName : entryPoints) {
try {
Class<?> epCls = Class.forName(epName, false, cl);
Object ep = XposedHelpers.callStaticMethod(
entryPointsCls, "get", ctx.getApplicationContext(), epCls);
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length != 0) {
continue;
}
if (m.getReturnType().getName().contains("ILoginStorage")) {
Object ls = m.invoke(ep);
if (ls != null) {
return ls;
}
}
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
Context ctx = getContext();
if (ctx == null || cl == null) {
return null;
}
Context appCtx = ctx.getApplicationContext() != null ? ctx.getApplicationContext() : ctx;
Class<?> entryPoint;
try {
entryPoint = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint", cl);
} catch (Throwable t) {
XposedBridge.log(TAG + " MoneyPacketEntryPoint missing: " + t.getMessage());
return null;
}
// 1) EntryPointAccessors.fromApplicationHilt Android 推荐)
try {
Class<?> accessors = XposedHelpers.findClass(
"dagger.hilt.android.EntryPointAccessors", cl);
Object ep = XposedHelpers.callStaticMethod(
accessors, "fromApplication", appCtx, entryPoint);
Object repo = invokeRepository(ep);
if (repo != null) {
return repo;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " EntryPointAccessors fail: " + t.getMessage());
}
// 2) EntryPoints.get
try {
Class<?> entryPoints = XposedHelpers.findClass("dagger.hilt.EntryPoints", cl);
Object ep = XposedHelpers.callStaticMethod(
entryPoints, "get", appCtx, entryPoint);
Object repo = invokeRepository(ep);
if (repo != null) {
return repo;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " EntryPoints.get fail: " + t.getMessage());
}
return null;
}
private static Object invokeRepository(Object ep) {
if (ep == null) {
return null;
}
try {
return XposedHelpers.callMethod(ep, "repository");
} catch (Throwable ignored) {
}
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length == 0
&& m.getReturnType().getName().contains("MoneyPacketRepository")) {
try {
return m.invoke(ep);
} catch (Throwable ignored) {
}
}
}
return null;
}
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,19 +1964,251 @@ public final class TngMoneyPacketHook {
if (snapshot == null || snapshot.claims.isEmpty()) { if (snapshot == null || snapshot.claims.isEmpty()) {
return; return;
} }
String dedupKey = snapshot.dedupKey(); forwardSnapshot(snapshot, sourceHint, channel);
if (!remember(dedupKey)) { }
private static void forwardSnapshot(MmpSnapshot snapshot, String sourceHint, String channel) {
if (snapshot == null) {
return;
}
if (snapshot.claims.isEmpty() && !snapshotHasPacketMeta(snapshot)) {
return;
}
dedupeClaims(snapshot);
snapshot.applyCachedIssueTime();
rememberPacketData(snapshot.packetId, snapshot, null);
if (!shouldForwardPacket(snapshot)) {
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()
+ " issued=" + snapshot.issueTime
+ (snapshot.finished ? " DONE" : "")
+ " via " + channel);
}
/** 直接从 Java 结果对象读 gmtModified / createTime不依赖 JSON 字段是否序列化成功 */
private static void enrichSnapshotFromJavaResult(Object result, MmpSnapshot snapshot) {
if (result == null || snapshot == null) {
return;
}
try {
Object pools = null;
try {
pools = XposedHelpers.callMethod(result, "getActivityPoolInfos");
} catch (Throwable ignored) {
}
if (pools == null) {
try {
pools = XposedHelpers.getObjectField(result, "activityPoolInfos");
} catch (Throwable ignored) {
}
}
if (pools instanceof Iterable) {
for (Object info : (Iterable<?>) pools) {
if (info == null) {
continue;
}
String name = stringField(info, "receiverName", "getReceiverName");
String receiverId = firstNonEmpty(
stringField(info, "receiverId", "getReceiverId"),
stringField(info, "userId", "getUserId"),
stringField(info, "receiverUserId", "getReceiverUserId"));
String poolId = stringField(info, "activityPoolId", "getActivityPoolId");
String claimTime = firstNonEmpty(
stringField(info, "gmtModified", "getGmtModified"),
stringField(info, "gmtCreate", "getGmtCreate"),
stringField(info, "createTime", "getCreateTime"));
String amount = null;
try {
Object money = XposedHelpers.callMethod(info, "getAmount");
if (money != null) {
amount = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
if (TextUtils.isEmpty(amount)) {
amount = normalizeMoneyText(stringField(info, "amount", "getAmount"));
}
if (TextUtils.isEmpty(name) && TextUtils.isEmpty(receiverId)) {
continue;
}
if (TextUtils.isEmpty(name)) {
name = "用户";
}
boolean matched = false;
for (ClaimLine line : snapshot.claims) {
boolean sameId = !TextUtils.isEmpty(receiverId)
&& receiverId.equals(line.receiverId);
boolean sameName = TextUtils.isEmpty(receiverId)
&& name.equals(line.nickname);
if (sameId || sameName) {
if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(claimTime)) {
line.claimTime = claimTime;
}
if (TextUtils.isEmpty(line.receiverId) && !TextUtils.isEmpty(receiverId)) {
line.receiverId = receiverId;
}
if (TextUtils.isEmpty(line.poolId) && !TextUtils.isEmpty(poolId)) {
line.poolId = poolId;
}
matched = true;
}
}
if (!matched && !TextUtils.isEmpty(amount)) {
ClaimLine line = new ClaimLine();
line.nickname = name;
line.receiverId = receiverId;
line.poolId = poolId;
line.amount = amount;
line.claimTime = claimTime;
snapshot.claims.add(line);
}
}
}
Object summary = null;
try {
summary = XposedHelpers.callMethod(result, "getSummaryInfo");
} catch (Throwable ignored) {
}
if (summary == null) {
try {
summary = XposedHelpers.getObjectField(result, "summaryInfo");
} catch (Throwable ignored) {
}
}
if (summary != null) {
if (TextUtils.isEmpty(snapshot.senderName)) {
snapshot.senderName = stringField(summary, "senderName", "getSenderName");
}
if (TextUtils.isEmpty(snapshot.packetId)) {
snapshot.packetId = stringField(summary, "activityId", "getActivityId");
}
if (TextUtils.isEmpty(snapshot.totalAmount)) {
try {
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
if (money != null) {
snapshot.totalAmount = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
}
snapshot.remainingCount = firstNonEmpty(
stringField(summary, "remainingCount", "getRemainingCount"),
stringField(result, "remainingCount", "getRemainingCount"));
snapshot.claimedCount = firstNonEmpty(
stringField(summary, "claimedCount", "getClaimedCount"),
stringField(result, "claimedCount", "getClaimedCount"));
snapshot.totalCount = firstNonEmpty(
stringField(summary, "totalCount", "getTotalCount"),
stringField(result, "totalCount", "getTotalCount"));
snapshot.activityStatus = firstNonEmpty(
stringField(summary, "activityStatus", "getActivityStatus"),
stringField(result, "activityStatus", "getActivityStatus"),
boolFieldText(summary, "isFinished", "isFinished"),
boolFieldText(result, "isFinished", "isFinished"));
snapshot.expireTime = firstNonEmpty(
stringField(summary, "expireTime", "getExpireTime"),
stringField(result, "expireTime", "getExpireTime"),
stringField(summary, "expiryTime", "getExpiryTime"),
stringField(result, "expiryTime", "getExpiryTime"));
if (TextUtils.isEmpty(snapshot.claimedAmountText)) {
try {
Object money = XposedHelpers.callMethod(summary, "getClaimedAmount");
if (money != null) {
snapshot.claimedAmountText = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
}
}
snapshot.applyCachedIssueTime();
snapshot.finished = evaluateFinished(snapshot);
} catch (Throwable t) {
XposedBridge.log(TAG + " enrich times failed: " + t.getMessage());
}
}
private static void cacheIssueTimesFromHistory(Object historyResult) {
if (historyResult == null) {
return;
}
try {
for (String[] job : extractHistoryJobs(historyResult)) {
if (job.length > 2 && looksLikeActivityId(job[0]) && !TextUtils.isEmpty(job[2])) {
synchronized (ACTIVITY_ISSUE_TIME) {
ACTIVITY_ISSUE_TIME.put(job[0], job[2]);
}
}
}
XposedBridge.log(TAG + " cached issue times size=" + ACTIVITY_ISSUE_TIME.size());
} catch (Throwable t) {
XposedBridge.log(TAG + " cache issue times failed: " + t.getMessage());
}
}
private static void dedupeClaims(MmpSnapshot snapshot) {
if (snapshot == null || snapshot.claims.size() <= 1) {
return;
}
java.util.LinkedHashMap<String, ClaimLine> map = new java.util.LinkedHashMap<>();
for (ClaimLine line : snapshot.claims) {
if (line == null) {
continue;
}
if (TextUtils.isEmpty(line.nickname) && TextUtils.isEmpty(line.receiverId)) {
continue;
}
String key = !TextUtils.isEmpty(line.receiverId)
? ("id:" + line.receiverId)
: ("n:" + line.nickname);
ClaimLine old = map.get(key);
if (old == null) {
map.put(key, line);
continue;
}
double oldAmt = parseMoneyDouble(old.amount) != null ? parseMoneyDouble(old.amount) : -1;
double newAmt = parseMoneyDouble(line.amount) != null ? parseMoneyDouble(line.amount) : -1;
if (newAmt >= oldAmt) {
if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(old.claimTime)) {
line.claimTime = old.claimTime;
}
if (TextUtils.isEmpty(line.receiverId)) {
line.receiverId = old.receiverId;
}
if (TextUtils.isEmpty(line.poolId)) {
line.poolId = old.poolId;
}
map.put(key, line);
} else {
if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
old.claimTime = line.claimTime;
}
if (TextUtils.isEmpty(old.receiverId) && !TextUtils.isEmpty(line.receiverId)) {
old.receiverId = line.receiverId;
}
if (TextUtils.isEmpty(old.poolId) && !TextUtils.isEmpty(line.poolId)) {
old.poolId = line.poolId;
}
}
}
snapshot.claims.clear();
snapshot.claims.addAll(map.values());
} }
private static MmpSnapshot parseSnapshot(String json) { private static MmpSnapshot parseSnapshot(String json) {
@@ -205,7 +2222,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 +2254,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 +2262,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 +2273,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 +2286,36 @@ 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");
putIfPresent(obj, snapshot, "createTime", "createdTime", "gmtCreate", "issueTime",
"issuedTime", "sendTime", "packetCreateTime");
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 +2331,176 @@ 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.contains("createtime") || keyLower.contains("created")
|| keyLower.contains("gmtcreate") || keyLower.contains("issue")
|| keyLower.contains("sendtime")) {
if (TextUtils.isEmpty(snapshot.issueTime) && !looksLikeMoneyJson(val)) {
snapshot.issueTime = val;
}
} else if (keyLower.equals("activityid") || keyLower.contains("packet")
|| keyLower.contains("mmp") || keyLower.contains("order")
|| keyLower.contains("biz") || keyLower.equals("packetcode")) {
// 已有 UUID 形态的 activityId 时不覆盖
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.receiverId = firstNonEmpty(
readFieldText(obj, "receiverId"),
readFieldText(obj, "receiverUserId"),
readFieldText(obj, "userId"),
readFieldText(obj, "participantId"));
// userName 字段误当 id 时:纯数字/长串才留
if (!TextUtils.isEmpty(line.receiverId) && line.receiverId.equals(name)) {
line.receiverId = null;
}
line.poolId = firstNonEmpty(
readFieldText(obj, "activityPoolId"),
readFieldText(obj, "poolId"));
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, "gmtModified"),
readFieldText(obj, "gmtModify"),
readFieldText(obj, "gmtCreate"),
readFieldText(obj, "createTime"),
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 +2636,44 @@ 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();
long dedupMs = sPacketDedupMs;
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 < dedupMs) {
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() > Math.max(dedupMs, 1L) * 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;
@@ -510,6 +2702,21 @@ public final class TngMoneyPacketHook {
return null; return null;
} }
private static void reportHookAlive(String reason) {
long now = System.currentTimeMillis();
if (!"install".equals(reason) && now - sLastHookStatusAt < HOOK_STATUS_COOLDOWN_MS) {
return;
}
Context ctx = getContext();
if (ctx == null) {
return;
}
sLastHookStatusAt = now;
HookForwarder.reportStatus(ctx, PACKAGE, HookBridge.SOURCE_XPOSED_TNG_MMP);
XposedBridge.log(TAG + " hook status reported (" + reason + ") mod="
+ HookBridge.MODULE_VERSION_NAME);
}
private static Context getContext() { private static Context getContext() {
try { try {
Class<?> activityThread = XposedHelpers.findClass("android.app.ActivityThread", null); Class<?> activityThread = XposedHelpers.findClass("android.app.ActivityThread", null);
@@ -528,36 +2735,102 @@ public final class TngMoneyPacketHook {
String title; String title;
String senderName; String senderName;
String totalAmount; String totalAmount;
/** 发放时间(历史 createTime 或详情里的 createTime */
String issueTime;
String remainingCount;
String claimedCount;
String totalCount;
String activityStatus;
String expireTime;
String claimedAmountText;
boolean finished;
final List<ClaimLine> claims = new ArrayList<>(); final List<ClaimLine> claims = new ArrayList<>();
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(';');
} }
return sb.toString(); return sb.toString();
} }
void applyCachedIssueTime() {
if (!TextUtils.isEmpty(issueTime) || TextUtils.isEmpty(packetId)) {
return;
}
synchronized (ACTIVITY_ISSUE_TIME) {
String cached = ACTIVITY_ISSUE_TIME.get(packetId);
if (!TextUtils.isEmpty(cached)) {
issueTime = cached;
}
}
}
String formatForForward(String channel, String sourceHint) { String formatForForward(String channel, String sourceHint) {
applyCachedIssueTime();
if (!finished) {
finished = evaluateFinished(this);
}
// 详情无发放时间时,用最早领取时间兜底(仍优于拉取时间)
if (TextUtils.isEmpty(issueTime)) {
for (ClaimLine line : claims) {
if (!TextUtils.isEmpty(line.claimTime)) {
if (TextUtils.isEmpty(issueTime)
|| line.claimTime.compareTo(issueTime) < 0) {
issueTime = line.claimTime;
}
}
}
}
StringBuilder sb = new StringBuilder(); 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); if (!TextUtils.isEmpty(issueTime)) {
sb.append(" | issued=").append(sanitizeMeta(issueTime));
}
if (!TextUtils.isEmpty(expireTime)) {
sb.append(" | expire=").append(sanitizeMeta(expireTime));
}
if (!TextUtils.isEmpty(activityStatus)) {
sb.append(" | status=").append(sanitizeMeta(activityStatus));
}
String claimed = claimedCount;
String totalCnt = totalCount;
if (TextUtils.isEmpty(claimed) && claims != null) {
claimed = String.valueOf(claims.size());
}
if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
sb.append(" | counts=").append(sanitizeMeta(nullToEmpty(claimed)))
.append('/').append(sanitizeMeta(nullToEmpty(totalCnt)));
}
if (finished) {
sb.append(" | done=1");
}
sb.append(" | via=").append(sanitizeMeta(channel));
sb.append(" | mod=").append(HookBridge.MODULE_VERSION_NAME)
.append('/').append(HookBridge.MODULE_VERSION_CODE);
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) {
@@ -565,14 +2838,30 @@ public final class TngMoneyPacketHook {
if (!TextUtils.isEmpty(line.claimTime)) { if (!TextUtils.isEmpty(line.claimTime)) {
sb.append(" (").append(line.claimTime).append(')'); sb.append(" (").append(line.claimTime).append(')');
} }
if (!TextUtils.isEmpty(line.receiverId)) {
sb.append(" #rid=").append(sanitizeMeta(line.receiverId));
}
if (!TextUtils.isEmpty(line.poolId)) {
sb.append(" #pool=").append(sanitizeMeta(line.poolId));
}
sb.append('\n'); sb.append('\n');
} }
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 {
String nickname; String nickname;
String receiverId;
String poolId;
String amount; String amount;
String claimTime; String claimTime;
} }