Compare commits

..

8 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
31 changed files with 7373 additions and 364 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

@@ -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

@@ -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,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,6 +2,8 @@
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。""" """notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
import json import json
import os
import re
import threading import threading
import time import time
from collections import deque from collections import deque
@@ -12,10 +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} _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():
@@ -34,7 +122,6 @@ def _add_message(payload):
item = dict(payload) item = dict(payload)
item["group"] = _resolve_group(payload) item["group"] = _resolve_group(payload)
item["receivedAt"] = _now_iso() item["receivedAt"] = _now_iso()
# 本机 + 局域网双推时可能各成功一次3 秒内同内容去重
dedup_key = "|".join([ dedup_key = "|".join([
str(payload.get("source") or ""), str(payload.get("source") or ""),
str(payload.get("packageName") or ""), str(payload.get("packageName") or ""),
@@ -42,17 +129,35 @@ def _add_message(payload):
str(payload.get("content") or ""), str(payload.get("content") or ""),
]) ])
now = time.time() now = time.time()
is_mmp = _is_mmp_message(item)
content_for_mod = ""
with _lock: with _lock:
if (_last_dedup["key"] == dedup_key if is_mmp:
and now - float(_last_dedup["ts"]) < 3.0): if (_mmp_last_dedup["key"] == dedup_key
if _messages: and now - float(_mmp_last_dedup["ts"]) < 3.0):
return _messages[0] if _mmp_messages:
_last_dedup["key"] = dedup_key return _mmp_messages[0]
_last_dedup["ts"] = now _mmp_last_dedup["key"] = dedup_key
_messages.appendleft(item) _mmp_last_dedup["ts"] = now
item["id"] = len(_messages) _mmp_messages.appendleft(item)
print("[{0}] [{1}] [{2}] {3} | {4}".format( 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)
item["id"] = len(_messages)
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", "")),
@@ -61,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)
@@ -74,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] = {
@@ -178,6 +295,11 @@ def _parse_mmp_content(content):
if "total" in meta: if "total" in meta:
total_num = _normalize_money(meta["total"]) total_num = _normalize_money(meta["total"])
meta["total"] = ("{0:.2f}".format(total_num) if total_num is not None else 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:]: for line in lines[1:]:
line = line.strip() line = line.strip()
if not line or "->" not in line: if not line or "->" not in line:
@@ -185,17 +307,40 @@ def _parse_mmp_content(content):
left, right = line.split("->", 1) left, right = line.split("->", 1)
nick = left.strip() nick = left.strip()
right = right.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 = "" claim_time = ""
if right.endswith(")") and "(" in right and not right.startswith("{"): amount_raw = right
if "(" in right and ")" in right and not right.startswith("{"):
amt, _, rest = right.partition("(") amt, _, rest = right.partition("(")
amount_raw = amt.strip() amount_raw = amt.strip()
claim_time = rest.rstrip(")").strip() claim_time = rest.split(")", 1)[0].strip()
else:
amount_raw = right
amount_num = _normalize_money(amount_raw) amount_num = _normalize_money(amount_raw)
if nick: if nick or user_id:
claims.append({ claims.append({
"nickname": nick, "nickname": nick or "?",
"userId": user_id,
"poolId": pool_id,
"amount": ("{0:.2f}".format(amount_num) if amount_num is not None else "0.00"), "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, "amountValue": amount_num if amount_num is not None else 0.0,
"claimTime": claim_time, "claimTime": claim_time,
@@ -206,34 +351,61 @@ def _parse_mmp_content(content):
def _claims_fingerprint(claims): def _claims_fingerprint(claims):
rows = [] rows = []
for c in claims: for c in claims:
rows.append("{0}={1}".format(c.get("nickname") or "?", c.get("amount") or "0")) key = c.get("userId") or c.get("nickname") or "?"
rows.append("{0}={1}".format(key, c.get("amount") or "0"))
rows.sort() rows.sort()
return "|".join(rows) return "|".join(rows)
def _aggregate_claims(claims): def _aggregate_claims(claims):
"""同一快照内同昵称合并;跨快照不应再混加(外层只取最新快照)""" """同一快照内按 userId无则昵称去重取较大金额禁止同人累加翻倍"""
buckets = {} buckets = {}
order = [] order = []
for c in claims: for c in claims:
nick = c.get("nickname") or "?" nick = c.get("nickname") or "?"
if nick not in buckets: uid = (c.get("userId") or "").strip()
buckets[nick] = {"nickname": nick, "amount": 0.0, "count": 0, "claimTime": ""} key = ("id:" + uid) if uid else ("n:" + nick)
order.append(nick)
b = buckets[nick]
b["count"] += 1
try: try:
b["amount"] += float(c.get("amountValue") if c.get("amountValue") is not None amt = float(c.get("amountValue") if c.get("amountValue") is not None
else _normalize_money(c.get("amount")) or 0.0) else _normalize_money(c.get("amount")) or 0.0)
except (TypeError, ValueError): except (TypeError, ValueError):
pass amt = 0.0
if c.get("claimTime") and not b["claimTime"]: 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 "" b["claimTime"] = c.get("claimTime") or ""
result = [] result = []
for nick in order: for key in order:
b = buckets[nick] b = buckets[key]
result.append({ result.append({
"nickname": nick, "nickname": b["nickname"],
"userId": b.get("userId") or "",
"poolId": b.get("poolId") or "",
"amount": round(b["amount"], 4), "amount": round(b["amount"], 4),
"amountText": "{0:.2f}".format(b["amount"]), "amountText": "{0:.2f}".format(b["amount"]),
"claimCount": b["count"], "claimCount": b["count"],
@@ -245,10 +417,326 @@ def _aggregate_claims(claims):
return result 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(): def _mmp_packets():
with _lock: with _lock:
msgs = [m for m in _messages if _is_mmp_message(m)] # 独立队列 + 兼容旧版混入通用消息的 MMP
# 每个红包只保留「最新一次完整领取榜」快照,避免多个红包因缺 packetId 被揉在一起 msgs = list(_mmp_messages) + [m for m in _messages if _is_mmp_message(m)]
# 每个红包只保留「最新一次完整领取榜」快照
packets = {} packets = {}
for msg in msgs: for msg in msgs:
meta, claims = _parse_mmp_content(msg.get("content") or "") meta, claims = _parse_mmp_content(msg.get("content") or "")
@@ -256,9 +744,12 @@ def _mmp_packets():
continue continue
packet_id = meta.get("packet") or meta.get("packetId") or "" packet_id = meta.get("packet") or meta.get("packetId") or ""
if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"): if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"):
# 用领取名单指纹区分不同红包
packet_id = "红包-" + _claims_fingerprint(claims)[:48] packet_id = "红包-" + _claims_fingerprint(claims)[:48]
key = str(packet_id) 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 = { item = {
"packetId": key, "packetId": key,
"title": msg.get("title") or "TNG 红包", "title": msg.get("title") or "TNG 红包",
@@ -266,26 +757,39 @@ def _mmp_packets():
"group": meta.get("group") or "", "group": meta.get("group") or "",
"total": meta.get("total") or "", "total": meta.get("total") or "",
"via": meta.get("via") or "", "via": meta.get("via") or "",
"latestAt": msg.get("receivedAt") 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, "snapshots": 1,
"claims": claims, "claims": claims,
"rawMessages": [{
"id": msg.get("id"),
"receivedAt": msg.get("receivedAt"),
"content": msg.get("content"),
"source": msg.get("source"),
}],
} }
_apply_counts_from_meta(item, meta)
existing = packets.get(key) existing = packets.get(key)
if existing is None or (item["latestAt"] or "") >= (existing.get("latestAt") or ""): 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: if existing is not None:
item["snapshots"] = int(existing.get("snapshots") or 1) + 1 item["snapshots"] = int(existing.get("snapshots") or 1) + 1
# 保留历史原始报文,但排行只用最新 claims if not item.get("issuedAt") and existing.get("issuedAt"):
item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"] item["issuedAt"] = existing["issuedAt"]
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
if existing.get("finished"):
item["finished"] = True
packets[key] = item packets[key] = item
else: else:
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1 existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
existing["rawMessages"].append(item["rawMessages"][0]) if not existing.get("issuedAt") and issued:
existing["issuedAt"] = issued
existing["latestAt"] = issued
if finished:
existing["finished"] = True
result = [] result = []
for p in packets.values(): for p in packets.values():
ranked = _aggregate_claims(p.get("claims") or []) ranked = _aggregate_claims(p.get("claims") or [])
@@ -296,194 +800,160 @@ def _mmp_packets():
p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4) p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4)
except Exception: except Exception:
p["sumClaimed"] = 0 p["sumClaimed"] = 0
# 总额缺失或只抓到单笔金额时,用领取合计兜底
total_num = _normalize_money(p.get("total")) total_num = _normalize_money(p.get("total"))
if total_num is None or (p["sumClaimed"] > 0 and abs(total_num - p["sumClaimed"]) > 0.001 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): and total_num <= max((x["amount"] for x in ranked), default=0) + 1e-9):
p["total"] = "{0:.2f}".format(p["sumClaimed"]) p["total"] = "{0:.2f}".format(p["sumClaimed"])
elif total_num is not None: elif total_num is not None:
p["total"] = "{0:.2f}".format(total_num) 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"] del p["claims"]
result.append(p) result.append(p)
result.sort(key=lambda x: x.get("latestAt") or "", reverse=True) # 合并手机全量同步落盘数据(电脑重启后仍可看)
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 return result
def _mmp_html_page(): def _mmp_time_sort_key(text):
html = """<!DOCTYPE html> """把 13/07/2026 11:54:44 / ISO 等统一成可比较字符串。"""
<html lang="zh-CN"> s = (text or "").strip()
<head> m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}:\d{2}(?::\d{2})?))?", s)
<meta charset="UTF-8" /> if m:
<meta name="viewport" content="width=device-width, initial-scale=1" /> return "{0}-{1}-{2} {3}".format(m.group(3), m.group(2), m.group(1), m.group(4) or "")
<title>红包领取台</title> return s
<style>
* { box-sizing: border-box; }
body { font-family: "Segoe UI", ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b1220; color: #e8eef7; height: 100vh; display: flex; flex-direction: column; }
header { padding: 14px 20px; background: linear-gradient(90deg,#1a1030,#0f1b2d); border-bottom: 1px solid #2a3550; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
h1 { margin: 0; font-size: 18px; letter-spacing: 0.02em; }
.stat { color: #9db0cc; font-size: 13px; }
a.nav { color: #8ec5ff; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
button { background: #c45c26; color: #fff; border: 0; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; }
button.secondary { background: #1c2740; border: 1px solid #314062; }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar { width: 320px; border-right: 1px solid #2a3550; background: #111a2c; overflow-y: auto; flex-shrink: 0; }
.sidebar h2 { margin: 0; padding: 14px 16px 8px; font-size: 12px; color: #8aa0c0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.pkt { padding: 14px 16px; border-bottom: 1px solid #1a2438; cursor: pointer; }
.pkt:hover { background: #162033; }
.pkt.active { background: #1a2744; border-left: 3px solid #ff7a45; padding-left: 13px; }
.pkt-id { font-size: 13px; color: #ffd2a8; word-break: break-all; font-family: ui-monospace, Consolas, monospace; }
.pkt-meta { color: #8aa0c0; font-size: 12px; margin-top: 6px; line-height: 1.45; }
.main { flex: 1; overflow-y: auto; padding: 18px 22px; }
.title { margin: 0 0 6px; font-size: 20px; color: #ffe0c2; }
.sub { color: #9db0cc; font-size: 13px; margin-bottom: 16px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 18px; }
.card { background: #141f35; border: 1px solid #273552; border-radius: 10px; padding: 12px 14px; }
.card .k { color: #8aa0c0; font-size: 11px; }
.card .v { margin-top: 4px; font-size: 18px; color: #fff; font-variant-numeric: tabular-nums; }
table { width: 100%; border-collapse: collapse; background: #111a2c; border-radius: 10px; overflow: hidden; border: 1px solid #273552; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #1e2a42; font-size: 13px; }
th { background: #182338; color: #9db0cc; font-weight: 600; }
tr:last-child td { border-bottom: 0; }
.rank { width: 48px; color: #ff9b5a; font-weight: 700; font-variant-numeric: tabular-nums; }
.amt { font-variant-numeric: tabular-nums; color: #7dffb3; font-weight: 600; }
.empty { padding: 48px 20px; text-align: center; color: #8aa0c0; }
.hint { margin-top: 10px; font-size: 12px; color: #6f84a6; }
.raw { margin-top: 18px; }
.raw summary { cursor: pointer; color: #8ec5ff; font-size: 13px; }
pre { background: #0d1524; border: 1px solid #273552; border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; color: #c9d6ea; white-space: pre-wrap; word-break: break-word; }
</style>
</head>
<body>
<header>
<h1>红包领取台</h1>
<span class="stat" id="pktCount">0 个红包</span>
<span class="stat" id="updated">-</span>
<a class="nav" href="/">← 返回消息台</a>
<button onclick="loadData()">刷新</button>
<button class="secondary" onclick="clearAll()">清空全部</button>
</header>
<div class="layout">
<aside class="sidebar">
<h2>红包列表</h2>
<div id="list"></div>
</aside>
<main class="main">
<div id="empty" class="empty">
暂无红包数据<br/>
<div class="hint">打开 Money Packet 历史页可自动拉取;若没有数据,先手动点开任意一条详情一次(缓存登录模板),再回历史页</div>
</div>
<div id="panel" style="display:none">
<h2 class="title" id="title">-</h2>
<div class="sub" id="sub">-</div>
<div class="cards">
<div class="card"><div class="k">领取人数</div><div class="v" id="cPeople">0</div></div>
<div class="card"><div class="k">领取合计</div><div class="v" id="cSum">0</div></div>
<div class="card"><div class="k">抓取次数</div><div class="v" id="cSnap">0</div></div>
<div class="card"><div class="k">红包总额</div><div class="v" id="cTotal">-</div></div>
</div>
<table>
<thead><tr><th class="rank">排名</th><th>昵称</th><th>领取金额</th><th>领取时间</th></tr></thead>
<tbody id="board"></tbody>
</table>
<div class="raw"><details><summary>查看原始数据</summary><pre id="raw"></pre></details></div>
</div>
</main>
</div>
<script>
let packets = [];
let activeId = null;
async function loadData() {
const res = await fetch('/api/mmp');
packets = await res.json();
document.getElementById('pktCount').textContent = packets.length + ' 个红包';
document.getElementById('updated').textContent = '更新: ' + new Date().toLocaleTimeString();
const list = document.getElementById('list');
list.innerHTML = '';
const empty = document.getElementById('empty');
const panel = document.getElementById('panel');
if (!packets.length) {
empty.style.display = 'block';
panel.style.display = 'none';
activeId = null;
return;
}
empty.style.display = 'none';
panel.style.display = 'block';
if (!activeId || !packets.find(p => p.packetId === activeId)) {
activeId = packets[0].packetId;
}
for (const p of packets) {
const div = document.createElement('div');
div.className = 'pkt' + (p.packetId === activeId ? ' active' : '');
div.onclick = () => { activeId = p.packetId; loadData(); };
div.innerHTML = `
<div class="pkt-id">${esc(shortId(p.packetId))} · ${esc((p.sender || '红包') + ' RM' + (p.sumClaimed ?? p.total ?? 0))}</div>
<div class="pkt-meta">${p.claimantCount || 0} 人领取 · ${esc(p.latestAt || '')}</div>`;
list.appendChild(div);
}
renderActive();
}
function shortId(id) { def _parse_mmp_time_ms(text):
const s = String(id || ''); s = (text or "").strip()
if (s.length >= 8 && s.indexOf('-') > 0) return s.slice(0, 8); if not s:
return s.slice(0, 16) || '-'; 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
function renderActive() {
const p = packets.find(x => x.packetId === activeId);
if (!p) return;
document.getElementById('title').textContent =
(p.sender ? p.sender + ' 的红包' : (p.title || '红包详情'));
const parts = [];
if (p.packetId) parts.push('activityId ' + p.packetId);
if (p.sender) parts.push('发送人 ' + p.sender);
if (p.group) parts.push('群组 ' + p.group);
if (p.via) parts.push('来源 ' + (p.via === 'http' ? '网络' : p.via === 'gson' ? '解析' : p.via === 'rpc' ? 'RPC' : p.via));
document.getElementById('sub').textContent = parts.join(' · ') || '单个红包领取排行';
document.getElementById('cPeople').textContent = p.claimantCount || 0;
document.getElementById('cSum').textContent = Number(p.sumClaimed ?? 0).toFixed(2);
document.getElementById('cSnap').textContent = p.snapshots || 0;
document.getElementById('cTotal').textContent = p.total || '-';
const board = document.getElementById('board');
board.innerHTML = '';
const rows = p.leaderboard || [];
if (!rows.length) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="4" style="color:#8aa0c0;text-align:center">暂无领取记录</td>';
board.appendChild(tr);
}
for (const row of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="rank">${row.rank}</td>
<td>${esc(row.nickname)}</td>
<td class="amt">${esc(row.amountText || Number(row.amount).toFixed(2))}</td>
<td>${esc(row.claimTime || '-')}</td>`;
board.appendChild(tr);
}
const raw = (p.rawMessages || []).map(m =>
'[' + (m.receivedAt || '') + '] ' + (m.source || '') + '\\n' + (m.content || '')
).join('\\n---\\n');
document.getElementById('raw').textContent = raw || '(无)';
}
async function clearAll() { def _is_terminal_status(status):
await fetch('/api/messages', { method: 'DELETE' }); s = (status or "").upper()
activeId = null; if not s:
loadData(); return False
} return any(k in s for k in (
function esc(s) { "FINISH", "COMPLETE", "EXPIRE", "ENDED", "CLOSED", "CANCEL", "DONE"))
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
loadData(); def _is_expire_passed(expire_text):
setInterval(loadData, 2000); ms = _parse_mmp_time_ms(expire_text)
</script> return ms > 0 and int(time.time() * 1000) >= ms
</body>
</html>"""
return html.encode("utf-8") 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():
@@ -527,7 +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> <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>
@@ -649,7 +1119,15 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
return return
if path == "/mmp": if path == "/mmp":
body = _mmp_html_page() 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_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -669,6 +1147,12 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/mmp": if path == "/api/mmp":
_json_response(self, 200, _mmp_packets()) _json_response(self, 200, _mmp_packets())
return 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
@@ -676,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
@@ -709,19 +1215,33 @@ 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("通用调试台: http://127.0.0.1:{0}/".format(PORT)) print("通用调试台: http://127.0.0.1:{0}/".format(PORT))
print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT)) print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT))
print("手机同步: POST /api/mmp/sync → 落盘 mmp_packets.json电脑打开 /mmp 即可查看")
print("手机经 USB: adb reverse tcp:8765 tcp:8765走 127.0.0.1") print("手机经 USB: adb reverse tcp:8765 tcp:8765走 127.0.0.1")
print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可") print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效") print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")

View File

@@ -7,11 +7,28 @@
抓取 TNG eWallet **Money Packet红包** 的领取排行:谁领了、领了多少,并在 PC 调试台按**单个红包**展示。 抓取 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 | | 本机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` | | 局域网 / 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/ |
@@ -55,36 +72,65 @@ powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
消费端详情领取列表字段主要是 **`activityPoolInfos`**`receiverName` + `amount`),不是商户版的 `receiverList` 消费端详情领取列表字段主要是 **`activityPoolInfos`**`receiverName` + `amount`),不是商户版的 `receiverList`
## 推荐操作流程(自动拉详情) ## 推荐操作流程(自动拉历史 + 详情)
历史页本身没有排行数据。自动拉取需要一次「带登录态的详情请求」作模板 登录后**无需再手动点详情灌模板**,也**不必反复进历史页**
1. 打开 TNG **Money Packet** 1. 打开 TNG 并完成登录 / PIN任意前台页面即可
2. **先手动点开任意一条历史红包详情**(看到排行榜即可) 2. Hook 从 `ILoginStorage` / 历史请求自动取 `sessionId``Activity.onResume` 按设置冷却主动调 `moneyPacketHistoryList`
- 日志:`cached detail request template`(缓存 session / 请求模板) 3. 历史返回后自动按 `activityId` 串行拉详情(间隔见设置),推送到领取台
3. **返回历史列表**,停留几秒 4. 仅打开 **Money Packet 历史页**时App 自己的列表响应也会立刻触发步骤 3
- 日志:`history hit → schedule auto detail` 5. 打开 http://127.0.0.1:8765/mmp ;点 **设置** 可调刷新速度,保存后约 10 秒内生效
- 随后:`auto-detail using template` / `auto-detail ok activityId=... claims=N`
4. 刷新领取台http://127.0.0.1:8765/mmp
若跳过第 2 步,自动请求常返回 `totalCount:0`(缺 `sessionId` 等),台上会没有领取人。 日志期望:
也可继续「只点详情」:每进一个详情就会捕获该 `activityId` 的排行。 - `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` 区分红包 | | 标识 | 以 `activityId` 区分红包 |
| 短时去重 | 同一 `activityId` + **相同领取名单**,约 **20 秒内**不重复推送(防双 Hook / 连点) | | sessionId | 自动:`ILoginStorage` / Repository.loginStorage有真实请求时也会缓存 |
| 可刷新 | 超过 20 秒再进详情,或领取名单变化,会再次推送 | | 自动历史 | 前台 `onResume` 触发;冷却见设置「历史列表冷却」 |
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后约 **60 秒**内不重复自动拉 | | 短时去重 | 同一 `activityId` + **相同领取名单**,「相同名单去重」秒内不重复推送 |
| 调试台 | 同一 `activityId` 只保留最新完整快照做排行;双通道推送有短时内容去重 | | 自动拉冷却 | 同一 `activityId` 自动拉详情成功后,「详情刷新冷却」内不重复自动拉 |
| 包间间隔 | 自动拉详情串行,「详情请求间隔」毫秒/条 |
| 无模板 | 「无模板时自动打开历史页」可关,避免突然跳转 |
| 调试台 | 同一 `activityId` 只保留最新完整快照;双通道短时内容去重 |
预设:**极速**(约 5s/ **标准**(约 8s/ **省流**(约 3045s
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。 日志里若出现 `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 ```text
adb logcat | findstr TngMmp adb logcat | findstr TngMmp
``` ```
@@ -95,26 +141,31 @@ adb logcat | findstr TngMmp
|------|------| |------|------|
| `TngMmp installed` | Hook 已加载 | | `TngMmp installed` | Hook 已加载 |
| `cached RPC task MoneyPacketRpcTask` | 已缓存 RPC 代理 | | `cached RPC task MoneyPacketRpcTask` | 已缓存 RPC 代理 |
| `cached detail request template` | 已缓存手动详情请求(自动拉可用 | | `cached detail request template` | 已缓存手动详情请求(可选加速 |
| `history hit → schedule auto detail` | 历史列表触发自动拉 | | `login from storage session=…` | 已从 ILoginStorage 自动取到 session |
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或模板未缓存 | | `auto-history ok → schedule details` | 已自动拉历史列表 |
| `auto-detail ok activityId=... claims=N` | 自动拉成功 | | `auto-detail empty claims ... totalCount:0` | 请求缺登录态或服务端空结果 |
| `auto-detail ok activityId=... claims=N` | 自动拉详情成功 |
| `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 | | `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 |
其它检查: 其它检查:
1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK 1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK(期望 **1.2.0 / versionCode 3**
2. 强停后再开 TNG使新 Hook 生效 2. 强停后再开 TNG使新 Hook 生效;领取台状态栏应显示「运行中:最新」
3. 调试台进程在跑USB 时执行过 `adb reverse` 3. 领取人重名时看详情里的 `ID``receiverId`),不要只看昵称
4. notiMessage 监听列表勾选了 TNGHook 转发依赖主 App 接收广播) 4. 调试台进程在跑USB 时执行过 `adb reverse`
5. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App 5. notiMessage 监听列表勾选了 TNGHook 转发依赖主 App 接收广播)
6. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App
## 相关文件 ## 相关文件
| 文件 | 作用 | | 文件 | 作用 |
|------|------| |------|------|
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、金额/`activityId` 解析 | | `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、轮询领取台设置 |
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp` | | `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/.../AppConfig.java` | `DEBUG_SERVER_URLS` 双地址 |
| `app/.../network/DebugForwarder.java` | 向多个调试地址推送 | | `app/.../network/DebugForwarder.java` | 向多个调试地址推送 |
| `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse | | `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse |

View File

@@ -26,8 +26,9 @@
### 要点 ### 要点
1. 历史列表只有摘要;领取排行来自详情 RPC`activityPoolInfos`)。 1. 历史列表只有摘要;领取排行来自详情 RPC`activityPoolInfos`)。
2. **自动拉详情**:先手动点开任意一条详情缓存请求模板(含 session再回历史页即可批量拉 2. **sessionId**`ILoginStorage` 自动读取,一般不必再手动点详情
3. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。 3. **自动拉历史**:前台 Activity resume约 45s 冷却)主动调 `moneyPacketHistoryList`,再级联拉详情。
4. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。
### eKYC「验证您的帐户」强制页2026-08-04 ### eKYC「验证您的帐户」强制页2026-08-04

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;
@@ -41,21 +48,66 @@ public final class TngMoneyPacketHook {
private static final java.util.HashMap<String, Long> RECENT_PACKET_AT = new java.util.HashMap<>(); private static final java.util.HashMap<String, Long> RECENT_PACKET_AT = new java.util.HashMap<>();
/** activityId → 领取指纹 */ /** activityId → 领取指纹 */
private static final java.util.HashMap<String, String> RECENT_PACKET_CLAIMS = new java.util.HashMap<>(); private static final java.util.HashMap<String, String> RECENT_PACKET_CLAIMS = new java.util.HashMap<>();
/** 同一 activityId + 相同领取名单20 秒内不重复推(防双 Hook / 连点);超时可刷新 */ /** 同一 activityId + 相同领取名单短时不重复推(可由领取台设置覆盖) */
private static final long PACKET_DEDUP_MS = 20_000L; 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.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 java.util.HashMap<String, Long> AUTO_DETAIL_AT = new java.util.HashMap<>();
private static final long AUTO_DETAIL_COOLDOWN_MS = 60_000L; 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 Object sMoneyPacketRpcTask;
private static volatile ClassLoader sAppClassLoader; private static volatile ClassLoader sAppClassLoader;
private static volatile String sCachedUserId; private static volatile String sCachedUserId;
private static volatile String sCachedSessionId; private static volatile String sCachedSessionId;
private static volatile String sCachedLoginId; private static volatile String sCachedLoginId;
/** 手动打开详情时抓到的真实请求,自动拉详情时克隆它 */ /** 真实详情/历史请求模板(有则克隆;无则用 ILoginStorage 自造) */
private static volatile Object sTemplateDetailRequest; 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 = private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> { java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "TngMmp-auto-detail"); Thread t = new Thread(r, "TngMmp-auto-detail");
@@ -75,7 +127,134 @@ public final class TngMoneyPacketHook {
hookResponseBody(lpparam); hookResponseBody(lpparam);
hookGsonFromJson(lpparam); hookGsonFromJson(lpparam);
hookQuakeRpc(lpparam); hookQuakeRpc(lpparam);
XposedBridge.log(TAG + " installed for " + lpparam.packageName); 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) {
@@ -209,9 +388,10 @@ public final class TngMoneyPacketHook {
} }
private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) { private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
// 只挂 TNG 封装层,避免与底层 RpcInvocationHandler 双触发导致重复 // TNG 封装 + Quake 底层都挂,避免历史/详情只走其中一条链路时漏抓
String[] rpcClasses = { String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost", "my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
}; };
XC_MethodHook hook = new XC_MethodHook() { XC_MethodHook hook = new XC_MethodHook() {
@Override @Override
@@ -225,9 +405,26 @@ public final class TngMoneyPacketHook {
if (op == null) { if (op == null) {
return; return;
} }
XposedBridge.log(TAG + " rpc op=" + op
+ " via=" + param.thisObject.getClass().getSimpleName());
Object result = param.getResult(); Object result = param.getResult();
// 历史列表:无领取排行,但可用 activityId 自动拉详情 // 历史列表:无领取排行,但可用 activityId 自动拉详情
if (isHistoryRpcOp(op)) { 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"); XposedBridge.log(TAG + " history hit → schedule auto detail");
scheduleAutoDetailsFromHistory(result); scheduleAutoDetailsFromHistory(result);
return; return;
@@ -248,9 +445,11 @@ public final class TngMoneyPacketHook {
return; return;
} }
MmpSnapshot probe = parseSnapshot(json); MmpSnapshot probe = parseSnapshot(json);
if (probe != null && !probe.claims.isEmpty()) { enrichSnapshotFromJavaResult(result, probe);
if (probe != null && (!probe.claims.isEmpty() || snapshotHasPacketMeta(probe))) {
if (detailReq != null) { if (detailReq != null) {
sTemplateDetailRequest = cloneRequestShallow(detailReq); sTemplateDetailRequest = cloneRequestShallow(detailReq);
sLoginDonorRequest = detailReq;
cacheLoginFromRequestObject(detailReq); cacheLoginFromRequestObject(detailReq);
XposedBridge.log(TAG + " cached detail request template" XposedBridge.log(TAG + " cached detail request template"
+ " userId=" + stringField(detailReq, "userId", "getUserId") + " userId=" + stringField(detailReq, "userId", "getUserId")
@@ -259,7 +458,7 @@ public final class TngMoneyPacketHook {
+ " page=" + stringField(detailReq, "page", "getPage") + " page=" + stringField(detailReq, "page", "getPage")
+ " max=" + stringField(detailReq, "maxResult", "getMaxResult")); + " max=" + stringField(detailReq, "maxResult", "getMaxResult"));
} }
forwardParsedJson(json, op, "rpc"); forwardSnapshot(probe, op, "rpc");
} else { } else {
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json; String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet); XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
@@ -287,6 +486,105 @@ public final class TngMoneyPacketHook {
XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage()); 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) { private static boolean isHistoryRpcOp(String op) {
@@ -331,6 +629,7 @@ public final class TngMoneyPacketHook {
if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) { if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) {
sMoneyPacketRpcTask = proxy; sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task " + name); XposedBridge.log(TAG + " cached RPC task " + name);
scheduleAutoHistory("rpc-task-cached");
return; return;
} }
} }
@@ -341,6 +640,7 @@ public final class TngMoneyPacketHook {
sAppClassLoader)); sAppClassLoader));
sMoneyPacketRpcTask = proxy; sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method"); XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method");
scheduleAutoHistory("rpc-task-cached");
} catch (Throwable ignored) { } catch (Throwable ignored) {
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
@@ -419,6 +719,26 @@ public final class TngMoneyPacketHook {
} catch (Throwable ignored) { } catch (Throwable ignored) {
trySet(req, "setSenderUserId", senderUserId); 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) {
}
}
} }
@@ -456,46 +776,75 @@ public final class TngMoneyPacketHook {
if (historyResult == null) { if (historyResult == null) {
return; return;
} }
AUTO_DETAIL_EXEC.execute(() -> { pollMmpSettings(false);
try { // 解析放调用线程;真正 RPC 必须丢到主线程Quake/登录态常绑主线程)
List<String[]> jobs = extractHistoryJobs(historyResult); final List<String[]> jobs;
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size()); try {
for (String[] job : jobs) { jobs = extractHistoryJobs(historyResult);
final String activityId = job[0]; } catch (Throwable t) {
final String senderUserId = job[1]; XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
long now = System.currentTimeMillis(); return;
synchronized (AUTO_DETAIL_AT) { }
Long last = AUTO_DETAIL_AT.get(activityId); XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
if (last != null && now - last < AUTO_DETAIL_COOLDOWN_MS) { if (jobs.isEmpty()) {
continue; return;
} }
if (!AUTO_DETAIL_PENDING.add(activityId)) { long delayMs = 0L;
continue; 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);
} }
try { boolean ok = fetchDetailByActivityId(activityId, senderUserId);
boolean ok = fetchDetailByActivityId(activityId, senderUserId); if (ok) {
if (ok) {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
}
}
} finally {
synchronized (AUTO_DETAIL_AT) { synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_PENDING.remove(activityId); AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
} }
} }
try { } catch (InterruptedException ie) {
Thread.sleep(400); Thread.currentThread().interrupt();
} catch (InterruptedException ie) { } finally {
Thread.currentThread().interrupt(); synchronized (AUTO_DETAIL_AT) {
return; AUTO_DETAIL_PENDING.remove(activityId);
} }
} }
} catch (Throwable t) { });
XposedBridge.log(TAG + " auto-detail schedule failed: " + t.getMessage()); }
}
});
} }
private static List<String[]> extractHistoryJobs(Object historyResult) { private static List<String[]> extractHistoryJobs(Object historyResult) {
@@ -521,8 +870,30 @@ public final class TngMoneyPacketHook {
} }
String activityId = stringField(info, "activityId", "getActivityId"); String activityId = stringField(info, "activityId", "getActivityId");
String senderUserId = stringField(info, "senderUserId", "getSenderUserId"); String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
String createTime = stringField(info, "createTime", "getCreateTime");
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) { if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
jobs.add(new String[]{activityId, 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) { } catch (Throwable t) {
@@ -549,8 +920,338 @@ public final class TngMoneyPacketHook {
return null; 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) { private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
ClassLoader cl = sAppClassLoader; ClassLoader cl = sAppClassLoader;
ensureRpcTaskAndLogin(cl);
Object task = sMoneyPacketRpcTask; Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) { if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId); XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
@@ -572,25 +1273,20 @@ public final class TngMoneyPacketHook {
XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId); XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId);
return false; return false;
} }
fillLoginOnRequest(req, cl);
} }
cacheLoginFromRequestObject(req); fillLoginOnRequest(req, cl);
prepareRpcRequest(req);
XposedBridge.log(TAG + " auto-detail req id=" + activityId XposedBridge.log(TAG + " auto-detail req id=" + activityId
+ " userId=" + stringField(req, "userId", "getUserId") + " userId=" + stringField(req, "userId", "getUserId")
+ " session=" + abbreviate(stringField(req, "sessionId", "getSessionId")) + " session=" + abbreviate(stringField(req, "sessionId", "getSessionId"))
+ " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId")) + " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId"))
+ " page=" + stringField(req, "page", "getPage") + " page=" + stringField(req, "page", "getPage")
+ " max=" + stringField(req, "maxResult", "getMaxResult")); + " max=" + stringField(req, "maxResult", "getMaxResult"));
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId")) if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
&& TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
+ " (先手动点开任意一个红包详情一次以缓存模板)"); + " (ILoginStorage 尚未就绪,等登录后 Activity resume 会重试)");
return false; return false;
} }
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))
&& !TextUtils.isEmpty(sCachedSessionId)) {
fillLoginOnRequest(req, cl);
}
Object result = XposedHelpers.callMethod(task, "moneyPacketDetail", req); Object result = XposedHelpers.callMethod(task, "moneyPacketDetail", req);
if (result == null) { if (result == null) {
XposedBridge.log(TAG + " auto-detail null result id=" + activityId); XposedBridge.log(TAG + " auto-detail null result id=" + activityId);
@@ -605,31 +1301,56 @@ public final class TngMoneyPacketHook {
return false; return false;
} }
MmpSnapshot probe = parseSnapshot(json); MmpSnapshot probe = parseSnapshot(json);
if (probe == null || probe.claims.isEmpty()) { 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; String snippet = json.length() > 240 ? json.substring(0, 240) + "..." : json;
XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId
+ " body=" + snippet + " body=" + snippet
+ " userId=" + stringField(req, "userId", "getUserId")); + " userId=" + stringField(req, "userId", "getUserId"));
return false; 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;
} }
forwardParsedJson(json, "auto:" + activityId, "rpc"); rememberPacketData(probe.packetId != null ? probe.packetId : activityId, probe, result);
forwardSnapshot(probe, "auto:" + activityId, "rpc");
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
+ " claims=" + probe.claims.size()); + " claims=" + probe.claims.size()
+ " issued=" + probe.issueTime
+ (COMPLETED_PACKETS.contains(activityId) ? " DONE" : ""));
noteSessionAlive();
return true; return true;
} catch (Throwable t) { } catch (Throwable t) {
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage()); XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
markMaybeDisconnected(t, "auto-detail");
return false; return false;
} }
} }
private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) { private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) {
// 优先: (activityId, senderUserId, loadTime, page, maxResult) // 优先: (activityId, senderUserId, loadTime, page, maxResult) — maxResult 拉大以尽量一次取全
try { try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 20); return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200);
} catch (Throwable ignored) { } catch (Throwable ignored) {
} }
try { try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 20, return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200,
31, null); 31, null);
} catch (Throwable ignored) { } catch (Throwable ignored) {
} }
@@ -637,14 +1358,7 @@ public final class TngMoneyPacketHook {
Object req = reqCls.getDeclaredConstructor().newInstance(); Object req = reqCls.getDeclaredConstructor().newInstance();
XposedHelpers.setObjectField(req, "activityId", activityId); XposedHelpers.setObjectField(req, "activityId", activityId);
XposedHelpers.setObjectField(req, "senderUserId", senderUserId); XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
try { bumpDetailPageSize(req);
XposedHelpers.setIntField(req, "page", 0);
} catch (Throwable ignored) {
}
try {
XposedHelpers.setIntField(req, "maxResult", 20);
} catch (Throwable ignored) {
}
return req; return req;
} catch (Throwable t) { } catch (Throwable t) {
XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage()); XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage());
@@ -653,7 +1367,20 @@ public final class TngMoneyPacketHook {
} }
private static void fillLoginOnRequest(Object req, ClassLoader cl) { private static void fillLoginOnRequest(Object req, ClassLoader cl) {
// 1) 优先用历史请求里缓存的登录态 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, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId); trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId); trySet(req, "setLoginId", sCachedLoginId);
@@ -673,37 +1400,60 @@ public final class TngMoneyPacketHook {
&& !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) { && !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
return; return;
} }
// 2) Hilt EntryPoint 兜底 // 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 { try {
Object repo = obtainMoneyPacketRepository(cl); if (!TextUtils.isEmpty(sCachedUserId)) {
if (repo == null) { XposedHelpers.setObjectField(req, "userId", sCachedUserId);
return;
} }
Object loginStorage = null; 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 { try {
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage"); 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) { } catch (Throwable ignored) {
} }
if (loginStorage == null) { }
return; // extParams 等 Map 也尽量带上
try {
Object ext = XposedHelpers.getObjectField(src, "extParams");
if (ext != null) {
XposedHelpers.setObjectField(dst, "extParams", ext);
} }
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId"); } catch (Throwable ignored) {
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
trySet(req, "setUserId", accountId);
trySet(req, "setSessionId", sessionId);
trySet(req, "setLoginId", loginId);
if (accountId != null) {
sCachedUserId = String.valueOf(accountId);
}
if (sessionId != null) {
sCachedSessionId = String.valueOf(sessionId);
}
if (loginId != null) {
sCachedLoginId = String.valueOf(loginId);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " fillLogin skip: " + t.getMessage());
} }
} }
@@ -717,30 +1467,461 @@ public final class TngMoneyPacketHook {
} }
} }
private static Object obtainMoneyPacketRepository(ClassLoader cl) { /** 被动缓存App 任何地方读 session 时同步到本 Hook */
private static void hookLoginStorage(XC_LoadPackage.LoadPackageParam lpparam) {
// 不能 hook 接口抽象方法;只挂实现类
try { try {
Context ctx = getContext(); Class<?> impl = XposedHelpers.findClass(
if (ctx == null) { "my.com.tngdigital.user.model.CacheLoginStorageImpl", lpparam.classLoader);
return null; 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);
} }
Class<?> entryPoint = Class.forName( XposedBridge.log(TAG + " hooked CacheLoginStorageImpl getters");
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint", } catch (Throwable t) {
false, cl); XposedBridge.log(TAG + " CacheLoginStorageImpl hook skip: " + t.getMessage());
Class<?> entryPoints = Class.forName("dagger.hilt.EntryPoints", false, cl); }
Object ep = XposedHelpers.callStaticMethod( }
entryPoints, "get", ctx.getApplicationContext(), entryPoint);
/** 前台 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 { try {
return XposedHelpers.callMethod(ep, "repository"); // 等登录/Hilt/Network 就绪;不要在主线程阻塞等 RPC
} catch (Throwable ignored) { 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;
} }
for (Method m : ep.getClass().getMethods()) { });
if (m.getParameterTypes().length == 0 }
&& m.getReturnType().getName().contains("MoneyPacketRepository")) {
return m.invoke(ep); /** 主动调 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) { } catch (Throwable t) {
XposedBridge.log(TAG + " obtain repo fail: " + t.getMessage()); 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; return null;
} }
@@ -783,6 +1964,19 @@ public final class TngMoneyPacketHook {
if (snapshot == null || snapshot.claims.isEmpty()) { if (snapshot == null || snapshot.claims.isEmpty()) {
return; return;
} }
forwardSnapshot(snapshot, sourceHint, channel);
}
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)) { if (!shouldForwardPacket(snapshot)) {
return; return;
} }
@@ -799,7 +1993,222 @@ public final class TngMoneyPacketHook {
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 activityId=" + 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) {
@@ -905,6 +2314,8 @@ public final class TngMoneyPacketHook {
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName"); putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount", putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
"totalPacketAmount", "originalAmount"); "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),
@@ -925,7 +2336,13 @@ public final class TngMoneyPacketHook {
continue; continue;
} }
String keyLower = key.toLowerCase(Locale.US); String keyLower = key.toLowerCase(Locale.US);
if (keyLower.equals("activityid") || keyLower.contains("packet") 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("mmp") || keyLower.contains("order")
|| keyLower.contains("biz") || keyLower.equals("packetcode")) { || keyLower.contains("biz") || keyLower.equals("packetcode")) {
// 已有 UUID 形态的 activityId 时不覆盖 // 已有 UUID 形态的 activityId 时不覆盖
@@ -998,12 +2415,27 @@ public final class TngMoneyPacketHook {
ClaimLine line = new ClaimLine(); ClaimLine line = new ClaimLine();
line.nickname = name.trim(); 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(
readFieldText(obj, "claimTime"), readFieldText(obj, "claimTime"),
readFieldText(obj, "claimedTime"), readFieldText(obj, "claimedTime"),
readFieldText(obj, "receiveTime"), readFieldText(obj, "receiveTime"),
readFieldText(obj, "gmtClaim"), readFieldText(obj, "gmtClaim"),
readFieldText(obj, "gmtModified"),
readFieldText(obj, "gmtModify"),
readFieldText(obj, "gmtCreate"), readFieldText(obj, "gmtCreate"),
readFieldText(obj, "createTime"),
readFieldText(obj, "time")); readFieldText(obj, "time"));
return line; return line;
} }
@@ -1212,12 +2644,13 @@ public final class TngMoneyPacketHook {
} }
String claimsFp = snapshot.claimsFingerprint(); String claimsFp = snapshot.claimsFingerprint();
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
long dedupMs = sPacketDedupMs;
synchronized (RECENT_PACKET_AT) { synchronized (RECENT_PACKET_AT) {
Long lastAt = RECENT_PACKET_AT.get(packetId); Long lastAt = RECENT_PACKET_AT.get(packetId);
String lastClaims = RECENT_PACKET_CLAIMS.get(packetId); String lastClaims = RECENT_PACKET_CLAIMS.get(packetId);
if (lastAt != null && lastClaims != null if (lastAt != null && lastClaims != null
&& claimsFp.equals(lastClaims) && claimsFp.equals(lastClaims)
&& now - lastAt < PACKET_DEDUP_MS) { && now - lastAt < dedupMs) {
XposedBridge.log(TAG + " skip dup activityId=" + packetId XposedBridge.log(TAG + " skip dup activityId=" + packetId
+ " ageMs=" + (now - lastAt)); + " ageMs=" + (now - lastAt));
return false; return false;
@@ -1230,7 +2663,7 @@ public final class TngMoneyPacketHook {
RECENT_PACKET_AT.entrySet().iterator(); RECENT_PACKET_AT.entrySet().iterator();
while (it.hasNext()) { while (it.hasNext()) {
java.util.Map.Entry<String, Long> e = it.next(); java.util.Map.Entry<String, Long> e = it.next();
if (now - e.getValue() > PACKET_DEDUP_MS * 3) { if (now - e.getValue() > Math.max(dedupMs, 1L) * 3) {
String k = e.getKey(); String k = e.getKey();
it.remove(); it.remove();
RECENT_PACKET_CLAIMS.remove(k); RECENT_PACKET_CLAIMS.remove(k);
@@ -1269,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);
@@ -1287,6 +2735,15 @@ 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() {
@@ -1304,7 +2761,34 @@ public final class TngMoneyPacketHook {
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)) {
@@ -1319,7 +2803,30 @@ public final class TngMoneyPacketHook {
if (!TextUtils.isEmpty(totalAmount)) { if (!TextUtils.isEmpty(totalAmount)) {
sb.append(" | total=").append(sanitizeMeta(totalAmount)); sb.append(" | total=").append(sanitizeMeta(totalAmount));
} }
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(" | 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)) {
String src = sourceHint.length() > 120 String src = sourceHint.length() > 120
? sourceHint.substring(0, 120) + "..." : sourceHint; ? sourceHint.substring(0, 120) + "..." : sourceHint;
@@ -1331,6 +2838,12 @@ 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();
@@ -1347,6 +2860,8 @@ public final class TngMoneyPacketHook {
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;
} }