feat(mmp): 独立红包领取台(手机+PC)、同步、筛选与功能说明

隔离通用消息台;支持设置/手气摘要/电脑同步;忽略临时逆向脚本与运行时 JSON。
This commit is contained in:
mars
2026-08-04 17:39:29 +08:00
parent 1517ff4671
commit c8cbfdff82
23 changed files with 4388 additions and 240 deletions

View File

@@ -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));
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 : "";
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
});
}
}

View File

@@ -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);
}
}

View File

@@ -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();