feat(mmp): 独立红包领取台(手机+PC)、同步、筛选与功能说明
隔离通用消息台;支持设置/手气摘要/电脑同步;忽略临时逆向脚本与运行时 JSON。
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -28,8 +28,17 @@ reverse/frida/logcat_capture.txt
|
||||
reverse/frida/*.out
|
||||
reverse/frida/*.err
|
||||
debug-server/__pycache__/
|
||||
# 调试台运行时落盘(设置/红包镜像,本机生成)
|
||||
debug-server/mmp_packets.json
|
||||
debug-server/mmp_settings.json
|
||||
magisk-modules/tng_exit_guard/obj/
|
||||
magisk-modules/tng_exit_guard/libs/
|
||||
# 调试抓包/截图/ANR/APK 产物,不入库
|
||||
reverse/dumps/
|
||||
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
|
||||
|
||||
@@ -74,6 +74,18 @@
|
||||
<activity
|
||||
android:name=".Activity.BankListActivity"
|
||||
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
|
||||
android:name=".Activity.PermissionActivity"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -72,6 +72,10 @@ public class MainActivity extends MiracleGardenActivity<ActivityMainBinding> {
|
||||
startActivity(new Intent(this, BankListActivity.class));
|
||||
});
|
||||
|
||||
binding.btnMmpClaim.setOnClickListener(v -> {
|
||||
startActivity(new Intent(this, MmpClaimActivity.class));
|
||||
});
|
||||
|
||||
binding.permissionNotificationAccess.setOnClickListener(v -> {
|
||||
startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
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.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.MmpPacket;
|
||||
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
|
||||
import com.miraclegarden.smsmessage.mmp.MmpSettingsClient;
|
||||
import com.miraclegarden.smsmessage.mmp.MmpSyncClient;
|
||||
|
||||
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;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
initView();
|
||||
reload();
|
||||
loadSettingsQuiet();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
MmpPacketStore.addListener(this);
|
||||
reload();
|
||||
MmpSyncClient.syncIfStale(this, 5000L);
|
||||
handler.removeCallbacks(refreshRunnable);
|
||||
handler.postDelayed(refreshRunnable, 2000);
|
||||
}
|
||||
|
||||
@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.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.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)));
|
||||
|
||||
android.widget.TextView status = holder.getView(R.id.tv_status);
|
||||
if (packet.finished) {
|
||||
status.setText("已领完");
|
||||
status.setTextColor(Color.parseColor("#047857"));
|
||||
status.setBackgroundColor(Color.parseColor("#ECFDF5"));
|
||||
} else {
|
||||
status.setText("领取中");
|
||||
status.setTextColor(Color.parseColor("#B45309"));
|
||||
status.setBackgroundColor(Color.parseColor("#FFFBEB"));
|
||||
}
|
||||
|
||||
String meta = packet.claimantCount + " 人";
|
||||
if (packet.issuedAt != null && !packet.issuedAt.isEmpty()) {
|
||||
meta += " · 发放 " + packet.issuedAt;
|
||||
}
|
||||
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();
|
||||
if (best != null) {
|
||||
String amt = best.amountText != null ? best.amountText : formatMoney(best.amount);
|
||||
String nick = best.nickname != null ? best.nickname : "?";
|
||||
holder.setText(R.id.tv_best, hi + " " + nick + " " + amt);
|
||||
} else {
|
||||
holder.setText(R.id.tv_best, hi + " -");
|
||||
}
|
||||
if (worst != null && packet.claimantCount > 1) {
|
||||
String amt = worst.amountText != null ? worst.amountText : formatMoney(worst.amount);
|
||||
String nick = worst.nickname != null ? worst.nickname : "?";
|
||||
holder.setText(R.id.tv_worst, lo + " " + nick + " " + amt);
|
||||
} else {
|
||||
holder.setText(R.id.tv_worst, lo + " -");
|
||||
}
|
||||
holder.setText(R.id.tv_id, packet.shortId());
|
||||
holder.itemView.setOnClickListener(v -> openDetail(packet.packetId));
|
||||
}
|
||||
};
|
||||
binding.recyclerview.setAdapter(adapter);
|
||||
updateChipUi();
|
||||
updateTimeChipUi();
|
||||
}
|
||||
|
||||
private void reload() {
|
||||
allPackets.clear();
|
||||
allPackets.addAll(MmpPacketStore.getPackets(this));
|
||||
applyFilter();
|
||||
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 toggleSettings() {
|
||||
settingsOpen = !settingsOpen;
|
||||
binding.settingsPanel.setVisibility(settingsOpen ? View.VISIBLE : View.GONE);
|
||||
if (settingsOpen) {
|
||||
loadSettingsQuiet();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadSettingsQuiet() {
|
||||
MmpSettingsClient.load((json, error) -> {
|
||||
if (json != null) {
|
||||
fillSettingsForm(json);
|
||||
binding.tvSettingsStatus.setText("已同步调试台设置");
|
||||
} else if (error != null) {
|
||||
binding.tvSettingsStatus.setText("设置未连上:" + 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));
|
||||
}
|
||||
|
||||
private void applyPreset(String name) {
|
||||
try {
|
||||
JSONObject o = new JSONObject();
|
||||
if ("fast".equals(name)) {
|
||||
o.put("historyCooldownSec", 5);
|
||||
o.put("detailCooldownSec", 5);
|
||||
o.put("dedupSec", 2);
|
||||
o.put("detailGapMs", 50);
|
||||
o.put("pagePollMs", 800);
|
||||
o.put("openHistoryIfNoTemplate", true);
|
||||
} 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);
|
||||
} else {
|
||||
o.put("historyCooldownSec", 8);
|
||||
o.put("detailCooldownSec", 8);
|
||||
o.put("dedupSec", 3);
|
||||
o.put("detailGapMs", 80);
|
||||
o.put("pagePollMs", 1000);
|
||||
o.put("openHistoryIfNoTemplate", 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());
|
||||
binding.tvSettingsStatus.setText("保存中…");
|
||||
MmpSettingsClient.save(body, (json, error) -> {
|
||||
if (json != null) {
|
||||
fillSettingsForm(json);
|
||||
binding.tvSettingsStatus.setText("已保存,Hook 约 10 秒内生效");
|
||||
Toast.makeText(this, "设置已保存", Toast.LENGTH_SHORT).show();
|
||||
} 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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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 : "?");
|
||||
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 (packet.finished) {
|
||||
if (sub.length() > 0) {
|
||||
sub.append(" · ");
|
||||
}
|
||||
sub.append("已领完");
|
||||
} else {
|
||||
if (sub.length() > 0) {
|
||||
sub.append(" · ");
|
||||
}
|
||||
sub.append("领取中");
|
||||
}
|
||||
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(best.nickname != null ? best.nickname : "?");
|
||||
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(worst.nickname != null ? worst.nickname : "?");
|
||||
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 double parseDouble(String s) {
|
||||
try {
|
||||
return s == null || s.isEmpty() ? 0 : Double.parseDouble(s);
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.miraclegarden.smsmessage.mmp;
|
||||
|
||||
/**
|
||||
* 单个红包领取人。
|
||||
*/
|
||||
public class MmpClaim {
|
||||
public String nickname;
|
||||
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 : "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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 updatedAt;
|
||||
public boolean finished;
|
||||
public int snapshots;
|
||||
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() {
|
||||
MmpClaim best = bestClaim();
|
||||
MmpClaim worst = worstClaim();
|
||||
String hi = finished ? "手气最佳" : "目前最高";
|
||||
String lo = finished ? "手气最差" : "目前最低";
|
||||
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 + ")";
|
||||
}
|
||||
|
||||
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) {
|
||||
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)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
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 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 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);
|
||||
if (parsed.claims.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String packetId = parsed.meta.get("packet");
|
||||
if (TextUtils.isEmpty(packetId)) {
|
||||
packetId = parsed.meta.get("packetId");
|
||||
}
|
||||
if (TextUtils.isEmpty(packetId) || "unknown".equals(packetId)
|
||||
|| "TNG 红包".equals(packetId) || "TNG Money Packet".equals(packetId)) {
|
||||
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.finished = "1".equals(parsed.meta.get("done"))
|
||||
|| "true".equalsIgnoreCase(parsed.meta.get("done"))
|
||||
|| "FINISHED".equalsIgnoreCase(parsed.meta.get("status"));
|
||||
incoming.updatedAt = nowText();
|
||||
incoming.snapshots = 1;
|
||||
List<MmpClaim> ranked = aggregateClaims(parsed.claims);
|
||||
incoming.leaderboard = ranked;
|
||||
incoming.claimantCount = ranked.size();
|
||||
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 (!incoming.finished && existing.finished) {
|
||||
incoming.finished = true;
|
||||
}
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
String ta = !TextUtils.isEmpty(a.issuedAt) ? a.issuedAt : nullToEmpty(a.updatedAt);
|
||||
String tb = !TextUtils.isEmpty(b.issuedAt) ? b.issuedAt : nullToEmpty(b.updatedAt);
|
||||
return tb.compareTo(ta);
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
public static MmpPacket getPacket(Context context, String packetId) {
|
||||
if (TextUtils.isEmpty(packetId)) {
|
||||
return null;
|
||||
}
|
||||
synchronized (MmpPacketStore.class) {
|
||||
return loadMap(context).get(packetId);
|
||||
}
|
||||
}
|
||||
|
||||
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 ---
|
||||
|
||||
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;
|
||||
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 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)) {
|
||||
RawClaim c = new RawClaim();
|
||||
c.nickname = nick;
|
||||
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 : "?";
|
||||
MmpClaim b = buckets.get(nick);
|
||||
if (b == null) {
|
||||
buckets.put(nick, new MmpClaim(nick, c.amount, c.claimTime));
|
||||
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;
|
||||
}
|
||||
} else if (TextUtils.isEmpty(b.claimTime) && !TextUtils.isEmpty(c.claimTime)) {
|
||||
b.claimTime = c.claimTime;
|
||||
}
|
||||
}
|
||||
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) {
|
||||
rows.add((c.nickname != null ? c.nickname : "?") + "="
|
||||
+ 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("updatedAt", nullToEmpty(p.updatedAt));
|
||||
o.put("finished", p.finished);
|
||||
o.put("snapshots", p.snapshots);
|
||||
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("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.updatedAt = o.optString("updatedAt", "");
|
||||
p.finished = o.optBoolean("finished", false);
|
||||
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.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();
|
||||
}
|
||||
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 "";
|
||||
}
|
||||
|
||||
private static String nowText() {
|
||||
return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
.format(new java.util.Date());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.miraclegarden.smsmessage.mmp;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 从 PC 调试台读写红包拉取设置(与 /mmp 设置同源)。
|
||||
*/
|
||||
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() {
|
||||
}
|
||||
|
||||
public static void load(CallbackJson cb) {
|
||||
EXEC.execute(() -> {
|
||||
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 obj = new JSONObject(resp.body().string());
|
||||
MAIN.post(() -> cb.onResult(obj, null));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
Exception err = last;
|
||||
MAIN.post(() -> cb.onResult(null,
|
||||
err != null ? err.getMessage() : "调试台不可达"));
|
||||
});
|
||||
}
|
||||
|
||||
public static void save(JSONObject body, CallbackJson cb) {
|
||||
EXEC.execute(() -> {
|
||||
Exception last = null;
|
||||
boolean anyOk = false;
|
||||
JSONObject lastOk = 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")
|
||||
.post(RequestBody.create(body.toString(), JSON))
|
||||
.build();
|
||||
try (Response resp = CLIENT.newCall(req).execute()) {
|
||||
if (resp.isSuccessful() && resp.body() != null) {
|
||||
lastOk = new JSONObject(resp.body().string());
|
||||
anyOk = true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
last = e;
|
||||
}
|
||||
}
|
||||
final boolean ok = anyOk;
|
||||
final JSONObject result = lastOk;
|
||||
final Exception err = last;
|
||||
MAIN.post(() -> {
|
||||
if (ok) {
|
||||
cb.onResult(result, null);
|
||||
} else {
|
||||
cb.onResult(null, err != null ? err.getMessage() : "保存失败");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 异步探测,不关心结果时用。 */
|
||||
public static void pingAsync() {
|
||||
load((json, error) -> {
|
||||
if (error != null) {
|
||||
Log.w(TAG, "settings ping: " + error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.miraclegarden.smsmessage.Activity.NotificationActivity;
|
||||
import com.miraclegarden.smsmessage.App;
|
||||
import com.miraclegarden.smsmessage.AppConfig;
|
||||
import com.miraclegarden.smsmessage.MessageInfo;
|
||||
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
|
||||
import com.miraclegarden.smsmessage.network.DebugForwarder;
|
||||
|
||||
/**
|
||||
@@ -58,6 +59,26 @@ public class HookMessageReceiver extends BroadcastReceiver {
|
||||
Log.i(TAG, "hook received: pkg=" + packageName + " source=" + source + " title=" + 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);
|
||||
|
||||
Context appContext = context.getApplicationContext();
|
||||
|
||||
@@ -92,6 +92,16 @@
|
||||
android:backgroundTint="#FF3700B3" />
|
||||
</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
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
553
app/src/main/res/layout/activity_mmp_claim.xml
Normal file
553
app/src/main/res/layout/activity_mmp_claim.xml
Normal file
@@ -0,0 +1,553 @@
|
||||
<?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="56dp"
|
||||
android:background="#111827"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="8dp">
|
||||
|
||||
<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" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="0 个"
|
||||
android:textColor="#FDBA74"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_sync"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:text="同步电脑"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_help"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
android:text="说明"
|
||||
android:textColor="#FDBA74"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_settings"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
android:text="设置"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_clear"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:text="清空"
|
||||
android:textColor="#FECACA"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<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="56dp"
|
||||
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" />
|
||||
|
||||
<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>
|
||||
324
app/src/main/res/layout/activity_mmp_detail.xml
Normal file
324
app/src/main/res/layout/activity_mmp_detail.xml
Normal 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="1"
|
||||
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="1"
|
||||
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>
|
||||
173
app/src/main/res/layout/activity_mmp_help.xml
Normal file
173
app/src/main/res/layout/activity_mmp_help.xml
Normal file
@@ -0,0 +1,173 @@
|
||||
<?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,登录 / 输 PIN\n4. 回到本页等待自动拉取(一般不必再进红包历史页)\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="• 监听列表是否勾选 TNG\n• Xposed 模块是否启用且作用域含 TNG\n• 是否强停过 TNG 再打开并登录\n• 电脑同步失败:调试台是否在跑、8765 是否多开、adb reverse 是否做好\n\n更细的技术说明见仓库 docs/TNG_MoneyPacket领取台.md"
|
||||
android:textColor="#4E342E"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
67
app/src/main/res/layout/item_mmp_claim.xml
Normal file
67
app/src/main/res/layout/item_mmp_claim.xml
Normal file
@@ -0,0 +1,67 @@
|
||||
<?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_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>
|
||||
128
app/src/main/res/layout/item_mmp_packet.xml
Normal file
128
app/src/main/res/layout/item_mmp_packet.xml
Normal file
@@ -0,0 +1,128 @@
|
||||
<?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">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="红包"
|
||||
android:textColor="#111827"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<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" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_id"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:ellipsize="middle"
|
||||
android:maxLines="1"
|
||||
android:text="id"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textSize="10sp" />
|
||||
</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="1"
|
||||
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="1"
|
||||
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>
|
||||
123
debug-server/mmp_help.html
Normal file
123
debug-server/mmp_help.html
Normal file
@@ -0,0 +1,123 @@
|
||||
<!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,登录 / 输 PIN</li>
|
||||
<li>打开手机「红包领取台」等待自动拉取(一般不必再进红包历史页)</li>
|
||||
<li>电脑本页看数据:调试台运行 + <code>adb reverse tcp:8765 tcp:8765</code>,手机点「同步电脑」或打开领取台自动同步</li>
|
||||
</ol>
|
||||
</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://<电脑IP>: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>
|
||||
462
debug-server/mmp_page.html
Normal file
462
debug-server/mmp_page.html
Normal file
@@ -0,0 +1,462 @@
|
||||
<!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; }
|
||||
.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-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;
|
||||
}
|
||||
.badge.done { background: var(--best-bg); color: var(--done); }
|
||||
.badge.open { background: var(--worst-bg); color: var(--open); }
|
||||
.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="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>
|
||||
</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>昵称</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 };
|
||||
|
||||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
function applyPreset(name){
|
||||
if (name==='fast') settings={historyCooldownSec:5,detailCooldownSec:5,dedupSec:2,detailGapMs:50,pagePollMs:800,openHistoryIfNoTemplate:true};
|
||||
else if (name==='slow') settings={historyCooldownSec:30,detailCooldownSec:45,dedupSec:15,detailGapMs:200,pagePollMs:3000,openHistoryIfNoTemplate:true};
|
||||
else settings={historyCooldownSec:8,detailCooldownSec:8,dedupSec:3,detailGapMs:80,pagePollMs:1000,openHistoryIfNoTemplate: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)].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 hiLo(p){
|
||||
return {
|
||||
hi: p&&p.finished?'手气最佳':'目前最高',
|
||||
lo: p&&p.finished?'手气最差':'目前最低'
|
||||
};
|
||||
}
|
||||
|
||||
async function loadData(){
|
||||
packets = await (await fetch('/api/mmp')).json();
|
||||
updated.textContent = '更新 ' + new Date().toLocaleTimeString();
|
||||
renderList();
|
||||
}
|
||||
|
||||
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 badge=p.finished?'<span class="badge done">已领完</span>':'<span class="badge open">领取中</span>';
|
||||
const hiShort = p.finished ? '最佳' : '最高';
|
||||
const loShort = p.finished ? '最差' : '最低';
|
||||
const bestLine = `${hiShort} ${esc(p.bestNick||'-')} ${esc(p.bestAmount||'')}`.trim();
|
||||
const worstLine = (p.claimantCount||0)<=1
|
||||
? `${loShort} -`
|
||||
: `${loShort} ${esc(p.worstNick||'-')} ${esc(p.worstAmount||'')}`.trim();
|
||||
div.innerHTML=`
|
||||
<div class="pkt-row">
|
||||
<div class="pkt-name">${esc(p.sender||p.title||'红包')}</div>
|
||||
<div class="pkt-money">${money(p.sumClaimed??p.total??0)}</div>
|
||||
</div>
|
||||
<div class="pkt-meta">${badge}<span>${p.claimantCount||0}人 · ${esc(p.issuedAt||'时间未知')}</span><span class="pkt-id">${esc(shortId(p.packetId))}</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.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 = p.finished ? '<span class="badge done">已领完</span>' : '<span class="badge open">领取中</span>';
|
||||
luckBestLab.textContent = labels.hi;
|
||||
luckWorstLab.textContent = labels.lo;
|
||||
luckBestName.textContent = p.bestNick || '-';
|
||||
luckBestAmt.textContent = p.bestAmount ? ('RM ' + p.bestAmount) : '-';
|
||||
if((p.claimantCount||0)<=1){
|
||||
luckWorstName.textContent='-'; luckWorstAmt.textContent='-';
|
||||
} else {
|
||||
luckWorstName.textContent = p.worstNick || '-';
|
||||
luckWorstAmt.textContent = p.worstAmount ? ('RM ' + p.worstAmount) : '-';
|
||||
}
|
||||
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>';
|
||||
tr.innerHTML=`
|
||||
<td class="rank">${row.rank}</td>
|
||||
<td class="nick">${nick}</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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
loadSettings().then(loadData);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,7 @@
|
||||
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
@@ -13,10 +14,80 @@ from urllib.parse import urlparse
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 8765
|
||||
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")
|
||||
|
||||
_messages = deque(maxlen=MAX_MESSAGES)
|
||||
_mmp_messages = deque(maxlen=MAX_MESSAGES)
|
||||
_mmp_synced = {} # packetId -> packet dict(手机全量同步,落盘)
|
||||
_lock = threading.Lock()
|
||||
_last_dedup = {"key": None, "ts": 0.0}
|
||||
_mmp_last_dedup = {"key": None, "ts": 0.0}
|
||||
_mmp_settings_lock = threading.Lock()
|
||||
_DEFAULT_MMP_SETTINGS = {
|
||||
"historyCooldownSec": 8,
|
||||
"detailCooldownSec": 8,
|
||||
"dedupSec": 3,
|
||||
"detailGapMs": 80,
|
||||
"pagePollMs": 1000,
|
||||
"openHistoryIfNoTemplate": True,
|
||||
}
|
||||
_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"]))
|
||||
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():
|
||||
@@ -35,7 +106,6 @@ def _add_message(payload):
|
||||
item = dict(payload)
|
||||
item["group"] = _resolve_group(payload)
|
||||
item["receivedAt"] = _now_iso()
|
||||
# 本机 + 局域网双推时可能各成功一次,3 秒内同内容去重
|
||||
dedup_key = "|".join([
|
||||
str(payload.get("source") or ""),
|
||||
str(payload.get("packageName") or ""),
|
||||
@@ -43,17 +113,31 @@ def _add_message(payload):
|
||||
str(payload.get("content") or ""),
|
||||
])
|
||||
now = time.time()
|
||||
is_mmp = _is_mmp_message(item)
|
||||
with _lock:
|
||||
if (_last_dedup["key"] == dedup_key
|
||||
and now - float(_last_dedup["ts"]) < 3.0):
|
||||
if _messages:
|
||||
return _messages[0]
|
||||
_last_dedup["key"] = dedup_key
|
||||
_last_dedup["ts"] = now
|
||||
_messages.appendleft(item)
|
||||
item["id"] = len(_messages)
|
||||
print("[{0}] [{1}] [{2}] {3} | {4}".format(
|
||||
if is_mmp:
|
||||
if (_mmp_last_dedup["key"] == dedup_key
|
||||
and now - float(_mmp_last_dedup["ts"]) < 3.0):
|
||||
if _mmp_messages:
|
||||
return _mmp_messages[0]
|
||||
_mmp_last_dedup["key"] = dedup_key
|
||||
_mmp_last_dedup["ts"] = now
|
||||
_mmp_messages.appendleft(item)
|
||||
item["id"] = len(_mmp_messages)
|
||||
channel = "MMP"
|
||||
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"
|
||||
print("[{0}] [{1}] [{2}] [{3}] {4} | {5}".format(
|
||||
_now_iso(),
|
||||
channel,
|
||||
item["group"],
|
||||
payload.get("source", "?"),
|
||||
payload.get("appName", payload.get("packageName", "")),
|
||||
@@ -62,6 +146,16 @@ def _add_message(payload):
|
||||
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):
|
||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
handler.send_response(status)
|
||||
@@ -75,6 +169,8 @@ def _json_response(handler, status, data):
|
||||
def _group_messages(messages):
|
||||
groups = {}
|
||||
for msg in messages:
|
||||
if _is_mmp_message(msg):
|
||||
continue
|
||||
key = msg.get("group") or _resolve_group(msg)
|
||||
if key not in groups:
|
||||
groups[key] = {
|
||||
@@ -257,10 +353,154 @@ def _aggregate_claims(claims):
|
||||
return result
|
||||
|
||||
|
||||
|
||||
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,
|
||||
"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 ""
|
||||
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,
|
||||
"updatedAt": raw.get("updatedAt") or "",
|
||||
"fetchedAt": raw.get("updatedAt") or raw.get("fetchedAt") or _now_iso(),
|
||||
"latestAt": issued or raw.get("updatedAt") or "",
|
||||
"finished": bool(raw.get("finished")),
|
||||
"snapshots": int(raw.get("snapshots") or 1),
|
||||
"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 []
|
||||
elif isinstance(payload, list):
|
||||
items = payload
|
||||
if not isinstance(items, list):
|
||||
return {"ok": False, "error": "packets must be list", "count": 0}
|
||||
merged = 0
|
||||
with _lock:
|
||||
for raw in items:
|
||||
p = _normalize_synced_packet(raw)
|
||||
if not p:
|
||||
continue
|
||||
key = p["packetId"]
|
||||
old = _mmp_synced.get(key)
|
||||
if old is None or _packet_score(p) >= _packet_score(old):
|
||||
if old and old.get("finished"):
|
||||
p["finished"] = True
|
||||
if old and not p.get("issuedAt") and old.get("issuedAt"):
|
||||
p["issuedAt"] = old["issuedAt"]
|
||||
p["latestAt"] = p["issuedAt"] or p.get("latestAt") or ""
|
||||
_mmp_synced[key] = p
|
||||
merged += 1
|
||||
total = len(_mmp_synced)
|
||||
_save_mmp_synced()
|
||||
return {"ok": True, "count": merged, "total": total}
|
||||
|
||||
|
||||
def _mmp_packets():
|
||||
with _lock:
|
||||
msgs = [m for m in _messages if _is_mmp_message(m)]
|
||||
# 每个红包只保留「最新一次完整领取榜」快照,避免多个红包因缺 packetId 被揉在一起
|
||||
# 独立队列 + 兼容旧版混入通用消息的 MMP
|
||||
msgs = list(_mmp_messages) + [m for m in _messages if _is_mmp_message(m)]
|
||||
# 每个红包只保留「最新一次完整领取榜」快照
|
||||
packets = {}
|
||||
for msg in msgs:
|
||||
meta, claims = _parse_mmp_content(msg.get("content") or "")
|
||||
@@ -268,10 +508,11 @@ def _mmp_packets():
|
||||
continue
|
||||
packet_id = meta.get("packet") or meta.get("packetId") or ""
|
||||
if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"):
|
||||
# 用领取名单指纹区分不同红包
|
||||
packet_id = "红包-" + _claims_fingerprint(claims)[:48]
|
||||
key = str(packet_id)
|
||||
issued = meta.get("issued") or ""
|
||||
finished = str(meta.get("done") or "").lower() in ("1", "true") \
|
||||
or str(meta.get("status") or "").upper() in ("FINISHED", "COMPLETE", "COMPLETED", "EXPIRED")
|
||||
item = {
|
||||
"packetId": key,
|
||||
"title": msg.get("title") or "TNG 红包",
|
||||
@@ -282,17 +523,11 @@ def _mmp_packets():
|
||||
"issuedAt": issued,
|
||||
"fetchedAt": msg.get("receivedAt") or "",
|
||||
"latestAt": issued or (msg.get("receivedAt") or ""),
|
||||
"finished": finished,
|
||||
"snapshots": 1,
|
||||
"claims": claims,
|
||||
"rawMessages": [{
|
||||
"id": msg.get("id"),
|
||||
"receivedAt": msg.get("receivedAt"),
|
||||
"content": msg.get("content"),
|
||||
"source": msg.get("source"),
|
||||
}],
|
||||
}
|
||||
existing = packets.get(key)
|
||||
# 优先保留「有发放时间 + 领取更全」的快照;时间戳用发放时间排序
|
||||
def _score(it):
|
||||
return (
|
||||
1 if it.get("issuedAt") else 0,
|
||||
@@ -302,17 +537,19 @@ def _mmp_packets():
|
||||
if existing is None or _score(item) >= _score(existing):
|
||||
if existing is not None:
|
||||
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
|
||||
item["rawMessages"] = existing.get("rawMessages", []) + item["rawMessages"]
|
||||
if not item.get("issuedAt") and existing.get("issuedAt"):
|
||||
item["issuedAt"] = existing["issuedAt"]
|
||||
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
|
||||
if existing.get("finished"):
|
||||
item["finished"] = True
|
||||
packets[key] = item
|
||||
else:
|
||||
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
|
||||
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 = []
|
||||
for p in packets.values():
|
||||
ranked = _aggregate_claims(p.get("claims") or [])
|
||||
@@ -323,15 +560,38 @@ def _mmp_packets():
|
||||
p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4)
|
||||
except Exception:
|
||||
p["sumClaimed"] = 0
|
||||
# 总额缺失或只抓到单笔金额时,用领取合计兜底
|
||||
total_num = _normalize_money(p.get("total"))
|
||||
if total_num is None or (p["sumClaimed"] > 0 and abs(total_num - p["sumClaimed"]) > 0.001
|
||||
and total_num <= max((x["amount"] for x in ranked), default=0) + 1e-9):
|
||||
p["total"] = "{0:.2f}".format(p["sumClaimed"])
|
||||
elif total_num is not None:
|
||||
p["total"] = "{0:.2f}".format(total_num)
|
||||
best = ranked[0] if ranked else None
|
||||
worst = ranked[-1] if ranked else None
|
||||
p["bestNick"] = (best or {}).get("nickname") or ""
|
||||
p["bestAmount"] = (best or {}).get("amountText") or ""
|
||||
p["worstNick"] = (worst or {}).get("nickname") or ""
|
||||
p["worstAmount"] = (worst or {}).get("amountText") or ""
|
||||
del p["claims"]
|
||||
result.append(p)
|
||||
# 合并手机全量同步落盘数据(电脑重启后仍可看)
|
||||
with _lock:
|
||||
synced_items = list(_mmp_synced.values())
|
||||
by_id = {p["packetId"]: p for p in result}
|
||||
for sp in synced_items:
|
||||
key = sp.get("packetId")
|
||||
if not key:
|
||||
continue
|
||||
cur = by_id.get(key)
|
||||
if cur is None or _packet_score(sp) >= _packet_score(cur):
|
||||
merged = dict(sp)
|
||||
if cur and cur.get("finished"):
|
||||
merged["finished"] = True
|
||||
if cur and not merged.get("issuedAt") and cur.get("issuedAt"):
|
||||
merged["issuedAt"] = cur["issuedAt"]
|
||||
merged["latestAt"] = merged["issuedAt"]
|
||||
by_id[key] = merged
|
||||
result = list(by_id.values())
|
||||
result.sort(key=lambda x: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
|
||||
return result
|
||||
|
||||
@@ -345,182 +605,14 @@ def _mmp_time_sort_key(text):
|
||||
return s
|
||||
|
||||
|
||||
def _mmp_html_page():
|
||||
html = """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>红包领取台</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: "Segoe UI", ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0b1220; color: #e8eef7; height: 100vh; display: flex; flex-direction: column; }
|
||||
header { padding: 14px 20px; background: linear-gradient(90deg,#1a1030,#0f1b2d); border-bottom: 1px solid #2a3550; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
|
||||
h1 { margin: 0; font-size: 18px; letter-spacing: 0.02em; }
|
||||
.stat { color: #9db0cc; font-size: 13px; }
|
||||
a.nav { color: #8ec5ff; text-decoration: none; font-size: 13px; }
|
||||
a.nav:hover { text-decoration: underline; }
|
||||
button { background: #c45c26; color: #fff; border: 0; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; }
|
||||
button.secondary { background: #1c2740; border: 1px solid #314062; }
|
||||
.layout { display: flex; flex: 1; min-height: 0; }
|
||||
.sidebar { width: 320px; border-right: 1px solid #2a3550; background: #111a2c; overflow-y: auto; flex-shrink: 0; }
|
||||
.sidebar h2 { margin: 0; padding: 14px 16px 8px; font-size: 12px; color: #8aa0c0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.pkt { padding: 14px 16px; border-bottom: 1px solid #1a2438; cursor: pointer; }
|
||||
.pkt:hover { background: #162033; }
|
||||
.pkt.active { background: #1a2744; border-left: 3px solid #ff7a45; padding-left: 13px; }
|
||||
.pkt-id { font-size: 13px; color: #ffd2a8; word-break: break-all; font-family: ui-monospace, Consolas, monospace; }
|
||||
.pkt-meta { color: #8aa0c0; font-size: 12px; margin-top: 6px; line-height: 1.45; }
|
||||
.main { flex: 1; overflow-y: auto; padding: 18px 22px; }
|
||||
.title { margin: 0 0 6px; font-size: 20px; color: #ffe0c2; }
|
||||
.sub { color: #9db0cc; font-size: 13px; margin-bottom: 16px; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 18px; }
|
||||
.card { background: #141f35; border: 1px solid #273552; border-radius: 10px; padding: 12px 14px; }
|
||||
.card .k { color: #8aa0c0; font-size: 11px; }
|
||||
.card .v { margin-top: 4px; font-size: 18px; color: #fff; font-variant-numeric: tabular-nums; }
|
||||
table { width: 100%; border-collapse: collapse; background: #111a2c; border-radius: 10px; overflow: hidden; border: 1px solid #273552; }
|
||||
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #1e2a42; font-size: 13px; }
|
||||
th { background: #182338; color: #9db0cc; font-weight: 600; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
.rank { width: 48px; color: #ff9b5a; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.amt { font-variant-numeric: tabular-nums; color: #7dffb3; font-weight: 600; }
|
||||
.empty { padding: 48px 20px; text-align: center; color: #8aa0c0; }
|
||||
.hint { margin-top: 10px; font-size: 12px; color: #6f84a6; }
|
||||
.raw { margin-top: 18px; }
|
||||
.raw summary { cursor: pointer; color: #8ec5ff; font-size: 13px; }
|
||||
pre { background: #0d1524; border: 1px solid #273552; border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; color: #c9d6ea; white-space: pre-wrap; word-break: break-word; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>红包领取台</h1>
|
||||
<span class="stat" id="pktCount">0 个红包</span>
|
||||
<span class="stat" id="updated">-</span>
|
||||
<a class="nav" href="/">← 返回消息台</a>
|
||||
<button onclick="loadData()">刷新</button>
|
||||
<button class="secondary" onclick="clearAll()">清空全部</button>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<h2>红包列表</h2>
|
||||
<div id="list"></div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div id="empty" class="empty">
|
||||
暂无红包数据<br/>
|
||||
<div class="hint">登录 TNG 后停留任意页面即可自动拉历史并拉详情;也可打开 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.issuedAt ? ('发放 ' + p.issuedAt) : '发放时间未知')}</div>`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
renderActive();
|
||||
}
|
||||
|
||||
function shortId(id) {
|
||||
const s = String(id || '');
|
||||
if (s.length >= 8 && s.indexOf('-') > 0) return s.slice(0, 8);
|
||||
return s.slice(0, 16) || '-';
|
||||
}
|
||||
|
||||
function renderActive() {
|
||||
const p = packets.find(x => x.packetId === activeId);
|
||||
if (!p) return;
|
||||
document.getElementById('title').textContent =
|
||||
(p.sender ? p.sender + ' 的红包' : (p.title || '红包详情'));
|
||||
const parts = [];
|
||||
if (p.packetId) parts.push('activityId ' + p.packetId);
|
||||
if (p.sender) parts.push('发送人 ' + p.sender);
|
||||
if (p.issuedAt) parts.push('发放 ' + p.issuedAt);
|
||||
if (p.group) parts.push('群组 ' + p.group);
|
||||
if (p.via) parts.push('来源 ' + (p.via === 'http' ? '网络' : p.via === 'gson' ? '解析' : p.via === 'rpc' ? 'RPC' : p.via));
|
||||
document.getElementById('sub').textContent = parts.join(' · ') || '单个红包领取排行';
|
||||
document.getElementById('cPeople').textContent = p.claimantCount || 0;
|
||||
document.getElementById('cSum').textContent = Number(p.sumClaimed ?? 0).toFixed(2);
|
||||
document.getElementById('cSnap').textContent = p.snapshots || 0;
|
||||
document.getElementById('cTotal').textContent = p.total || '-';
|
||||
const board = document.getElementById('board');
|
||||
board.innerHTML = '';
|
||||
const rows = p.leaderboard || [];
|
||||
if (!rows.length) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td colspan="4" style="color:#8aa0c0;text-align:center">暂无领取记录</td>';
|
||||
board.appendChild(tr);
|
||||
}
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td class="rank">${row.rank}</td>
|
||||
<td>${esc(row.nickname)}</td>
|
||||
<td class="amt">${esc(row.amountText || Number(row.amount).toFixed(2))}</td>
|
||||
<td>${esc(row.claimTime || '-')}</td>`;
|
||||
board.appendChild(tr);
|
||||
}
|
||||
const raw = (p.rawMessages || []).map(m =>
|
||||
'[' + (m.receivedAt || '') + '] ' + (m.source || '') + '\\n' + (m.content || '')
|
||||
).join('\\n---\\n');
|
||||
document.getElementById('raw').textContent = raw || '(无)';
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
await fetch('/api/messages', { method: 'DELETE' });
|
||||
activeId = null;
|
||||
loadData();
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
loadData();
|
||||
setInterval(loadData, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
return html.encode("utf-8")
|
||||
def _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():
|
||||
@@ -564,7 +656,7 @@ def _html_page():
|
||||
<span class="stat" id="count">0 条</span>
|
||||
<span class="stat" id="groupCount">0 群</span>
|
||||
<span class="stat" id="updated">-</span>
|
||||
<a class="nav" href="/mmp">红包领取台 →</a>
|
||||
<a class="nav" href="/mmp">红包领取台(独立) →</a>
|
||||
<button onclick="loadMessages()">刷新</button>
|
||||
<button class="secondary" onclick="clearMessages()">清空</button>
|
||||
</header>
|
||||
@@ -686,7 +778,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(body)
|
||||
return
|
||||
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_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
@@ -706,6 +806,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == "/api/mmp":
|
||||
_json_response(self, 200, _mmp_packets())
|
||||
return
|
||||
if path == "/api/mmp/settings":
|
||||
_json_response(self, 200, _get_mmp_settings())
|
||||
return
|
||||
if path == "/health":
|
||||
_json_response(self, 200, {"ok": True})
|
||||
return
|
||||
@@ -713,6 +816,28 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
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"):
|
||||
_json_response(self, 404, {"error": "not found"})
|
||||
return
|
||||
@@ -746,19 +871,32 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_DELETE(self):
|
||||
path = urlparse(self.path).path
|
||||
if path == "/api/mmp":
|
||||
_clear_mmp_messages()
|
||||
_json_response(self, 200, {"ok": True})
|
||||
return
|
||||
if path != "/api/messages":
|
||||
_json_response(self, 404, {"error": "not found"})
|
||||
return
|
||||
with _lock:
|
||||
# 只清通用消息,保留红包独立队列
|
||||
keep = [m for m in _messages if _is_mmp_message(m)]
|
||||
_messages.clear()
|
||||
# 旧数据里的 MMP 迁入独立队列
|
||||
for m in keep:
|
||||
_mmp_messages.appendleft(m)
|
||||
_json_response(self, 200, {"ok": True})
|
||||
|
||||
|
||||
_load_mmp_synced()
|
||||
|
||||
|
||||
def main():
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
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}/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("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
|
||||
print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")
|
||||
|
||||
@@ -7,11 +7,28 @@
|
||||
|
||||
抓取 TNG eWallet **Money Packet(红包)** 的领取排行:谁领了、领了多少,并在 PC 调试台按**单个红包**展示。
|
||||
|
||||
## 手机端领取台
|
||||
|
||||
主 App 首页有 **红包领取台(独立)** 入口(`MmpClaimActivity`):
|
||||
|
||||
- Hook 收到 `[MMP统计]` 后写入本地 `MmpPacketStore`,**不进入**通用「情况」监听台、也不走正式上传
|
||||
- 手机数据可同步到 PC:打开领取台会自动同步,也可点「同步电脑」;PC 打开 http://127.0.0.1:8765/mmp 即可看(落盘 `mmp_packets.json`,重启调试台不丢)
|
||||
- PC 调试台红包数据走独立队列,与 `/` 通用消息台隔离
|
||||
- 列表支持筛选(全部/领取中/已领完 + 关键词)、详情/卡片显示手气(领完)或目前最高/最低(领取中)
|
||||
- 点进看领取排行;支持清空(仅红包)
|
||||
- 需监听列表勾选 TNG,且 Xposed 模块生效
|
||||
- **功能说明页**:手机顶栏「说明」→ `MmpHelpActivity`;电脑 http://127.0.0.1:8765/mmp/help
|
||||
|
||||
PC 页 http://127.0.0.1:8765/mmp ;手机浏览器也可访问(USB reverse 或局域网 IP)。
|
||||
|
||||
---
|
||||
|
||||
## 页面与地址
|
||||
|
||||
| 入口 | 地址 |
|
||||
|------|------|
|
||||
| 本机(USB + `adb reverse`) | http://127.0.0.1:8765/mmp |
|
||||
| 功能说明 | http://127.0.0.1:8765/mmp/help |
|
||||
| 局域网 / USB 共享网 | http://<电脑IP>:8765/mmp (当前常见:`http://10.151.104.25:8765/mmp`) |
|
||||
| 通用消息台 | http://127.0.0.1:8765/ |
|
||||
|
||||
@@ -60,10 +77,10 @@ powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
|
||||
登录后**无需再手动点详情灌模板**,也**不必反复进历史页**:
|
||||
|
||||
1. 打开 TNG 并完成登录 / PIN(任意前台页面即可)
|
||||
2. Hook 从 `ILoginStorage` / 历史请求自动取 `sessionId`;`Activity.onResume` 约每 **45 秒**主动调一次 `moneyPacketHistoryList`(主线程)
|
||||
3. 历史返回后自动按 `activityId` 在主线程串行拉详情(间隔约 200ms),推送到领取台
|
||||
2. Hook 从 `ILoginStorage` / 历史请求自动取 `sessionId`;`Activity.onResume` 按设置冷却主动调 `moneyPacketHistoryList`
|
||||
3. 历史返回后自动按 `activityId` 串行拉详情(间隔见设置),推送到领取台
|
||||
4. 仅打开 **Money Packet 历史页**时,App 自己的列表响应也会立刻触发步骤 3
|
||||
5. 打开 http://127.0.0.1:8765/mmp 刷新查看
|
||||
5. 打开 http://127.0.0.1:8765/mmp ;点 **设置** 可调刷新速度,保存后约 10 秒内生效
|
||||
|
||||
日志期望:
|
||||
|
||||
@@ -75,17 +92,30 @@ powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
|
||||
|
||||
## 去重与刷新行为
|
||||
|
||||
冷却时间可在领取台 **设置** 面板调整(`/mmp` → 设置),保存后约 **10 秒内**手机 Hook 生效。默认偏快(历史/详情约 8 秒)。
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 标识 | 以 `activityId` 区分红包 |
|
||||
| sessionId | 自动:`ILoginStorage` / Repository.loginStorage;有真实请求时也会缓存 |
|
||||
| 自动历史 | 前台 `onResume` 触发,约 **45 秒**冷却;也可由首次缓存到 RpcTask 触发 |
|
||||
| 短时去重 | 同一 `activityId` + **相同领取名单**,约 **20 秒内**不重复推送 |
|
||||
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后约 **60 秒**内不重复自动拉 |
|
||||
| 包间间隔 | 自动拉详情串行,约 **150ms**/条 |
|
||||
| 自动历史 | 前台 `onResume` 触发;冷却见设置「历史列表冷却」 |
|
||||
| 短时去重 | 同一 `activityId` + **相同领取名单**,「相同名单去重」秒内不重复推送 |
|
||||
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后,「详情刷新冷却」内不重复自动拉 |
|
||||
| 包间间隔 | 自动拉详情串行,「详情请求间隔」毫秒/条 |
|
||||
| 无模板 | 「无模板时自动打开历史页」可关,避免突然跳转 |
|
||||
| 调试台 | 同一 `activityId` 只保留最新完整快照;双通道短时内容去重 |
|
||||
|
||||
预设:**极速**(约 5s)/ **标准**(约 8s)/ **省流**(约 30–45s)。
|
||||
|
||||
日志里若出现 `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`。
|
||||
|
||||
---
|
||||
|
||||
## 排障
|
||||
|
||||
@@ -118,8 +148,9 @@ adb logcat | findstr TngMmp
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、金额/`activityId` 解析 |
|
||||
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp` |
|
||||
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、轮询领取台设置 |
|
||||
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp`、`/api/mmp/settings` |
|
||||
| `debug-server/mmp_settings.json` | 领取台刷新设置(自动生成) |
|
||||
| `app/.../AppConfig.java` | `DEBUG_SERVER_URLS` 双地址 |
|
||||
| `app/.../network/DebugForwarder.java` | 向多个调试地址推送 |
|
||||
| `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse |
|
||||
|
||||
@@ -11,9 +11,14 @@ import com.miraclegarden.smsmessage.xposed.HookForwarder;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.lang.reflect.Array;
|
||||
import java.lang.reflect.Field;
|
||||
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.ArrayList;
|
||||
import java.util.HashSet;
|
||||
@@ -43,21 +48,41 @@ public final class TngMoneyPacketHook {
|
||||
private static final java.util.HashMap<String, Long> RECENT_PACKET_AT = new java.util.HashMap<>();
|
||||
/** activityId → 领取指纹 */
|
||||
private static final java.util.HashMap<String, String> RECENT_PACKET_CLAIMS = new java.util.HashMap<>();
|
||||
/** 同一 activityId + 相同领取名单,20 秒内不重复推(防双 Hook / 连点);超时可刷新 */
|
||||
private static final long PACKET_DEDUP_MS = 20_000L;
|
||||
/** 同一 activityId + 相同领取名单短时不重复推(可由领取台设置覆盖) */
|
||||
private static final long DEFAULT_PACKET_DEDUP_MS = 3_000L;
|
||||
private static final ThreadLocal<String> CURRENT_REQUEST_URL = new ThreadLocal<>();
|
||||
|
||||
/** 历史列表触发的自动拉详情:已排队 / 上次成功时间 */
|
||||
private static final java.util.HashSet<String> AUTO_DETAIL_PENDING = new java.util.HashSet<>();
|
||||
private static final java.util.HashMap<String, Long> AUTO_DETAIL_AT = new java.util.HashMap<>();
|
||||
private static final long AUTO_DETAIL_COOLDOWN_MS = 60_000L;
|
||||
private static 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<>();
|
||||
/** 自动拉历史列表冷却(无需打开历史页) */
|
||||
private static final long AUTO_HISTORY_COOLDOWN_MS = 45_000L;
|
||||
/** 已成功拿到领取名单的 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 long sLastSettingsPollAt;
|
||||
private static volatile long sLastAutoHistoryAt;
|
||||
private static volatile boolean sAutoHistoryRunning;
|
||||
/** 曾拿到过 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;
|
||||
@@ -93,9 +118,100 @@ public final class TngMoneyPacketHook {
|
||||
hookQuakeRpc(lpparam);
|
||||
hookLoginStorage(lpparam);
|
||||
hookActivityResumeForAutoHistory(lpparam);
|
||||
startSettingsPoller();
|
||||
XposedBridge.log(TAG + " installed for " + lpparam.packageName);
|
||||
}
|
||||
|
||||
/** 后台轮询领取台 /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 changed = historyMs != sHistoryCooldownMs
|
||||
|| detailMs != sDetailCooldownMs
|
||||
|| dedupMs != sPacketDedupMs
|
||||
|| gapMs != sDetailGapMs
|
||||
|| openHistory != sOpenHistoryIfNoTemplate;
|
||||
sHistoryCooldownMs = historyMs;
|
||||
sDetailCooldownMs = detailMs;
|
||||
sPacketDedupMs = dedupMs;
|
||||
sDetailGapMs = gapMs;
|
||||
sOpenHistoryIfNoTemplate = openHistory;
|
||||
if (changed) {
|
||||
XposedBridge.log(TAG + " settings applied hist=" + (historyMs / 1000)
|
||||
+ "s detail=" + (detailMs / 1000) + "s dedup=" + (dedupMs / 1000)
|
||||
+ "s gap=" + gapMs + "ms openHist=" + openHistory
|
||||
+ " from " + urlStr);
|
||||
}
|
||||
return true;
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void hookOkHttpUrl(XC_LoadPackage.LoadPackageParam lpparam) {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
@@ -557,6 +673,26 @@ public final class TngMoneyPacketHook {
|
||||
} catch (Throwable ignored) {
|
||||
trySet(req, "setSenderUserId", senderUserId);
|
||||
}
|
||||
// 尽量一次拉全领取名单(避免默认 20 条截断)
|
||||
bumpDetailPageSize(req);
|
||||
}
|
||||
|
||||
private static void bumpDetailPageSize(Object req) {
|
||||
if (req == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
XposedHelpers.setIntField(req, "page", 0);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
XposedHelpers.setIntField(req, "maxResult", 200);
|
||||
} catch (Throwable ignored) {
|
||||
try {
|
||||
XposedHelpers.setObjectField(req, "maxResult", 200);
|
||||
} catch (Throwable ignored2) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -594,6 +730,7 @@ public final class TngMoneyPacketHook {
|
||||
if (historyResult == null) {
|
||||
return;
|
||||
}
|
||||
pollMmpSettings(false);
|
||||
// 解析放调用线程;真正 RPC 必须丢到主线程(Quake/登录态常绑主线程)
|
||||
final List<String[]> jobs;
|
||||
try {
|
||||
@@ -617,9 +754,23 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
}
|
||||
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 < AUTO_DETAIL_COOLDOWN_MS) {
|
||||
if (last != null && now - last < detailCooldown) {
|
||||
continue;
|
||||
}
|
||||
if (!AUTO_DETAIL_PENDING.add(activityId)) {
|
||||
@@ -627,7 +778,7 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
}
|
||||
final long thisDelay = delayMs;
|
||||
delayMs += 250L;
|
||||
delayMs += Math.max(0L, sDetailGapMs);
|
||||
AUTO_DETAIL_EXEC.execute(() -> {
|
||||
try {
|
||||
if (thisDelay > 0) {
|
||||
@@ -675,8 +826,28 @@ public final class TngMoneyPacketHook {
|
||||
String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
|
||||
String createTime = stringField(info, "createTime", "getCreateTime");
|
||||
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
|
||||
jobs.add(new String[]{activityId, senderUserId,
|
||||
TextUtils.isEmpty(createTime) ? "" : createTime});
|
||||
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) {
|
||||
@@ -703,6 +874,210 @@ public final class TngMoneyPacketHook {
|
||||
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);
|
||||
if (s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|
||||
|| 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;
|
||||
}
|
||||
// 已领金额 ≈ 总额
|
||||
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;
|
||||
}
|
||||
|
||||
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 || snapshot.claims == null || snapshot.claims.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String id = !TextUtils.isEmpty(packetId) ? packetId : snapshot.packetId;
|
||||
if (TextUtils.isEmpty(id) || !looksLikeActivityId(id)) {
|
||||
// 指纹 id 也记,避免反复刷
|
||||
if (!TextUtils.isEmpty(id)) {
|
||||
PACKETS_WITH_DATA.add(id);
|
||||
}
|
||||
} else {
|
||||
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 && !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) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
|
||||
ClassLoader cl = sAppClassLoader;
|
||||
ensureRpcTaskAndLogin(cl);
|
||||
@@ -761,27 +1136,32 @@ public final class TngMoneyPacketHook {
|
||||
XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId
|
||||
+ " body=" + snippet
|
||||
+ " userId=" + stringField(req, "userId", "getUserId"));
|
||||
notePossibleDisconnectFromEmpty(result, activityId);
|
||||
return false;
|
||||
}
|
||||
rememberPacketData(probe.packetId != null ? probe.packetId : activityId, probe, result);
|
||||
forwardSnapshot(probe, "auto:" + activityId, "rpc");
|
||||
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
|
||||
+ " claims=" + probe.claims.size()
|
||||
+ " issued=" + probe.issueTime);
|
||||
+ " issued=" + probe.issueTime
|
||||
+ (COMPLETED_PACKETS.contains(activityId) ? " DONE" : ""));
|
||||
noteSessionAlive();
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
|
||||
markMaybeDisconnected(t, "auto-detail");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) {
|
||||
// 优先: (activityId, senderUserId, loadTime, page, maxResult)
|
||||
// 优先: (activityId, senderUserId, loadTime, page, maxResult) — maxResult 拉大以尽量一次取全
|
||||
try {
|
||||
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 20);
|
||||
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 20,
|
||||
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200,
|
||||
31, null);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
@@ -789,14 +1169,7 @@ public final class TngMoneyPacketHook {
|
||||
Object req = reqCls.getDeclaredConstructor().newInstance();
|
||||
XposedHelpers.setObjectField(req, "activityId", activityId);
|
||||
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
|
||||
try {
|
||||
XposedHelpers.setIntField(req, "page", 0);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
try {
|
||||
XposedHelpers.setIntField(req, "maxResult", 20);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
bumpDetailPageSize(req);
|
||||
return req;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage());
|
||||
@@ -915,12 +1288,11 @@ public final class TngMoneyPacketHook {
|
||||
@Override
|
||||
protected void afterHookedMethod(MethodHookParam param) {
|
||||
Object v = param.getResult();
|
||||
if (v == null || TextUtils.isEmpty(String.valueOf(v))) {
|
||||
return;
|
||||
}
|
||||
String name = param.method.getName();
|
||||
if ("getSessionId".equals(name)) {
|
||||
sCachedSessionId = String.valueOf(v);
|
||||
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)) {
|
||||
@@ -957,7 +1329,12 @@ public final class TngMoneyPacketHook {
|
||||
if (!PACKAGE.equals(pkg)) {
|
||||
return;
|
||||
}
|
||||
scheduleAutoHistory("activity-resume");
|
||||
if (sSessionDisconnected) {
|
||||
tryRefreshLogin(sAppClassLoader);
|
||||
scheduleAutoHistoryForced("reconnect-resume");
|
||||
} else {
|
||||
scheduleAutoHistory("activity-resume");
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
@@ -969,11 +1346,21 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
|
||||
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 (now - sLastAutoHistoryAt < AUTO_HISTORY_COOLDOWN_MS) {
|
||||
if (!force && now - sLastAutoHistoryAt < sHistoryCooldownMs) {
|
||||
return;
|
||||
}
|
||||
sAutoHistoryRunning = true;
|
||||
@@ -985,6 +1372,7 @@ public final class TngMoneyPacketHook {
|
||||
boolean ok = fetchHistoryListAuto(why);
|
||||
if (ok) {
|
||||
sLastAutoHistoryAt = System.currentTimeMillis();
|
||||
noteSessionAlive();
|
||||
}
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
@@ -1011,6 +1399,9 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
if (TextUtils.isEmpty(sCachedSessionId)) {
|
||||
XposedBridge.log(TAG + " auto-history skip: no sessionId yet (" + reason + ")");
|
||||
if (sHadSession) {
|
||||
sSessionDisconnected = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -1038,11 +1429,17 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
}
|
||||
// 2) 无模板时:自造请求易「非法参数」,直接拉起官方历史页让 App 发正确 RPC
|
||||
if (!sOpenHistoryIfNoTemplate) {
|
||||
XposedBridge.log(TAG + " auto-history no template, openHistory disabled ("
|
||||
+ reason + ")");
|
||||
return false;
|
||||
}
|
||||
XposedBridge.log(TAG + " auto-history no template → open HistoryActivity (" + reason + ")");
|
||||
openMoneyPacketHistoryActivity();
|
||||
return false;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " auto-history fail: " + t.getMessage());
|
||||
markMaybeDisconnected(t, "auto-history");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1120,7 +1517,9 @@ public final class TngMoneyPacketHook {
|
||||
sCachedUserId = String.valueOf(accountId);
|
||||
}
|
||||
if (sessionId != null && !TextUtils.isEmpty(String.valueOf(sessionId))) {
|
||||
sCachedSessionId = String.valueOf(sessionId);
|
||||
onSessionObserved(String.valueOf(sessionId));
|
||||
} else if (sHadSession) {
|
||||
onSessionObserved("");
|
||||
}
|
||||
if (loginId != null && !TextUtils.isEmpty(String.valueOf(loginId))) {
|
||||
sCachedLoginId = String.valueOf(loginId);
|
||||
@@ -1294,6 +1693,7 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
dedupeClaims(snapshot);
|
||||
snapshot.applyCachedIssueTime();
|
||||
rememberPacketData(snapshot.packetId, snapshot, null);
|
||||
if (!shouldForwardPacket(snapshot)) {
|
||||
return;
|
||||
}
|
||||
@@ -1312,6 +1712,7 @@ public final class TngMoneyPacketHook {
|
||||
XposedBridge.log(TAG + " captured activityId=" + snapshot.packetId
|
||||
+ " claims=" + snapshot.claims.size()
|
||||
+ " issued=" + snapshot.issueTime
|
||||
+ (snapshot.finished ? " DONE" : "")
|
||||
+ " via " + channel);
|
||||
}
|
||||
|
||||
@@ -1403,8 +1804,33 @@ public final class TngMoneyPacketHook {
|
||||
} 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"));
|
||||
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());
|
||||
}
|
||||
@@ -1885,12 +2311,13 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
String claimsFp = snapshot.claimsFingerprint();
|
||||
long now = System.currentTimeMillis();
|
||||
long dedupMs = sPacketDedupMs;
|
||||
synchronized (RECENT_PACKET_AT) {
|
||||
Long lastAt = RECENT_PACKET_AT.get(packetId);
|
||||
String lastClaims = RECENT_PACKET_CLAIMS.get(packetId);
|
||||
if (lastAt != null && lastClaims != null
|
||||
&& claimsFp.equals(lastClaims)
|
||||
&& now - lastAt < PACKET_DEDUP_MS) {
|
||||
&& now - lastAt < dedupMs) {
|
||||
XposedBridge.log(TAG + " skip dup activityId=" + packetId
|
||||
+ " ageMs=" + (now - lastAt));
|
||||
return false;
|
||||
@@ -1903,7 +2330,7 @@ public final class TngMoneyPacketHook {
|
||||
RECENT_PACKET_AT.entrySet().iterator();
|
||||
while (it.hasNext()) {
|
||||
java.util.Map.Entry<String, Long> e = it.next();
|
||||
if (now - e.getValue() > PACKET_DEDUP_MS * 3) {
|
||||
if (now - e.getValue() > Math.max(dedupMs, 1L) * 3) {
|
||||
String k = e.getKey();
|
||||
it.remove();
|
||||
RECENT_PACKET_CLAIMS.remove(k);
|
||||
@@ -1962,6 +2389,12 @@ public final class TngMoneyPacketHook {
|
||||
String totalAmount;
|
||||
/** 发放时间(历史 createTime 或详情里的 createTime) */
|
||||
String issueTime;
|
||||
String remainingCount;
|
||||
String claimedCount;
|
||||
String totalCount;
|
||||
String activityStatus;
|
||||
String claimedAmountText;
|
||||
boolean finished;
|
||||
final List<ClaimLine> claims = new ArrayList<>();
|
||||
|
||||
String dedupKey() {
|
||||
@@ -1993,6 +2426,9 @@ public final class TngMoneyPacketHook {
|
||||
|
||||
String formatForForward(String channel, String sourceHint) {
|
||||
applyCachedIssueTime();
|
||||
if (!finished) {
|
||||
finished = evaluateFinished(this);
|
||||
}
|
||||
// 详情无发放时间时,用最早领取时间兜底(仍优于拉取时间)
|
||||
if (TextUtils.isEmpty(issueTime)) {
|
||||
for (ClaimLine line : claims) {
|
||||
@@ -2021,6 +2457,9 @@ public final class TngMoneyPacketHook {
|
||||
if (!TextUtils.isEmpty(issueTime)) {
|
||||
sb.append(" | issued=").append(sanitizeMeta(issueTime));
|
||||
}
|
||||
if (finished) {
|
||||
sb.append(" | done=1");
|
||||
}
|
||||
sb.append(" | via=").append(sanitizeMeta(channel));
|
||||
if (!TextUtils.isEmpty(sourceHint)) {
|
||||
String src = sourceHint.length() > 120
|
||||
|
||||
Reference in New Issue
Block a user