feat(mmp): 领取进度/过期状态,并展示红包与领取人ID

状态优先显示已全部领取或已领取N/M,未领完才附加已过期;列表与最高最低补全 activityId、receiverId。
This commit is contained in:
mars
2026-08-05 15:45:39 +08:00
parent 293979122d
commit b3f2b2b3c2
12 changed files with 559 additions and 76 deletions

View File

@@ -7,6 +7,7 @@ import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.TextView;
@@ -157,42 +158,52 @@ public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBind
holder.setText(R.id.tv_sum, formatMoney(packet.sumClaimed > 0
? packet.sumClaimed : parseDouble(packet.total)));
TextView idView = holder.getView(R.id.tv_id);
if (!TextUtils.isEmpty(packet.packetId)) {
idView.setVisibility(View.VISIBLE);
idView.setText("ID " + packet.packetId);
} else {
idView.setVisibility(View.GONE);
idView.setText("");
}
android.widget.TextView status = holder.getView(R.id.tv_status);
if (packet.finished) {
status.setText("已领完");
status.setText(packet.statusLabel());
if (packet.isFullyClaimed()) {
status.setTextColor(Color.parseColor("#047857"));
status.setBackgroundColor(Color.parseColor("#ECFDF5"));
} else if (packet.isExpired()) {
status.setTextColor(Color.parseColor("#6B7280"));
status.setBackgroundColor(Color.parseColor("#F3F4F6"));
} else {
status.setText("领取中");
status.setTextColor(Color.parseColor("#B45309"));
status.setBackgroundColor(Color.parseColor("#FFFBEB"));
}
String meta = packet.claimantCount + "";
String meta;
if (packet.effectiveTotalCount() > 0) {
meta = packet.effectiveClaimedCount() + "/" + packet.effectiveTotalCount() + "";
} else {
meta = packet.claimantCount + "";
}
if (packet.issuedAt != null && !packet.issuedAt.isEmpty()) {
meta += " · 发放 " + packet.issuedAt;
}
if (packet.expiresAt != null && !packet.expiresAt.isEmpty()) {
meta += " · 到期 " + packet.expiresAt;
}
holder.setText(R.id.tv_meta, meta);
String hi = packet.finished ? "最佳" : "最高";
String lo = packet.finished ? "最差" : "最低";
com.miraclegarden.smsmessage.mmp.MmpClaim best = packet.bestClaim();
com.miraclegarden.smsmessage.mmp.MmpClaim worst = packet.worstClaim();
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 + " -");
}
holder.setText(R.id.tv_best, formatHiLoLine(hi, best));
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);
holder.setText(R.id.tv_worst, formatHiLoLine(lo, worst));
} else {
holder.setText(R.id.tv_worst, lo + " -");
}
holder.setText(R.id.tv_id, packet.shortId());
holder.itemView.setOnClickListener(v -> openDetail(packet.packetId));
}
};
@@ -538,6 +549,19 @@ public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBind
startActivity(intent);
}
private static String formatHiLoLine(String prefix,
com.miraclegarden.smsmessage.mmp.MmpClaim claim) {
if (claim == null) {
return prefix + " -";
}
String nick = claim.nickname != null ? claim.nickname : "?";
String amt = claim.amountText != null ? claim.amountText : formatMoney(claim.amount);
if (!TextUtils.isEmpty(claim.userId)) {
return prefix + " " + nick + " " + amt + "\nID " + claim.userId;
}
return prefix + " " + nick + " " + amt;
}
private void confirmClear() {
new AlertDialog.Builder(this)
.setTitle("清空红包台")

View File

@@ -156,17 +156,16 @@ public class MmpDetailActivity extends MiracleGardenActivity<ActivityMmpDetailBi
}
sub.append("发放 ").append(packet.issuedAt);
}
if (packet.finished) {
if (!TextUtils.isEmpty(packet.expiresAt)) {
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append("已领完");
} else {
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append("领取中");
sub.append("到期 ").append(packet.expiresAt);
}
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append(packet.statusLabel());
binding.tvSub.setText(sub.toString());
binding.tvStatPeople.setText(String.valueOf(packet.claimantCount));
@@ -181,7 +180,7 @@ public class MmpDetailActivity extends MiracleGardenActivity<ActivityMmpDetailBi
MmpClaim worst = packet.worstClaim();
if (best != null) {
bestAmount = best.amount;
binding.tvBestName.setText(best.nickname != null ? best.nickname : "?");
binding.tvBestName.setText(formatClaimName(best));
binding.tvBestAmt.setText(best.amountText != null ? best.amountText
: String.format(Locale.US, "%.2f", best.amount));
} else {
@@ -191,7 +190,7 @@ public class MmpDetailActivity extends MiracleGardenActivity<ActivityMmpDetailBi
}
if (worst != null && packet.claimantCount > 1) {
worstAmount = worst.amount;
binding.tvWorstName.setText(worst.nickname != null ? worst.nickname : "?");
binding.tvWorstName.setText(formatClaimName(worst));
binding.tvWorstAmt.setText(worst.amountText != null ? worst.amountText
: String.format(Locale.US, "%.2f", worst.amount));
} else {
@@ -213,6 +212,17 @@ public class MmpDetailActivity extends MiracleGardenActivity<ActivityMmpDetailBi
handler.postDelayed(refreshRunnable, 2000);
}
private static String formatClaimName(MmpClaim claim) {
if (claim == null) {
return "-";
}
String nick = claim.nickname != null ? claim.nickname : "?";
if (!TextUtils.isEmpty(claim.userId)) {
return nick + "\nID " + claim.userId;
}
return nick;
}
private static double parseDouble(String s) {
try {
return s == null || s.isEmpty() ? 0 : Double.parseDouble(s);

View File

@@ -5,8 +5,8 @@ package com.miraclegarden.smsmessage.mmp;
*/
public final class MmpModuleInfo {
public static final String XPOSED_PACKAGE = "com.miraclegarden.smsmessage.xposed";
public static final int EXPECTED_VERSION_CODE = 3;
public static final String EXPECTED_VERSION_NAME = "1.2.0";
public static final int EXPECTED_VERSION_CODE = 5;
public static final String EXPECTED_VERSION_NAME = "1.2.2";
private MmpModuleInfo() {
}

View File

@@ -15,9 +15,16 @@ public class MmpPacket {
public String total;
public String via;
public String issuedAt;
public String expiresAt;
/** TNG activityStatusACTIVE / FINISHED / ENDED / CLOSED / CANCELLED … */
public String status;
public String updatedAt;
public boolean finished;
public int snapshots;
/** 已领人数(来自 TNG claimedCount缺省用名单人数 */
public int claimedCount;
/** 红包总份数TNG totalCount */
public int totalCount;
public int claimantCount;
public double sumClaimed;
public List<MmpClaim> leaderboard = new ArrayList<>();
@@ -74,10 +81,12 @@ public class MmpPacket {
}
public String luckSummaryLine() {
refreshClosedState();
MmpClaim best = bestClaim();
MmpClaim worst = worstClaim();
String hi = finished ? "手气最佳" : "目前最高";
String lo = finished ? "手气最" : "目前最";
boolean closed = finished;
String hi = closed ? "手气最" : "目前最";
String lo = closed ? "手气最差" : "目前最低";
if (best == null) {
return hi + "- " + lo + "-";
}
@@ -93,6 +102,87 @@ public class MmpPacket {
return hi + "" + bn + "" + ba + " " + lo + "" + wn + "" + wa + "";
}
/** 根据 expire/status/人数 刷新 finished过期未领完也会标结束 */
public void refreshClosedState() {
if (isFullyClaimed()) {
finished = true;
return;
}
if (finished) {
return;
}
if (isTerminalStatus(status) || isExpirePassed()) {
finished = true;
}
}
public boolean isExpired() {
if (isExpirePassed()) {
return true;
}
if (status == null) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("EXPIRE") || s.contains("ENDED") || s.contains("CLOSED")
|| s.contains("CANCEL");
}
public boolean isFullyClaimed() {
int claimed = effectiveClaimedCount();
int total = effectiveTotalCount();
return total > 0 && claimed >= total;
}
public int effectiveClaimedCount() {
return Math.max(claimedCount, claimantCount);
}
public int effectiveTotalCount() {
return Math.max(0, totalCount);
}
/**
* 优先展示领取进度,过期放后面:
* 已全部领取 / 已领取 N/M 人 · 已过期
*/
public String statusLabel() {
refreshClosedState();
String progress;
if (isFullyClaimed()) {
progress = "已全部领取";
} else {
int claimed = effectiveClaimedCount();
int total = effectiveTotalCount();
if (total > 0) {
progress = "已领取 " + claimed + "/" + total + "";
} else if (claimed > 0) {
progress = "已领取 " + claimed + "";
} else {
progress = finished ? "未领取" : "领取中";
}
}
if (isExpired() && !isFullyClaimed()) {
return progress + " · 已过期";
}
return progress;
}
private boolean isExpirePassed() {
long ms = parseIssueMs(expiresAt);
return ms > 0 && System.currentTimeMillis() >= ms;
}
private static boolean isTerminalStatus(String status) {
if (status == null || status.isEmpty()) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE");
}
public boolean matchesFilter(String keyword, int statusFilter) {
return matchesFilter(keyword, statusFilter, 0, 0L, 0L);
}
@@ -103,6 +193,7 @@ public class MmpPacket {
*/
public boolean matchesFilter(String keyword, int statusFilter,
int timeMode, long customFromMs, long customToMs) {
refreshClosedState();
if (statusFilter == 1 && finished) {
return false;
}

View File

@@ -96,14 +96,30 @@ public final class MmpPacketStore {
incoming.total = nullToEmpty(parsed.meta.get("total"));
incoming.via = nullToEmpty(parsed.meta.get("via"));
incoming.issuedAt = firstNonEmpty(parsed.meta, "issued", "createTime", "issuedAt", "create");
incoming.expiresAt = firstNonEmpty(parsed.meta, "expire", "expireTime", "expiry", "expiresAt");
incoming.status = firstNonEmpty(parsed.meta, "status", "activityStatus");
parseCountsMeta(incoming, parsed.meta.get("counts"));
if (incoming.claimedCount <= 0) {
tryParseIntField(incoming, parsed.meta, "claimed", "claimedCount");
}
if (incoming.totalCount <= 0) {
tryParseIntField(incoming, parsed.meta, "slots", "totalCount", "quantity");
}
incoming.finished = "1".equals(parsed.meta.get("done"))
|| "true".equalsIgnoreCase(parsed.meta.get("done"))
|| "FINISHED".equalsIgnoreCase(parsed.meta.get("status"));
|| isTerminalStatusMeta(incoming.status);
incoming.refreshClosedState();
incoming.updatedAt = nowText();
incoming.snapshots = 1;
List<MmpClaim> ranked = aggregateClaims(parsed.claims);
incoming.leaderboard = ranked;
incoming.claimantCount = ranked.size();
if (incoming.claimedCount <= 0) {
incoming.claimedCount = ranked.size();
}
if (incoming.totalCount > 0 && incoming.claimedCount >= incoming.totalCount) {
incoming.finished = true;
}
double sum = 0;
for (MmpClaim c : ranked) {
sum += c.amount;
@@ -127,9 +143,23 @@ public final class MmpPacketStore {
if (TextUtils.isEmpty(incoming.issuedAt) && !TextUtils.isEmpty(existing.issuedAt)) {
incoming.issuedAt = existing.issuedAt;
}
if (TextUtils.isEmpty(incoming.expiresAt) && !TextUtils.isEmpty(existing.expiresAt)) {
incoming.expiresAt = existing.expiresAt;
}
if (TextUtils.isEmpty(incoming.status) && !TextUtils.isEmpty(existing.status)) {
incoming.status = existing.status;
}
if (incoming.totalCount <= 0 && existing.totalCount > 0) {
incoming.totalCount = existing.totalCount;
}
if (incoming.claimedCount < existing.claimedCount) {
// 保留更大的已领人数
incoming.claimedCount = existing.claimedCount;
}
if (!incoming.finished && existing.finished) {
incoming.finished = true;
}
incoming.refreshClosedState();
map.put(packetId, incoming);
} else {
existing.snapshots = existing.snapshots + 1;
@@ -193,6 +223,11 @@ public final class MmpPacketStore {
return nullToEmpty(b.packetId).compareTo(nullToEmpty(a.packetId));
}
});
for (MmpPacket p : list) {
if (p != null) {
p.refreshClosedState();
}
}
return list;
}
}
@@ -213,7 +248,11 @@ public final class MmpPacketStore {
return null;
}
synchronized (MmpPacketStore.class) {
return loadMap(context).get(packetId);
MmpPacket p = loadMap(context).get(packetId);
if (p != null) {
p.refreshClosedState();
}
return p;
}
}
@@ -601,9 +640,13 @@ public final class MmpPacketStore {
o.put("total", nullToEmpty(p.total));
o.put("via", nullToEmpty(p.via));
o.put("issuedAt", nullToEmpty(p.issuedAt));
o.put("expiresAt", nullToEmpty(p.expiresAt));
o.put("status", nullToEmpty(p.status));
o.put("updatedAt", nullToEmpty(p.updatedAt));
o.put("finished", p.finished);
o.put("snapshots", p.snapshots);
o.put("claimedCount", p.claimedCount);
o.put("totalCount", p.totalCount);
o.put("claimantCount", p.claimantCount);
o.put("sumClaimed", p.sumClaimed);
JSONArray board = new JSONArray();
@@ -633,9 +676,16 @@ public final class MmpPacketStore {
p.total = o.optString("total", "");
p.via = o.optString("via", "");
p.issuedAt = o.optString("issuedAt", "");
p.expiresAt = o.optString("expiresAt", "");
p.status = o.optString("status", "");
p.updatedAt = o.optString("updatedAt", "");
p.finished = o.optBoolean("finished", false);
p.snapshots = o.optInt("snapshots", 1);
p.claimedCount = o.optInt("claimedCount", 0);
p.totalCount = o.optInt("totalCount", 0);
p.claimantCount = o.optInt("claimantCount", 0);
p.refreshClosedState();
p.snapshots = o.optInt("snapshots", 1);
p.claimantCount = o.optInt("claimantCount", 0);
p.sumClaimed = o.optDouble("sumClaimed", 0);
JSONArray board = o.optJSONArray("leaderboard");
@@ -657,6 +707,10 @@ public final class MmpPacketStore {
if (p.claimantCount <= 0) {
p.claimantCount = p.leaderboard.size();
}
if (p.claimedCount <= 0) {
p.claimedCount = p.claimantCount;
}
p.refreshClosedState();
return p;
}
@@ -684,6 +738,66 @@ public final class MmpPacketStore {
return "";
}
/** counts=1/5 */
private static void parseCountsMeta(MmpPacket packet, String counts) {
if (packet == null || TextUtils.isEmpty(counts)) {
return;
}
String raw = counts.trim();
int slash = raw.indexOf('/');
try {
if (slash >= 0) {
String left = raw.substring(0, slash).trim();
String right = raw.substring(slash + 1).trim();
if (!left.isEmpty()) {
packet.claimedCount = Integer.parseInt(left);
}
if (!right.isEmpty()) {
packet.totalCount = Integer.parseInt(right);
}
} else {
packet.claimedCount = Integer.parseInt(raw);
}
} catch (NumberFormatException ignored) {
}
}
private static void tryParseIntField(MmpPacket packet, Map<String, String> meta,
String... keys) {
if (packet == null || meta == null) {
return;
}
for (String k : keys) {
String v = meta.get(k);
if (TextUtils.isEmpty(v)) {
continue;
}
try {
int n = Integer.parseInt(v.trim());
if (n < 0) {
continue;
}
if ("claimed".equals(k) || "claimedCount".equals(k)) {
packet.claimedCount = n;
} else {
packet.totalCount = n;
}
return;
} catch (NumberFormatException ignored) {
}
}
}
private static boolean isTerminalStatusMeta(String status) {
if (TextUtils.isEmpty(status)) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE");
}
private static String nowText() {
return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
.format(new java.util.Date());

View File

@@ -179,7 +179,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:maxLines="2"
android:text="-"
android:textColor="#047857"
android:textSize="16sp"
@@ -219,7 +219,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:maxLines="2"
android:text="-"
android:textColor="#B45309"
android:textSize="16sp"

View File

@@ -16,17 +16,34 @@
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_title"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="红包"
android:textColor="#111827"
android:textSize="14sp"
android:textStyle="bold" />
android:orientation="vertical">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="红包"
android:textColor="#111827"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
android:text=""
android:textColor="#9CA3AF"
android:textSize="11sp"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_sum"
@@ -71,17 +88,6 @@
android:text="0 人"
android:textColor="#6B7280"
android:textSize="11sp" />
<TextView
android:id="@+id/tv_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:ellipsize="middle"
android:maxLines="1"
android:text="id"
android:textColor="#9CA3AF"
android:textSize="10sp" />
</LinearLayout>
<LinearLayout
@@ -97,7 +103,7 @@
android:layout_weight="1"
android:background="#ECFDF5"
android:ellipsize="end"
android:maxLines="1"
android:maxLines="2"
android:paddingStart="6dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"
@@ -115,7 +121,7 @@
android:layout_weight="1"
android:background="#FFFBEB"
android:ellipsize="end"
android:maxLines="1"
android:maxLines="2"
android:paddingStart="6dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"

View File

@@ -96,6 +96,7 @@
.pkt.active { background: #fff7ed; box-shadow: inset 3px 0 0 #ea580c; }
.pkt-row { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
.pkt-name { font-size: 13px; font-weight: 700; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pkt-uid { font-size: 11px; color: #9ca3af; font-weight: 500; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, Consolas, monospace; }
.pkt-money {
font-size: 15px; font-weight: 800; color: var(--money);
font-variant-numeric: tabular-nums; white-space: nowrap;
@@ -104,9 +105,11 @@
.pkt-meta span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge {
display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 10px; font-weight: 700; flex-shrink: 0;
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.badge.done { background: var(--best-bg); color: var(--done); }
.badge.open { background: var(--worst-bg); color: var(--open); }
.badge.expired { background: #f3f4f6; color: #6b7280; }
.pkt-luck {
margin-top: 4px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
}
@@ -366,6 +369,14 @@
}
function money(v){ const n=Number(v); return Number.isFinite(n)?n.toFixed(2):String(v??'-'); }
function shortId(id){ const s=String(id||''); return (s.length>=8&&s.indexOf('-')>0)?s.slice(0,8):(s.slice(0,16)||'-'); }
function formatHiLo(prefix, row){
if(!row) return prefix + ' -';
const nick = row.nickname || row.bestNick || '-';
const amt = row.amountText || row.bestAmount || (row.amount!=null ? money(row.amount) : '');
const uid = row.userId || '';
if(uid) return `${prefix} ${esc(nick)} ${esc(amt)}<div class="pkt-uid">ID ${esc(uid)}</div>`;
return `${prefix} ${esc(nick)} ${esc(amt)}`.trim();
}
function hiLo(p){
return {
hi: p&&p.finished?'手气最佳':'目前最高',
@@ -407,19 +418,35 @@
const div=document.createElement('div');
div.className='pkt'+(p.packetId===activeId?' active':'');
div.onclick=()=>{ activeId=p.packetId; renderList(); };
const badge=p.finished?'<span class="badge done">已领完</span>':'<span class="badge open">领取中</span>';
const hiShort = p.finished ? '最佳' : '最高';
const loShort = p.finished ? '最差' : '最低';
const bestLine = `${hiShort} ${esc(p.bestNick||'-')} ${esc(p.bestAmount||'')}`.trim();
const label = p.statusLabel || (p.finished ? '已领完' : '领取中');
const fully = label.indexOf('已全部领取') >= 0;
const expired = label.indexOf('已过期') >= 0;
const badgeClass = fully ? 'done' : (expired ? 'expired' : 'open');
const badge=`<span class="badge ${badgeClass}">${esc(label)}</span>`;
const hiShort = p.finished || fully ? '最佳' : '最高';
const loShort = p.finished || fully ? '最差' : '最低';
const best = (p.leaderboard && p.leaderboard[0]) || null;
const worst = (p.leaderboard && p.leaderboard.length > 1)
? p.leaderboard[p.leaderboard.length - 1] : null;
const bestLine = formatHiLo(hiShort, best || {nickname:p.bestNick, amountText:p.bestAmount, userId:p.bestUserId});
const worstLine = (p.claimantCount||0)<=1
? `${loShort} -`
: `${loShort} ${esc(p.worstNick||'-')} ${esc(p.worstAmount||'')}`.trim();
: formatHiLo(loShort, worst || {nickname:p.worstNick, amountText:p.worstAmount, userId:p.worstUserId});
const timeLine = p.expiresAt
? `${esc(p.issuedAt||'时间未知')} · 到期 ${esc(p.expiresAt)}`
: esc(p.issuedAt||'时间未知');
const countLine = (p.totalCount > 0)
? `${p.claimedCount||p.claimantCount||0}/${p.totalCount}`
: `${p.claimantCount||0}`;
div.innerHTML=`
<div class="pkt-row">
<div class="pkt-name">${esc(p.sender||p.title||'红包')}</div>
<div style="min-width:0;flex:1">
<div class="pkt-name">${esc(p.sender||p.title||'红包')}</div>
${p.packetId ? `<div class="pkt-uid">ID ${esc(p.packetId)}</div>` : ''}
</div>
<div class="pkt-money">${money(p.sumClaimed??p.total??0)}</div>
</div>
<div class="pkt-meta">${badge}<span>${p.claimantCount||0} · ${esc(p.issuedAt||'时间未知')}</span><span class="pkt-id">${esc(shortId(p.packetId))}</span></div>
<div class="pkt-meta">${badge}<span>${countLine} · ${timeLine}</span></div>
<div class="pkt-luck">
<div class="mini best">${bestLine}</div>
<div class="mini worst">${worstLine}</div>
@@ -437,21 +464,42 @@
const parts=[];
if(p.packetId) parts.push('ID '+p.packetId);
if(p.issuedAt) parts.push('发放 '+p.issuedAt);
if(p.expiresAt) parts.push('到期 '+p.expiresAt);
if(p.via) parts.push(p.via);
sub.textContent = parts.join(' · ');
cPeople.textContent = p.claimantCount || 0;
cSum.textContent = money(p.sumClaimed ?? 0);
cTotal.textContent = p.total || '-';
cStatus.innerHTML = p.finished ? '<span class="badge done">已领完</span>' : '<span class="badge open">领取中</span>';
cStatus.innerHTML = (()=>{
const label = p.statusLabel || (p.finished ? '已领完' : '领取中');
const fully = label.indexOf('已全部领取') >= 0;
const expired = label.indexOf('已过期') >= 0;
const cls = fully ? 'done' : (expired ? 'expired' : 'open');
return `<span class="badge ${cls}">${esc(label)}</span>`;
})();
luckBestLab.textContent = labels.hi;
luckWorstLab.textContent = labels.lo;
luckBestName.textContent = p.bestNick || '-';
luckBestAmt.textContent = p.bestAmount ? ('RM ' + p.bestAmount) : '-';
if((p.claimantCount||0)<=1){
luckWorstName.textContent='-'; luckWorstAmt.textContent='-';
const best = (p.leaderboard && p.leaderboard[0]) || null;
const worst = (p.leaderboard && p.leaderboard.length > 1)
? p.leaderboard[p.leaderboard.length - 1] : null;
if(best){
luckBestName.innerHTML = best.userId
? `${esc(best.nickname||'-')}<div class="pkt-uid">ID ${esc(best.userId)}</div>`
: esc(best.nickname||p.bestNick||'-');
luckBestAmt.textContent = best.amountText ? ('RM ' + best.amountText)
: (p.bestAmount ? ('RM ' + p.bestAmount) : '-');
} else {
luckWorstName.textContent = p.worstNick || '-';
luckWorstAmt.textContent = p.worstAmount ? ('RM ' + p.worstAmount) : '-';
luckBestName.textContent = p.bestNick || '-';
luckBestAmt.textContent = p.bestAmount ? ('RM ' + p.bestAmount) : '-';
}
if(worst && (p.claimantCount||0)>1){
luckWorstName.innerHTML = worst.userId
? `${esc(worst.nickname||'-')}<div class="pkt-uid">ID ${esc(worst.userId)}</div>`
: esc(worst.nickname||p.worstNick||'-');
luckWorstAmt.textContent = worst.amountText ? ('RM ' + worst.amountText)
: (p.worstAmount ? ('RM ' + p.worstAmount) : '-');
} else {
luckWorstName.textContent='-'; luckWorstAmt.textContent='-';
}
const rows=p.leaderboard||[];
board.innerHTML='';

View File

@@ -19,8 +19,8 @@ PACKETS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_pac
HOOK_STATUS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_hook_status.json")
# 与 xposed-module / App MmpModuleInfo 同步
EXPECTED_MODULE_CODE = 3
EXPECTED_MODULE_NAME = "1.2.0"
EXPECTED_MODULE_CODE = 5
EXPECTED_MODULE_NAME = "1.2.2"
_messages = deque(maxlen=MAX_MESSAGES)
_mmp_messages = deque(maxlen=MAX_MESSAGES)
@@ -629,6 +629,9 @@ def _normalize_synced_packet(raw):
elif sum_claimed:
total = "{0:.2f}".format(sum_claimed)
issued = raw.get("issuedAt") or raw.get("issued") or ""
expires = raw.get("expiresAt") or raw.get("expire") or raw.get("expireTime") or ""
status = raw.get("status") or raw.get("activityStatus") or ""
finished = bool(raw.get("finished")) or _is_terminal_status(status) or _is_expire_passed(expires)
return {
"packetId": pid,
"title": raw.get("title") or "TNG 红包",
@@ -637,11 +640,15 @@ def _normalize_synced_packet(raw):
"total": total,
"via": raw.get("via") or "phone-sync",
"issuedAt": issued,
"expiresAt": expires,
"status": status,
"updatedAt": raw.get("updatedAt") or "",
"fetchedAt": raw.get("updatedAt") or raw.get("fetchedAt") or _now_iso(),
"latestAt": issued or raw.get("updatedAt") or "",
"finished": bool(raw.get("finished")),
"finished": finished,
"snapshots": int(raw.get("snapshots") or 1),
"claimedCount": int(raw.get("claimedCount") or 0),
"totalCount": int(raw.get("totalCount") or 0),
"leaderboard": norm_board,
"claimantCount": len(norm_board) if norm_board else int(raw.get("claimantCount") or 0),
"sumClaimed": sum_claimed,
@@ -741,7 +748,8 @@ def _mmp_packets():
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")
or _is_terminal_status(meta.get("status") or "") \
or _is_expire_passed(meta.get("expire") or meta.get("expireTime") or "")
item = {
"packetId": key,
"title": msg.get("title") or "TNG 红包",
@@ -750,12 +758,15 @@ def _mmp_packets():
"total": meta.get("total") or "",
"via": meta.get("via") or "",
"issuedAt": issued,
"expiresAt": meta.get("expire") or meta.get("expireTime") or "",
"status": meta.get("status") or "",
"fetchedAt": msg.get("receivedAt") or "",
"latestAt": issued or (msg.get("receivedAt") or ""),
"finished": finished,
"snapshots": 1,
"claims": claims,
}
_apply_counts_from_meta(item, meta)
existing = packets.get(key)
def _score(it):
return (
@@ -801,6 +812,10 @@ def _mmp_packets():
p["bestAmount"] = (best or {}).get("amountText") or ""
p["worstNick"] = (worst or {}).get("nickname") or ""
p["worstAmount"] = (worst or {}).get("amountText") or ""
if not p.get("claimedCount"):
p["claimedCount"] = len(ranked)
if p.get("totalCount") and int(p.get("claimedCount") or 0) >= int(p.get("totalCount") or 0):
p["finished"] = True
del p["claims"]
result.append(p)
# 合并手机全量同步落盘数据(电脑重启后仍可看)
@@ -821,6 +836,9 @@ def _mmp_packets():
merged["latestAt"] = merged["issuedAt"]
by_id[key] = merged
result = list(by_id.values())
for p in result:
_refresh_packet_closed(p)
p["statusLabel"] = _packet_status_label(p)
result.sort(key=lambda x: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
return result
@@ -834,6 +852,100 @@ def _mmp_time_sort_key(text):
return s
def _parse_mmp_time_ms(text):
s = (text or "").strip()
if not s:
return 0
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?", s)
if m:
try:
dt = datetime(
int(m.group(3)), int(m.group(2)), int(m.group(1)),
int(m.group(4) or 23), int(m.group(5) or 59), int(m.group(6) or 59))
return int(dt.timestamp() * 1000)
except ValueError:
return 0
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?", s)
if m:
try:
dt = datetime(
int(m.group(1)), int(m.group(2)), int(m.group(3)),
int(m.group(4) or 0), int(m.group(5) or 0), int(m.group(6) or 0))
return int(dt.timestamp() * 1000)
except ValueError:
return 0
if re.match(r"^\d{13}$", s):
return int(s)
if re.match(r"^\d{10}$", s):
return int(s) * 1000
return 0
def _is_terminal_status(status):
s = (status or "").upper()
if not s:
return False
return any(k in s for k in (
"FINISH", "COMPLETE", "EXPIRE", "ENDED", "CLOSED", "CANCEL", "DONE"))
def _is_expire_passed(expire_text):
ms = _parse_mmp_time_ms(expire_text)
return ms > 0 and int(time.time() * 1000) >= ms
def _refresh_packet_closed(p):
if not isinstance(p, dict):
return p
if p.get("finished"):
return p
if _is_terminal_status(p.get("status") or "") or _is_expire_passed(
p.get("expiresAt") or p.get("expire") or ""):
p["finished"] = True
return p
def _packet_status_label(p):
_refresh_packet_closed(p)
claimed = int(p.get("claimedCount") or 0)
total = int(p.get("totalCount") or 0)
board_n = len(p.get("leaderboard") or []) if isinstance(p.get("leaderboard"), list) else 0
claimed = max(claimed, board_n, int(p.get("claimantCount") or 0))
fully = total > 0 and claimed >= total
if fully:
progress = "已全部领取"
elif total > 0:
progress = "已领取 {0}/{1}".format(claimed, total)
elif claimed > 0:
progress = "已领取 {0}".format(claimed)
else:
progress = "未领取" if p.get("finished") else "领取中"
expired = _is_expire_passed(p.get("expiresAt") or "") or any(
k in (p.get("status") or "").upper() for k in ("EXPIRE", "ENDED", "CLOSED", "CANCEL"))
if expired and not fully:
return progress + " · 已过期"
return progress
def _apply_counts_from_meta(item, meta):
counts = (meta.get("counts") or "").strip()
if counts and "/" in counts:
left, right = counts.split("/", 1)
try:
item["claimedCount"] = int(left.strip() or 0)
except ValueError:
pass
try:
item["totalCount"] = int(right.strip() or 0)
except ValueError:
pass
board = item.get("claims") or item.get("leaderboard") or []
if not item.get("claimedCount") and isinstance(board, list):
item["claimedCount"] = len(board)
if item.get("totalCount") and item.get("claimedCount", 0) >= item["totalCount"]:
item["finished"] = True
def _mmp_html_page(filename="mmp_page.html"):
page = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
try:

View File

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

View File

@@ -14,8 +14,8 @@ public final class HookBridge {
public static final String EXTRA_MODULE_VERSION_NAME = "moduleVersionName";
public static final String EXTRA_HOST_PACKAGE = "hostPackage";
/** 与 xposed-module/build.gradle 保持同步 */
public static final int MODULE_VERSION_CODE = 3;
public static final String MODULE_VERSION_NAME = "1.2.0";
public static final int MODULE_VERSION_CODE = 5;
public static final String MODULE_VERSION_NAME = "1.2.2";
public static final String SOURCE_XPOSED_SQLITE = "xposed_sqlite";
public static final String SOURCE_XPOSED_WECHAT = "xposed_wechat";
public static final String SOURCE_XPOSED_TELEGRAM = "xposed_telegram";

View File

@@ -938,7 +938,7 @@ public final class TngMoneyPacketHook {
return s == null ? "" : s;
}
/** remaining / claimed/total / status 任一表明已领完 */
/** remaining / claimed/total / status 任一表明已领完或已结束 */
private static boolean isFinishedFlags(String remain, String claimedSlashTotal, String status) {
if (!TextUtils.isEmpty(remain)) {
try {
@@ -961,7 +961,9 @@ public final class TngMoneyPacketHook {
}
if (!TextUtils.isEmpty(status)) {
String s = status.toUpperCase(Locale.US);
// TNG ActivityStatus: ACTIVE/INIT/FINISHED/ENDED/CLOSED/CANCELLED
if (s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE") || "TRUE".equals(s)) {
return true;
}
@@ -978,6 +980,9 @@ public final class TngMoneyPacketHook {
snapshot.activityStatus)) {
return true;
}
if (isExpireTimePassed(snapshot.expireTime)) {
return true;
}
// 已领金额 ≈ 总额
Double claimedAmt = parseMoneyDouble(snapshot.claimedAmountText);
Double totalAmt = parseMoneyDouble(snapshot.totalAmount);
@@ -998,6 +1003,58 @@ public final class TngMoneyPacketHook {
return false;
}
/** expireTime 形如 05/08/2026 14:06:56 或 ISO已过则视为结束 */
private static boolean isExpireTimePassed(String expireTime) {
long ms = parseIssueTimeMs(expireTime);
return ms > 0 && System.currentTimeMillis() >= ms;
}
private static long parseIssueTimeMs(String text) {
if (TextUtils.isEmpty(text)) {
return 0L;
}
String s = text.trim();
try {
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("^(\\d{2})/(\\d{2})/(\\d{4})(?:\\s+(\\d{2}):(\\d{2})(?::(\\d{2}))?)?")
.matcher(s);
if (m.find()) {
java.util.Calendar c = java.util.Calendar.getInstance();
c.set(Integer.parseInt(m.group(3)),
Integer.parseInt(m.group(2)) - 1,
Integer.parseInt(m.group(1)),
m.group(4) != null ? Integer.parseInt(m.group(4)) : 23,
m.group(5) != null ? Integer.parseInt(m.group(5)) : 59,
m.group(6) != null ? Integer.parseInt(m.group(6)) : 59);
c.set(java.util.Calendar.MILLISECOND, 999);
return c.getTimeInMillis();
}
if (s.matches("^\\d{13}$")) {
return Long.parseLong(s);
}
if (s.matches("^\\d{10}$")) {
return Long.parseLong(s) * 1000L;
}
// 2026-08-05T14:06:56 / 2026-08-05 14:06:56
java.util.regex.Matcher iso = java.util.regex.Pattern
.compile("^(\\d{4})-(\\d{2})-(\\d{2})[ T](\\d{2}):(\\d{2})(?::(\\d{2}))?")
.matcher(s);
if (iso.find()) {
java.util.Calendar c = java.util.Calendar.getInstance();
c.set(Integer.parseInt(iso.group(1)),
Integer.parseInt(iso.group(2)) - 1,
Integer.parseInt(iso.group(3)),
Integer.parseInt(iso.group(4)),
Integer.parseInt(iso.group(5)),
iso.group(6) != null ? Integer.parseInt(iso.group(6)) : 0);
c.set(java.util.Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
} catch (Throwable ignored) {
}
return 0L;
}
private static String buildCountHint(String claimed, String total) {
if (TextUtils.isEmpty(claimed) && TextUtils.isEmpty(total)) {
return null;
@@ -2034,6 +2091,11 @@ public final class TngMoneyPacketHook {
stringField(result, "activityStatus", "getActivityStatus"),
boolFieldText(summary, "isFinished", "isFinished"),
boolFieldText(result, "isFinished", "isFinished"));
snapshot.expireTime = firstNonEmpty(
stringField(summary, "expireTime", "getExpireTime"),
stringField(result, "expireTime", "getExpireTime"),
stringField(summary, "expiryTime", "getExpiryTime"),
stringField(result, "expiryTime", "getExpiryTime"));
if (TextUtils.isEmpty(snapshot.claimedAmountText)) {
try {
Object money = XposedHelpers.callMethod(summary, "getClaimedAmount");
@@ -2649,6 +2711,7 @@ public final class TngMoneyPacketHook {
String claimedCount;
String totalCount;
String activityStatus;
String expireTime;
String claimedAmountText;
boolean finished;
final List<ClaimLine> claims = new ArrayList<>();
@@ -2713,6 +2776,21 @@ public final class TngMoneyPacketHook {
if (!TextUtils.isEmpty(issueTime)) {
sb.append(" | issued=").append(sanitizeMeta(issueTime));
}
if (!TextUtils.isEmpty(expireTime)) {
sb.append(" | expire=").append(sanitizeMeta(expireTime));
}
if (!TextUtils.isEmpty(activityStatus)) {
sb.append(" | status=").append(sanitizeMeta(activityStatus));
}
String claimed = claimedCount;
String totalCnt = totalCount;
if (TextUtils.isEmpty(claimed) && claims != null) {
claimed = String.valueOf(claims.size());
}
if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
sb.append(" | counts=").append(sanitizeMeta(nullToEmpty(claimed)))
.append('/').append(sanitizeMeta(nullToEmpty(totalCnt)));
}
if (finished) {
sb.append(" | done=1");
}