feat(mmp): 领取人ID/模块版本检测,并修正状态条误报红
展示 receiverId 与 activityPoolId;检测 Hook 是否最新;按真实时间排序;有心跳与定时拉取时不再因久无新数据爆红。
This commit is contained in:
@@ -124,6 +124,7 @@
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.miraclegarden.smsmessage.action.HOOK_MESSAGE" />
|
||||
<action android:name="com.miraclegarden.smsmessage.action.HOOK_STATUS" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -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<ActivityMmpClaimBind
|
||||
private long customToMs;
|
||||
private String keyword = "";
|
||||
private boolean settingsOpen;
|
||||
private boolean autoWatchWhileOpen = true;
|
||||
private boolean autoLaunchTngIfKilled = true;
|
||||
private long lastTngLaunchAt;
|
||||
private final Runnable watchRunnable = this::watchTngOnce;
|
||||
/** null=检测中 true=通 false=不通 */
|
||||
private Boolean debugServerOk;
|
||||
private long lastDebugPingAt;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
JSONObject local = MmpLocalSettings.load(this);
|
||||
autoWatchWhileOpen = local.optBoolean("autoWatchWhileOpen", true);
|
||||
autoLaunchTngIfKilled = local.optBoolean("autoLaunchTngIfKilled", true);
|
||||
initView();
|
||||
reload();
|
||||
loadSettingsQuiet();
|
||||
@@ -69,6 +83,9 @@ public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBind
|
||||
MmpSyncClient.syncIfStale(this, 5000L);
|
||||
handler.removeCallbacks(refreshRunnable);
|
||||
handler.postDelayed(refreshRunnable, 2000);
|
||||
scheduleWatch();
|
||||
updateConnStatus();
|
||||
pingDebugServer();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -76,11 +93,15 @@ public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBind
|
||||
super.onPause();
|
||||
MmpPacketStore.removeListener(this);
|
||||
handler.removeCallbacks(refreshRunnable);
|
||||
handler.removeCallbacks(watchRunnable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMmpPacketsChanged() {
|
||||
runOnUiThread(this::reload);
|
||||
runOnUiThread(() -> {
|
||||
reload();
|
||||
updateConnStatus();
|
||||
});
|
||||
}
|
||||
|
||||
private void initView() {
|
||||
@@ -94,6 +115,7 @@ public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBind
|
||||
binding.settingsPanel.setVisibility(View.GONE);
|
||||
});
|
||||
binding.btnSync.setOnClickListener(v -> 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<ActivityMmpClaimBind
|
||||
allPackets.clear();
|
||||
allPackets.addAll(MmpPacketStore.getPackets(this));
|
||||
applyFilter();
|
||||
updateConnStatus();
|
||||
handler.removeCallbacks(refreshRunnable);
|
||||
handler.postDelayed(refreshRunnable, 2000);
|
||||
}
|
||||
@@ -274,6 +297,145 @@ public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBind
|
||||
Toast.LENGTH_SHORT).show()));
|
||||
}
|
||||
|
||||
private void manualRefresh() {
|
||||
reload();
|
||||
MmpSyncClient.syncIfStale(this, 0L);
|
||||
boolean alive = TngProcessHelper.isRunning(this);
|
||||
String tip = "已刷新,当前 " + packets.size() + "/" + allPackets.size() + " 个";
|
||||
if (!alive) {
|
||||
tip += ";TNG 未运行";
|
||||
if (autoLaunchTngIfKilled) {
|
||||
if (TngProcessHelper.launch(this)) {
|
||||
lastTngLaunchAt = System.currentTimeMillis();
|
||||
tip += ",已尝试打开";
|
||||
} else {
|
||||
tip += ",打开失败";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tip += ";TNG 在跑";
|
||||
}
|
||||
Toast.makeText(this, tip, Toast.LENGTH_SHORT).show();
|
||||
updateConnStatus();
|
||||
pingDebugServer();
|
||||
scheduleWatch();
|
||||
}
|
||||
|
||||
private void scheduleWatch() {
|
||||
handler.removeCallbacks(watchRunnable);
|
||||
handler.postDelayed(watchRunnable, 3_000L);
|
||||
}
|
||||
|
||||
private void watchTngOnce() {
|
||||
if (isFinishing()) {
|
||||
return;
|
||||
}
|
||||
boolean alive = TngProcessHelper.isRunning(this);
|
||||
if (autoWatchWhileOpen && !alive && autoLaunchTngIfKilled) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastTngLaunchAt > 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<ActivityMmpClaimBind
|
||||
}
|
||||
|
||||
private void loadSettingsQuiet() {
|
||||
MmpSettingsClient.load((json, error) -> {
|
||||
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<ActivityMmpClaimBind
|
||||
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));
|
||||
binding.cfgBounceHistory.setChecked(json.optBoolean("autoBounceHistoryOnDisconnect", true));
|
||||
binding.cfgAutoWatch.setChecked(json.optBoolean("autoWatchWhileOpen", true));
|
||||
binding.cfgAutoLaunch.setChecked(json.optBoolean("autoLaunchTngIfKilled", true));
|
||||
autoWatchWhileOpen = binding.cfgAutoWatch.isChecked();
|
||||
autoLaunchTngIfKilled = binding.cfgAutoLaunch.isChecked();
|
||||
}
|
||||
|
||||
private void applyPreset(String name) {
|
||||
try {
|
||||
JSONObject o = new JSONObject();
|
||||
JSONObject o = MmpLocalSettings.defaults();
|
||||
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);
|
||||
}
|
||||
o.put("openHistoryIfNoTemplate", true);
|
||||
o.put("autoBounceHistoryOnDisconnect", true);
|
||||
o.put("autoWatchWhileOpen", true);
|
||||
o.put("autoLaunchTngIfKilled", true);
|
||||
fillSettingsForm(o);
|
||||
binding.tvSettingsStatus.setText("已套用预设,点保存生效");
|
||||
} catch (Exception e) {
|
||||
@@ -342,15 +509,22 @@ public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBind
|
||||
body.put("detailGapMs", parseIntSafe(binding.cfgGap.getText().toString(), 80));
|
||||
body.put("pagePollMs", 1000);
|
||||
body.put("openHistoryIfNoTemplate", binding.cfgOpenHistory.isChecked());
|
||||
body.put("autoBounceHistoryOnDisconnect", binding.cfgBounceHistory.isChecked());
|
||||
body.put("autoWatchWhileOpen", binding.cfgAutoWatch.isChecked());
|
||||
body.put("autoLaunchTngIfKilled", binding.cfgAutoLaunch.isChecked());
|
||||
autoWatchWhileOpen = binding.cfgAutoWatch.isChecked();
|
||||
autoLaunchTngIfKilled = binding.cfgAutoLaunch.isChecked();
|
||||
binding.tvSettingsStatus.setText("保存中…");
|
||||
MmpSettingsClient.save(body, (json, error) -> {
|
||||
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) {
|
||||
|
||||
@@ -79,6 +79,22 @@ public class MmpDetailActivity extends MiracleGardenActivity<ActivityMmpDetailBi
|
||||
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 : "?");
|
||||
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));
|
||||
|
||||
@@ -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) + "…";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Listener> 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<MmpPacket> getPackets(Context context) {
|
||||
synchronized (MmpPacketStore.class) {
|
||||
List<MmpPacket> list = new ArrayList<>(loadMap(context).values());
|
||||
Collections.sort(list, new Comparator<MmpPacket>() {
|
||||
@Override
|
||||
public int compare(MmpPacket a, MmpPacket b) {
|
||||
String ta = !TextUtils.isEmpty(a.issuedAt) ? a.issuedAt : nullToEmpty(a.updatedAt);
|
||||
String tb = !TextUtils.isEmpty(b.issuedAt) ? b.issuedAt : nullToEmpty(b.updatedAt);
|
||||
return tb.compareTo(ta);
|
||||
// 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<String, String> 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<String, String> meta = new LinkedHashMap<>();
|
||||
final List<RawClaim> 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<String, MmpClaim> 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<MmpClaim> result = new ArrayList<>(buckets.values());
|
||||
@@ -360,8 +483,8 @@ public final class MmpPacketStore {
|
||||
private static String claimsFingerprint(List<RawClaim> claims) {
|
||||
List<String> rows = new ArrayList<>();
|
||||
for (RawClaim c : claims) {
|
||||
rows.add((c.nickname != null ? c.nickname : "?") + "="
|
||||
+ String.format(Locale.US, "%.2f", c.amount));
|
||||
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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ActivityManager.RunningAppProcessInfo> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,76 +11,123 @@
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#111827"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="8dp">
|
||||
android:orientation="vertical"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/back_iv"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="返回"
|
||||
android:src="@drawable/ic_action_back" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:text="红包领取台"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
<ImageView
|
||||
android:id="@+id/back_iv"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="返回"
|
||||
android:src="@drawable/ic_action_back" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:text="0 个"
|
||||
android:textColor="#FDBA74"
|
||||
android:textSize="13sp" />
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="红包领取台"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_sync"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:text="同步电脑"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
<TextView
|
||||
android:id="@+id/tv_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:maxLines="1"
|
||||
android:text="0 个"
|
||||
android:textColor="#FDBA74"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_help"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
android:text="说明"
|
||||
android:textColor="#FDBA74"
|
||||
android:textSize="14sp" />
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="36dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_settings"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="6dp"
|
||||
android:text="设置"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="14sp" />
|
||||
<TextView
|
||||
android:id="@+id/btn_refresh"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="刷新"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_clear"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="8dp"
|
||||
android:text="清空"
|
||||
android:textColor="#FECACA"
|
||||
android:textSize="14sp" />
|
||||
<TextView
|
||||
android:id="@+id/btn_sync"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="同步"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_help"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="说明"
|
||||
android:textColor="#FDBA74"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_settings"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="设置"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btn_clear"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:text="清空"
|
||||
android:textColor="#FECACA"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_conn_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="#1F2937"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:text="连接检测中…"
|
||||
android:textColor="#E5E7EB"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<!-- 筛选 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -296,7 +343,7 @@
|
||||
android:id="@+id/settings_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="56dp"
|
||||
android:layout_marginTop="84dp"
|
||||
android:background="#FFF8F1"
|
||||
android:clickable="true"
|
||||
android:elevation="12dp"
|
||||
@@ -474,6 +521,39 @@
|
||||
android:textColor="#222222"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/cfg_bounce_history"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:buttonTint="#C45C26"
|
||||
android:checked="true"
|
||||
android:text="检测不到 Hook 连接时:自动进一下红包历史页再返回(约 45 秒最多一次)"
|
||||
android:textColor="#222222"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/cfg_auto_watch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:buttonTint="#C45C26"
|
||||
android:checked="true"
|
||||
android:text="领取台打开时自动检测 TNG 是否在跑"
|
||||
android:textColor="#222222"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/cfg_auto_launch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:buttonTint="#C45C26"
|
||||
android:checked="true"
|
||||
android:text="检测到 TNG 被完全杀掉时,自动重新打开 TNG"
|
||||
android:textColor="#222222"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -151,6 +151,24 @@
|
||||
android:textColor="#4E342E"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="断线自动进历史页"
|
||||
android:textColor="#1C1410"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="设置里可勾选:\n• 检测不到 Hook 连接 → 自动进历史页再返回\n• 领取台打开时自动检测 TNG 是否在跑\n• TNG 被完全杀掉时自动重新打开\n\n这些设置保存在手机本地,不依赖电脑调试台。顶栏「刷新」可立刻重载列表并检测 TNG。\n\n注意:自动打开 TNG 需要领取台在前台(或刚打开);完全退出本 App 后不会后台常驻拉起。"
|
||||
android:textColor="#4E342E"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -35,6 +35,28 @@
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_user_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" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_pool_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" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_tag"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -66,6 +66,17 @@
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>断线自动进历史页</h2>
|
||||
<p>设置可勾选「检测不到连接时:自动进历史页再返回」。</p>
|
||||
<ul>
|
||||
<li>session 丢失、历史 RPC 失败、或还没有请求模板时触发</li>
|
||||
<li>短暂打开 TNG 红包历史页约 2 秒后自动返回,用来重建连接</li>
|
||||
<li>约 45 秒最多一次,避免刷屏</li>
|
||||
<li>仍需要 TNG 进程在跑;完全杀掉后不会自己拉起</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>打开 TNG 闪退怎么办</h2>
|
||||
<p>装了 Xposed / 防护相关模块后,TNG 偶发一打开就闪退,属常见现象,可按顺序试:</p>
|
||||
|
||||
@@ -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 @@
|
||||
<button class="btn" onclick="loadData()">刷新</button>
|
||||
<button class="btn primary" onclick="clearAll()">清空</button>
|
||||
</header>
|
||||
<div class="conn-bar warn" id="connBar">模块检测中…</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="row">
|
||||
@@ -223,6 +232,7 @@
|
||||
<input type="number" id="cfgPagePoll" min="500" max="30000" step="100" />
|
||||
</label>
|
||||
<label class="check"><input type="checkbox" id="cfgOpenHistory" /> 无模板时自动打开历史页</label>
|
||||
<label class="check"><input type="checkbox" id="cfgBounceHistory" /> 检测不到连接时:自动进历史页再返回(重建连接)</label>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="button" class="chip" onclick="applyPreset('fast')">极速</button>
|
||||
@@ -267,7 +277,7 @@
|
||||
</div>
|
||||
<div class="section">领取排行</div>
|
||||
<table>
|
||||
<thead><tr><th class="rank">#</th><th>昵称</th><th>金额</th><th>时间</th></tr></thead>
|
||||
<thead><tr><th class="rank">#</th><th>昵称 / ID</th><th>金额</th><th>时间</th></tr></thead>
|
||||
<tbody id="board"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -277,26 +287,28 @@
|
||||
<script>
|
||||
let packets = [], activeId = null, pagePollTimer = null;
|
||||
let statusFilter = 'all', timeFilter = 'all', keyword = '';
|
||||
let settings = { historyCooldownSec: 8, detailCooldownSec: 8, dedupSec: 3, detailGapMs: 80, pagePollMs: 1000, openHistoryIfNoTemplate: true };
|
||||
let settings = { historyCooldownSec: 8, detailCooldownSec: 8, dedupSec: 3, detailGapMs: 80, pagePollMs: 1000, openHistoryIfNoTemplate: true, autoBounceHistoryOnDisconnect: true };
|
||||
|
||||
function toggleSettings(){ document.getElementById('settingsPanel').classList.toggle('open'); }
|
||||
function fillSettingsForm(){
|
||||
cfgHistory.value = settings.historyCooldownSec; cfgDetail.value = settings.detailCooldownSec;
|
||||
cfgDedup.value = settings.dedupSec; cfgGap.value = settings.detailGapMs;
|
||||
cfgPagePoll.value = settings.pagePollMs; cfgOpenHistory.checked = !!settings.openHistoryIfNoTemplate;
|
||||
cfgBounceHistory.checked = settings.autoBounceHistoryOnDisconnect !== false;
|
||||
cfgHint.textContent = '刷新: 历史' + settings.historyCooldownSec + 's / 详情' + settings.detailCooldownSec + 's';
|
||||
}
|
||||
function readSettingsForm(){
|
||||
return {
|
||||
historyCooldownSec: Number(cfgHistory.value), detailCooldownSec: Number(cfgDetail.value),
|
||||
dedupSec: Number(cfgDedup.value), detailGapMs: Number(cfgGap.value),
|
||||
pagePollMs: Number(cfgPagePoll.value), openHistoryIfNoTemplate: cfgOpenHistory.checked
|
||||
pagePollMs: Number(cfgPagePoll.value), openHistoryIfNoTemplate: cfgOpenHistory.checked,
|
||||
autoBounceHistoryOnDisconnect: cfgBounceHistory.checked
|
||||
};
|
||||
}
|
||||
function applyPreset(name){
|
||||
if (name==='fast') settings={historyCooldownSec:5,detailCooldownSec:5,dedupSec:2,detailGapMs:50,pagePollMs:800,openHistoryIfNoTemplate:true};
|
||||
else if (name==='slow') settings={historyCooldownSec:30,detailCooldownSec:45,dedupSec:15,detailGapMs:200,pagePollMs:3000,openHistoryIfNoTemplate:true};
|
||||
else settings={historyCooldownSec:8,detailCooldownSec:8,dedupSec:3,detailGapMs:80,pagePollMs:1000,openHistoryIfNoTemplate:true};
|
||||
if (name==='fast') settings={historyCooldownSec:5,detailCooldownSec:5,dedupSec:2,detailGapMs:50,pagePollMs:800,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
|
||||
else if (name==='slow') settings={historyCooldownSec:30,detailCooldownSec:45,dedupSec:15,detailGapMs:200,pagePollMs:3000,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
|
||||
else settings={historyCooldownSec:8,detailCooldownSec:8,dedupSec:3,detailGapMs:80,pagePollMs:1000,openHistoryIfNoTemplate:true,autoBounceHistoryOnDisconnect:true};
|
||||
fillSettingsForm();
|
||||
}
|
||||
async function loadSettings(){ settings = await (await fetch('/api/mmp/settings')).json(); fillSettingsForm(); restartPagePoll(); }
|
||||
@@ -348,7 +360,7 @@
|
||||
if(statusFilter==='done'&&!p.finished) return false;
|
||||
if(!inTimeRange(p)) return false;
|
||||
if(!keyword) return true;
|
||||
const blob=[p.packetId,p.sender,p.title,p.group,p.total,p.bestNick,p.worstNick,...(p.leaderboard||[]).map(r=>r.nickname)].join(' ').toLowerCase();
|
||||
const blob=[p.packetId,p.sender,p.title,p.group,p.total,p.bestNick,p.worstNick,...(p.leaderboard||[]).map(r=>[r.nickname,r.userId,r.poolId].join(' '))].join(' ').toLowerCase();
|
||||
return blob.indexOf(keyword)>=0;
|
||||
});
|
||||
}
|
||||
@@ -362,11 +374,28 @@
|
||||
}
|
||||
|
||||
async function loadData(){
|
||||
packets = await (await fetch('/api/mmp')).json();
|
||||
const [pktRes, stRes] = await Promise.all([
|
||||
fetch('/api/mmp'),
|
||||
fetch('/api/mmp/status')
|
||||
]);
|
||||
packets = await pktRes.json();
|
||||
updated.textContent = '更新 ' + new Date().toLocaleTimeString();
|
||||
try {
|
||||
const st = await stRes.json();
|
||||
renderConnBar(st);
|
||||
} catch (e) {
|
||||
renderConnBar({ text: '模块状态不可用', ok: false });
|
||||
}
|
||||
renderList();
|
||||
}
|
||||
|
||||
function renderConnBar(st){
|
||||
const el = document.getElementById('connBar');
|
||||
if (!el) return;
|
||||
el.textContent = (st && st.text) ? st.text : '模块状态未知';
|
||||
el.className = 'conn-bar ' + (st && st.ok ? 'ok' : (st && st.liveAt ? 'bad' : 'warn'));
|
||||
}
|
||||
|
||||
function renderList(){
|
||||
const view=filteredPackets();
|
||||
pktCount.textContent = view.length + '/' + packets.length + ' 个';
|
||||
@@ -442,9 +471,12 @@
|
||||
let nick=esc(row.nickname);
|
||||
if(isBest) nick += '<span class="tag-inline best">最高</span>';
|
||||
if(isWorst) nick += '<span class="tag-inline worst">最低</span>';
|
||||
let nickHtml=`<div>${nick}</div>`;
|
||||
if(row.userId) nickHtml += `<span class="uid">用户ID ${esc(row.userId)}</span>`;
|
||||
if(row.poolId) nickHtml += `<span class="uid">领取ID ${esc(row.poolId)}</span>`;
|
||||
tr.innerHTML=`
|
||||
<td class="rank">${row.rank}</td>
|
||||
<td class="nick">${nick}</td>
|
||||
<td class="nick">${nickHtml}</td>
|
||||
<td class="amt">${esc(row.amountText || money(row.amount))}</td>
|
||||
<td>${esc(row.claimTime || '-')}</td>`;
|
||||
board.appendChild(tr);
|
||||
|
||||
@@ -16,10 +16,23 @@ 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")
|
||||
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"
|
||||
|
||||
_messages = deque(maxlen=MAX_MESSAGES)
|
||||
_mmp_messages = deque(maxlen=MAX_MESSAGES)
|
||||
_mmp_synced = {} # packetId -> packet dict(手机全量同步,落盘)
|
||||
_mmp_hook_status = {
|
||||
"liveCode": 0,
|
||||
"liveName": "",
|
||||
"liveAt": 0, # epoch ms
|
||||
"installedCode": 0,
|
||||
"installedName": "",
|
||||
"source": "",
|
||||
}
|
||||
_lock = threading.Lock()
|
||||
_last_dedup = {"key": None, "ts": 0.0}
|
||||
_mmp_last_dedup = {"key": None, "ts": 0.0}
|
||||
@@ -31,6 +44,7 @@ _DEFAULT_MMP_SETTINGS = {
|
||||
"detailGapMs": 80,
|
||||
"pagePollMs": 1000,
|
||||
"openHistoryIfNoTemplate": True,
|
||||
"autoBounceHistoryOnDisconnect": True,
|
||||
}
|
||||
_mmp_settings = dict(_DEFAULT_MMP_SETTINGS)
|
||||
|
||||
@@ -69,6 +83,8 @@ def _normalize_mmp_settings(raw):
|
||||
out["pagePollMs"] = max(500, min(30000, int(raw.get("pagePollMs", out["pagePollMs"]))))
|
||||
out["openHistoryIfNoTemplate"] = bool(raw.get(
|
||||
"openHistoryIfNoTemplate", out["openHistoryIfNoTemplate"]))
|
||||
out["autoBounceHistoryOnDisconnect"] = bool(raw.get(
|
||||
"autoBounceHistoryOnDisconnect", out["autoBounceHistoryOnDisconnect"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return out
|
||||
@@ -114,6 +130,7 @@ def _add_message(payload):
|
||||
])
|
||||
now = time.time()
|
||||
is_mmp = _is_mmp_message(item)
|
||||
content_for_mod = ""
|
||||
with _lock:
|
||||
if is_mmp:
|
||||
if (_mmp_last_dedup["key"] == dedup_key
|
||||
@@ -125,6 +142,7 @@ def _add_message(payload):
|
||||
_mmp_messages.appendleft(item)
|
||||
item["id"] = len(_mmp_messages)
|
||||
channel = "MMP"
|
||||
content_for_mod = item.get("content") or ""
|
||||
else:
|
||||
if (_last_dedup["key"] == dedup_key
|
||||
and now - float(_last_dedup["ts"]) < 3.0):
|
||||
@@ -135,6 +153,8 @@ def _add_message(payload):
|
||||
_messages.appendleft(item)
|
||||
item["id"] = len(_messages)
|
||||
channel = "MSG"
|
||||
if content_for_mod:
|
||||
_note_module_from_content(content_for_mod)
|
||||
print("[{0}] [{1}] [{2}] [{3}] {4} | {5}".format(
|
||||
_now_iso(),
|
||||
channel,
|
||||
@@ -287,17 +307,40 @@ def _parse_mmp_content(content):
|
||||
left, right = line.split("->", 1)
|
||||
nick = left.strip()
|
||||
right = right.strip()
|
||||
user_id = ""
|
||||
pool_id = ""
|
||||
if " #rid=" in right:
|
||||
before, _, rest = right.partition(" #rid=")
|
||||
right = before.strip()
|
||||
rid_part = rest.strip()
|
||||
if " #pool=" in rid_part:
|
||||
user_id, _, pool_rest = rid_part.partition(" #pool=")
|
||||
user_id = user_id.strip()
|
||||
pool_id = pool_rest.strip()
|
||||
elif " " in rid_part:
|
||||
user_id = rid_part.split(" ", 1)[0].strip()
|
||||
else:
|
||||
user_id = rid_part
|
||||
if "#pool=" in user_id:
|
||||
user_id, _, pool_id = user_id.partition("#pool=")
|
||||
user_id = user_id.strip()
|
||||
pool_id = pool_id.strip()
|
||||
if " #pool=" in right and not pool_id:
|
||||
before, _, pool_rest = right.partition(" #pool=")
|
||||
right = before.strip()
|
||||
pool_id = pool_rest.strip()
|
||||
claim_time = ""
|
||||
if right.endswith(")") and "(" in right and not right.startswith("{"):
|
||||
amount_raw = right
|
||||
if "(" in right and ")" in right and not right.startswith("{"):
|
||||
amt, _, rest = right.partition("(")
|
||||
amount_raw = amt.strip()
|
||||
claim_time = rest.rstrip(")").strip()
|
||||
else:
|
||||
amount_raw = right
|
||||
claim_time = rest.split(")", 1)[0].strip()
|
||||
amount_num = _normalize_money(amount_raw)
|
||||
if nick:
|
||||
if nick or user_id:
|
||||
claims.append({
|
||||
"nickname": nick,
|
||||
"nickname": nick or "?",
|
||||
"userId": user_id,
|
||||
"poolId": pool_id,
|
||||
"amount": ("{0:.2f}".format(amount_num) if amount_num is not None else "0.00"),
|
||||
"amountValue": amount_num if amount_num is not None else 0.0,
|
||||
"claimTime": claim_time,
|
||||
@@ -308,40 +351,61 @@ def _parse_mmp_content(content):
|
||||
def _claims_fingerprint(claims):
|
||||
rows = []
|
||||
for c in claims:
|
||||
rows.append("{0}={1}".format(c.get("nickname") or "?", c.get("amount") or "0"))
|
||||
key = c.get("userId") or c.get("nickname") or "?"
|
||||
rows.append("{0}={1}".format(key, c.get("amount") or "0"))
|
||||
rows.sort()
|
||||
return "|".join(rows)
|
||||
|
||||
|
||||
def _aggregate_claims(claims):
|
||||
"""同一快照内同昵称只保留一笔(取较大金额),红包每人只领一次,禁止累加导致翻倍。"""
|
||||
"""同一快照内按 userId(无则昵称)去重,取较大金额,禁止同人累加翻倍。"""
|
||||
buckets = {}
|
||||
order = []
|
||||
for c in claims:
|
||||
nick = c.get("nickname") or "?"
|
||||
uid = (c.get("userId") or "").strip()
|
||||
key = ("id:" + uid) if uid else ("n:" + nick)
|
||||
try:
|
||||
amt = float(c.get("amountValue") if c.get("amountValue") is not None
|
||||
else _normalize_money(c.get("amount")) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
amt = 0.0
|
||||
if nick not in buckets:
|
||||
buckets[nick] = {"nickname": nick, "amount": amt, "count": 1,
|
||||
"claimTime": c.get("claimTime") or ""}
|
||||
order.append(nick)
|
||||
if key not in buckets:
|
||||
buckets[key] = {
|
||||
"nickname": nick,
|
||||
"userId": uid,
|
||||
"poolId": (c.get("poolId") or "").strip(),
|
||||
"amount": amt,
|
||||
"count": 1,
|
||||
"claimTime": c.get("claimTime") or "",
|
||||
}
|
||||
order.append(key)
|
||||
continue
|
||||
b = buckets[nick]
|
||||
b = buckets[key]
|
||||
b["count"] += 1
|
||||
if uid and not b.get("userId"):
|
||||
b["userId"] = uid
|
||||
if c.get("poolId") and not b.get("poolId"):
|
||||
b["poolId"] = c.get("poolId") or ""
|
||||
if amt >= b["amount"]:
|
||||
b["amount"] = amt
|
||||
if nick and nick != "?":
|
||||
b["nickname"] = nick
|
||||
if c.get("claimTime"):
|
||||
b["claimTime"] = c.get("claimTime") or b["claimTime"]
|
||||
if uid:
|
||||
b["userId"] = uid
|
||||
if c.get("poolId"):
|
||||
b["poolId"] = c.get("poolId") or b.get("poolId") or ""
|
||||
elif c.get("claimTime") and not b["claimTime"]:
|
||||
b["claimTime"] = c.get("claimTime") or ""
|
||||
result = []
|
||||
for nick in order:
|
||||
b = buckets[nick]
|
||||
for key in order:
|
||||
b = buckets[key]
|
||||
result.append({
|
||||
"nickname": nick,
|
||||
"nickname": b["nickname"],
|
||||
"userId": b.get("userId") or "",
|
||||
"poolId": b.get("poolId") or "",
|
||||
"amount": round(b["amount"], 4),
|
||||
"amountText": "{0:.2f}".format(b["amount"]),
|
||||
"claimCount": b["count"],
|
||||
@@ -353,6 +417,159 @@ def _aggregate_claims(claims):
|
||||
return result
|
||||
|
||||
|
||||
def _parse_mod_token(mod):
|
||||
"""mod=1.2.0/3 → (name, code)"""
|
||||
text = (mod or "").strip()
|
||||
if not text:
|
||||
return "", 0
|
||||
name = text
|
||||
code = 0
|
||||
if "/" in text:
|
||||
name, _, code_part = text.rpartition("/")
|
||||
name = name.strip()
|
||||
try:
|
||||
code = int(code_part.strip())
|
||||
except (TypeError, ValueError):
|
||||
code = 0
|
||||
return name, code
|
||||
|
||||
|
||||
def _note_module_from_content(content):
|
||||
if not content or "[MMP统计]" not in content:
|
||||
return
|
||||
head = content.strip().split("\n", 1)[0]
|
||||
if "mod=" not in head:
|
||||
return
|
||||
raw = head.split("mod=", 1)[1]
|
||||
for stop in (" | ", " src=", " "):
|
||||
if stop in raw:
|
||||
raw = raw.split(stop, 1)[0]
|
||||
break
|
||||
name, code = _parse_mod_token(raw)
|
||||
_update_hook_status({
|
||||
"liveCode": code,
|
||||
"liveName": name,
|
||||
"liveAt": int(time.time() * 1000),
|
||||
"source": "message",
|
||||
})
|
||||
|
||||
|
||||
def _load_hook_status():
|
||||
global _mmp_hook_status
|
||||
try:
|
||||
if not os.path.isfile(HOOK_STATUS_PATH):
|
||||
return
|
||||
with open(HOOK_STATUS_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
with _lock:
|
||||
_mmp_hook_status.update({
|
||||
"liveCode": int(data.get("liveCode") or 0),
|
||||
"liveName": str(data.get("liveName") or ""),
|
||||
"liveAt": int(data.get("liveAt") or 0),
|
||||
"installedCode": int(data.get("installedCode") or 0),
|
||||
"installedName": str(data.get("installedName") or ""),
|
||||
"source": str(data.get("source") or ""),
|
||||
})
|
||||
except Exception as e:
|
||||
print("load mmp hook status failed:", e)
|
||||
|
||||
|
||||
def _save_hook_status():
|
||||
try:
|
||||
with _lock:
|
||||
data = dict(_mmp_hook_status)
|
||||
with open(HOOK_STATUS_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print("save mmp hook status failed:", e)
|
||||
|
||||
|
||||
def _update_hook_status(patch):
|
||||
if not isinstance(patch, dict):
|
||||
return
|
||||
with _lock:
|
||||
try:
|
||||
incoming_live_at = int(patch.get("liveAt") or 0)
|
||||
except (TypeError, ValueError):
|
||||
incoming_live_at = 0
|
||||
stale_live = (incoming_live_at > 0
|
||||
and int(_mmp_hook_status.get("liveAt") or 0) > 0
|
||||
and incoming_live_at < int(_mmp_hook_status.get("liveAt") or 0))
|
||||
for k in ("liveCode", "liveName", "liveAt", "installedCode", "installedName", "source"):
|
||||
if k not in patch:
|
||||
continue
|
||||
if stale_live and k in ("liveCode", "liveName", "liveAt", "source"):
|
||||
continue
|
||||
# 无心跳时不要用 0 覆盖已有运行版本
|
||||
if incoming_live_at <= 0 and k in ("liveCode", "liveName", "liveAt"):
|
||||
continue
|
||||
val = patch[k]
|
||||
if k in ("liveCode", "liveAt", "installedCode"):
|
||||
try:
|
||||
val = int(val or 0)
|
||||
except (TypeError, ValueError):
|
||||
val = 0
|
||||
else:
|
||||
val = str(val or "")
|
||||
if k in ("installedCode",) and val <= 0:
|
||||
continue
|
||||
if k in ("installedName",) and not val:
|
||||
continue
|
||||
_mmp_hook_status[k] = val
|
||||
_save_hook_status()
|
||||
|
||||
|
||||
def _describe_hook_status():
|
||||
with _lock:
|
||||
st = dict(_mmp_hook_status)
|
||||
installed = int(st.get("installedCode") or 0)
|
||||
installed_name = st.get("installedName") or ""
|
||||
live_code = int(st.get("liveCode") or 0)
|
||||
live_name = st.get("liveName") or ""
|
||||
live_at = int(st.get("liveAt") or 0)
|
||||
now_ms = int(time.time() * 1000)
|
||||
|
||||
if installed > 0:
|
||||
apk = "模块APK:" + (installed_name or str(installed))
|
||||
if installed < EXPECTED_MODULE_CODE:
|
||||
apk += "(旧,期望{0})".format(EXPECTED_MODULE_NAME)
|
||||
else:
|
||||
apk = "模块APK:未知(等手机同步)"
|
||||
|
||||
age = (now_ms - live_at) if live_at > 0 else -1
|
||||
if age < 0:
|
||||
live = "运行中:未检测到(请强停再开 TNG)"
|
||||
ok = False
|
||||
elif age > 3 * 60 * 1000:
|
||||
live = "运行中:心跳偏旧({0}分钟前 {1})".format(
|
||||
age // 60000, live_name or live_code or "?")
|
||||
ok = False
|
||||
elif live_code >= EXPECTED_MODULE_CODE:
|
||||
live = "运行中:最新({0})".format(live_name or live_code)
|
||||
ok = True
|
||||
elif live_code > 0:
|
||||
live = "运行中:旧版({0},期望{1})".format(
|
||||
live_name or live_code, EXPECTED_MODULE_NAME)
|
||||
ok = False
|
||||
else:
|
||||
live = "运行中:未知"
|
||||
ok = False
|
||||
|
||||
return {
|
||||
"ok": ok,
|
||||
"text": apk + " · " + live,
|
||||
"expectedCode": EXPECTED_MODULE_CODE,
|
||||
"expectedName": EXPECTED_MODULE_NAME,
|
||||
"liveCode": live_code,
|
||||
"liveName": live_name,
|
||||
"liveAt": live_at,
|
||||
"installedCode": installed,
|
||||
"installedName": installed_name,
|
||||
"ageMs": age,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _packet_score(it):
|
||||
board = it.get("leaderboard") or it.get("claims") or []
|
||||
@@ -387,6 +604,8 @@ def _normalize_synced_packet(raw):
|
||||
nick = (row.get("nickname") or "?").strip() or "?"
|
||||
norm_board.append({
|
||||
"nickname": nick,
|
||||
"userId": (row.get("userId") or "").strip(),
|
||||
"poolId": (row.get("poolId") or "").strip(),
|
||||
"amount": round(amt, 4),
|
||||
"amountText": row.get("amountText") or "{0:.2f}".format(amt),
|
||||
"claimTime": row.get("claimTime") or "",
|
||||
@@ -471,6 +690,16 @@ def _sync_mmp_packets(payload):
|
||||
items = []
|
||||
if isinstance(payload, dict):
|
||||
items = payload.get("packets") or []
|
||||
hook = payload.get("hookStatus")
|
||||
if isinstance(hook, dict):
|
||||
_update_hook_status({
|
||||
"liveCode": hook.get("liveCode") or hook.get("moduleVersionCode") or 0,
|
||||
"liveName": hook.get("liveName") or hook.get("moduleVersionName") or "",
|
||||
"liveAt": hook.get("liveAt") or hook.get("updatedAt") or int(time.time() * 1000),
|
||||
"installedCode": hook.get("installedCode") or 0,
|
||||
"installedName": hook.get("installedName") or "",
|
||||
"source": "phone-sync",
|
||||
})
|
||||
elif isinstance(payload, list):
|
||||
items = payload
|
||||
if not isinstance(items, list):
|
||||
@@ -806,6 +1035,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == "/api/mmp":
|
||||
_json_response(self, 200, _mmp_packets())
|
||||
return
|
||||
if path == "/api/mmp/status":
|
||||
_json_response(self, 200, _describe_hook_status())
|
||||
return
|
||||
if path == "/api/mmp/settings":
|
||||
_json_response(self, 200, _get_mmp_settings())
|
||||
return
|
||||
@@ -889,6 +1121,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
_load_mmp_synced()
|
||||
_load_hook_status()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -150,17 +150,20 @@ adb logcat | findstr TngMmp
|
||||
|
||||
其它检查:
|
||||
|
||||
1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK
|
||||
2. 强停后再开 TNG,使新 Hook 生效
|
||||
3. 调试台进程在跑;USB 时执行过 `adb reverse`
|
||||
4. notiMessage 监听列表勾选了 TNG(Hook 转发依赖主 App 接收广播)
|
||||
5. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App
|
||||
1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK(期望 **1.2.0 / versionCode 3**)
|
||||
2. 强停后再开 TNG,使新 Hook 生效;领取台状态栏应显示「运行中:最新」
|
||||
3. 领取人重名时看详情里的 `ID`(`receiverId`),不要只看昵称
|
||||
4. 调试台进程在跑;USB 时执行过 `adb reverse`
|
||||
5. notiMessage 监听列表勾选了 TNG(Hook 转发依赖主 App 接收广播)
|
||||
6. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、轮询领取台设置 |
|
||||
| `app/.../mmp/MmpHookStatus.java` | 检测 TNG 内 Hook 是否最新模块 |
|
||||
| `app/.../mmp/MmpClaim.java` | 领取人含 `userId`(receiverId) |
|
||||
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp`、`/api/mmp/settings` |
|
||||
| `debug-server/mmp_settings.json` | 领取台刷新设置(自动生成) |
|
||||
| `app/.../AppConfig.java` | `DEBUG_SERVER_URLS` 双地址 |
|
||||
|
||||
@@ -10,8 +10,8 @@ android {
|
||||
applicationId "com.miraclegarden.smsmessage.xposed"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 2
|
||||
versionName "1.1.0"
|
||||
versionCode 3
|
||||
versionName "1.2.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -4,11 +4,18 @@ public final class HookBridge {
|
||||
|
||||
public static final String TARGET_APP_PACKAGE = "com.miraclegarden.smsmessage";
|
||||
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";
|
||||
/** 与 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 String SOURCE_XPOSED_SQLITE = "xposed_sqlite";
|
||||
public static final String SOURCE_XPOSED_WECHAT = "xposed_wechat";
|
||||
public static final String SOURCE_XPOSED_TELEGRAM = "xposed_telegram";
|
||||
|
||||
@@ -33,4 +33,23 @@ public final class HookForwarder {
|
||||
XposedBridge.log(TAG + " sendBroadcast failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 向主 App 汇报当前进程内 Hook 模块版本(用于检测是否最新) */
|
||||
public static void reportStatus(Context context, String hostPackage, String source) {
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Intent intent = new Intent(HookBridge.ACTION_HOOK_STATUS);
|
||||
intent.setPackage(HookBridge.TARGET_APP_PACKAGE);
|
||||
intent.putExtra(HookBridge.EXTRA_HOST_PACKAGE, hostPackage);
|
||||
intent.putExtra(HookBridge.EXTRA_SOURCE, source);
|
||||
intent.putExtra(HookBridge.EXTRA_MODULE_VERSION_CODE, HookBridge.MODULE_VERSION_CODE);
|
||||
intent.putExtra(HookBridge.EXTRA_MODULE_VERSION_NAME, HookBridge.MODULE_VERSION_NAME);
|
||||
intent.putExtra(HookBridge.EXTRA_TIMESTAMP, System.currentTimeMillis());
|
||||
context.sendBroadcast(intent);
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " reportStatus failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +77,17 @@ public final class TngMoneyPacketHook {
|
||||
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 boolean sAutoBounceHistoryOnDisconnect = true;
|
||||
private static volatile long sLastSettingsPollAt;
|
||||
private static volatile long sLastAutoHistoryAt;
|
||||
private static volatile boolean sAutoHistoryRunning;
|
||||
private static volatile long sLastHistoryBounceAt;
|
||||
private static volatile boolean sBounceHistoryPending;
|
||||
private static volatile long sLastHookStatusAt;
|
||||
private static final long HISTORY_BOUNCE_COOLDOWN_MS = 45_000L;
|
||||
private static final long HISTORY_BOUNCE_FINISH_DELAY_MS = 2_200L;
|
||||
private static final long HOOK_STATUS_COOLDOWN_MS = 20_000L;
|
||||
/** 曾拿到过 session,之后变空 / RPC 鉴权失败 → 视为断线,恢复后强制重拉 */
|
||||
private static volatile boolean sHadSession;
|
||||
private static volatile boolean sSessionDisconnected;
|
||||
@@ -119,7 +127,37 @@ public final class TngMoneyPacketHook {
|
||||
hookLoginStorage(lpparam);
|
||||
hookActivityResumeForAutoHistory(lpparam);
|
||||
startSettingsPoller();
|
||||
XposedBridge.log(TAG + " installed for " + lpparam.packageName);
|
||||
startAutoHistoryPoller();
|
||||
reportHookAlive("install");
|
||||
XposedBridge.log(TAG + " installed for " + lpparam.packageName
|
||||
+ " mod=" + HookBridge.MODULE_VERSION_NAME
|
||||
+ "/" + HookBridge.MODULE_VERSION_CODE);
|
||||
}
|
||||
|
||||
/** 按历史冷却定时拉列表 + 心跳,避免无新红包时状态条误报「久无数据」 */
|
||||
private static void startAutoHistoryPoller() {
|
||||
Thread t = new Thread(() -> {
|
||||
while (true) {
|
||||
try {
|
||||
reportHookAlive("poll");
|
||||
scheduleAutoHistory("poll");
|
||||
long sleepMs = Math.max(5_000L, sHistoryCooldownMs);
|
||||
Thread.sleep(sleepMs);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (Throwable ignored) {
|
||||
try {
|
||||
Thread.sleep(Math.max(5_000L, sHistoryCooldownMs));
|
||||
} catch (InterruptedException ie2) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, "TngMmp-auto-hist-poll");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
/** 后台轮询领取台 /api/mmp/settings,约 10 秒内生效 */
|
||||
@@ -186,20 +224,24 @@ public final class TngMoneyPacketHook {
|
||||
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 bounceHistory = json.optBoolean("autoBounceHistoryOnDisconnect", true);
|
||||
boolean changed = historyMs != sHistoryCooldownMs
|
||||
|| detailMs != sDetailCooldownMs
|
||||
|| dedupMs != sPacketDedupMs
|
||||
|| gapMs != sDetailGapMs
|
||||
|| openHistory != sOpenHistoryIfNoTemplate;
|
||||
|| openHistory != sOpenHistoryIfNoTemplate
|
||||
|| bounceHistory != sAutoBounceHistoryOnDisconnect;
|
||||
sHistoryCooldownMs = historyMs;
|
||||
sDetailCooldownMs = detailMs;
|
||||
sPacketDedupMs = dedupMs;
|
||||
sDetailGapMs = gapMs;
|
||||
sOpenHistoryIfNoTemplate = openHistory;
|
||||
sAutoBounceHistoryOnDisconnect = bounceHistory;
|
||||
if (changed) {
|
||||
XposedBridge.log(TAG + " settings applied hist=" + (historyMs / 1000)
|
||||
+ "s detail=" + (detailMs / 1000) + "s dedup=" + (dedupMs / 1000)
|
||||
+ "s gap=" + gapMs + "ms openHist=" + openHistory
|
||||
+ " bounceHist=" + bounceHistory
|
||||
+ " from " + urlStr);
|
||||
}
|
||||
return true;
|
||||
@@ -400,7 +442,7 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
MmpSnapshot probe = parseSnapshot(json);
|
||||
enrichSnapshotFromJavaResult(result, probe);
|
||||
if (probe != null && !probe.claims.isEmpty()) {
|
||||
if (probe != null && (!probe.claims.isEmpty() || snapshotHasPacketMeta(probe))) {
|
||||
if (detailReq != null) {
|
||||
sTemplateDetailRequest = cloneRequestShallow(detailReq);
|
||||
sLoginDonorRequest = detailReq;
|
||||
@@ -979,16 +1021,20 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
|
||||
private static void rememberPacketData(String packetId, MmpSnapshot snapshot, Object result) {
|
||||
if (snapshot == null || snapshot.claims == null || snapshot.claims.isEmpty()) {
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
boolean hasClaims = snapshot.claims != null && !snapshot.claims.isEmpty();
|
||||
if (!hasClaims && !snapshotHasPacketMeta(snapshot)) {
|
||||
return;
|
||||
}
|
||||
String id = !TextUtils.isEmpty(packetId) ? packetId : snapshot.packetId;
|
||||
if (TextUtils.isEmpty(id) || !looksLikeActivityId(id)) {
|
||||
// 指纹 id 也记,避免反复刷
|
||||
if (!TextUtils.isEmpty(id)) {
|
||||
if (!TextUtils.isEmpty(id) && hasClaims) {
|
||||
PACKETS_WITH_DATA.add(id);
|
||||
}
|
||||
} else {
|
||||
} else if (hasClaims) {
|
||||
PACKETS_WITH_DATA.add(id);
|
||||
}
|
||||
if (result != null
|
||||
@@ -1000,7 +1046,7 @@ public final class TngMoneyPacketHook {
|
||||
if (!snapshot.finished) {
|
||||
snapshot.finished = evaluateFinished(snapshot);
|
||||
}
|
||||
if (snapshot.finished && !TextUtils.isEmpty(id)) {
|
||||
if (snapshot.finished && hasClaims && !TextUtils.isEmpty(id)) {
|
||||
if (COMPLETED_PACKETS.add(id)) {
|
||||
XposedBridge.log(TAG + " packet DONE stop-refresh id=" + id
|
||||
+ " claims=" + snapshot.claims.size()
|
||||
@@ -1068,7 +1114,13 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
|
||||
private static void notePossibleDisconnectFromEmpty(Object result, String activityId) {
|
||||
// totalCount:0 + 无领取人 常见于缺登录态
|
||||
// 新发红包无人领取时 activityPoolInfos 为空、totalCount/claimedCount 也常为 0,不是断线
|
||||
if (looksLikeValidEmptyPacket(result, activityId)) {
|
||||
XposedBridge.log(TAG + " empty claims but valid summary id=" + activityId
|
||||
+ " → keep (0 claimants)");
|
||||
return;
|
||||
}
|
||||
// totalCount:0 + 无领取人 且无有效摘要时,才怀疑缺登录态
|
||||
String totalCnt = stringField(result, "totalCount", "getTotalCount");
|
||||
if ("0".equals(totalCnt) && sHadSession) {
|
||||
XposedBridge.log(TAG + " empty claims+totalCount0 id=" + activityId
|
||||
@@ -1078,6 +1130,64 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情有 summary(ACTIVE / 有总额)但领取名单为空:刚发出去、还没人领 */
|
||||
private static boolean looksLikeValidEmptyPacket(Object result, String activityId) {
|
||||
if (result == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Object summary = null;
|
||||
try {
|
||||
summary = XposedHelpers.callMethod(result, "getSummaryInfo");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (summary == null) {
|
||||
try {
|
||||
summary = XposedHelpers.getObjectField(result, "summaryInfo");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
if (summary == null) {
|
||||
return false;
|
||||
}
|
||||
String id = firstNonEmpty(
|
||||
stringField(summary, "activityId", "getActivityId"),
|
||||
activityId);
|
||||
if (!looksLikeActivityId(id)) {
|
||||
return false;
|
||||
}
|
||||
String status = stringField(summary, "activityStatus", "getActivityStatus");
|
||||
if (!TextUtils.isEmpty(status)) {
|
||||
String up = status.toUpperCase(Locale.US);
|
||||
if (up.contains("ACTIVE") || up.contains("OPEN") || up.contains("PROGRESS")
|
||||
|| up.contains("FINISH") || up.contains("COMPLETE")
|
||||
|| up.contains("EXPIRE")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String total = null;
|
||||
try {
|
||||
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
|
||||
if (money != null) {
|
||||
total = normalizeMoneyText(String.valueOf(
|
||||
XposedHelpers.callMethod(money, "getAmount")));
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return !TextUtils.isEmpty(total);
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean snapshotHasPacketMeta(MmpSnapshot snapshot) {
|
||||
return snapshot != null
|
||||
&& looksLikeActivityId(snapshot.packetId)
|
||||
&& (!TextUtils.isEmpty(snapshot.totalAmount)
|
||||
|| !TextUtils.isEmpty(snapshot.activityStatus)
|
||||
|| !TextUtils.isEmpty(snapshot.senderName));
|
||||
}
|
||||
|
||||
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
|
||||
ClassLoader cl = sAppClassLoader;
|
||||
ensureRpcTaskAndLogin(cl);
|
||||
@@ -1131,13 +1241,31 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
MmpSnapshot probe = parseSnapshot(json);
|
||||
enrichSnapshotFromJavaResult(result, probe);
|
||||
if (probe == null || probe.claims.isEmpty()) {
|
||||
if (probe == null) {
|
||||
XposedBridge.log(TAG + " auto-detail parse null id=" + activityId);
|
||||
return false;
|
||||
}
|
||||
if (TextUtils.isEmpty(probe.packetId)) {
|
||||
probe.packetId = activityId;
|
||||
}
|
||||
if (probe.claims.isEmpty()) {
|
||||
String snippet = json.length() > 240 ? json.substring(0, 240) + "..." : json;
|
||||
XposedBridge.log(TAG + " auto-detail empty claims id=" + activityId
|
||||
+ " body=" + snippet
|
||||
+ " userId=" + stringField(req, "userId", "getUserId"));
|
||||
notePossibleDisconnectFromEmpty(result, activityId);
|
||||
return false;
|
||||
if (!snapshotHasPacketMeta(probe) && !looksLikeValidEmptyPacket(result, activityId)) {
|
||||
notePossibleDisconnectFromEmpty(result, activityId);
|
||||
return false;
|
||||
}
|
||||
// 刚发出、还没人领:照样入库,方便列表看到「领取中 0人」
|
||||
rememberPacketData(probe.packetId, probe, result);
|
||||
forwardSnapshot(probe, "auto:" + activityId, "rpc");
|
||||
XposedBridge.log(TAG + " auto-detail ok(empty) activityId=" + activityId
|
||||
+ " status=" + probe.activityStatus
|
||||
+ " total=" + probe.totalAmount
|
||||
+ " issued=" + probe.issueTime);
|
||||
noteSessionAlive();
|
||||
return true;
|
||||
}
|
||||
rememberPacketData(probe.packetId != null ? probe.packetId : activityId, probe, result);
|
||||
forwardSnapshot(probe, "auto:" + activityId, "rpc");
|
||||
@@ -1329,6 +1457,12 @@ public final class TngMoneyPacketHook {
|
||||
if (!PACKAGE.equals(pkg)) {
|
||||
return;
|
||||
}
|
||||
String actName = act.getClass().getName();
|
||||
if (actName.contains("MoneyPacketHistoryActivity")
|
||||
&& sBounceHistoryPending) {
|
||||
scheduleHistoryBounceFinish(act);
|
||||
}
|
||||
reportHookAlive("activity-resume");
|
||||
if (sSessionDisconnected) {
|
||||
tryRefreshLogin(sAppClassLoader);
|
||||
scheduleAutoHistoryForced("reconnect-resume");
|
||||
@@ -1373,6 +1507,7 @@ public final class TngMoneyPacketHook {
|
||||
if (ok) {
|
||||
sLastAutoHistoryAt = System.currentTimeMillis();
|
||||
noteSessionAlive();
|
||||
reportHookAlive("auto-history-ok");
|
||||
}
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
@@ -1389,6 +1524,7 @@ public final class TngMoneyPacketHook {
|
||||
Object task = sMoneyPacketRpcTask;
|
||||
if (cl == null || task == null) {
|
||||
XposedBridge.log(TAG + " auto-history skip: no rpc task (" + reason + ")");
|
||||
maybeBounceHistoryPage("no-rpc-task");
|
||||
return false;
|
||||
}
|
||||
if (TextUtils.isEmpty(sCachedSessionId)) {
|
||||
@@ -1402,6 +1538,7 @@ public final class TngMoneyPacketHook {
|
||||
if (sHadSession) {
|
||||
sSessionDisconnected = true;
|
||||
}
|
||||
maybeBounceHistoryPage("no-session");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
@@ -1422,24 +1559,33 @@ public final class TngMoneyPacketHook {
|
||||
} finally {
|
||||
AUTO_HISTORY_SELF.remove();
|
||||
}
|
||||
if (result != null && historyHasJobs(result)) {
|
||||
XposedBridge.log(TAG + " auto-history ok(template) → schedule details");
|
||||
scheduleAutoDetailsFromHistory(result);
|
||||
if (result != null) {
|
||||
if (historyHasJobs(result)) {
|
||||
XposedBridge.log(TAG + " auto-history ok(template) → schedule details");
|
||||
scheduleAutoDetailsFromHistory(result);
|
||||
} else {
|
||||
XposedBridge.log(TAG + " auto-history ok(template) but empty list");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// 有模板但 RPC 失败:不要误当成「无模板」反复乱跳;按断线处理并短进历史页
|
||||
XposedBridge.log(TAG + " auto-history template rpc failed (" + reason + ")");
|
||||
maybeBounceHistoryPage("template-rpc-fail");
|
||||
return false;
|
||||
}
|
||||
// 2) 无模板时:自造请求易「非法参数」,直接拉起官方历史页让 App 发正确 RPC
|
||||
if (!sOpenHistoryIfNoTemplate) {
|
||||
if (!sOpenHistoryIfNoTemplate && !sAutoBounceHistoryOnDisconnect) {
|
||||
XposedBridge.log(TAG + " auto-history no template, openHistory disabled ("
|
||||
+ reason + ")");
|
||||
return false;
|
||||
}
|
||||
XposedBridge.log(TAG + " auto-history no template → open HistoryActivity (" + reason + ")");
|
||||
openMoneyPacketHistoryActivity();
|
||||
openMoneyPacketHistoryActivity(true);
|
||||
return false;
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " auto-history fail: " + t.getMessage());
|
||||
markMaybeDisconnected(t, "auto-history");
|
||||
maybeBounceHistoryPage("history-exception");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1448,11 +1594,32 @@ public final class TngMoneyPacketHook {
|
||||
return !extractHistoryJobs(historyResult).isEmpty();
|
||||
}
|
||||
|
||||
/** 拉起官方历史页;App 自己的 moneyPacketHistoryList 成功后 Hook 会缓存模板并级联拉详情 */
|
||||
private static void openMoneyPacketHistoryActivity() {
|
||||
/** 断线/失败时短进历史页再建连(有冷却,避免刷屏) */
|
||||
private static void maybeBounceHistoryPage(String reason) {
|
||||
if (!sAutoBounceHistoryOnDisconnect) {
|
||||
XposedBridge.log(TAG + " bounce history disabled (" + reason + ")");
|
||||
return;
|
||||
}
|
||||
openMoneyPacketHistoryActivity(true);
|
||||
XposedBridge.log(TAG + " bounce history for " + reason);
|
||||
}
|
||||
|
||||
/** 拉起官方历史页;bounceBack=true 时约 2 秒后自动 finish 返回 */
|
||||
private static void openMoneyPacketHistoryActivity(boolean bounceBack) {
|
||||
try {
|
||||
long now = System.currentTimeMillis();
|
||||
if (bounceBack) {
|
||||
if (now - sLastHistoryBounceAt < HISTORY_BOUNCE_COOLDOWN_MS) {
|
||||
XposedBridge.log(TAG + " bounce history cooldown skip ageMs="
|
||||
+ (now - sLastHistoryBounceAt));
|
||||
return;
|
||||
}
|
||||
sLastHistoryBounceAt = now;
|
||||
sBounceHistoryPending = true;
|
||||
}
|
||||
Context ctx = getContext();
|
||||
if (ctx == null) {
|
||||
sBounceHistoryPending = false;
|
||||
return;
|
||||
}
|
||||
android.content.Intent intent = new android.content.Intent();
|
||||
@@ -1461,17 +1628,43 @@ public final class TngMoneyPacketHook {
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
| android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION);
|
||||
ctx.startActivity(intent);
|
||||
XposedBridge.log(TAG + " started MoneyPacketHistoryActivity");
|
||||
XposedBridge.log(TAG + " started MoneyPacketHistoryActivity bounce=" + bounceBack);
|
||||
} catch (Throwable t) {
|
||||
sBounceHistoryPending = false;
|
||||
XposedBridge.log(TAG + " start HistoryActivity fail: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static void scheduleHistoryBounceFinish(final Object activity) {
|
||||
if (!(activity instanceof android.app.Activity)) {
|
||||
return;
|
||||
}
|
||||
final android.app.Activity act = (android.app.Activity) activity;
|
||||
MAIN_HANDLER.postDelayed(() -> {
|
||||
try {
|
||||
if (!sBounceHistoryPending) {
|
||||
return;
|
||||
}
|
||||
if (act.isFinishing()) {
|
||||
sBounceHistoryPending = false;
|
||||
return;
|
||||
}
|
||||
act.finish();
|
||||
XposedBridge.log(TAG + " bounce history finished → back");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " bounce history finish fail: " + t.getMessage());
|
||||
} finally {
|
||||
sBounceHistoryPending = false;
|
||||
}
|
||||
}, HISTORY_BOUNCE_FINISH_DELAY_MS);
|
||||
}
|
||||
|
||||
private static Object callHistoryListRpc(Object task, Object req) {
|
||||
try {
|
||||
return XposedHelpers.callMethod(task, "moneyPacketHistoryList", req);
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " history rpc fail: " + t.getMessage());
|
||||
markMaybeDisconnected(t, "history-rpc");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1688,7 +1881,10 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
|
||||
private static void forwardSnapshot(MmpSnapshot snapshot, String sourceHint, String channel) {
|
||||
if (snapshot == null || snapshot.claims.isEmpty()) {
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
if (snapshot.claims.isEmpty() && !snapshotHasPacketMeta(snapshot)) {
|
||||
return;
|
||||
}
|
||||
dedupeClaims(snapshot);
|
||||
@@ -1739,6 +1935,11 @@ public final class TngMoneyPacketHook {
|
||||
continue;
|
||||
}
|
||||
String name = stringField(info, "receiverName", "getReceiverName");
|
||||
String receiverId = firstNonEmpty(
|
||||
stringField(info, "receiverId", "getReceiverId"),
|
||||
stringField(info, "userId", "getUserId"),
|
||||
stringField(info, "receiverUserId", "getReceiverUserId"));
|
||||
String poolId = stringField(info, "activityPoolId", "getActivityPoolId");
|
||||
String claimTime = firstNonEmpty(
|
||||
stringField(info, "gmtModified", "getGmtModified"),
|
||||
stringField(info, "gmtCreate", "getGmtCreate"),
|
||||
@@ -1755,21 +1956,36 @@ public final class TngMoneyPacketHook {
|
||||
if (TextUtils.isEmpty(amount)) {
|
||||
amount = normalizeMoneyText(stringField(info, "amount", "getAmount"));
|
||||
}
|
||||
if (TextUtils.isEmpty(name)) {
|
||||
if (TextUtils.isEmpty(name) && TextUtils.isEmpty(receiverId)) {
|
||||
continue;
|
||||
}
|
||||
if (TextUtils.isEmpty(name)) {
|
||||
name = "用户";
|
||||
}
|
||||
boolean matched = false;
|
||||
for (ClaimLine line : snapshot.claims) {
|
||||
if (name.equals(line.nickname)) {
|
||||
boolean sameId = !TextUtils.isEmpty(receiverId)
|
||||
&& receiverId.equals(line.receiverId);
|
||||
boolean sameName = TextUtils.isEmpty(receiverId)
|
||||
&& name.equals(line.nickname);
|
||||
if (sameId || sameName) {
|
||||
if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(claimTime)) {
|
||||
line.claimTime = claimTime;
|
||||
}
|
||||
if (TextUtils.isEmpty(line.receiverId) && !TextUtils.isEmpty(receiverId)) {
|
||||
line.receiverId = receiverId;
|
||||
}
|
||||
if (TextUtils.isEmpty(line.poolId) && !TextUtils.isEmpty(poolId)) {
|
||||
line.poolId = poolId;
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (!matched && !TextUtils.isEmpty(amount)) {
|
||||
ClaimLine line = new ClaimLine();
|
||||
line.nickname = name;
|
||||
line.receiverId = receiverId;
|
||||
line.poolId = poolId;
|
||||
line.amount = amount;
|
||||
line.claimTime = claimTime;
|
||||
snapshot.claims.add(line);
|
||||
@@ -1860,30 +2076,43 @@ public final class TngMoneyPacketHook {
|
||||
}
|
||||
java.util.LinkedHashMap<String, ClaimLine> map = new java.util.LinkedHashMap<>();
|
||||
for (ClaimLine line : snapshot.claims) {
|
||||
if (line == null || TextUtils.isEmpty(line.nickname)) {
|
||||
if (line == null) {
|
||||
continue;
|
||||
}
|
||||
ClaimLine old = map.get(line.nickname);
|
||||
if (TextUtils.isEmpty(line.nickname) && TextUtils.isEmpty(line.receiverId)) {
|
||||
continue;
|
||||
}
|
||||
String key = !TextUtils.isEmpty(line.receiverId)
|
||||
? ("id:" + line.receiverId)
|
||||
: ("n:" + line.nickname);
|
||||
ClaimLine old = map.get(key);
|
||||
if (old == null) {
|
||||
map.put(line.nickname, line);
|
||||
map.put(key, line);
|
||||
continue;
|
||||
}
|
||||
// 同昵称保留金额更大的;时间有则补上
|
||||
try {
|
||||
double a = Double.parseDouble(old.amount);
|
||||
double b = Double.parseDouble(line.amount);
|
||||
if (b > a) {
|
||||
if (TextUtils.isEmpty(line.claimTime)) {
|
||||
line.claimTime = old.claimTime;
|
||||
}
|
||||
map.put(line.nickname, line);
|
||||
} else if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
|
||||
old.claimTime = line.claimTime;
|
||||
double oldAmt = parseMoneyDouble(old.amount) != null ? parseMoneyDouble(old.amount) : -1;
|
||||
double newAmt = parseMoneyDouble(line.amount) != null ? parseMoneyDouble(line.amount) : -1;
|
||||
if (newAmt >= oldAmt) {
|
||||
if (TextUtils.isEmpty(line.claimTime) && !TextUtils.isEmpty(old.claimTime)) {
|
||||
line.claimTime = old.claimTime;
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
if (TextUtils.isEmpty(line.receiverId)) {
|
||||
line.receiverId = old.receiverId;
|
||||
}
|
||||
if (TextUtils.isEmpty(line.poolId)) {
|
||||
line.poolId = old.poolId;
|
||||
}
|
||||
map.put(key, line);
|
||||
} else {
|
||||
if (TextUtils.isEmpty(old.claimTime) && !TextUtils.isEmpty(line.claimTime)) {
|
||||
old.claimTime = line.claimTime;
|
||||
}
|
||||
if (TextUtils.isEmpty(old.receiverId) && !TextUtils.isEmpty(line.receiverId)) {
|
||||
old.receiverId = line.receiverId;
|
||||
}
|
||||
if (TextUtils.isEmpty(old.poolId) && !TextUtils.isEmpty(line.poolId)) {
|
||||
old.poolId = line.poolId;
|
||||
}
|
||||
}
|
||||
}
|
||||
snapshot.claims.clear();
|
||||
@@ -2094,6 +2323,18 @@ public final class TngMoneyPacketHook {
|
||||
ClaimLine line = new ClaimLine();
|
||||
line.nickname = name.trim();
|
||||
line.amount = amount;
|
||||
line.receiverId = firstNonEmpty(
|
||||
readFieldText(obj, "receiverId"),
|
||||
readFieldText(obj, "receiverUserId"),
|
||||
readFieldText(obj, "userId"),
|
||||
readFieldText(obj, "participantId"));
|
||||
// userName 字段误当 id 时:纯数字/长串才留
|
||||
if (!TextUtils.isEmpty(line.receiverId) && line.receiverId.equals(name)) {
|
||||
line.receiverId = null;
|
||||
}
|
||||
line.poolId = firstNonEmpty(
|
||||
readFieldText(obj, "activityPoolId"),
|
||||
readFieldText(obj, "poolId"));
|
||||
line.claimTime = firstNonEmpty(
|
||||
readFieldText(obj, "claimTime"),
|
||||
readFieldText(obj, "claimedTime"),
|
||||
@@ -2369,6 +2610,21 @@ public final class TngMoneyPacketHook {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void reportHookAlive(String reason) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (!"install".equals(reason) && now - sLastHookStatusAt < HOOK_STATUS_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
Context ctx = getContext();
|
||||
if (ctx == null) {
|
||||
return;
|
||||
}
|
||||
sLastHookStatusAt = now;
|
||||
HookForwarder.reportStatus(ctx, PACKAGE, HookBridge.SOURCE_XPOSED_TNG_MMP);
|
||||
XposedBridge.log(TAG + " hook status reported (" + reason + ") mod="
|
||||
+ HookBridge.MODULE_VERSION_NAME);
|
||||
}
|
||||
|
||||
private static Context getContext() {
|
||||
try {
|
||||
Class<?> activityThread = XposedHelpers.findClass("android.app.ActivityThread", null);
|
||||
@@ -2461,6 +2717,8 @@ public final class TngMoneyPacketHook {
|
||||
sb.append(" | done=1");
|
||||
}
|
||||
sb.append(" | via=").append(sanitizeMeta(channel));
|
||||
sb.append(" | mod=").append(HookBridge.MODULE_VERSION_NAME)
|
||||
.append('/').append(HookBridge.MODULE_VERSION_CODE);
|
||||
if (!TextUtils.isEmpty(sourceHint)) {
|
||||
String src = sourceHint.length() > 120
|
||||
? sourceHint.substring(0, 120) + "..." : sourceHint;
|
||||
@@ -2472,6 +2730,12 @@ public final class TngMoneyPacketHook {
|
||||
if (!TextUtils.isEmpty(line.claimTime)) {
|
||||
sb.append(" (").append(line.claimTime).append(')');
|
||||
}
|
||||
if (!TextUtils.isEmpty(line.receiverId)) {
|
||||
sb.append(" #rid=").append(sanitizeMeta(line.receiverId));
|
||||
}
|
||||
if (!TextUtils.isEmpty(line.poolId)) {
|
||||
sb.append(" #pool=").append(sanitizeMeta(line.poolId));
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
return sb.toString().trim();
|
||||
@@ -2488,6 +2752,8 @@ public final class TngMoneyPacketHook {
|
||||
|
||||
private static final class ClaimLine {
|
||||
String nickname;
|
||||
String receiverId;
|
||||
String poolId;
|
||||
String amount;
|
||||
String claimTime;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user