diff --git a/.gitignore b/.gitignore index 7126adf..bfe449f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 98eecd7..4093e1c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,6 +74,18 @@ + + + diff --git a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MainActivity.java b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MainActivity.java index fed7d91..b54cfc0 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MainActivity.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MainActivity.java @@ -72,6 +72,10 @@ public class MainActivity extends MiracleGardenActivity { 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)); }); diff --git a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpClaimActivity.java b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpClaimActivity.java new file mode 100644 index 0000000..2241464 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpClaimActivity.java @@ -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 + implements MmpPacketStore.Listener { + + private final List allPackets = new ArrayList<>(); + private final List packets = new ArrayList<>(); + private CommonAdapter 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(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; + } + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpDetailActivity.java b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpDetailActivity.java new file mode 100644 index 0000000..cc877a6 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpDetailActivity.java @@ -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 + implements MmpPacketStore.Listener { + + public static final String EXTRA_PACKET_ID = "packetId"; + + private String packetId; + private final List claims = new ArrayList<>(); + private CommonAdapter 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(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; + } + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpHelpActivity.java b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpHelpActivity.java new file mode 100644 index 0000000..5f3354d --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpHelpActivity.java @@ -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 { + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding.backIv.setOnClickListener(v -> finish()); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpClaim.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpClaim.java new file mode 100644 index 0000000..2c95f87 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpClaim.java @@ -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 : ""; + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacket.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacket.java new file mode 100644 index 0000000..3235935 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacket.java @@ -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 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); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacketStore.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacketStore.java new file mode 100644 index 0000000..28a5fa0 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacketStore.java @@ -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 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 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 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 getPackets(Context context) { + synchronized (MmpPacketStore.class) { + List list = new ArrayList<>(loadMap(context).values()); + Collections.sort(list, new Comparator() { + @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 meta = new LinkedHashMap<>(); + final List 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 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 splitPipe(String head) { + String[] arr = head.split(" \\| "); + List parts = new ArrayList<>(); + for (String p : arr) { + if (!TextUtils.isEmpty(p.trim())) { + parts.add(p.trim()); + } + } + return parts; + } + + private static List splitLegacy(String head) { + List 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 aggregateClaims(List claims) { + LinkedHashMap 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 result = new ArrayList<>(buckets.values()); + Collections.sort(result, new Comparator() { + @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 claims) { + List 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 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 map) { + if (map.size() <= MAX_PACKETS) { + return; + } + List list = new ArrayList<>(map.values()); + Collections.sort(list, new Comparator() { + @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 loadMap(Context context) { + Map 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 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 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()); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSettingsClient.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSettingsClient.java new file mode 100644 index 0000000..7d18b4e --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSettingsClient.java @@ -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); + } + }); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSyncClient.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSyncClient.java new file mode 100644 index 0000000..e65fa4a --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSyncClient.java @@ -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); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/service/HookMessageReceiver.java b/app/src/main/java/com/miraclegarden/smsmessage/service/HookMessageReceiver.java index 29783ae..edfc0a2 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/service/HookMessageReceiver.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/service/HookMessageReceiver.java @@ -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(); diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 44f12f7..8419717 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -92,6 +92,16 @@ android:backgroundTint="#FF3700B3" /> + + + + + +
+
+ + + + +
+
+ + + + + +
+ + +
+
+
+ +
+
+ + + + + + +
+
+ + + + + +
+
不懂就选「标准」再保存。约 10 秒后对手机 Hook 生效。
+
+ +
+ +
+
暂无数据。打开手机红包领取台会自动同步到这里。
+ +
+
+ + + + diff --git a/debug-server/server.py b/debug-server/server.py index 2658d05..0280849 100644 --- a/debug-server/server.py +++ b/debug-server/server.py @@ -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 = """ - - - - - 红包领取台 - - - -
-

红包领取台

- 0 个红包 - - - ← 返回消息台 - - -
-
- -
-
- 暂无红包数据
-
登录 TNG 后停留任意页面即可自动拉历史并拉详情;也可打开 Money Packet 历史页加速。无需再手动点详情。
-
- -
-
- - -""" - 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 = "

%s missing

%s
" % (filename, e) + return body.encode("utf-8") def _html_page(): @@ -564,7 +656,7 @@ def _html_page(): 0 条 0 群 - - 红包领取台 → + 红包领取台(独立) → @@ -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 全部推送;不可达的会失败,可达的生效") diff --git a/docs/TNG_MoneyPacket领取台.md b/docs/TNG_MoneyPacket领取台.md index b24a6ea..36638b9 100644 --- a/docs/TNG_MoneyPacket领取台.md +++ b/docs/TNG_MoneyPacket领取台.md @@ -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 | diff --git a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java index d89861f..8ae6321 100644 --- a/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java +++ b/xposed-module/src/main/java/com/miraclegarden/smsmessage/xposed/hook/TngMoneyPacketHook.java @@ -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 RECENT_PACKET_AT = new java.util.HashMap<>(); /** activityId → 领取指纹 */ private static final java.util.HashMap 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 CURRENT_REQUEST_URL = new ThreadLocal<>(); /** 历史列表触发的自动拉详情:已排队 / 上次成功时间 */ private static final java.util.HashSet AUTO_DETAIL_PENDING = new java.util.HashSet<>(); private static final java.util.HashMap 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 ACTIVITY_ISSUE_TIME = new java.util.HashMap<>(); - /** 自动拉历史列表冷却(无需打开历史页) */ - private static final long AUTO_HISTORY_COOLDOWN_MS = 45_000L; + /** 已成功拿到领取名单的 activityId */ + private static final java.util.HashSet PACKETS_WITH_DATA = new java.util.HashSet<>(); + /** 已领完且已有数据 → 不再自动刷详情 */ + private static final java.util.HashSet 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 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 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 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 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