From 293979122dc7896e076dbd9a03784b6b3a86583a Mon Sep 17 00:00:00 2001 From: mars <3361409208@qq.com> Date: Wed, 5 Aug 2026 15:09:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(mmp):=20=E9=A2=86=E5=8F=96=E4=BA=BAID/?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=89=88=E6=9C=AC=E6=A3=80=E6=B5=8B=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E6=AD=A3=E7=8A=B6=E6=80=81=E6=9D=A1=E8=AF=AF?= =?UTF-8?q?=E6=8A=A5=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 展示 receiverId 与 activityPoolId;检测 Hook 是否最新;按真实时间排序;有心跳与定时拉取时不再因久无新数据爆红。 --- app/src/main/AndroidManifest.xml | 1 + .../smsmessage/Activity/MmpClaimActivity.java | 210 ++++++++++- .../Activity/MmpDetailActivity.java | 16 + .../smsmessage/mmp/MmpClaim.java | 11 + .../smsmessage/mmp/MmpHookStatus.java | 136 +++++++ .../smsmessage/mmp/MmpLocalSettings.java | 85 +++++ .../smsmessage/mmp/MmpModuleInfo.java | 13 + .../smsmessage/mmp/MmpPacket.java | 4 +- .../smsmessage/mmp/MmpPacketStore.java | 159 +++++++- .../smsmessage/mmp/MmpSettingsClient.java | 101 ++++-- .../smsmessage/mmp/MmpSyncClient.java | 8 + .../smsmessage/mmp/TngProcessHelper.java | 114 ++++++ .../service/HookMessageReceiver.java | 18 +- .../main/res/layout/activity_mmp_claim.xml | 202 +++++++---- app/src/main/res/layout/activity_mmp_help.xml | 18 + app/src/main/res/layout/item_mmp_claim.xml | 22 ++ debug-server/mmp_help.html | 11 + debug-server/mmp_page.html | 50 ++- debug-server/server.py | 265 +++++++++++++- docs/TNG_MoneyPacket领取台.md | 13 +- xposed-module/build.gradle | 4 +- .../smsmessage/xposed/HookBridge.java | 7 + .../smsmessage/xposed/HookForwarder.java | 19 + .../xposed/hook/TngMoneyPacketHook.java | 340 ++++++++++++++++-- 24 files changed, 1641 insertions(+), 186 deletions(-) create mode 100644 app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpHookStatus.java create mode 100644 app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpLocalSettings.java create mode 100644 app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpModuleInfo.java create mode 100644 app/src/main/java/com/miraclegarden/smsmessage/mmp/TngProcessHelper.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4093e1c..2e3c45d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -124,6 +124,7 @@ android:exported="true"> + diff --git a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpClaimActivity.java b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpClaimActivity.java index 2241464..7a19fca 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpClaimActivity.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpClaimActivity.java @@ -21,10 +21,14 @@ import com.miraclegarden.smsmessage.R; import com.miraclegarden.smsmessage.comm.CommonAdapter; import com.miraclegarden.smsmessage.comm.ViewHolder; import com.miraclegarden.smsmessage.databinding.ActivityMmpClaimBinding; +import com.miraclegarden.smsmessage.mmp.MmpHookStatus; +import com.miraclegarden.smsmessage.mmp.MmpLocalSettings; +import com.miraclegarden.smsmessage.mmp.MmpModuleInfo; import com.miraclegarden.smsmessage.mmp.MmpPacket; import com.miraclegarden.smsmessage.mmp.MmpPacketStore; import com.miraclegarden.smsmessage.mmp.MmpSettingsClient; import com.miraclegarden.smsmessage.mmp.MmpSyncClient; +import com.miraclegarden.smsmessage.mmp.TngProcessHelper; import org.json.JSONObject; @@ -52,10 +56,20 @@ public class MmpClaimActivity extends MiracleGardenActivity { + reload(); + updateConnStatus(); + }); } private void initView() { @@ -94,6 +115,7 @@ public class MmpClaimActivity extends MiracleGardenActivity syncNow()); + binding.btnRefresh.setOnClickListener(v -> manualRefresh()); binding.chipAll.setOnClickListener(v -> setStatusFilter(0)); binding.chipOpen.setOnClickListener(v -> setStatusFilter(1)); @@ -183,6 +205,7 @@ public class MmpClaimActivity extends MiracleGardenActivity 60_000L) { + if (TngProcessHelper.launch(this)) { + lastTngLaunchAt = now; + Toast.makeText(this, "检测到 TNG 已退出,正在重新打开…", Toast.LENGTH_SHORT).show(); + } + } + } + updateConnStatus(); + if (System.currentTimeMillis() - lastDebugPingAt > 20_000L) { + pingDebugServer(); + } + handler.removeCallbacks(watchRunnable); + handler.postDelayed(watchRunnable, 12_000L); + } + + private void pingDebugServer() { + lastDebugPingAt = System.currentTimeMillis(); + MmpSettingsClient.pingHealth(ok -> runOnUiThread(() -> { + debugServerOk = ok; + updateConnStatus(); + })); + } + + private void updateConnStatus() { + if (binding.tvConnStatus == null) { + return; + } + boolean tngAlive = TngProcessHelper.isRunning(this); + long lastIngest = MmpPacketStore.lastIngestAt(this); + long ageMs = lastIngest > 0 ? System.currentTimeMillis() - lastIngest : -1L; + long hookAge = MmpHookStatus.lastAliveAt(this) > 0 + ? System.currentTimeMillis() - MmpHookStatus.lastAliveAt(this) : -1L; + // 有近期心跳 = Hook 活着(定时拉取会报活);久无入库不等于故障 + boolean hookAlive = hookAge >= 0 && hookAge <= 3 * 60_000L; + boolean modLatest = MmpHookStatus.liveVersionCode(this) >= 0 + && MmpHookStatus.isLiveLatest(this); + boolean modKnownOld = hookAlive + && MmpHookStatus.liveVersionCode(this) > 0 + && MmpHookStatus.liveVersionCode(this) < MmpModuleInfo.EXPECTED_VERSION_CODE; + + String tngPart = tngAlive ? "TNG:运行中" : "TNG:未运行"; + String hookPart; + if (!tngAlive) { + hookPart = "Hook:未连接(TNG 未开)"; + } else if (hookAlive && ageMs >= 0 && ageMs <= 120_000L) { + hookPart = "Hook:正常(" + formatAge(ageMs) + "前有数据)"; + } else if (hookAlive) { + if (ageMs >= 0) { + hookPart = "Hook:正常(定时拉取中,上次数据 " + formatAge(ageMs) + "前)"; + } else { + hookPart = "Hook:正常(定时拉取中,尚无入库)"; + } + } else if (ageMs >= 0 && ageMs <= 10 * 60_000L) { + hookPart = "Hook:待确认(" + formatAge(ageMs) + "前有数据,心跳偏旧)"; + } else if (ageMs < 0) { + hookPart = "Hook:待确认(无心跳,尚无入库)"; + } else { + hookPart = "Hook:异常(无心跳," + formatAge(ageMs) + "前曾有数据)"; + } + + String watchPart = autoWatchWhileOpen + ? (autoLaunchTngIfKilled ? "检测:开(杀掉会拉起)" : "检测:开(不自动拉起)") + : "检测:关"; + + String debugPart; + if (debugServerOk == null) { + debugPart = "调试台:检测中"; + } else if (debugServerOk) { + debugPart = "调试台:已连接"; + } else { + debugPart = "调试台:未连接"; + } + + String text = tngPart + " · " + hookPart + "\n" + + watchPart + " · " + debugPart + "\n" + + MmpHookStatus.describe(this); + binding.tvConnStatus.setText(text); + + // 红:TNG 挂了 / 无心跳 / 模块明确是旧版;久无新红包数据不爆红 + boolean okAll = tngAlive && hookAlive && modLatest; + boolean bad = !tngAlive || !hookAlive || modKnownOld; + if (okAll) { + binding.tvConnStatus.setBackgroundColor(Color.parseColor("#064E3B")); + binding.tvConnStatus.setTextColor(Color.parseColor("#D1FAE5")); + } else if (bad) { + binding.tvConnStatus.setBackgroundColor(Color.parseColor("#7F1D1D")); + binding.tvConnStatus.setTextColor(Color.parseColor("#FEE2E2")); + } else { + binding.tvConnStatus.setBackgroundColor(Color.parseColor("#78350F")); + binding.tvConnStatus.setTextColor(Color.parseColor("#FEF3C7")); + } + } + + private static String formatAge(long ageMs) { + if (ageMs < 60_000L) { + return Math.max(1, ageMs / 1000) + "秒"; + } + if (ageMs < 3600_000L) { + return (ageMs / 60_000L) + "分钟"; + } + return (ageMs / 3600_000L) + "小时"; + } + private void toggleSettings() { settingsOpen = !settingsOpen; binding.settingsPanel.setVisibility(settingsOpen ? View.VISIBLE : View.GONE); @@ -283,12 +445,17 @@ public class MmpClaimActivity extends MiracleGardenActivity { + MmpSettingsClient.load(this, (json, error) -> { if (json != null) { fillSettingsForm(json); - binding.tvSettingsStatus.setText("已同步调试台设置"); - } else if (error != null) { - binding.tvSettingsStatus.setText("设置未连上:" + error); + if (error != null && !error.isEmpty()) { + binding.tvSettingsStatus.setText(error); + } else { + binding.tvSettingsStatus.setText("设置已加载(本地优先,可离线保存)"); + } + } else { + fillSettingsForm(MmpLocalSettings.load(this)); + binding.tvSettingsStatus.setText(error != null ? error : "已用本地默认设置"); } }); } @@ -299,33 +466,33 @@ public class MmpClaimActivity extends MiracleGardenActivity { + MmpSettingsClient.save(this, body, (json, error) -> { if (json != null) { fillSettingsForm(json); - binding.tvSettingsStatus.setText("已保存,Hook 约 10 秒内生效"); + String msg = error != null ? error : "已保存;Hook 参数约 10 秒内生效(需调试台)"; + binding.tvSettingsStatus.setText(msg); Toast.makeText(this, "设置已保存", Toast.LENGTH_SHORT).show(); + scheduleWatch(); } else { binding.tvSettingsStatus.setText("保存失败:" + error); - Toast.makeText(this, "保存失败,请确认调试台已开", Toast.LENGTH_SHORT).show(); + Toast.makeText(this, "保存失败", Toast.LENGTH_SHORT).show(); } }); } catch (Exception e) { diff --git a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpDetailActivity.java b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpDetailActivity.java index cc877a6..f3e9d0c 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpDetailActivity.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/Activity/MmpDetailActivity.java @@ -79,6 +79,22 @@ public class MmpDetailActivity extends MiracleGardenActivity 0 ? claim.rank : index + 1)); holder.setText(R.id.tv_nickname, claim.nickname != null ? claim.nickname : "?"); + TextView userIdView = holder.getView(R.id.tv_user_id); + if (!TextUtils.isEmpty(claim.userId)) { + userIdView.setVisibility(View.VISIBLE); + userIdView.setText("用户ID " + claim.userId); + } else { + userIdView.setVisibility(View.GONE); + userIdView.setText(""); + } + TextView poolIdView = holder.getView(R.id.tv_pool_id); + if (!TextUtils.isEmpty(claim.poolId)) { + poolIdView.setVisibility(View.VISIBLE); + poolIdView.setText("领取ID " + claim.poolId); + } else { + poolIdView.setVisibility(View.GONE); + poolIdView.setText(""); + } holder.setText(R.id.tv_amount, claim.amountText != null ? claim.amountText : String.format(Locale.US, "%.2f", claim.amount)); diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpClaim.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpClaim.java index 2c95f87..d244f14 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpClaim.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpClaim.java @@ -5,6 +5,10 @@ package com.miraclegarden.smsmessage.mmp; */ public class MmpClaim { public String nickname; + /** TNG receiverId,重名时用来区分 */ + public String userId; + /** activityPoolId(单次领取池条目) */ + public String poolId; public String amountText; public double amount; public String claimTime; @@ -19,4 +23,11 @@ public class MmpClaim { this.amountText = String.format(java.util.Locale.US, "%.2f", amount); this.claimTime = claimTime != null ? claimTime : ""; } + + public String shortUserId() { + if (userId == null || userId.isEmpty()) { + return ""; + } + return userId.length() <= 10 ? userId : userId.substring(0, 8) + "…"; + } } diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpHookStatus.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpHookStatus.java new file mode 100644 index 0000000..04fee3d --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpHookStatus.java @@ -0,0 +1,136 @@ +package com.miraclegarden.smsmessage.mmp; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.text.TextUtils; + +/** + * 记录 TNG 进程内 Hook 心跳,用于判断是否挂上最新模块。 + */ +public final class MmpHookStatus { + + private static final String PREF = "mmp_hook_status"; + private static volatile int sLiveCode; + private static volatile String sLiveName = ""; + private static volatile long sLiveAt; + private static volatile String sHostPkg = ""; + + private MmpHookStatus() { + } + + public static void noteAlive(Context context, int versionCode, String versionName, + String hostPackage) { + sLiveCode = versionCode; + sLiveName = versionName != null ? versionName : ""; + sLiveAt = System.currentTimeMillis(); + sHostPkg = hostPackage != null ? hostPackage : ""; + if (context == null) { + return; + } + prefs(context).edit() + .putInt("live_code", sLiveCode) + .putString("live_name", sLiveName) + .putLong("live_at", sLiveAt) + .putString("host_pkg", sHostPkg) + .apply(); + } + + public static void ensureLoaded(Context context) { + if (sLiveAt > 0 || context == null) { + return; + } + SharedPreferences sp = prefs(context); + sLiveCode = sp.getInt("live_code", 0); + sLiveName = sp.getString("live_name", ""); + sLiveAt = sp.getLong("live_at", 0L); + sHostPkg = sp.getString("host_pkg", ""); + } + + public static long lastAliveAt(Context context) { + ensureLoaded(context); + return sLiveAt; + } + + public static int liveVersionCode(Context context) { + ensureLoaded(context); + return sLiveCode; + } + + public static String liveVersionName(Context context) { + ensureLoaded(context); + return sLiveName != null ? sLiveName : ""; + } + + public static int installedVersionCode(Context context) { + if (context == null) { + return 0; + } + try { + PackageInfo pi = context.getPackageManager() + .getPackageInfo(MmpModuleInfo.XPOSED_PACKAGE, 0); + return pi != null ? pi.versionCode : 0; + } catch (PackageManager.NameNotFoundException e) { + return 0; + } + } + + public static String installedVersionName(Context context) { + if (context == null) { + return ""; + } + try { + PackageInfo pi = context.getPackageManager() + .getPackageInfo(MmpModuleInfo.XPOSED_PACKAGE, 0); + return pi != null && pi.versionName != null ? pi.versionName : ""; + } catch (PackageManager.NameNotFoundException e) { + return ""; + } + } + + /** + * @return 人类可读模块状态 + */ + public static String describe(Context context) { + ensureLoaded(context); + int installed = installedVersionCode(context); + String installedName = installedVersionName(context); + if (installed <= 0) { + return "模块APK:未安装"; + } + String apkPart = "模块APK:" + (TextUtils.isEmpty(installedName) + ? String.valueOf(installed) : installedName); + boolean apkLatest = installed >= MmpModuleInfo.EXPECTED_VERSION_CODE; + if (!apkLatest) { + apkPart += "(旧,期望" + MmpModuleInfo.EXPECTED_VERSION_NAME + ")"; + } + + long age = sLiveAt > 0 ? System.currentTimeMillis() - sLiveAt : -1L; + String livePart; + if (age < 0) { + livePart = "运行中:未检测到(请强停再开 TNG)"; + } else if (age > 3 * 60_000L) { + livePart = "运行中:心跳偏旧(" + (age / 60_000L) + "分钟前 " + + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + ")"; + } else if (sLiveCode >= MmpModuleInfo.EXPECTED_VERSION_CODE) { + livePart = "运行中:最新(" + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + ")"; + } else if (sLiveCode > 0) { + livePart = "运行中:旧版(" + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + + ",期望" + MmpModuleInfo.EXPECTED_VERSION_NAME + ")"; + } else { + livePart = "运行中:未知"; + } + return apkPart + " · " + livePart; + } + + public static boolean isLiveLatest(Context context) { + ensureLoaded(context); + long age = sLiveAt > 0 ? System.currentTimeMillis() - sLiveAt : -1L; + return age >= 0 && age <= 3 * 60_000L && sLiveCode >= MmpModuleInfo.EXPECTED_VERSION_CODE; + } + + private static SharedPreferences prefs(Context context) { + return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpLocalSettings.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpLocalSettings.java new file mode 100644 index 0000000..086b0cb --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpLocalSettings.java @@ -0,0 +1,85 @@ +package com.miraclegarden.smsmessage.mmp; + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.TextUtils; + +import org.json.JSONObject; + +/** + * 领取台本地设置(不依赖电脑调试台也能读写)。 + */ +public final class MmpLocalSettings { + + private static final String PREF = "mmp_settings_local"; + + private MmpLocalSettings() { + } + + public static JSONObject defaults() { + JSONObject o = new JSONObject(); + try { + o.put("historyCooldownSec", 8); + o.put("detailCooldownSec", 8); + o.put("dedupSec", 3); + o.put("detailGapMs", 80); + o.put("pagePollMs", 1000); + o.put("openHistoryIfNoTemplate", true); + o.put("autoBounceHistoryOnDisconnect", true); + o.put("autoLaunchTngIfKilled", true); + o.put("autoWatchWhileOpen", true); + } catch (Exception ignored) { + } + return o; + } + + public static JSONObject load(Context context) { + JSONObject out = defaults(); + if (context == null) { + return out; + } + SharedPreferences sp = prefs(context); + try { + String raw = sp.getString("json", null); + if (!TextUtils.isEmpty(raw)) { + JSONObject saved = new JSONObject(raw); + mergeInto(out, saved); + } + } catch (Exception ignored) { + } + return out; + } + + public static void save(Context context, JSONObject json) { + if (context == null || json == null) { + return; + } + JSONObject merged = defaults(); + mergeInto(merged, json); + prefs(context).edit().putString("json", merged.toString()).apply(); + } + + public static void mergeInto(JSONObject target, JSONObject src) { + if (target == null || src == null) { + return; + } + String[] keys = { + "historyCooldownSec", "detailCooldownSec", "dedupSec", "detailGapMs", "pagePollMs", + "openHistoryIfNoTemplate", "autoBounceHistoryOnDisconnect", + "autoLaunchTngIfKilled", "autoWatchWhileOpen" + }; + for (String k : keys) { + if (!src.has(k)) { + continue; + } + try { + target.put(k, src.get(k)); + } catch (Exception ignored) { + } + } + } + + private static SharedPreferences prefs(Context context) { + return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpModuleInfo.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpModuleInfo.java new file mode 100644 index 0000000..592297d --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpModuleInfo.java @@ -0,0 +1,13 @@ +package com.miraclegarden.smsmessage.mmp; + +/** + * Xposed 模块版本约定(与 xposed-module/build.gradle 同步)。 + */ +public final class MmpModuleInfo { + public static final String XPOSED_PACKAGE = "com.miraclegarden.smsmessage.xposed"; + public static final int EXPECTED_VERSION_CODE = 3; + public static final String EXPECTED_VERSION_NAME = "1.2.0"; + + private MmpModuleInfo() { + } +} diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacket.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacket.java index 3235935..09bf7b7 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacket.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacket.java @@ -123,7 +123,9 @@ public class MmpPacket { } if (leaderboard != null) { for (MmpClaim c : leaderboard) { - if (c != null && containsIgnoreCase(c.nickname, q)) { + if (c != null && (containsIgnoreCase(c.nickname, q) + || containsIgnoreCase(c.userId, q) + || containsIgnoreCase(c.poolId, q))) { return true; } } diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacketStore.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacketStore.java index 28a5fa0..32c2f46 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacketStore.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpPacketStore.java @@ -28,6 +28,7 @@ public final class MmpPacketStore { private static final String TAG = "MmpPacketStore"; private static final String PREF = "mmp_packets"; private static final String KEY_JSON = "packets_json"; + private static final String KEY_LAST_INGEST = "last_ingest_at"; private static final int MAX_PACKETS = 200; private static final Pattern AMOUNT_JSON = Pattern.compile( @@ -39,6 +40,7 @@ public final class MmpPacketStore { } private static final CopyOnWriteArrayList LISTENERS = new CopyOnWriteArrayList<>(); + private static volatile long sLastIngestAt; private MmpPacketStore() { } @@ -66,15 +68,20 @@ public final class MmpPacketStore { return false; } ParseResult parsed = parse(content); - if (parsed.claims.isEmpty()) { - return false; - } + noteModuleFromMeta(context, parsed.meta); 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)) { + boolean hasRealPacketId = !TextUtils.isEmpty(packetId) + && !"unknown".equals(packetId) + && !"TNG 红包".equals(packetId) + && !"TNG Money Packet".equals(packetId); + // 允许无人领取的新红包入库(只有 packet/meta,claims 为空) + if (parsed.claims.isEmpty() && !hasRealPacketId) { + return false; + } + if (!hasRealPacketId) { packetId = "红包-" + claimsFingerprint(parsed.claims); if (packetId.length() > 56) { packetId = packetId.substring(0, 56); @@ -138,6 +145,11 @@ public final class MmpPacketStore { saveMap(context, map); } notifyListeners(); + sLastIngestAt = System.currentTimeMillis(); + try { + prefs(context).edit().putLong(KEY_LAST_INGEST, sLastIngestAt).apply(); + } catch (Exception ignored) { + } Log.i(TAG, "ingest packet=" + packetId + " claims=" + ranked.size()); try { MmpSyncClient.syncIfStale(context.getApplicationContext(), 3000L); @@ -147,21 +159,55 @@ public final class MmpPacketStore { return true; } + /** Hook 最近一次成功写入本地的时间戳;0 表示尚无 */ + public static long lastIngestAt(Context context) { + if (sLastIngestAt > 0) { + return sLastIngestAt; + } + if (context == null) { + return 0L; + } + try { + long v = prefs(context).getLong(KEY_LAST_INGEST, 0L); + if (v > 0) { + sLastIngestAt = v; + } + return v; + } catch (Exception e) { + return 0L; + } + } + public static List 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); + // DD/MM/YYYY 不能按字符串比;按真实时间降序,最新在前 + long ta = sortTimeMs(a); + long tb = sortTimeMs(b); + if (tb != ta) { + return Long.compare(tb, ta); + } + return nullToEmpty(b.packetId).compareTo(nullToEmpty(a.packetId)); } }); return list; } } + private static long sortTimeMs(MmpPacket p) { + if (p == null) { + return 0L; + } + long ms = MmpPacket.parseIssueMs(p.issuedAt); + if (ms <= 0) { + ms = MmpPacket.parseIssueMs(p.updatedAt); + } + return ms; + } + public static MmpPacket getPacket(Context context, String packetId) { if (TextUtils.isEmpty(packetId)) { return null; @@ -195,6 +241,30 @@ public final class MmpPacketStore { // --- parse --- + /** 从 header 的 mod=1.2.0/3 更新 Hook 运行版本(兜底) */ + private static void noteModuleFromMeta(Context context, Map meta) { + if (context == null || meta == null) { + return; + } + String mod = meta.get("mod"); + if (TextUtils.isEmpty(mod)) { + return; + } + int code = 0; + String name = mod.trim(); + int slash = name.lastIndexOf('/'); + if (slash >= 0 && slash + 1 < name.length()) { + String codePart = name.substring(slash + 1).trim(); + name = name.substring(0, slash).trim(); + try { + code = Integer.parseInt(codePart); + } catch (NumberFormatException ignored) { + code = 0; + } + } + MmpHookStatus.noteAlive(context.getApplicationContext(), code, name, "my.com.tngdigital.ewallet"); + } + private static final class ParseResult { final Map meta = new LinkedHashMap<>(); final List claims = new ArrayList<>(); @@ -202,6 +272,8 @@ public final class MmpPacketStore { private static final class RawClaim { String nickname; + String userId; + String poolId; double amount; String claimTime; } @@ -258,6 +330,34 @@ public final class MmpPacketStore { int arrow = line.indexOf("->"); String nick = line.substring(0, arrow).trim(); String right = line.substring(arrow + 2).trim(); + String userId = ""; + String poolId = ""; + int ridAt = right.indexOf(" #rid="); + if (ridAt >= 0) { + String rest = right.substring(ridAt + 6).trim(); + right = right.substring(0, ridAt).trim(); + int sp = rest.indexOf(' '); + if (sp > 0) { + userId = rest.substring(0, sp).trim(); + String more = rest.substring(sp + 1).trim(); + if (more.startsWith("#pool=")) { + poolId = more.substring(6).trim(); + } + } else { + userId = rest; + } + } + int poolAt = right.indexOf(" #pool="); + if (poolAt >= 0 && TextUtils.isEmpty(poolId)) { + poolId = right.substring(poolAt + 7).trim(); + right = right.substring(0, poolAt).trim(); + } + // 兼容 #rid= 后仍有 #pool= + int poolInId = userId.indexOf("#pool="); + if (poolInId >= 0) { + poolId = userId.substring(poolInId + 6).trim(); + userId = userId.substring(0, poolInId).trim(); + } String claimTime = ""; String amountRaw = right; if (right.endsWith(")") && right.contains("(") && !right.startsWith("{")) { @@ -266,9 +366,11 @@ public final class MmpPacketStore { claimTime = right.substring(lp + 1, right.length() - 1).trim(); } Double amt = normalizeMoney(amountRaw); - if (!TextUtils.isEmpty(nick)) { + if (!TextUtils.isEmpty(nick) || !TextUtils.isEmpty(userId)) { RawClaim c = new RawClaim(); - c.nickname = nick; + c.nickname = TextUtils.isEmpty(nick) ? "?" : nick; + c.userId = userId; + c.poolId = poolId; c.amount = amt != null ? amt : 0; c.claimTime = claimTime; out.claims.add(c); @@ -329,9 +431,13 @@ public final class MmpPacketStore { LinkedHashMap buckets = new LinkedHashMap<>(); for (RawClaim c : claims) { String nick = c.nickname != null ? c.nickname : "?"; - MmpClaim b = buckets.get(nick); + String key = !TextUtils.isEmpty(c.userId) ? ("id:" + c.userId) : ("n:" + nick); + MmpClaim b = buckets.get(key); if (b == null) { - buckets.put(nick, new MmpClaim(nick, c.amount, c.claimTime)); + MmpClaim created = new MmpClaim(nick, c.amount, c.claimTime); + created.userId = c.userId; + created.poolId = c.poolId; + buckets.put(key, created); continue; } if (c.amount >= b.amount) { @@ -340,8 +446,25 @@ public final class MmpPacketStore { if (!TextUtils.isEmpty(c.claimTime)) { b.claimTime = c.claimTime; } - } else if (TextUtils.isEmpty(b.claimTime) && !TextUtils.isEmpty(c.claimTime)) { - b.claimTime = c.claimTime; + if (!TextUtils.isEmpty(c.userId)) { + b.userId = c.userId; + } + if (!TextUtils.isEmpty(c.poolId)) { + b.poolId = c.poolId; + } + if (!TextUtils.isEmpty(nick) && !"?".equals(nick)) { + b.nickname = nick; + } + } else { + if (TextUtils.isEmpty(b.claimTime) && !TextUtils.isEmpty(c.claimTime)) { + b.claimTime = c.claimTime; + } + if (TextUtils.isEmpty(b.userId) && !TextUtils.isEmpty(c.userId)) { + b.userId = c.userId; + } + if (TextUtils.isEmpty(b.poolId) && !TextUtils.isEmpty(c.poolId)) { + b.poolId = c.poolId; + } } } List result = new ArrayList<>(buckets.values()); @@ -360,8 +483,8 @@ public final class MmpPacketStore { 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)); + String id = !TextUtils.isEmpty(c.userId) ? c.userId : (c.nickname != null ? c.nickname : "?"); + rows.add(id + "=" + String.format(Locale.US, "%.2f", c.amount)); } Collections.sort(rows); StringBuilder sb = new StringBuilder(); @@ -488,6 +611,8 @@ public final class MmpPacketStore { for (MmpClaim c : p.leaderboard) { JSONObject row = new JSONObject(); row.put("nickname", nullToEmpty(c.nickname)); + row.put("userId", nullToEmpty(c.userId)); + row.put("poolId", nullToEmpty(c.poolId)); row.put("amountText", nullToEmpty(c.amountText)); row.put("amount", c.amount); row.put("claimTime", nullToEmpty(c.claimTime)); @@ -519,6 +644,8 @@ public final class MmpPacketStore { JSONObject row = board.getJSONObject(i); MmpClaim c = new MmpClaim(); c.nickname = row.optString("nickname", ""); + c.userId = row.optString("userId", ""); + c.poolId = row.optString("poolId", ""); c.amount = row.optDouble("amount", 0); c.amountText = row.optString("amountText", String.format(Locale.US, "%.2f", c.amount)); diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSettingsClient.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSettingsClient.java index 7d18b4e..a55be5e 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSettingsClient.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSettingsClient.java @@ -1,5 +1,6 @@ package com.miraclegarden.smsmessage.mmp; +import android.content.Context; import android.os.Handler; import android.os.Looper; import android.util.Log; @@ -19,7 +20,7 @@ import okhttp3.RequestBody; import okhttp3.Response; /** - * 从 PC 调试台读写红包拉取设置(与 /mmp 设置同源)。 + * 领取台设置:本地 SharedPreferences 优先,能连上调试台时再同步 Hook 侧参数。 */ public final class MmpSettingsClient { @@ -40,8 +41,11 @@ public final class MmpSettingsClient { private MmpSettingsClient() { } - public static void load(CallbackJson cb) { + /** 先本地,再尝试合并调试台(Hook 冷却等) */ + public static void load(Context context, CallbackJson cb) { + final Context app = context != null ? context.getApplicationContext() : null; EXEC.execute(() -> { + JSONObject local = MmpLocalSettings.load(app); Exception last = null; for (String base : AppConfig.DEBUG_SERVER_URLS) { if (base == null || base.isEmpty()) { @@ -54,8 +58,16 @@ public final class MmpSettingsClient { .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)); + JSONObject remote = new JSONObject(resp.body().string()); + // 远程覆盖 Hook 相关;手机专属开关保留本地 + boolean launch = local.optBoolean("autoLaunchTngIfKilled", true); + boolean watch = local.optBoolean("autoWatchWhileOpen", true); + MmpLocalSettings.mergeInto(local, remote); + local.put("autoLaunchTngIfKilled", launch); + local.put("autoWatchWhileOpen", watch); + MmpLocalSettings.save(app, local); + JSONObject out = local; + MAIN.post(() -> cb.onResult(out, null)); return; } } @@ -63,17 +75,22 @@ public final class MmpSettingsClient { last = e; } } - Exception err = last; - MAIN.post(() -> cb.onResult(null, - err != null ? err.getMessage() : "调试台不可达")); + JSONObject out = local; + String tip = last != null + ? ("已用本地设置(调试台未连上:" + last.getMessage() + ")") + : null; + MAIN.post(() -> cb.onResult(out, tip)); }); } - public static void save(JSONObject body, CallbackJson cb) { + /** 始终写本地;能连调试台则同步 Hook 参数 */ + public static void save(Context context, JSONObject body, CallbackJson cb) { + final Context app = context != null ? context.getApplicationContext() : null; EXEC.execute(() -> { + MmpLocalSettings.save(app, body); + JSONObject saved = MmpLocalSettings.load(app); Exception last = null; - boolean anyOk = false; - JSONObject lastOk = null; + boolean remoteOk = false; for (String base : AppConfig.DEBUG_SERVER_URLS) { if (base == null || base.isEmpty()) { continue; @@ -81,37 +98,81 @@ public final class MmpSettingsClient { try { Request req = new Request.Builder() .url(base.replaceAll("/$", "") + "/api/mmp/settings") - .post(RequestBody.create(body.toString(), JSON)) + .post(RequestBody.create(saved.toString(), JSON)) .build(); try (Response resp = CLIENT.newCall(req).execute()) { if (resp.isSuccessful() && resp.body() != null) { - lastOk = new JSONObject(resp.body().string()); - anyOk = true; + JSONObject remote = new JSONObject(resp.body().string()); + boolean launch = saved.optBoolean("autoLaunchTngIfKilled", true); + boolean watch = saved.optBoolean("autoWatchWhileOpen", true); + MmpLocalSettings.mergeInto(saved, remote); + saved.put("autoLaunchTngIfKilled", launch); + saved.put("autoWatchWhileOpen", watch); + MmpLocalSettings.save(app, saved); + remoteOk = true; + break; } } } catch (Exception e) { last = e; } } - final boolean ok = anyOk; - final JSONObject result = lastOk; + final boolean okRemote = remoteOk; + final JSONObject out = saved; final Exception err = last; MAIN.post(() -> { - if (ok) { - cb.onResult(result, null); + if (okRemote) { + cb.onResult(out, null); } else { - cb.onResult(null, err != null ? err.getMessage() : "保存失败"); + // 本地已保存成功 + cb.onResult(out, err != null + ? ("已保存到手机;调试台未同步:" + err.getMessage()) + : "已保存到手机"); } }); }); } - /** 异步探测,不关心结果时用。 */ public static void pingAsync() { - load((json, error) -> { + load(null, (json, error) -> { if (error != null) { Log.w(TAG, "settings ping: " + error); } }); } + + public interface HealthCallback { + void onResult(boolean ok); + } + + /** 探测调试台 /health 是否可达 */ + public static void pingHealth(HealthCallback cb) { + EXEC.execute(() -> { + boolean ok = false; + for (String base : AppConfig.DEBUG_SERVER_URLS) { + if (base == null || base.isEmpty()) { + continue; + } + try { + Request req = new Request.Builder() + .url(base.replaceAll("/$", "") + "/health") + .get() + .build(); + try (Response resp = CLIENT.newCall(req).execute()) { + if (resp.isSuccessful()) { + ok = true; + break; + } + } + } catch (Exception ignored) { + } + } + boolean result = ok; + MAIN.post(() -> { + if (cb != null) { + cb.onResult(result); + } + }); + }); + } } diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSyncClient.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSyncClient.java index e65fa4a..3046073 100644 --- a/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSyncClient.java +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/MmpSyncClient.java @@ -68,6 +68,14 @@ public final class MmpSyncClient { body.put("packets", packets); body.put("device", "android"); body.put("syncedAt", System.currentTimeMillis()); + JSONObject hook = new JSONObject(); + MmpHookStatus.ensureLoaded(app); + hook.put("liveCode", MmpHookStatus.liveVersionCode(app)); + hook.put("liveName", MmpHookStatus.liveVersionName(app)); + hook.put("liveAt", MmpHookStatus.lastAliveAt(app)); + hook.put("installedCode", MmpHookStatus.installedVersionCode(app)); + hook.put("installedName", MmpHookStatus.installedVersionName(app)); + body.put("hookStatus", hook); String payload = body.toString(); boolean anyOk = false; diff --git a/app/src/main/java/com/miraclegarden/smsmessage/mmp/TngProcessHelper.java b/app/src/main/java/com/miraclegarden/smsmessage/mmp/TngProcessHelper.java new file mode 100644 index 0000000..124e548 --- /dev/null +++ b/app/src/main/java/com/miraclegarden/smsmessage/mmp/TngProcessHelper.java @@ -0,0 +1,114 @@ +package com.miraclegarden.smsmessage.mmp; + +import android.app.ActivityManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.text.TextUtils; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * 检测 / 拉起 TNG,供领取台「自动检测」「杀掉后自动打开」使用。 + */ +public final class TngProcessHelper { + + private static final String TAG = "TngProcessHelper"; + public static final String TNG_PACKAGE = "my.com.tngdigital.ewallet"; + + private TngProcessHelper() { + } + + public static boolean isRunning(Context context) { + if (context == null) { + return false; + } + if (pidofAlive()) { + return true; + } + try { + ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (am == null) { + return false; + } + List list = am.getRunningAppProcesses(); + if (list == null) { + return false; + } + for (ActivityManager.RunningAppProcessInfo info : list) { + if (info == null || TextUtils.isEmpty(info.processName)) { + continue; + } + if (info.processName.equals(TNG_PACKAGE) + || info.processName.startsWith(TNG_PACKAGE + ":")) { + return true; + } + } + } catch (Exception e) { + Log.w(TAG, "running check fail", e); + } + return false; + } + + private static boolean pidofAlive() { + String out = shell("pidof " + TNG_PACKAGE); + if (TextUtils.isEmpty(out)) { + out = shell("su -c pidof " + TNG_PACKAGE); + } + return !TextUtils.isEmpty(out) && out.trim().matches(".*\\d.*"); + } + + private static String shell(String cmd) { + Process p = null; + try { + p = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd}); + if (!p.waitFor(2, TimeUnit.SECONDS)) { + p.destroy(); + return ""; + } + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader( + new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + } + } + return sb.toString().trim(); + } catch (Exception e) { + return ""; + } finally { + if (p != null) { + p.destroy(); + } + } + } + + /** @return true 已发出打开 Intent */ + public static boolean launch(Context context) { + if (context == null) { + return false; + } + try { + PackageManager pm = context.getPackageManager(); + Intent intent = pm.getLaunchIntentForPackage(TNG_PACKAGE); + if (intent == null) { + Log.w(TAG, "no launch intent for TNG"); + return false; + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED); + context.startActivity(intent); + Log.i(TAG, "launched TNG"); + return true; + } catch (Exception e) { + Log.e(TAG, "launch TNG fail", e); + return false; + } + } +} 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 edfc0a2..9181475 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.MmpHookStatus; import com.miraclegarden.smsmessage.mmp.MmpPacketStore; import com.miraclegarden.smsmessage.network.DebugForwarder; @@ -22,15 +23,30 @@ public class HookMessageReceiver extends BroadcastReceiver { private static final String TAG = "HookMessageReceiver"; public static final String ACTION_HOOK_MESSAGE = "com.miraclegarden.smsmessage.action.HOOK_MESSAGE"; + public static final String ACTION_HOOK_STATUS = "com.miraclegarden.smsmessage.action.HOOK_STATUS"; public static final String EXTRA_PACKAGE_NAME = "packageName"; public static final String EXTRA_TITLE = "title"; public static final String EXTRA_CONTENT = "content"; public static final String EXTRA_TIMESTAMP = "timestamp"; public static final String EXTRA_SOURCE = "source"; + public static final String EXTRA_MODULE_VERSION_CODE = "moduleVersionCode"; + public static final String EXTRA_MODULE_VERSION_NAME = "moduleVersionName"; + public static final String EXTRA_HOST_PACKAGE = "hostPackage"; @Override public void onReceive(Context context, Intent intent) { - if (intent == null || !ACTION_HOOK_MESSAGE.equals(intent.getAction())) { + if (intent == null || intent.getAction() == null) { + return; + } + if (ACTION_HOOK_STATUS.equals(intent.getAction())) { + int code = intent.getIntExtra(EXTRA_MODULE_VERSION_CODE, 0); + String name = intent.getStringExtra(EXTRA_MODULE_VERSION_NAME); + String host = intent.getStringExtra(EXTRA_HOST_PACKAGE); + MmpHookStatus.noteAlive(context.getApplicationContext(), code, name, host); + Log.i(TAG, "hook status host=" + host + " mod=" + name + "/" + code); + return; + } + if (!ACTION_HOOK_MESSAGE.equals(intent.getAction())) { return; } diff --git a/app/src/main/res/layout/activity_mmp_claim.xml b/app/src/main/res/layout/activity_mmp_claim.xml index a5b204a..7bba8e3 100644 --- a/app/src/main/res/layout/activity_mmp_claim.xml +++ b/app/src/main/res/layout/activity_mmp_claim.xml @@ -11,76 +11,123 @@ + android:orientation="vertical" + android:paddingBottom="4dp"> - + - + - + - + + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + +
+

断线自动进历史页

+

设置可勾选「检测不到连接时:自动进历史页再返回」。

+
    +
  • session 丢失、历史 RPC 失败、或还没有请求模板时触发
  • +
  • 短暂打开 TNG 红包历史页约 2 秒后自动返回,用来重建连接
  • +
  • 约 45 秒最多一次,避免刷屏
  • +
  • 仍需要 TNG 进程在跑;完全杀掉后不会自己拉起
  • +
+
+

打开 TNG 闪退怎么办

装了 Xposed / 防护相关模块后,TNG 偶发一打开就闪退,属常见现象,可按顺序试:

diff --git a/debug-server/mmp_page.html b/debug-server/mmp_page.html index f38c747..9dcc21a 100644 --- a/debug-server/mmp_page.html +++ b/debug-server/mmp_page.html @@ -41,6 +41,14 @@ padding: 7px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; } .btn.primary { background: #ea580c; border-color: #ea580c; color: #fff; font-weight: 600; } + .conn-bar { + background: #1f2937; color: #e5e7eb; padding: 8px 16px; font-size: 12px; + line-height: 1.45; flex-shrink: 0; border-bottom: 1px solid #111827; + } + .conn-bar.ok { background: #064e3b; color: #d1fae5; } + .conn-bar.bad { background: #7f1d1d; color: #fee2e2; } + .conn-bar.warn { background: #78350f; color: #fef3c7; } + .uid { display: block; font-size: 11px; color: var(--muted); font-weight: 500; margin-top: 2px; } .toolbar { background: var(--panel); border-bottom: 1px solid var(--line); padding: 10px 16px; display: grid; gap: 8px; flex-shrink: 0; @@ -184,6 +192,7 @@ +
模块检测中…
@@ -223,6 +232,7 @@ +
@@ -267,7 +277,7 @@
领取排行
- +
#昵称金额时间
#昵称 / ID金额时间
@@ -277,26 +287,28 @@