Compare commits

...

14 Commits

Author SHA1 Message Date
mars
ae65351d8e feat(mmp): Hook 无心跳时自动唤醒或强停重开 TNG
领取台检测开启时,进程在跑但心跳过期会先进历史页唤醒,仍无效则 force-stop 重建 Hook。
2026-08-05 17:29:36 +08:00
mars
abf41b9ab6 fix(mmp): 状态条标明调试台可选,避免误解为故障
调试台未连接不参与爆红判定,文案改为可选提示。
2026-08-05 17:18:16 +08:00
mars
b3f2b2b3c2 feat(mmp): 领取进度/过期状态,并展示红包与领取人ID
状态优先显示已全部领取或已领取N/M,未领完才附加已过期;列表与最高最低补全 activityId、receiverId。
2026-08-05 15:45:39 +08:00
mars
293979122d feat(mmp): 领取人ID/模块版本检测,并修正状态条误报红
展示 receiverId 与 activityPoolId;检测 Hook 是否最新;按真实时间排序;有心跳与定时拉取时不再因久无新数据爆红。
2026-08-05 15:09:51 +08:00
mars
a4aed01b29 docs(mmp): 功能说明补充 TNG 闪退与强制停止排障
说明多点重试、强停、清缓存、更新 Hook 后强停及作用域排查步骤。
2026-08-04 17:49:22 +08:00
mars
c8cbfdff82 feat(mmp): 独立红包领取台(手机+PC)、同步、筛选与功能说明
隔离通用消息台;支持设置/手气摘要/电脑同步;忽略临时逆向脚本与运行时 JSON。
2026-08-04 17:39:29 +08:00
mars
1517ff4671 feat(tng): 自动拉红包历史/详情,并展示发放与领取时间
从 ILoginStorage 与官方历史页缓存请求模板,级联拉详情;领取台列表用发放时间而非拉取时间。
2026-08-04 16:20:13 +08:00
mars
1e2021d8fd docs(tng): 补充 Money Packet 领取台说明,并同步自动拉详情 Hook
说明 activityId、手动缓存模板后再自动拉历史详情的流程,以及 /mmp 双通道与去重行为。
2026-08-04 15:39:44 +08:00
mars
88a2b319f8 feat(tng): 红包领取台双通道推送,并修复金额/按包分组
Hook Quake RPC 抓 Money Packet;解析 Money 对象金额;调试台按单个红包展示排行;支持 USB 本机与共享网段双地址转发。
2026-08-04 15:09:28 +08:00
mars
e80f7f908c fix(tng): Splash 卡住才救援,并跳过首页强制 eKYC
避免已登录冷启动被强拉回登录页;HomeEkycVerify 测试期 finish + canBypass/enforce stub。
2026-08-04 14:10:24 +08:00
mars
f9426d6b68 chore: 清理临时逆向脚本,保留 TNG 安装工具与 captcha 文档
删除未跟踪的一次性 _*.py 扫描脚本,并从仓库移除已过时辅助脚本。
2026-08-04 13:48:47 +08:00
mars
bc51fb35cc chore(tng): 清理死代码并停止跟踪 reverse/dumps
精简 login-first 不可达 Hook,忽略调试产物目录,从仓库移除已入库的 dumps 文件。
2026-08-04 13:32:57 +08:00
mars
5378a34f58 fix(tng): 修复区号页 HWUI 闪退并放行 Compose HW 绘制
拦截 HardwareRenderer.setName,校验/缓存 libandroid.so,避免软件绘制撞 hardware bitmap;附带 Money Packet hook 与 mitm 脚本。
2026-08-04 12:26:27 +08:00
mars
609635aba1 chore: 备份 TNG 注册/captcha 逆向与 MariBank SG bypass 进展
TngRootBypassHook 增强 captcha 诊断、TigerTally/JNIC 分层与 HWUI 策略;新增逆向脚本、Frida 工具与 UI dump;同步 MariBank SG hook 与 tng_exit_guard 更新。
2026-08-03 15:23:02 +08:00
140 changed files with 18222 additions and 1109 deletions

15
.gitignore vendored
View File

@@ -27,3 +27,18 @@ reverse/frida/*.log.err
reverse/frida/logcat_capture.txt
reverse/frida/*.out
reverse/frida/*.err
debug-server/__pycache__/
# 调试台运行时落盘(设置/红包镜像,本机生成)
debug-server/mmp_packets.json
debug-server/mmp_settings.json
magisk-modules/tng_exit_guard/obj/
magisk-modules/tng_exit_guard/libs/
# 调试抓包/截图/ANR/APK 产物,不入库
reverse/dumps/
reverse/scripts/__pycache__/
# 临时 MMP 逆向探测脚本(一次性 dump/scan不入库
reverse/scripts/_dump_mmp_*.py
reverse/scripts/_scan_mmp_*.py
reverse/scripts/_verify_mmp_*.py
reverse/scripts/_find_mmp_*.py
reverse/scripts/_dump_rpc_request.py

View File

@@ -5,7 +5,8 @@ Android 应用,监听通知栏消息并通过 **双通道**(通知监听 + X
> **Hook 架构与扩展指南**:详见 [docs/Hook指南.md](docs/Hook指南.md)(含 **Xposed / LSPosed**、接入新 App
> **Telegram 抓消息专文**[docs/Telegram抓消息说明.md](docs/Telegram抓消息说明.md)
> **手机部署与银行 bypass 操作**:详见 [docs/手机操作手册.md](docs/手机操作手册.md)
> **MariBank 风控与 register 载荷**[docs/MariBank风控与载荷说明.md](docs/MariBank风控与载荷说明.md)
> **MariBank 风控与 register 载荷**[docs/MariBank风控与载荷说明.md](docs/MariBank风控与载荷说明.md)
> **TNG Money Packet 领取台**[docs/TNG_MoneyPacket领取台.md](docs/TNG_MoneyPacket领取台.md)
## 项目结构

View File

@@ -74,6 +74,18 @@
<activity
android:name=".Activity.BankListActivity"
android:exported="false" />
<activity
android:name=".Activity.MmpClaimActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity
android:name=".Activity.MmpDetailActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity
android:name=".Activity.MmpHelpActivity"
android:exported="false"
android:theme="@style/Theme.SmsMessage1" />
<activity
android:name=".Activity.PermissionActivity"
android:exported="false" />
@@ -112,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>

View File

@@ -72,6 +72,10 @@ public class MainActivity extends MiracleGardenActivity<ActivityMainBinding> {
startActivity(new Intent(this, BankListActivity.class));
});
binding.btnMmpClaim.setOnClickListener(v -> {
startActivity(new Intent(this, MmpClaimActivity.class));
});
binding.permissionNotificationAccess.setOnClickListener(v -> {
startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS));
});

View File

@@ -0,0 +1,663 @@
package com.miraclegarden.smsmessage.Activity;
import android.app.DatePickerDialog;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.miraclegarden.library.app.MiracleGardenActivity;
import com.miraclegarden.smsmessage.R;
import com.miraclegarden.smsmessage.comm.CommonAdapter;
import com.miraclegarden.smsmessage.comm.ViewHolder;
import com.miraclegarden.smsmessage.databinding.ActivityMmpClaimBinding;
import com.miraclegarden.smsmessage.mmp.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;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.Locale;
/**
* 手机端红包领取台(独立于通用消息/情况监听)。
*/
public class MmpClaimActivity extends MiracleGardenActivity<ActivityMmpClaimBinding>
implements MmpPacketStore.Listener {
private final List<MmpPacket> allPackets = new ArrayList<>();
private final List<MmpPacket> packets = new ArrayList<>();
private CommonAdapter<MmpPacket> adapter;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable refreshRunnable = this::reload;
/** 0全部 1领取中 2已领完 */
private int statusFilter = 0;
/** 0全部时间 1近1天 2近3天 3近7天 4自定义 */
private int timeMode = 0;
private long customFromMs;
private long customToMs;
private String keyword = "";
private boolean settingsOpen;
private boolean autoWatchWhileOpen = true;
private boolean autoLaunchTngIfKilled = true;
private long lastTngLaunchAt;
/** 0=未处理 1=已唤醒/进历史 2=已强停重开 */
private int hookRecoverStep;
private long lastHookRecoverAt;
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();
}
@Override
protected void onResume() {
super.onResume();
MmpPacketStore.addListener(this);
reload();
MmpSyncClient.syncIfStale(this, 5000L);
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
scheduleWatch();
updateConnStatus();
pingDebugServer();
}
@Override
protected void onPause() {
super.onPause();
MmpPacketStore.removeListener(this);
handler.removeCallbacks(refreshRunnable);
handler.removeCallbacks(watchRunnable);
}
@Override
public void onMmpPacketsChanged() {
runOnUiThread(() -> {
reload();
updateConnStatus();
});
}
private void initView() {
binding.backIv.setOnClickListener(v -> finish());
binding.btnClear.setOnClickListener(v -> confirmClear());
binding.btnHelp.setOnClickListener(v ->
startActivity(new android.content.Intent(this, MmpHelpActivity.class)));
binding.btnSettings.setOnClickListener(v -> toggleSettings());
binding.btnCloseSettings.setOnClickListener(v -> {
settingsOpen = false;
binding.settingsPanel.setVisibility(View.GONE);
});
binding.btnSync.setOnClickListener(v -> syncNow());
binding.btnRefresh.setOnClickListener(v -> manualRefresh());
binding.chipAll.setOnClickListener(v -> setStatusFilter(0));
binding.chipOpen.setOnClickListener(v -> setStatusFilter(1));
binding.chipDone.setOnClickListener(v -> setStatusFilter(2));
binding.chipTimeAll.setOnClickListener(v -> setTimeMode(0));
binding.chipTime1.setOnClickListener(v -> setTimeMode(1));
binding.chipTime3.setOnClickListener(v -> setTimeMode(2));
binding.chipTime7.setOnClickListener(v -> setTimeMode(3));
binding.chipTimeCustom.setOnClickListener(v -> setTimeMode(4));
binding.btnDateFrom.setOnClickListener(v -> pickDate(true));
binding.btnDateTo.setOnClickListener(v -> pickDate(false));
binding.etFilter.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
keyword = s != null ? s.toString() : "";
applyFilter();
}
@Override
public void afterTextChanged(Editable s) {
}
});
binding.btnPresetFast.setOnClickListener(v -> applyPreset("fast"));
binding.btnPresetNormal.setOnClickListener(v -> applyPreset("normal"));
binding.btnPresetSlow.setOnClickListener(v -> applyPreset("slow"));
binding.btnSaveSettings.setOnClickListener(v -> saveSettings());
binding.recyclerview.setLayoutManager(new LinearLayoutManager(this));
adapter = new CommonAdapter<MmpPacket>(this, R.layout.item_mmp_packet, packets) {
@Override
public void convert(ViewHolder holder, MmpPacket packet, int index) {
holder.setText(R.id.tv_title, packet.displayTitle());
holder.setText(R.id.tv_sum, formatMoney(packet.sumClaimed > 0
? packet.sumClaimed : parseDouble(packet.total)));
TextView idView = holder.getView(R.id.tv_id);
if (!TextUtils.isEmpty(packet.packetId)) {
idView.setVisibility(View.VISIBLE);
idView.setText("ID " + packet.packetId);
} else {
idView.setVisibility(View.GONE);
idView.setText("");
}
android.widget.TextView status = holder.getView(R.id.tv_status);
status.setText(packet.statusLabel());
if (packet.isFullyClaimed()) {
status.setTextColor(Color.parseColor("#047857"));
status.setBackgroundColor(Color.parseColor("#ECFDF5"));
} else if (packet.isExpired()) {
status.setTextColor(Color.parseColor("#6B7280"));
status.setBackgroundColor(Color.parseColor("#F3F4F6"));
} else {
status.setTextColor(Color.parseColor("#B45309"));
status.setBackgroundColor(Color.parseColor("#FFFBEB"));
}
String meta;
if (packet.effectiveTotalCount() > 0) {
meta = packet.effectiveClaimedCount() + "/" + packet.effectiveTotalCount() + "";
} else {
meta = packet.claimantCount + "";
}
if (packet.issuedAt != null && !packet.issuedAt.isEmpty()) {
meta += " · 发放 " + packet.issuedAt;
}
if (packet.expiresAt != null && !packet.expiresAt.isEmpty()) {
meta += " · 到期 " + packet.expiresAt;
}
holder.setText(R.id.tv_meta, meta);
String hi = packet.finished ? "最佳" : "最高";
String lo = packet.finished ? "最差" : "最低";
com.miraclegarden.smsmessage.mmp.MmpClaim best = packet.bestClaim();
com.miraclegarden.smsmessage.mmp.MmpClaim worst = packet.worstClaim();
holder.setText(R.id.tv_best, formatHiLoLine(hi, best));
if (worst != null && packet.claimantCount > 1) {
holder.setText(R.id.tv_worst, formatHiLoLine(lo, worst));
} else {
holder.setText(R.id.tv_worst, lo + " -");
}
holder.itemView.setOnClickListener(v -> openDetail(packet.packetId));
}
};
binding.recyclerview.setAdapter(adapter);
updateChipUi();
updateTimeChipUi();
}
private void reload() {
allPackets.clear();
allPackets.addAll(MmpPacketStore.getPackets(this));
applyFilter();
updateConnStatus();
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
}
private void applyFilter() {
packets.clear();
for (MmpPacket p : allPackets) {
if (p.matchesFilter(keyword, statusFilter, timeMode, customFromMs, customToMs)) {
packets.add(p);
}
}
adapter.notifyDataSetChanged();
binding.tvCount.setText(packets.size() + "/" + allPackets.size() + "");
binding.emptyLy.setVisibility(packets.isEmpty() ? View.VISIBLE : View.GONE);
}
private void setStatusFilter(int filter) {
statusFilter = filter;
updateChipUi();
applyFilter();
}
private void setTimeMode(int mode) {
timeMode = mode;
binding.customRangeLy.setVisibility(mode == 4 ? View.VISIBLE : View.GONE);
updateTimeChipUi();
applyFilter();
}
private void pickDate(boolean from) {
Calendar cal = Calendar.getInstance();
long base = from ? customFromMs : customToMs;
if (base > 0) {
cal.setTimeInMillis(base);
}
new DatePickerDialog(this, (view, y, m, d) -> {
Calendar c = Calendar.getInstance();
if (from) {
c.set(y, m, d, 0, 0, 0);
c.set(Calendar.MILLISECOND, 0);
customFromMs = c.getTimeInMillis();
binding.btnDateFrom.setText(String.format(Locale.US, "%04d-%02d-%02d", y, m + 1, d));
} else {
c.set(y, m, d, 23, 59, 59);
c.set(Calendar.MILLISECOND, 999);
customToMs = c.getTimeInMillis();
binding.btnDateTo.setText(String.format(Locale.US, "%04d-%02d-%02d", y, m + 1, d));
}
if (timeMode != 4) {
setTimeMode(4);
} else {
applyFilter();
}
}, cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH)).show();
}
private void updateChipUi() {
styleChip(binding.chipAll, statusFilter == 0, true);
styleChip(binding.chipOpen, statusFilter == 1, true);
styleChip(binding.chipDone, statusFilter == 2, true);
}
private void updateTimeChipUi() {
styleChip(binding.chipTimeAll, timeMode == 0, false);
styleChip(binding.chipTime1, timeMode == 1, false);
styleChip(binding.chipTime3, timeMode == 2, false);
styleChip(binding.chipTime7, timeMode == 3, false);
styleChip(binding.chipTimeCustom, timeMode == 4, false);
}
private static void styleChip(View chip, boolean active, boolean brand) {
if (!(chip instanceof android.widget.TextView)) {
return;
}
android.widget.TextView tv = (android.widget.TextView) chip;
if (active) {
tv.setBackgroundColor(Color.parseColor(brand ? "#C45C26" : "#1C1410"));
tv.setTextColor(Color.WHITE);
} else {
tv.setBackgroundColor(Color.parseColor("#EEEEEE"));
tv.setTextColor(Color.parseColor("#333333"));
}
}
private void syncNow() {
Toast.makeText(this, "正在同步到电脑…", Toast.LENGTH_SHORT).show();
MmpSyncClient.syncToPc(this, (ok, message) -> runOnUiThread(() ->
Toast.makeText(this, message != null ? message : (ok ? "同步成功" : "同步失败"),
Toast.LENGTH_SHORT).show()));
}
private void 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 && autoLaunchTngIfKilled) {
if (!alive) {
long now = System.currentTimeMillis();
if (now - lastTngLaunchAt > 60_000L) {
if (TngProcessHelper.launch(this)) {
lastTngLaunchAt = now;
Toast.makeText(this, "检测到 TNG 已退出,正在重新打开…", Toast.LENGTH_SHORT).show();
}
}
} else {
maybeRecoverStaleHook();
}
}
updateConnStatus();
if (System.currentTimeMillis() - lastDebugPingAt > 20_000L) {
pingDebugServer();
}
handler.removeCallbacks(watchRunnable);
handler.postDelayed(watchRunnable, 12_000L);
}
/**
* TNG 还在跑但 Hook 长时间无心跳:先唤醒/进历史页,仍无效再强停重开。
*/
private void maybeRecoverStaleHook() {
long hookAt = MmpHookStatus.lastAliveAt(this);
long age = hookAt > 0 ? System.currentTimeMillis() - hookAt : Long.MAX_VALUE;
if (age <= 3 * 60_000L) {
hookRecoverStep = 0;
return;
}
long now = System.currentTimeMillis();
boolean bounce = MmpLocalSettings.load(this)
.optBoolean("autoBounceHistoryOnDisconnect", true);
if (hookRecoverStep == 0) {
if (now - lastHookRecoverAt < 45_000L) {
return;
}
lastHookRecoverAt = now;
hookRecoverStep = 1;
boolean ok = bounce
? TngProcessHelper.openMoneyPacketHistory(this)
: TngProcessHelper.launch(this);
if (ok) {
Toast.makeText(this, "Hook 无心跳,正在唤醒 TNG…", Toast.LENGTH_SHORT).show();
}
return;
}
if (hookRecoverStep == 1) {
if (now - lastHookRecoverAt < 90_000L) {
return;
}
// 唤醒后仍无心跳 → 强停重建
lastHookRecoverAt = now;
hookRecoverStep = 2;
Toast.makeText(this, "仍无心跳,强制重启 TNG…", Toast.LENGTH_SHORT).show();
new Thread(() -> {
boolean ok = TngProcessHelper.forceStopAndLaunch(this);
runOnUiThread(() -> {
if (ok) {
lastTngLaunchAt = System.currentTimeMillis();
} else {
Toast.makeText(this, "强制重启 TNG 失败", Toast.LENGTH_SHORT).show();
hookRecoverStep = 0;
}
});
}, "mmp-hook-recover").start();
return;
}
// step 2 后隔 3 分钟仍无心跳,再重试一轮
if (now - lastHookRecoverAt > 3 * 60_000L) {
hookRecoverStep = 0;
}
}
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 / Hook 心跳 / 模块版本;调试台可有可无,绝不因此爆红
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);
if (settingsOpen) {
loadSettingsQuiet();
}
}
private void loadSettingsQuiet() {
MmpSettingsClient.load(this, (json, error) -> {
if (json != null) {
fillSettingsForm(json);
if (error != null && !error.isEmpty()) {
binding.tvSettingsStatus.setText(error);
} else {
binding.tvSettingsStatus.setText("设置已加载(本地优先,可离线保存)");
}
} else {
fillSettingsForm(MmpLocalSettings.load(this));
binding.tvSettingsStatus.setText(error != null ? error : "已用本地默认设置");
}
});
}
private void fillSettingsForm(JSONObject json) {
binding.cfgHistory.setText(String.valueOf(json.optInt("historyCooldownSec", 8)));
binding.cfgDetail.setText(String.valueOf(json.optInt("detailCooldownSec", 8)));
binding.cfgDedup.setText(String.valueOf(json.optInt("dedupSec", 3)));
binding.cfgGap.setText(String.valueOf(json.optInt("detailGapMs", 80)));
binding.cfgOpenHistory.setChecked(json.optBoolean("openHistoryIfNoTemplate", true));
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 = 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);
} 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);
o.put("autoBounceHistoryOnDisconnect", true);
o.put("autoWatchWhileOpen", true);
o.put("autoLaunchTngIfKilled", true);
fillSettingsForm(o);
binding.tvSettingsStatus.setText("已套用预设,点保存生效");
} catch (Exception e) {
binding.tvSettingsStatus.setText("预设失败");
}
}
private void saveSettings() {
try {
JSONObject body = new JSONObject();
body.put("historyCooldownSec", parseIntSafe(binding.cfgHistory.getText().toString(), 8));
body.put("detailCooldownSec", parseIntSafe(binding.cfgDetail.getText().toString(), 8));
body.put("dedupSec", parseIntSafe(binding.cfgDedup.getText().toString(), 3));
body.put("detailGapMs", parseIntSafe(binding.cfgGap.getText().toString(), 80));
body.put("pagePollMs", 1000);
body.put("openHistoryIfNoTemplate", binding.cfgOpenHistory.isChecked());
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(this, body, (json, error) -> {
if (json != null) {
fillSettingsForm(json);
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();
}
});
} catch (Exception e) {
binding.tvSettingsStatus.setText("保存异常");
}
}
private void openDetail(String packetId) {
Intent intent = new Intent(this, MmpDetailActivity.class);
intent.putExtra(MmpDetailActivity.EXTRA_PACKET_ID, packetId);
startActivity(intent);
}
private static String formatHiLoLine(String prefix,
com.miraclegarden.smsmessage.mmp.MmpClaim claim) {
if (claim == null) {
return prefix + " -";
}
String nick = claim.nickname != null ? claim.nickname : "?";
String amt = claim.amountText != null ? claim.amountText : formatMoney(claim.amount);
if (!TextUtils.isEmpty(claim.userId)) {
return prefix + " " + nick + " " + amt + "\nID " + claim.userId;
}
return prefix + " " + nick + " " + amt;
}
private void confirmClear() {
new AlertDialog.Builder(this)
.setTitle("清空红包台")
.setMessage("仅清空本机红包领取记录,不影响通用消息台。")
.setPositiveButton("清空", (d, w) -> {
MmpPacketStore.clear(this);
Toast.makeText(this, "已清空", Toast.LENGTH_SHORT).show();
reload();
})
.setNegativeButton("取消", null)
.show();
}
private static String formatMoney(double v) {
return String.format(Locale.US, "%.2f", v);
}
private static double parseDouble(String s) {
try {
return s == null || s.isEmpty() ? 0 : Double.parseDouble(s);
} catch (NumberFormatException e) {
return 0;
}
}
private static int parseIntSafe(String s, int def) {
try {
return Integer.parseInt(s.trim());
} catch (Exception e) {
return def;
}
}
}

View File

@@ -0,0 +1,233 @@
package com.miraclegarden.smsmessage.Activity;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.miraclegarden.library.app.MiracleGardenActivity;
import com.miraclegarden.smsmessage.R;
import com.miraclegarden.smsmessage.comm.CommonAdapter;
import com.miraclegarden.smsmessage.comm.ViewHolder;
import com.miraclegarden.smsmessage.databinding.ActivityMmpDetailBinding;
import com.miraclegarden.smsmessage.mmp.MmpClaim;
import com.miraclegarden.smsmessage.mmp.MmpPacket;
import com.miraclegarden.smsmessage.mmp.MmpPacketStore;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* 单个红包领取排行(数据优先展示)。
*/
public class MmpDetailActivity extends MiracleGardenActivity<ActivityMmpDetailBinding>
implements MmpPacketStore.Listener {
public static final String EXTRA_PACKET_ID = "packetId";
private String packetId;
private final List<MmpClaim> claims = new ArrayList<>();
private CommonAdapter<MmpClaim> adapter;
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable refreshRunnable = this::reload;
private double bestAmount = -1;
private double worstAmount = -1;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
packetId = getIntent().getStringExtra(EXTRA_PACKET_ID);
if (TextUtils.isEmpty(packetId)) {
finish();
return;
}
initView();
reload();
}
@Override
protected void onResume() {
super.onResume();
MmpPacketStore.addListener(this);
reload();
}
@Override
protected void onPause() {
super.onPause();
MmpPacketStore.removeListener(this);
handler.removeCallbacks(refreshRunnable);
}
@Override
public void onMmpPacketsChanged() {
runOnUiThread(this::reload);
}
private void initView() {
binding.backIv.setOnClickListener(v -> finish());
binding.recyclerview.setLayoutManager(new LinearLayoutManager(this));
adapter = new CommonAdapter<MmpClaim>(this, R.layout.item_mmp_claim, claims) {
@Override
public void convert(ViewHolder holder, MmpClaim claim, int index) {
holder.setText(R.id.tv_rank, String.valueOf(claim.rank > 0 ? claim.rank : index + 1));
holder.setText(R.id.tv_nickname, claim.nickname != null ? claim.nickname : "?");
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));
holder.setText(R.id.tv_time,
TextUtils.isEmpty(claim.claimTime) ? "-" : claim.claimTime);
TextView tag = holder.getView(R.id.tv_tag);
boolean isBest = claims.size() > 1 && bestAmount >= 0
&& Math.abs(claim.amount - bestAmount) < 0.0001;
boolean isWorst = claims.size() > 1 && worstAmount >= 0
&& Math.abs(claim.amount - worstAmount) < 0.0001
&& Math.abs(bestAmount - worstAmount) > 0.0001;
if (isBest) {
tag.setVisibility(View.VISIBLE);
tag.setText("最高");
tag.setTextColor(Color.parseColor("#047857"));
holder.itemView.setBackgroundColor(Color.parseColor("#ECFDF5"));
} else if (isWorst) {
tag.setVisibility(View.VISIBLE);
tag.setText("最低");
tag.setTextColor(Color.parseColor("#B45309"));
holder.itemView.setBackgroundColor(Color.parseColor("#FFFBEB"));
} else {
tag.setVisibility(View.GONE);
holder.itemView.setBackgroundColor(Color.WHITE);
}
}
};
binding.recyclerview.setAdapter(adapter);
}
private void reload() {
MmpPacket packet = MmpPacketStore.getPacket(this, packetId);
if (packet == null) {
binding.tvTitle.setText("红包详情");
binding.tvHeaderMoney.setText("-");
binding.tvSub.setText("记录不存在或已清空");
binding.tvBestName.setText("-");
binding.tvBestAmt.setText("-");
binding.tvWorstName.setText("-");
binding.tvWorstAmt.setText("-");
claims.clear();
adapter.notifyDataSetChanged();
binding.emptyLy.setVisibility(View.VISIBLE);
binding.recyclerview.setVisibility(View.GONE);
return;
}
binding.tvTitle.setText(packet.displayTitle());
binding.tvHeaderMoney.setText(String.format(Locale.US, "%.2f",
packet.sumClaimed > 0 ? packet.sumClaimed : parseDouble(packet.total)));
StringBuilder sub = new StringBuilder();
if (!TextUtils.isEmpty(packet.packetId)) {
sub.append("ID ").append(packet.packetId);
}
if (!TextUtils.isEmpty(packet.issuedAt)) {
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append("发放 ").append(packet.issuedAt);
}
if (!TextUtils.isEmpty(packet.expiresAt)) {
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append("到期 ").append(packet.expiresAt);
}
if (sub.length() > 0) {
sub.append(" · ");
}
sub.append(packet.statusLabel());
binding.tvSub.setText(sub.toString());
binding.tvStatPeople.setText(String.valueOf(packet.claimantCount));
binding.tvStatSum.setText(String.format(Locale.US, "%.2f", packet.sumClaimed));
binding.tvStatTotal.setText(TextUtils.isEmpty(packet.total) ? "-" : packet.total);
String hi = packet.finished ? "手气最佳" : "目前最高";
String lo = packet.finished ? "手气最差" : "目前最低";
binding.tvBestLab.setText(hi);
binding.tvWorstLab.setText(lo);
MmpClaim best = packet.bestClaim();
MmpClaim worst = packet.worstClaim();
if (best != null) {
bestAmount = best.amount;
binding.tvBestName.setText(formatClaimName(best));
binding.tvBestAmt.setText(best.amountText != null ? best.amountText
: String.format(Locale.US, "%.2f", best.amount));
} else {
bestAmount = -1;
binding.tvBestName.setText("-");
binding.tvBestAmt.setText("-");
}
if (worst != null && packet.claimantCount > 1) {
worstAmount = worst.amount;
binding.tvWorstName.setText(formatClaimName(worst));
binding.tvWorstAmt.setText(worst.amountText != null ? worst.amountText
: String.format(Locale.US, "%.2f", worst.amount));
} else {
worstAmount = -1;
binding.tvWorstName.setText("-");
binding.tvWorstAmt.setText("-");
}
claims.clear();
if (packet.leaderboard != null) {
claims.addAll(packet.leaderboard);
}
adapter.notifyDataSetChanged();
boolean empty = claims.isEmpty();
binding.emptyLy.setVisibility(empty ? View.VISIBLE : View.GONE);
binding.recyclerview.setVisibility(empty ? View.GONE : View.VISIBLE);
handler.removeCallbacks(refreshRunnable);
handler.postDelayed(refreshRunnable, 2000);
}
private static String formatClaimName(MmpClaim claim) {
if (claim == null) {
return "-";
}
String nick = claim.nickname != null ? claim.nickname : "?";
if (!TextUtils.isEmpty(claim.userId)) {
return nick + "\nID " + claim.userId;
}
return nick;
}
private static double parseDouble(String s) {
try {
return s == null || s.isEmpty() ? 0 : Double.parseDouble(s);
} catch (NumberFormatException e) {
return 0;
}
}
}

View File

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

View File

@@ -12,11 +12,18 @@ public final class AppConfig {
public static final boolean ENABLE_DEBUG_FORWARD = true;
/**
* 调试服务地址。
* 调试服务地址(可同时配置 USB 本机 + 局域网)
* USB + adb reverse: http://127.0.0.1:8765
* Wi-Fi: http://&lt;电脑局域网IP&gt;:8765
* 会向列表中每个地址各推送一次;不可达的静默失败。
*/
public static final String DEBUG_SERVER_URL = "http://127.0.0.1:8765";
public static final String[] DEBUG_SERVER_URLS = {
"http://127.0.0.1:8765",
"http://10.151.104.25:8765",
};
/** 兼容旧引用:取第一个调试地址 */
public static final String DEBUG_SERVER_URL = DEBUG_SERVER_URLS[0];
/** 是否定期唤醒监听列表中的 App保持进程 / Hook 加载) */
public static final boolean ENABLE_MONITORED_APP_KEEP_ALIVE = true;

View File

@@ -0,0 +1,33 @@
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;
public int rank;
public MmpClaim() {
}
public MmpClaim(String nickname, double amount, String claimTime) {
this.nickname = nickname;
this.amount = amount;
this.amountText = String.format(java.util.Locale.US, "%.2f", amount);
this.claimTime = claimTime != null ? claimTime : "";
}
public String shortUserId() {
if (userId == null || userId.isEmpty()) {
return "";
}
return userId.length() <= 10 ? userId : userId.substring(0, 8) + "";
}
}

View File

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

View File

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

View File

@@ -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 = 5;
public static final String EXPECTED_VERSION_NAME = "1.2.2";
private MmpModuleInfo() {
}
}

View File

@@ -0,0 +1,292 @@
package com.miraclegarden.smsmessage.mmp;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* 按 activityId 聚合的红包快照。
*/
public class MmpPacket {
public String packetId;
public String title;
public String sender;
public String group;
public String total;
public String via;
public String issuedAt;
public String expiresAt;
/** TNG activityStatusACTIVE / FINISHED / ENDED / CLOSED / CANCELLED … */
public String status;
public String updatedAt;
public boolean finished;
public int snapshots;
/** 已领人数(来自 TNG claimedCount缺省用名单人数 */
public int claimedCount;
/** 红包总份数TNG totalCount */
public int totalCount;
public int claimantCount;
public double sumClaimed;
public List<MmpClaim> leaderboard = new ArrayList<>();
public MmpPacket() {
}
public String displayTitle() {
if (sender != null && !sender.isEmpty()) {
return sender + " 的红包";
}
if (title != null && !title.isEmpty()) {
return title;
}
return "红包";
}
public String shortId() {
if (packetId == null || packetId.isEmpty()) {
return "-";
}
if (packetId.length() >= 8 && packetId.indexOf('-') > 0) {
return packetId.substring(0, 8);
}
return packetId.length() > 16 ? packetId.substring(0, 16) : packetId;
}
/** 手气最佳(金额最大);排行已按金额降序时取首位。 */
public MmpClaim bestClaim() {
if (leaderboard == null || leaderboard.isEmpty()) {
return null;
}
MmpClaim best = leaderboard.get(0);
for (MmpClaim c : leaderboard) {
if (c != null && c.amount > best.amount) {
best = c;
}
}
return best;
}
/** 手气最差(金额最小)。 */
public MmpClaim worstClaim() {
if (leaderboard == null || leaderboard.isEmpty()) {
return null;
}
MmpClaim worst = leaderboard.get(0);
for (MmpClaim c : leaderboard) {
if (c != null && c.amount < worst.amount) {
worst = c;
}
}
return worst;
}
public String luckSummaryLine() {
refreshClosedState();
MmpClaim best = bestClaim();
MmpClaim worst = worstClaim();
boolean closed = finished;
String hi = closed ? "手气最佳" : "目前最高";
String lo = closed ? "手气最差" : "目前最低";
if (best == null) {
return hi + "- " + lo + "-";
}
String bn = best.nickname != null ? best.nickname : "?";
String ba = best.amountText != null ? best.amountText
: String.format(Locale.US, "%.2f", best.amount);
if (worst == null || leaderboard.size() == 1) {
return hi + "" + bn + "" + ba + " " + lo + "-";
}
String wn = worst.nickname != null ? worst.nickname : "?";
String wa = worst.amountText != null ? worst.amountText
: String.format(Locale.US, "%.2f", worst.amount);
return hi + "" + bn + "" + ba + " " + lo + "" + wn + "" + wa + "";
}
/** 根据 expire/status/人数 刷新 finished过期未领完也会标结束 */
public void refreshClosedState() {
if (isFullyClaimed()) {
finished = true;
return;
}
if (finished) {
return;
}
if (isTerminalStatus(status) || isExpirePassed()) {
finished = true;
}
}
public boolean isExpired() {
if (isExpirePassed()) {
return true;
}
if (status == null) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("EXPIRE") || s.contains("ENDED") || s.contains("CLOSED")
|| s.contains("CANCEL");
}
public boolean isFullyClaimed() {
int claimed = effectiveClaimedCount();
int total = effectiveTotalCount();
return total > 0 && claimed >= total;
}
public int effectiveClaimedCount() {
return Math.max(claimedCount, claimantCount);
}
public int effectiveTotalCount() {
return Math.max(0, totalCount);
}
/**
* 优先展示领取进度,过期放后面:
* 已全部领取 / 已领取 N/M 人 · 已过期
*/
public String statusLabel() {
refreshClosedState();
String progress;
if (isFullyClaimed()) {
progress = "已全部领取";
} else {
int claimed = effectiveClaimedCount();
int total = effectiveTotalCount();
if (total > 0) {
progress = "已领取 " + claimed + "/" + total + "";
} else if (claimed > 0) {
progress = "已领取 " + claimed + "";
} else {
progress = finished ? "未领取" : "领取中";
}
}
if (isExpired() && !isFullyClaimed()) {
return progress + " · 已过期";
}
return progress;
}
private boolean isExpirePassed() {
long ms = parseIssueMs(expiresAt);
return ms > 0 && System.currentTimeMillis() >= ms;
}
private static boolean isTerminalStatus(String status) {
if (status == null || status.isEmpty()) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE");
}
public boolean matchesFilter(String keyword, int statusFilter) {
return matchesFilter(keyword, statusFilter, 0, 0L, 0L);
}
/**
* @param timeMode 0全部 1近1天 2近3天 3近7天 4自定义
* @param customFromMs / customToMs 自定义起止(含当天),未用传 0
*/
public boolean matchesFilter(String keyword, int statusFilter,
int timeMode, long customFromMs, long customToMs) {
refreshClosedState();
if (statusFilter == 1 && finished) {
return false;
}
if (statusFilter == 2 && !finished) {
return false;
}
if (!matchesTime(timeMode, customFromMs, customToMs)) {
return false;
}
if (keyword == null || keyword.trim().isEmpty()) {
return true;
}
String q = keyword.trim().toLowerCase(Locale.US);
if (containsIgnoreCase(packetId, q) || containsIgnoreCase(sender, q)
|| containsIgnoreCase(title, q) || containsIgnoreCase(group, q)
|| containsIgnoreCase(total, q)) {
return true;
}
if (leaderboard != null) {
for (MmpClaim c : leaderboard) {
if (c != null && (containsIgnoreCase(c.nickname, q)
|| containsIgnoreCase(c.userId, q)
|| containsIgnoreCase(c.poolId, q))) {
return true;
}
}
}
return false;
}
private boolean matchesTime(int timeMode, long customFromMs, long customToMs) {
if (timeMode <= 0) {
return true;
}
long ms = parseIssueMs(issuedAt);
if (ms <= 0) {
ms = parseIssueMs(updatedAt);
}
if (ms <= 0) {
return false;
}
long now = System.currentTimeMillis();
if (timeMode == 1) {
return ms >= now - 1L * 24 * 3600 * 1000;
}
if (timeMode == 2) {
return ms >= now - 3L * 24 * 3600 * 1000;
}
if (timeMode == 3) {
return ms >= now - 7L * 24 * 3600 * 1000;
}
if (timeMode == 4) {
if (customFromMs > 0 && ms < customFromMs) {
return false;
}
if (customToMs > 0 && ms > customToMs) {
return false;
}
return true;
}
return true;
}
/** 解析发放时间13/07/2026 11:39:15 或 yyyy-MM-dd… */
public static long parseIssueMs(String text) {
if (text == null || text.trim().isEmpty()) {
return 0;
}
String s = text.trim();
try {
java.text.SimpleDateFormat[] formats = new java.text.SimpleDateFormat[]{
new java.text.SimpleDateFormat("dd/MM/yyyy HH:mm:ss", Locale.US),
new java.text.SimpleDateFormat("dd/MM/yyyy HH:mm", Locale.US),
new java.text.SimpleDateFormat("dd/MM/yyyy", Locale.US),
new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US),
new java.text.SimpleDateFormat("yyyy-MM-dd", Locale.US),
};
for (java.text.SimpleDateFormat f : formats) {
f.setLenient(true);
try {
java.util.Date d = f.parse(s);
if (d != null) {
return d.getTime();
}
} catch (Exception ignored) {
}
}
} catch (Exception ignored) {
}
return 0;
}
private static boolean containsIgnoreCase(String hay, String needle) {
return hay != null && hay.toLowerCase(Locale.US).contains(needle);
}
}

View File

@@ -0,0 +1,805 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 本地红包领取台数据:解析 Hook 的 [MMP统计] 文本,按 activityId 持久化。
*/
public final class MmpPacketStore {
private static final String TAG = "MmpPacketStore";
private static final String PREF = "mmp_packets";
private static final String KEY_JSON = "packets_json";
private static final String KEY_LAST_INGEST = "last_ingest_at";
private static final int MAX_PACKETS = 200;
private static final Pattern AMOUNT_JSON = Pattern.compile(
"\"amount\"\\s*:\\s*\"?([0-9]+(?:\\.[0-9]+)?)\"?");
private static final Pattern AMOUNT_PLAIN = Pattern.compile("([0-9]+(?:\\.[0-9]+)?)");
public interface Listener {
void onMmpPacketsChanged();
}
private static final CopyOnWriteArrayList<Listener> LISTENERS = new CopyOnWriteArrayList<>();
private static volatile long sLastIngestAt;
private MmpPacketStore() {
}
public static void addListener(Listener listener) {
if (listener != null) {
LISTENERS.add(listener);
}
}
public static void removeListener(Listener listener) {
LISTENERS.remove(listener);
}
public static boolean isMmpContent(String content, String source) {
if (source != null && source.toLowerCase(Locale.US).contains("tng_mmp")) {
return true;
}
return content != null && content.contains("[MMP统计]");
}
/** 解析并写入;非 MMP 或无领取人则忽略。 */
public static boolean ingest(Context context, String title, String content, String source) {
if (context == null || TextUtils.isEmpty(content) || !isMmpContent(content, source)) {
return false;
}
ParseResult parsed = parse(content);
noteModuleFromMeta(context, parsed.meta);
String packetId = parsed.meta.get("packet");
if (TextUtils.isEmpty(packetId)) {
packetId = parsed.meta.get("packetId");
}
boolean hasRealPacketId = !TextUtils.isEmpty(packetId)
&& !"unknown".equals(packetId)
&& !"TNG 红包".equals(packetId)
&& !"TNG Money Packet".equals(packetId);
// 允许无人领取的新红包入库(只有 packet/metaclaims 为空)
if (parsed.claims.isEmpty() && !hasRealPacketId) {
return false;
}
if (!hasRealPacketId) {
packetId = "红包-" + claimsFingerprint(parsed.claims);
if (packetId.length() > 56) {
packetId = packetId.substring(0, 56);
}
}
MmpPacket incoming = new MmpPacket();
incoming.packetId = packetId;
incoming.title = TextUtils.isEmpty(title) ? "TNG 红包" : title;
incoming.sender = nullToEmpty(parsed.meta.get("sender"));
incoming.group = nullToEmpty(parsed.meta.get("group"));
incoming.total = nullToEmpty(parsed.meta.get("total"));
incoming.via = nullToEmpty(parsed.meta.get("via"));
incoming.issuedAt = firstNonEmpty(parsed.meta, "issued", "createTime", "issuedAt", "create");
incoming.expiresAt = firstNonEmpty(parsed.meta, "expire", "expireTime", "expiry", "expiresAt");
incoming.status = firstNonEmpty(parsed.meta, "status", "activityStatus");
parseCountsMeta(incoming, parsed.meta.get("counts"));
if (incoming.claimedCount <= 0) {
tryParseIntField(incoming, parsed.meta, "claimed", "claimedCount");
}
if (incoming.totalCount <= 0) {
tryParseIntField(incoming, parsed.meta, "slots", "totalCount", "quantity");
}
incoming.finished = "1".equals(parsed.meta.get("done"))
|| "true".equalsIgnoreCase(parsed.meta.get("done"))
|| isTerminalStatusMeta(incoming.status);
incoming.refreshClosedState();
incoming.updatedAt = nowText();
incoming.snapshots = 1;
List<MmpClaim> ranked = aggregateClaims(parsed.claims);
incoming.leaderboard = ranked;
incoming.claimantCount = ranked.size();
if (incoming.claimedCount <= 0) {
incoming.claimedCount = ranked.size();
}
if (incoming.totalCount > 0 && incoming.claimedCount >= incoming.totalCount) {
incoming.finished = true;
}
double sum = 0;
for (MmpClaim c : ranked) {
sum += c.amount;
}
incoming.sumClaimed = Math.round(sum * 10000.0) / 10000.0;
Double totalNum = normalizeMoney(incoming.total);
if (totalNum == null || (incoming.sumClaimed > 0
&& Math.abs(totalNum - incoming.sumClaimed) > 0.001
&& totalNum <= maxClaimAmount(ranked) + 1e-9)) {
incoming.total = String.format(Locale.US, "%.2f", incoming.sumClaimed);
} else {
incoming.total = String.format(Locale.US, "%.2f", totalNum);
}
synchronized (MmpPacketStore.class) {
Map<String, MmpPacket> map = loadMap(context);
MmpPacket existing = map.get(packetId);
if (existing != null) {
if (score(incoming) >= score(existing)) {
incoming.snapshots = existing.snapshots + 1;
if (TextUtils.isEmpty(incoming.issuedAt) && !TextUtils.isEmpty(existing.issuedAt)) {
incoming.issuedAt = existing.issuedAt;
}
if (TextUtils.isEmpty(incoming.expiresAt) && !TextUtils.isEmpty(existing.expiresAt)) {
incoming.expiresAt = existing.expiresAt;
}
if (TextUtils.isEmpty(incoming.status) && !TextUtils.isEmpty(existing.status)) {
incoming.status = existing.status;
}
if (incoming.totalCount <= 0 && existing.totalCount > 0) {
incoming.totalCount = existing.totalCount;
}
if (incoming.claimedCount < existing.claimedCount) {
// 保留更大的已领人数
incoming.claimedCount = existing.claimedCount;
}
if (!incoming.finished && existing.finished) {
incoming.finished = true;
}
incoming.refreshClosedState();
map.put(packetId, incoming);
} else {
existing.snapshots = existing.snapshots + 1;
if (TextUtils.isEmpty(existing.issuedAt) && !TextUtils.isEmpty(incoming.issuedAt)) {
existing.issuedAt = incoming.issuedAt;
}
existing.updatedAt = nowText();
}
} else {
map.put(packetId, incoming);
}
trimMap(map);
saveMap(context, map);
}
notifyListeners();
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);
} catch (Exception e) {
Log.w(TAG, "auto sync skip", e);
}
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) {
// 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));
}
});
for (MmpPacket p : list) {
if (p != null) {
p.refreshClosedState();
}
}
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;
}
synchronized (MmpPacketStore.class) {
MmpPacket p = loadMap(context).get(packetId);
if (p != null) {
p.refreshClosedState();
}
return p;
}
}
public static void clear(Context context) {
synchronized (MmpPacketStore.class) {
prefs(context).edit().remove(KEY_JSON).apply();
}
notifyListeners();
}
/** 导出给 PC /api/mmp/sync 的全量 JSON。 */
public static JSONArray exportJsonArray(Context context) {
JSONArray arr = new JSONArray();
synchronized (MmpPacketStore.class) {
for (MmpPacket p : loadMap(context).values()) {
try {
arr.put(toJson(p));
} catch (Exception e) {
Log.e(TAG, "export packet failed", e);
}
}
}
return arr;
}
// --- parse ---
/** 从 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<>();
}
private static final class RawClaim {
String nickname;
String userId;
String poolId;
double amount;
String claimTime;
}
private static ParseResult parse(String content) {
ParseResult out = new ParseResult();
String text = content.trim();
String[] lines = text.split("\\r?\\n");
if (lines.length == 0) {
return out;
}
String head = lines[0].trim();
if (head.startsWith("[MMP统计]")) {
head = head.substring("[MMP统计]".length()).trim();
}
List<String> parts;
if (head.contains(" | ")) {
parts = splitPipe(head);
} else {
parts = splitLegacy(head);
}
for (String part : parts) {
int eq = part.indexOf('=');
if (eq <= 0) {
continue;
}
out.meta.put(part.substring(0, eq).trim(), part.substring(eq + 1).trim());
}
// sender 被空格截断时尽量还原
String sender = out.meta.get("sender");
if (sender != null && head.contains("sender=")) {
String raw = head.substring(head.indexOf("sender=") + "sender=".length());
for (String stop : new String[]{" | ", " total=", " via=", " group=", " packet=", " src=", " issued="}) {
int i = raw.indexOf(stop);
if (i >= 0) {
raw = raw.substring(0, i);
break;
}
}
raw = raw.trim();
if (raw.length() > sender.length()) {
out.meta.put("sender", raw);
}
}
Double totalNum = normalizeMoney(out.meta.get("total"));
if (totalNum != null) {
out.meta.put("total", String.format(Locale.US, "%.2f", totalNum));
}
for (int i = 1; i < lines.length; i++) {
String line = lines[i].trim();
if (line.isEmpty() || !line.contains("->")) {
continue;
}
int arrow = line.indexOf("->");
String nick = line.substring(0, arrow).trim();
String right = line.substring(arrow + 2).trim();
String 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("{")) {
int lp = right.lastIndexOf('(');
amountRaw = right.substring(0, lp).trim();
claimTime = right.substring(lp + 1, right.length() - 1).trim();
}
Double amt = normalizeMoney(amountRaw);
if (!TextUtils.isEmpty(nick) || !TextUtils.isEmpty(userId)) {
RawClaim c = new RawClaim();
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);
}
}
return out;
}
private static List<String> splitPipe(String head) {
String[] arr = head.split(" \\| ");
List<String> parts = new ArrayList<>();
for (String p : arr) {
if (!TextUtils.isEmpty(p.trim())) {
parts.add(p.trim());
}
}
return parts;
}
private static List<String> splitLegacy(String head) {
List<String> parts = new ArrayList<>();
String buf = "";
for (String part : head.split(" ")) {
if (!buf.isEmpty()) {
buf += " " + part;
if (countChar(buf, '{') <= countChar(buf, '}')) {
parts.add(buf);
buf = "";
}
continue;
}
if (part.contains("=")) {
String v = part.substring(part.indexOf('=') + 1);
if (v.startsWith("{") && countChar(part, '{') > countChar(part, '}')) {
buf = part;
continue;
}
}
parts.add(part);
}
if (!buf.isEmpty()) {
parts.add(buf);
}
return parts;
}
private static int countChar(String s, char c) {
int n = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == c) {
n++;
}
}
return n;
}
private static List<MmpClaim> aggregateClaims(List<RawClaim> claims) {
LinkedHashMap<String, MmpClaim> buckets = new LinkedHashMap<>();
for (RawClaim c : claims) {
String nick = c.nickname != null ? c.nickname : "?";
String key = !TextUtils.isEmpty(c.userId) ? ("id:" + c.userId) : ("n:" + nick);
MmpClaim b = buckets.get(key);
if (b == null) {
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) {
b.amount = c.amount;
b.amountText = String.format(Locale.US, "%.2f", c.amount);
if (!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());
Collections.sort(result, new Comparator<MmpClaim>() {
@Override
public int compare(MmpClaim a, MmpClaim b) {
return Double.compare(b.amount, a.amount);
}
});
for (int i = 0; i < result.size(); i++) {
result.get(i).rank = i + 1;
}
return result;
}
private static String claimsFingerprint(List<RawClaim> claims) {
List<String> rows = new ArrayList<>();
for (RawClaim c : claims) {
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();
for (String r : rows) {
if (sb.length() > 0) {
sb.append('|');
}
sb.append(r);
}
return sb.toString();
}
static Double normalizeMoney(String raw) {
if (TextUtils.isEmpty(raw)) {
return null;
}
String s = raw.trim();
if (s.startsWith("{")) {
Matcher m = AMOUNT_JSON.matcher(s);
if (m.find()) {
try {
return Double.parseDouble(m.group(1));
} catch (NumberFormatException ignored) {
}
}
}
Matcher m = AMOUNT_PLAIN.matcher(s.replace(",", ""));
if (m.find()) {
try {
return Double.parseDouble(m.group(1));
} catch (NumberFormatException ignored) {
}
}
return null;
}
private static double maxClaimAmount(List<MmpClaim> ranked) {
double max = 0;
for (MmpClaim c : ranked) {
if (c.amount > max) {
max = c.amount;
}
}
return max;
}
private static int score(MmpPacket p) {
int issued = TextUtils.isEmpty(p.issuedAt) ? 0 : 1;
int claims = p.leaderboard != null ? p.leaderboard.size() : 0;
return issued * 1000 + claims;
}
private static void trimMap(Map<String, MmpPacket> map) {
if (map.size() <= MAX_PACKETS) {
return;
}
List<MmpPacket> list = new ArrayList<>(map.values());
Collections.sort(list, new Comparator<MmpPacket>() {
@Override
public int compare(MmpPacket a, MmpPacket b) {
return nullToEmpty(a.updatedAt).compareTo(nullToEmpty(b.updatedAt));
}
});
int remove = map.size() - MAX_PACKETS;
for (int i = 0; i < remove; i++) {
map.remove(list.get(i).packetId);
}
}
// --- persistence ---
private static SharedPreferences prefs(Context context) {
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
}
private static Map<String, MmpPacket> loadMap(Context context) {
Map<String, MmpPacket> map = new HashMap<>();
String raw = prefs(context).getString(KEY_JSON, null);
if (TextUtils.isEmpty(raw)) {
return map;
}
try {
JSONArray arr = new JSONArray(raw);
for (int i = 0; i < arr.length(); i++) {
MmpPacket p = fromJson(arr.getJSONObject(i));
if (p != null && !TextUtils.isEmpty(p.packetId)) {
map.put(p.packetId, p);
}
}
} catch (Exception e) {
Log.e(TAG, "load failed", e);
}
return map;
}
private static void saveMap(Context context, Map<String, MmpPacket> map) {
try {
JSONArray arr = new JSONArray();
for (MmpPacket p : map.values()) {
arr.put(toJson(p));
}
prefs(context).edit().putString(KEY_JSON, arr.toString()).apply();
} catch (Exception e) {
Log.e(TAG, "save failed", e);
}
}
private static JSONObject toJson(MmpPacket p) throws Exception {
JSONObject o = new JSONObject();
o.put("packetId", nullToEmpty(p.packetId));
o.put("title", nullToEmpty(p.title));
o.put("sender", nullToEmpty(p.sender));
o.put("group", nullToEmpty(p.group));
o.put("total", nullToEmpty(p.total));
o.put("via", nullToEmpty(p.via));
o.put("issuedAt", nullToEmpty(p.issuedAt));
o.put("expiresAt", nullToEmpty(p.expiresAt));
o.put("status", nullToEmpty(p.status));
o.put("updatedAt", nullToEmpty(p.updatedAt));
o.put("finished", p.finished);
o.put("snapshots", p.snapshots);
o.put("claimedCount", p.claimedCount);
o.put("totalCount", p.totalCount);
o.put("claimantCount", p.claimantCount);
o.put("sumClaimed", p.sumClaimed);
JSONArray board = new JSONArray();
if (p.leaderboard != null) {
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));
row.put("rank", c.rank);
board.put(row);
}
}
o.put("leaderboard", board);
return o;
}
private static MmpPacket fromJson(JSONObject o) throws Exception {
MmpPacket p = new MmpPacket();
p.packetId = o.optString("packetId", "");
p.title = o.optString("title", "");
p.sender = o.optString("sender", "");
p.group = o.optString("group", "");
p.total = o.optString("total", "");
p.via = o.optString("via", "");
p.issuedAt = o.optString("issuedAt", "");
p.expiresAt = o.optString("expiresAt", "");
p.status = o.optString("status", "");
p.updatedAt = o.optString("updatedAt", "");
p.finished = o.optBoolean("finished", false);
p.snapshots = o.optInt("snapshots", 1);
p.claimedCount = o.optInt("claimedCount", 0);
p.totalCount = o.optInt("totalCount", 0);
p.claimantCount = o.optInt("claimantCount", 0);
p.refreshClosedState();
p.snapshots = o.optInt("snapshots", 1);
p.claimantCount = o.optInt("claimantCount", 0);
p.sumClaimed = o.optDouble("sumClaimed", 0);
JSONArray board = o.optJSONArray("leaderboard");
if (board != null) {
for (int i = 0; i < board.length(); i++) {
JSONObject row = board.getJSONObject(i);
MmpClaim c = new MmpClaim();
c.nickname = row.optString("nickname", "");
c.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));
c.claimTime = row.optString("claimTime", "");
c.rank = row.optInt("rank", i + 1);
p.leaderboard.add(c);
}
}
if (p.claimantCount <= 0) {
p.claimantCount = p.leaderboard.size();
}
if (p.claimedCount <= 0) {
p.claimedCount = p.claimantCount;
}
p.refreshClosedState();
return p;
}
private static void notifyListeners() {
for (Listener l : LISTENERS) {
try {
l.onMmpPacketsChanged();
} catch (Exception e) {
Log.e(TAG, "listener error", e);
}
}
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}
private static String firstNonEmpty(Map<String, String> meta, String... keys) {
for (String k : keys) {
String v = meta.get(k);
if (!TextUtils.isEmpty(v)) {
return v;
}
}
return "";
}
/** counts=1/5 */
private static void parseCountsMeta(MmpPacket packet, String counts) {
if (packet == null || TextUtils.isEmpty(counts)) {
return;
}
String raw = counts.trim();
int slash = raw.indexOf('/');
try {
if (slash >= 0) {
String left = raw.substring(0, slash).trim();
String right = raw.substring(slash + 1).trim();
if (!left.isEmpty()) {
packet.claimedCount = Integer.parseInt(left);
}
if (!right.isEmpty()) {
packet.totalCount = Integer.parseInt(right);
}
} else {
packet.claimedCount = Integer.parseInt(raw);
}
} catch (NumberFormatException ignored) {
}
}
private static void tryParseIntField(MmpPacket packet, Map<String, String> meta,
String... keys) {
if (packet == null || meta == null) {
return;
}
for (String k : keys) {
String v = meta.get(k);
if (TextUtils.isEmpty(v)) {
continue;
}
try {
int n = Integer.parseInt(v.trim());
if (n < 0) {
continue;
}
if ("claimed".equals(k) || "claimedCount".equals(k)) {
packet.claimedCount = n;
} else {
packet.totalCount = n;
}
return;
} catch (NumberFormatException ignored) {
}
}
}
private static boolean isTerminalStatusMeta(String status) {
if (TextUtils.isEmpty(status)) {
return false;
}
String s = status.toUpperCase(Locale.US);
return s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE");
}
private static String nowText() {
return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
.format(new java.util.Date());
}
}

View File

@@ -0,0 +1,178 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.miraclegarden.smsmessage.AppConfig;
import org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* 领取台设置:本地 SharedPreferences 优先,能连上调试台时再同步 Hook 侧参数。
*/
public final class MmpSettingsClient {
private static final String TAG = "MmpSettingsClient";
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
.connectTimeout(4, TimeUnit.SECONDS)
.readTimeout(4, TimeUnit.SECONDS)
.writeTimeout(4, TimeUnit.SECONDS)
.build();
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor();
private static final Handler MAIN = new Handler(Looper.getMainLooper());
public interface CallbackJson {
void onResult(JSONObject json, String error);
}
private MmpSettingsClient() {
}
/** 先本地再尝试合并调试台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()) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/settings")
.get()
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
if (resp.isSuccessful() && resp.body() != null) {
JSONObject 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;
}
}
} catch (Exception e) {
last = e;
}
}
JSONObject out = local;
String tip = last != null
? ("已用本地设置(调试台未连上:" + last.getMessage() + "")
: null;
MAIN.post(() -> cb.onResult(out, tip));
});
}
/** 始终写本地;能连调试台则同步 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 remoteOk = false;
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (base == null || base.isEmpty()) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/settings")
.post(RequestBody.create(saved.toString(), JSON))
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
if (resp.isSuccessful() && resp.body() != null) {
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 okRemote = remoteOk;
final JSONObject out = saved;
final Exception err = last;
MAIN.post(() -> {
if (okRemote) {
cb.onResult(out, null);
} else {
// 本地已保存成功
cb.onResult(out, err != null
? ("已保存到手机;调试台未同步:" + err.getMessage())
: "已保存到手机");
}
});
});
}
public static void pingAsync() {
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);
}
});
});
}
}

View File

@@ -0,0 +1,134 @@
package com.miraclegarden.smsmessage.mmp;
import android.content.Context;
import android.text.TextUtils;
import android.util.Log;
import com.miraclegarden.smsmessage.AppConfig;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* 把手机本地红包台全量同步到 PC 调试台,方便电脑打开 /mmp 查看。
*/
public final class MmpSyncClient {
private static final String TAG = "MmpSyncClient";
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static final OkHttpClient CLIENT = new OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build();
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor();
private static final AtomicBoolean IN_FLIGHT = new AtomicBoolean(false);
private static volatile long sLastSyncAt;
public interface Callback {
void onDone(boolean ok, String message);
}
private MmpSyncClient() {
}
public static void syncToPc(Context context) {
syncToPc(context, null);
}
public static void syncToPc(Context context, Callback callback) {
if (context == null || !AppConfig.ENABLE_DEBUG_FORWARD) {
if (callback != null) {
callback.onDone(false, "调试转发未开启");
}
return;
}
final Context app = context.getApplicationContext();
EXEC.execute(() -> {
if (!IN_FLIGHT.compareAndSet(false, true)) {
if (callback != null) {
callback.onDone(false, "同步进行中");
}
return;
}
try {
JSONArray packets = MmpPacketStore.exportJsonArray(app);
JSONObject body = new JSONObject();
body.put("packets", packets);
body.put("device", "android");
body.put("syncedAt", System.currentTimeMillis());
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;
String lastErr = "调试台不可达";
String lastOkMsg = "";
for (String base : AppConfig.DEBUG_SERVER_URLS) {
if (TextUtils.isEmpty(base)) {
continue;
}
try {
Request req = new Request.Builder()
.url(base.replaceAll("/$", "") + "/api/mmp/sync")
.post(RequestBody.create(payload, JSON))
.build();
try (Response resp = CLIENT.newCall(req).execute()) {
String respBody = resp.body() != null ? resp.body().string() : "";
if (resp.isSuccessful()) {
anyOk = true;
lastOkMsg = "已同步 " + packets.length() + " 个红包到电脑";
Log.i(TAG, "sync ok -> " + base + " " + respBody);
} else {
lastErr = "HTTP " + resp.code();
}
}
} catch (Exception e) {
lastErr = e.getMessage();
Log.w(TAG, "sync fail " + base + ": " + lastErr);
}
}
sLastSyncAt = System.currentTimeMillis();
if (callback != null) {
if (anyOk) {
callback.onDone(true, lastOkMsg);
} else {
callback.onDone(false, lastErr != null ? lastErr : "同步失败");
}
}
} catch (Exception e) {
Log.e(TAG, "sync error", e);
if (callback != null) {
callback.onDone(false, e.getMessage());
}
} finally {
IN_FLIGHT.set(false);
}
});
}
/** 距上次同步超过 cooldownMs 才再同步。 */
public static void syncIfStale(Context context, long cooldownMs) {
if (System.currentTimeMillis() - sLastSyncAt < cooldownMs) {
return;
}
syncToPc(context, null);
}
}

View File

@@ -0,0 +1,155 @@
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;
}
}
/** 打开红包历史页,触发 Hook onResume / 自动拉取 */
public static boolean openMoneyPacketHistory(Context context) {
if (context == null) {
return false;
}
try {
Intent intent = new Intent();
intent.setClassName(TNG_PACKAGE,
"my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_NO_ANIMATION);
context.startActivity(intent);
Log.i(TAG, "opened MoneyPacketHistoryActivity");
return true;
} catch (Exception e) {
Log.w(TAG, "open history fail, fallback launch: " + e.getMessage());
return launch(context);
}
}
/** 强制停止 TNG优先 su */
public static boolean forceStop(Context context) {
String out = shell("su -c am force-stop " + TNG_PACKAGE);
if (TextUtils.isEmpty(out)) {
out = shell("am force-stop " + TNG_PACKAGE);
}
Log.i(TAG, "force-stop TNG done");
return true;
}
/** 强停后再打开,用于 Hook 假死重建 */
public static boolean forceStopAndLaunch(Context context) {
forceStop(context);
try {
Thread.sleep(900L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return launch(context);
}
}

View File

@@ -10,6 +10,7 @@ import com.miraclegarden.smsmessage.MessageInfo;
import org.json.JSONObject;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import okhttp3.Call;
import okhttp3.Callback;
@@ -21,6 +22,7 @@ import okhttp3.Response;
/**
* 将抓取到的消息转发到 PC 本地调试服务,便于浏览器查看。
* 支持同时向本机USB/adb reverse和局域网地址推送。
*/
public final class DebugForwarder {
@@ -51,6 +53,14 @@ public final class DebugForwarder {
return;
}
String[] urls = AppConfig.DEBUG_SERVER_URLS;
if (urls == null || urls.length == 0) {
if (onComplete != null) {
onComplete.run();
}
return;
}
try {
JSONObject json = new JSONObject();
json.put("source", source != null ? source : "unknown");
@@ -60,39 +70,49 @@ public final class DebugForwarder {
json.put("group", resolveGroup(title, messageInfo));
json.put("content", content != null ? content : "");
json.put("timestamp", timestamp);
final String body = json.toString();
final AtomicInteger pending = new AtomicInteger(urls.length);
Request request = new Request.Builder()
.url(AppConfig.DEBUG_SERVER_URL + "/api/debug/push")
.header("Content-Type", "application/json")
.post(RequestBody.create(json.toString(), JSON))
.build();
Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
+ " -> " + AppConfig.DEBUG_SERVER_URL);
CLIENT.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, java.io.IOException e) {
Log.w(TAG, "forward failed [" + source + "]: " + e.getMessage());
if (onComplete != null) {
onComplete.run();
}
for (String baseUrl : urls) {
if (TextUtils.isEmpty(baseUrl)) {
finishOne(pending, onComplete);
continue;
}
final String target = baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
Request request = new Request.Builder()
.url(target + "/api/debug/push")
.header("Content-Type", "application/json")
.post(RequestBody.create(body, JSON))
.build();
@Override
public void onResponse(Call call, Response response) {
int code = response.code();
response.close();
if (code >= 200 && code < 300) {
Log.d(TAG, "forward ok [" + source + "] " + title);
} else {
Log.w(TAG, "forward http " + code + " [" + source + "] " + title);
Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
+ " -> " + target);
CLIENT.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, java.io.IOException e) {
Log.w(TAG, "forward failed [" + source + "] -> " + target
+ ": " + e.getMessage());
finishOne(pending, onComplete);
}
if (onComplete != null) {
onComplete.run();
@Override
public void onResponse(Call call, Response response) {
int code = response.code();
response.close();
if (code >= 200 && code < 300) {
Log.d(TAG, "forward ok [" + source + "] -> " + target
+ " " + title);
} else {
Log.w(TAG, "forward http " + code + " [" + source + "] -> "
+ target + " " + title);
}
finishOne(pending, onComplete);
}
}
});
});
}
} catch (Exception e) {
Log.w(TAG, "forward build failed: " + e.getMessage());
if (onComplete != null) {
@@ -101,6 +121,12 @@ public final class DebugForwarder {
}
}
private static void finishOne(AtomicInteger pending, Runnable onComplete) {
if (pending.decrementAndGet() == 0 && onComplete != null) {
onComplete.run();
}
}
private static String resolveGroup(String title, MessageInfo messageInfo) {
if (!TextUtils.isEmpty(title)) {
return title.trim();

View File

@@ -10,6 +10,8 @@ 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;
/**
@@ -21,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;
}
@@ -58,6 +75,26 @@ public class HookMessageReceiver extends BroadcastReceiver {
Log.i(TAG, "hook received: pkg=" + packageName + " source=" + source + " title=" + title);
final String finalTitle = title;
final boolean isMmp = MmpPacketStore.isMmpContent(content, source);
// 红包统计:只进领取台,不混入「情况」监听台 / 正式上传
if (isMmp) {
try {
MmpPacketStore.ingest(context.getApplicationContext(), finalTitle, content, source);
} catch (Exception e) {
Log.e(TAG, "mmp ingest failed", e);
}
Context appContext = context.getApplicationContext();
PendingResult pendingResult = goAsync();
Runnable finish = pendingResult::finish;
if (AppConfig.ENABLE_DEBUG_FORWARD) {
DebugForwarder.forward(appContext, messageInfo, finalTitle, content, timestamp, source, finish);
} else {
finish.run();
}
return;
}
NotificationActivity.sendMessage("[Hook/" + source + "] " + finalTitle + " " + content);
Context appContext = context.getApplicationContext();

View File

@@ -92,6 +92,16 @@
android:backgroundTint="#FF3700B3" />
</LinearLayout>
<Button
android:id="@+id/btn_mmp_claim"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="12dp"
android:text="红包领取台(独立)"
android:textSize="16sp"
android:textColor="@color/white"
android:backgroundTint="#C45C26" />
<!-- 权限设置 -->
<TextView
android:layout_width="wrap_content"

View File

@@ -0,0 +1,633 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F6F1EA">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#111827"
android:orientation="vertical"
android:paddingBottom="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="44dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp">
<ImageView
android:id="@+id/back_iv"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="返回"
android:src="@drawable/ic_action_back" />
<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/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>
<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_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_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"
android:layout_height="wrap_content"
android:background="@color/white"
android:orientation="vertical"
android:paddingStart="12dp"
android:paddingTop="8dp"
android:paddingEnd="12dp"
android:paddingBottom="8dp">
<EditText
android:id="@+id/et_filter"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="#F0F0F0"
android:hint="筛选:昵称 / 发送人 / ID"
android:imeOptions="actionSearch"
android:inputType="text"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:textColor="#222222"
android:textColorHint="#666666"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/chip_all"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_weight="1"
android:background="#C45C26"
android:gravity="center"
android:text="全部"
android:textColor="@color/white"
android:textSize="13sp" />
<TextView
android:id="@+id/chip_open"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#EEEEEE"
android:gravity="center"
android:text="领取中"
android:textColor="#333333"
android:textSize="13sp" />
<TextView
android:id="@+id/chip_done"
android:layout_width="0dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#EEEEEE"
android:gravity="center"
android:text="已领完"
android:textColor="#333333"
android:textSize="13sp" />
</LinearLayout>
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:scrollbars="none">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/chip_time_all"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:background="#1C1410"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="全部时间"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_1"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近1天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_3"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近3天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_7"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="近7天"
android:textColor="#333333"
android:textSize="12sp" />
<TextView
android:id="@+id/chip_time_custom"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:layout_marginStart="6dp"
android:background="#EEEEEE"
android:gravity="center"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:text="自定义"
android:textColor="#333333"
android:textSize="12sp" />
</LinearLayout>
</HorizontalScrollView>
<LinearLayout
android:id="@+id/custom_range_ly"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal"
android:visibility="gone">
<Button
android:id="@+id/btn_date_from"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:backgroundTint="#FFF3E0"
android:text="起始日期"
android:textColor="#4E342E"
android:textSize="12sp" />
<Button
android:id="@+id/btn_date_to"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:backgroundTint="#FFF3E0"
android:text="结束日期"
android:textColor="#4E342E"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/empty_ly"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂无红包数据"
android:textColor="#999999"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:text="确认监听列表已勾选 TNG打开 TNG 登录后等待自动拉取全部历史与详情"
android:textColor="#FF9800"
android:textSize="13sp" />
</LinearLayout>
<!-- 设置全屏可滚动层:盖在列表之上,可滚到底,避免底部裁切 -->
<ScrollView
android:id="@+id/settings_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="84dp"
android:background="#FFF8F1"
android:clickable="true"
android:elevation="12dp"
android:fillViewport="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
android:paddingBottom="48dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="拉取设置"
android:textColor="#1C1410"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/btn_close_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="8dp"
android:text="关闭"
android:textColor="#C2410C"
android:textSize="14sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="控制 TNG 里多久自动扫一次红包列表/详情。改完点保存,约 10 秒后生效。不懂就用下面「标准」。"
android:textColor="#5D4037"
android:textSize="12sp" />
<!-- 历史冷却 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="① 历史列表冷却(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="多久再自动拉一次「红包历史列表」。越小越勤,越费电。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_history"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 8"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 详情冷却 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="② 详情刷新冷却(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="还在领的红包,多久再拉一次领取名单。已领完的会自动停刷。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_detail"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 8"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 去重 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="③ 相同名单去重(秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="领取名单没变化时,多少秒内不重复推送到领取台(防刷屏)。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_dedup"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 3"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<!-- 间隔 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="④ 详情请求间隔(毫秒)"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="连续拉多个红包详情时每个之间停顿多久。1000 毫秒 = 1 秒。"
android:textColor="#6D4C41"
android:textSize="11sp" />
<EditText
android:id="@+id/cfg_gap"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="4dp"
android:background="#FFFFFF"
android:hint="例如 80"
android:inputType="number"
android:padding="8dp"
android:textColor="#222222"
android:textColorHint="#999999"
android:textSize="14sp" />
<CheckBox
android:id="@+id/cfg_open_history"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:buttonTint="#C45C26"
android:text="无请求模板时,自动打开 TNG 红包历史页(方便首次抓取)"
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"
android:layout_marginTop="10dp"
android:text="快捷预设"
android:textColor="#1C1410"
android:textSize="13sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="极速=更勤拉(费电) 标准=日常推荐 省流=少拉省电"
android:textColor="#6D4C41"
android:textSize="11sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<Button
android:id="@+id/btn_preset_fast"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="极速"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_preset_normal"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="标准"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_preset_slow"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#5D4037"
android:text="省流"
android:textColor="#FFFFFF"
android:textSize="12sp" />
<Button
android:id="@+id/btn_save_settings"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:backgroundTint="#C45C26"
android:text="保存"
android:textColor="#FFFFFF"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tv_settings_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textColor="#4E342E"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>
</FrameLayout>

View File

@@ -0,0 +1,324 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F3F4F6"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="#111827"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<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_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
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/tv_header_money"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0.00"
android:textColor="#FDBA74"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fillViewport="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#FFFFFF"
android:orientation="vertical"
android:padding="14dp">
<TextView
android:id="@+id/tv_sub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#6B7280"
android:textSize="12sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#F9FAFB"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="人数"
android:textColor="#6B7280"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_people"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="0"
android:textColor="#111827"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFF7ED"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="合计"
android:textColor="#9A3412"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_sum"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFF7ED"
android:orientation="vertical"
android:padding="10dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="总额"
android:textColor="#9A3412"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_stat_total"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#C2410C"
android:textSize="20sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#ECFDF5"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tv_best_lab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="手气最佳"
android:textColor="#047857"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_best_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:text="-"
android:textColor="#047857"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_best_amt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#047857"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:background="#FFFBEB"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/tv_worst_lab"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="手气最差"
android:textColor="#B45309"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:text="-"
android:textColor="#B45309"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst_amt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="-"
android:textColor="#B45309"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<!-- keep old tv_luck hidden for binding safety if referenced - remove from activity instead -->
</LinearLayout>
</ScrollView>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="14dp"
android:paddingTop="10dp"
android:paddingEnd="14dp"
android:paddingBottom="6dp"
android:text="领取排行"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#F9FAFB"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="8dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="#"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="昵称"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="金额"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:layout_width="96dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="时间"
android:textColor="#6B7280"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerview"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:background="#FFFFFF" />
<LinearLayout
android:id="@+id/empty_ly"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂无领取记录"
android:textColor="#9CA3AF"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F6F1EA"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="#111827"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="16dp">
<ImageView
android:id="@+id/back_iv"
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="返回"
android:src="@drawable/ic_action_back" />
<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" />
</LinearLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:paddingBottom="32dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
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="3dp"
android:text="独立统计 TNG Money Packet红包谁发的、谁领了、领了多少。数据只进本台不会进通用「情况」监听台也不会上传正式服务器。"
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="1. 主 App 监听列表勾选 TNG\n2. LSPosed 勾选本 Xposed 模块,作用域含 TNG\n3. 强制停止后再打开 TNG若闪退见下方「打开 TNG 闪退怎么办」\n4. 登录 / 输 PIN 后回到本页等待自动拉取(一般不必再进红包历史页)\n5. 要电脑看:电脑开调试台 + adb reverse再点「同步电脑」或打开本页自动同步"
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• 领取中 / 已领完:红包是否还在领\n• 最高 / 最低(领取中)或 最佳 / 最差(已领完):当前手气两端\n• 点进卡片:看完整领取排行\n• 清空:只清本台红包数据,不影响通用消息"
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="• 关键词:昵称 / 发送人 / 红包 ID\n• 状态:全部、领取中、已领完\n• 时间:近 1 / 3 / 7 天,或自定义起止日期"
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="手机是数据源。点「同步电脑」会把本地红包全量推到调试台(落盘),电脑打开 http://127.0.0.1:8765/mmp 即可看。手机浏览器也可访问USB reverse 或局域网 IP。"
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② 详情刷新冷却:领取中的包多久再拉一次名单\n③ 相同名单去重:名单没变时,多少秒内不重复推送\n④ 详情请求间隔:连拉多个包之间停顿(毫秒)\n\n已领完的包会停刷详情。不懂就用预设「标准」再保存约 10 秒后对 Hook 生效。"
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"
android:layout_marginTop="18dp"
android:text="打开 TNG 闪退怎么办"
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="装了 Xposed / 防护相关模块后TNG 偶发一打开就闪退,属常见现象,可按下面顺序试:\n\n1. 多点几次图标再打开(有时前几次会崩,后面就能进)\n2. 仍不行:系统设置 → 应用 → TNG → 强制停止,再重新打开\n3. 还不行:强制停止后清一下「缓存」(尽量别清「数据」,否则要重新登录)\n4. 推送/更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪\n5. 连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开\n\n能进首页并登录 / 输 PIN 后,再回本领取台等自动拉数据。"
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="• 监听列表是否勾选 TNG\n• Xposed 模块是否启用且作用域含 TNG\n• 是否按上面做过「强制停止再开」并完成登录\n• 电脑同步失败调试台是否在跑、8765 是否多开、adb reverse 是否做好\n\n更细的技术说明见仓库 docs/TNG_MoneyPacket领取台.md"
android:textColor="#4E342E"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="12dp"
android:paddingEnd="14dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/tv_rank"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="1"
android:textColor="#EA580C"
android:textSize="16sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_nickname"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="昵称"
android:textColor="#111827"
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"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text=""
android:textSize="11sp"
android:textStyle="bold"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_amount"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="17sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_time"
android:layout_width="96dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="-"
android:textColor="#9CA3AF"
android:textSize="11sp" />
</LinearLayout>

View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:background="#FFFFFF"
android:orientation="vertical"
android:paddingStart="10dp"
android:paddingTop="8dp"
android:paddingEnd="10dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="红包"
android:textColor="#111827"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="middle"
android:maxLines="1"
android:text=""
android:textColor="#9CA3AF"
android:textSize="11sp"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tv_sum"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:text="0.00"
android:textColor="#C2410C"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#ECFDF5"
android:paddingStart="5dp"
android:paddingTop="1dp"
android:paddingEnd="5dp"
android:paddingBottom="1dp"
android:text="已领完"
android:textColor="#047857"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_meta"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="6dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="0 人"
android:textColor="#6B7280"
android:textSize="11sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="5dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_best"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="#ECFDF5"
android:ellipsize="end"
android:maxLines="2"
android:paddingStart="6dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"
android:paddingBottom="4dp"
android:text="最高 -"
android:textColor="#047857"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_worst"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:background="#FFFBEB"
android:ellipsize="end"
android:maxLines="2"
android:paddingStart="6dp"
android:paddingTop="4dp"
android:paddingEnd="6dp"
android:paddingBottom="4dp"
android:text="最低 -"
android:textColor="#B45309"
android:textSize="11sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

147
debug-server/mmp_help.html Normal file
View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>红包领取台 · 功能说明</title>
<style>
:root {
--bg: #f6f1ea; --panel: #fff; --ink: #1c1410; --muted: #5d4037;
--line: #e7e0d8; --accent: #c45c26; --nav: #111827;
}
* { box-sizing: border-box; }
body {
margin: 0; font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--ink); line-height: 1.55;
}
header {
position: sticky; top: 0; z-index: 2;
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 12px 16px; background: var(--nav); color: #fff;
}
header h1 { margin: 0; font-size: 17px; font-weight: 700; }
a.nav { color: #fdba74; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
main { max-width: 720px; margin: 0 auto; padding: 20px 16px 48px; }
section {
background: var(--panel); border: 1px solid var(--line);
border-radius: 10px; padding: 16px 18px; margin-bottom: 12px;
}
h2 { margin: 0 0 8px; font-size: 16px; }
p, li { margin: 0; color: var(--muted); font-size: 14px; }
p + p { margin-top: 8px; }
ol, ul { margin: 6px 0 0; padding-left: 1.25em; color: var(--muted); font-size: 14px; }
li + li { margin-top: 4px; }
code {
font-family: ui-monospace, Consolas, monospace; font-size: 12px;
background: #f3eee6; padding: 1px 5px; border-radius: 4px; color: #1c1410;
}
.tip {
margin-top: 10px; padding: 10px 12px; border-radius: 8px;
background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; font-size: 13px;
}
</style>
</head>
<body>
<header>
<h1>功能说明</h1>
<a class="nav" href="/mmp">← 返回领取台</a>
<a class="nav" href="/">通用消息台</a>
</header>
<main>
<section>
<h2>红包领取台做什么</h2>
<p>独立统计 TNG Money Packet红包谁发的、谁领了、领了多少。</p>
<p>数据只进本台,不进入通用「情况」监听台,也不走正式上传。</p>
</section>
<section>
<h2>怎么用</h2>
<ol>
<li>主 App 监听列表勾选 TNG</li>
<li>LSPosed 勾选本 Xposed 模块,作用域含 TNG</li>
<li>强制停止后再打开 TNG若闪退见下方「打开 TNG 闪退怎么办」</li>
<li>登录 / 输 PIN 后,打开手机「红包领取台」等待自动拉取(一般不必再进红包历史页)</li>
<li>电脑本页看数据:调试台运行 + <code>adb reverse tcp:8765 tcp:8765</code>,手机点「同步电脑」或打开领取台自动同步</li>
</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>
<ol>
<li>多点几次图标再打开(有时前几次会崩,后面就能进)</li>
<li>仍不行:系统设置 → 应用 → TNG → <strong>强制停止</strong>,再重新打开</li>
<li>还不行:强制停止后清一下「缓存」(尽量别清「数据」,否则要重新登录)</li>
<li>推送 / 更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪</li>
<li>连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开</li>
</ol>
<div class="tip">能进首页并登录 / 输 PIN 后,再回领取台等自动拉数据。</div>
</section>
<section>
<h2>列表怎么看</h2>
<ul>
<li>右侧橙色金额:已领合计</li>
<li>领取中 / 已领完:红包是否还在领</li>
<li>最高/最低(领取中)或 最佳/最差(已领完):手气两端</li>
<li>点选左侧条目:右侧看完整领取排行</li>
<li>清空:只清本台红包数据</li>
</ul>
</section>
<section>
<h2>筛选</h2>
<ul>
<li>关键词:昵称 / 发送人 / 红包 ID</li>
<li>状态:全部、领取中、已领完</li>
<li>时间:近 1 / 3 / 7 天,或自定义起止</li>
</ul>
</section>
<section>
<h2>同步与电脑访问</h2>
<p>手机是数据源。同步后落盘到调试台,刷新本页即可。</p>
<ul>
<li>本机:<code>http://127.0.0.1:8765/mmp</code></li>
<li>局域网:<code>http://&lt;电脑IP&gt;:8765/mmp</code></li>
</ul>
<div class="tip">8765 不要多开多个调试台进程,否则同步会挂。</div>
</section>
<section>
<h2>设置含义</h2>
<ul>
<li>① 历史列表冷却:多久再扫一次红包历史列表</li>
<li>② 详情刷新冷却:领取中的包多久再拉名单</li>
<li>③ 相同名单去重:名单没变时防重复推送</li>
<li>④ 详情请求间隔:连拉多个包之间的停顿(毫秒)</li>
<li>页面轮询:电脑页多久向服务器拉一次列表</li>
</ul>
<p style="margin-top:8px">已领完的包会停刷详情。不懂就用「标准」预设再保存,约 10 秒后对手机 Hook 生效。</p>
</section>
<section>
<h2>没数据时先查</h2>
<ul>
<li>监听列表是否勾选 TNG</li>
<li>Xposed 是否启用且作用域含 TNG</li>
<li>是否按「打开 TNG 闪退怎么办」做过强制停止再开,并完成登录</li>
<li>同步失败调试台是否在跑、端口是否冲突、adb reverse 是否做好</li>
</ul>
<p style="margin-top:8px">更细的技术说明见仓库 <code>docs/TNG_MoneyPacket领取台.md</code></p>
</section>
</main>
</body>
</html>

542
debug-server/mmp_page.html Normal file
View File

@@ -0,0 +1,542 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>红包领取台</title>
<style>
:root {
--bg: #f3f4f6;
--panel: #ffffff;
--ink: #111827;
--muted: #6b7280;
--line: #e5e7eb;
--money: #c2410c;
--best: #047857;
--best-bg: #ecfdf5;
--worst: #b45309;
--worst-bg: #fffbeb;
--done: #047857;
--open: #b45309;
--rank: #ea580c;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--ink);
display: flex; flex-direction: column;
}
button, input { font: inherit; }
header {
background: var(--panel); border-bottom: 1px solid var(--line);
padding: 12px 16px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
flex-shrink: 0;
}
h1 { margin: 0; font-size: 18px; font-weight: 700; margin-right: auto; }
.stat { color: var(--muted); font-size: 13px; }
a.nav { color: #2563eb; text-decoration: none; font-size: 13px; }
.btn {
border: 1px solid var(--line); background: #fff; color: var(--ink);
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;
}
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.search {
flex: 1; min-width: 180px; border: 1px solid var(--line); border-radius: 6px;
padding: 8px 10px; background: #fff;
}
.chip {
border: 1px solid var(--line); background: #fff; color: var(--muted);
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
.chip.active { background: #111827; color: #fff; border-color: #111827; }
.chip.tone.active { background: #ea580c; border-color: #ea580c; }
.range { display: none; gap: 8px; align-items: center; flex-wrap: wrap; }
.range.open { display: flex; }
.range input { border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; }
.settings-panel {
display: none; background: #fff; border-bottom: 1px solid var(--line);
padding: 12px 16px; flex-shrink: 0;
}
.settings-panel.open { display: block; }
.settings-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px;
}
.settings-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
.settings-grid input[type=number] { border: 1px solid var(--line); border-radius: 6px; padding: 8px; }
.check { flex-direction: row !important; align-items: center; gap: 8px !important; }
.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; align-items: center; }
.settings-hint { color: var(--muted); font-size: 12px; margin-top: 8px; }
.layout { display: flex; flex: 1; min-height: 0; }
.sidebar {
width: 340px; background: var(--panel); border-right: 1px solid var(--line);
overflow-y: auto; flex-shrink: 0;
}
.sidebar h2 {
margin: 0; padding: 10px 14px; font-size: 12px; color: var(--muted);
border-bottom: 1px solid var(--line); background: #fafafa; font-weight: 600;
}
.pkt {
padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: pointer;
}
.pkt:hover { background: #f9fafb; }
.pkt.active { background: #fff7ed; box-shadow: inset 3px 0 0 #ea580c; }
.pkt-row { display: flex; justify-content: space-between; gap: 8px; align-items: center; }
.pkt-name { font-size: 13px; font-weight: 700; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pkt-uid { font-size: 11px; color: #9ca3af; font-weight: 500; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: ui-monospace, Consolas, monospace; }
.pkt-money {
font-size: 15px; font-weight: 800; color: var(--money);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.pkt-meta { margin-top: 3px; font-size: 11px; color: var(--muted); display: flex; gap: 5px; flex-wrap: nowrap; align-items: center; overflow: hidden; }
.pkt-meta span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge {
display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 10px; font-weight: 700; flex-shrink: 0;
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.badge.done { background: var(--best-bg); color: var(--done); }
.badge.open { background: var(--worst-bg); color: var(--open); }
.badge.expired { background: #f3f4f6; color: #6b7280; }
.pkt-luck {
margin-top: 4px; display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
}
.mini {
border-radius: 4px; padding: 3px 6px; font-size: 11px; line-height: 1.25;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 700;
}
.mini.best { background: var(--best-bg); color: var(--best); }
.mini.worst { background: var(--worst-bg); color: var(--worst); }
.pkt-id { margin-left: auto; flex-shrink: 0; font-size: 10px; color: #9ca3af; font-family: ui-monospace, Consolas, monospace; }
.main { flex: 1; overflow-y: auto; padding: 16px 18px; }
.empty { padding: 60px 20px; text-align: center; color: var(--muted); }
.title-row { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 6px; }
.title { margin: 0; font-size: 22px; font-weight: 800; }
.title-money {
font-size: 28px; font-weight: 900; color: var(--money);
font-variant-numeric: tabular-nums; line-height: 1;
}
.sub { color: var(--muted); font-size: 12px; margin-bottom: 14px; word-break: break-all; }
.kpi {
display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; margin-bottom: 12px;
}
.kpi .box {
background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px;
}
.kpi .k { font-size: 12px; color: var(--muted); font-weight: 600; }
.kpi .v {
margin-top: 4px; font-size: 22px; font-weight: 800;
font-variant-numeric: tabular-nums;
}
.kpi .v.money { color: var(--money); }
.luck-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 16px;
}
.luck-card {
border-radius: 8px; padding: 14px 16px; border: 1px solid transparent;
}
.luck-card.best { background: var(--best-bg); border-color: #a7f3d0; }
.luck-card.worst { background: var(--worst-bg); border-color: #fde68a; }
.luck-card .lab { font-size: 12px; font-weight: 700; opacity: .9; }
.luck-card .name { margin-top: 4px; font-size: 18px; font-weight: 800; }
.luck-card .amt {
margin-top: 2px; font-size: 24px; font-weight: 900;
font-variant-numeric: tabular-nums;
}
.luck-card.best .lab, .luck-card.best .name, .luck-card.best .amt { color: var(--best); }
.luck-card.worst .lab, .luck-card.worst .name, .luck-card.worst .amt { color: var(--worst); }
.section { font-size: 13px; font-weight: 700; color: var(--muted); margin: 0 0 8px; }
table {
width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
}
th, td { padding: 11px 12px; text-align: left; border-bottom: 1px solid var(--line); font-size: 13px; }
th { background: #f9fafb; color: var(--muted); font-weight: 700; font-size: 12px; }
tr:last-child td { border-bottom: 0; }
tr.row-best { background: #ecfdf5; }
tr.row-worst { background: #fffbeb; }
.rank { width: 48px; color: var(--rank); font-weight: 800; font-variant-numeric: tabular-nums; }
.nick { font-weight: 700; }
.amt { color: var(--money); font-weight: 800; font-variant-numeric: tabular-nums; font-size: 15px; }
.tag-inline {
margin-left: 6px; font-size: 11px; font-weight: 700; padding: 1px 5px; border-radius: 3px;
}
.tag-inline.best { background: #d1fae5; color: var(--best); }
.tag-inline.worst { background: #fef3c7; color: var(--worst); }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar { width: 100%; max-height: 36vh; border-right: 0; border-bottom: 1px solid var(--line); }
.kpi, .luck-grid { grid-template-columns: 1fr 1fr; }
.title-money { font-size: 22px; }
}
</style>
</head>
<body>
<header>
<h1>红包领取台</h1>
<span class="stat" id="pktCount">0 个</span>
<span class="stat" id="updated">-</span>
<span class="stat" id="cfgHint">刷新: -</span>
<a class="nav" href="/mmp/help">功能说明</a>
<a class="nav" href="/">通用消息台</a>
<button class="btn" onclick="toggleSettings()">设置</button>
<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">
<input class="search" type="search" id="filterQ" placeholder="搜索昵称 / 发送人 / ID" oninput="onFilterChange()" />
<button type="button" class="chip tone active" id="fAll" onclick="setStatus('all')">全部</button>
<button type="button" class="chip" id="fOpen" onclick="setStatus('open')">领取中</button>
<button type="button" class="chip" id="fDone" onclick="setStatus('done')">已领完</button>
</div>
<div class="row">
<button type="button" class="chip active" id="tAll" onclick="setTime('all')">全部时间</button>
<button type="button" class="chip" id="t1" onclick="setTime('1')">近1天</button>
<button type="button" class="chip" id="t3" onclick="setTime('3')">近3天</button>
<button type="button" class="chip" id="t7" onclick="setTime('7')">近7天</button>
<button type="button" class="chip" id="tCustom" onclick="setTime('custom')">自定义</button>
<div class="range" id="customRange">
<label><input type="date" id="dateFrom" onchange="onFilterChange()" /></label>
<label><input type="date" id="dateTo" onchange="onFilterChange()" /></label>
</div>
</div>
</div>
<div class="settings-panel" id="settingsPanel">
<div class="settings-grid">
<label>① 历史列表冷却(秒)— 多久扫一次红包列表
<input type="number" id="cfgHistory" min="1" max="300" step="1" />
</label>
<label>② 详情刷新冷却(秒)— 领取中的包多久再拉名单
<input type="number" id="cfgDetail" min="1" max="300" step="1" />
</label>
<label>③ 相同名单去重(秒)— 名单没变时防重复推送
<input type="number" id="cfgDedup" min="0" max="120" step="1" />
</label>
<label>④ 详情请求间隔(毫秒)— 连拉多个包之间的停顿
<input type="number" id="cfgGap" min="0" max="5000" step="10" />
</label>
<label>页面轮询(毫秒)
<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>
<button type="button" class="chip" onclick="applyPreset('normal')">标准</button>
<button type="button" class="chip" onclick="applyPreset('slow')">省流</button>
<button type="button" class="btn primary" onclick="saveSettings()">保存</button>
<span class="stat" id="cfgStatus"></span>
</div>
<div class="settings-hint">不懂就选「标准」再保存。约 10 秒后对手机 Hook 生效。</div>
</div>
<div class="layout">
<aside class="sidebar">
<h2>红包列表</h2>
<div id="list"></div>
</aside>
<main class="main">
<div id="empty" class="empty">暂无数据。打开手机红包领取台会自动同步到这里。</div>
<div id="panel" style="display:none">
<div class="title-row">
<h2 class="title" id="title">-</h2>
<div class="title-money" id="titleMoney">RM 0.00</div>
</div>
<div class="sub" id="sub">-</div>
<div class="kpi">
<div class="box"><div class="k">领取人数</div><div class="v" id="cPeople">0</div></div>
<div class="box"><div class="k">领取合计</div><div class="v money" id="cSum">0.00</div></div>
<div class="box"><div class="k">红包总额</div><div class="v money" id="cTotal">-</div></div>
<div class="box"><div class="k">状态</div><div class="v" id="cStatus">-</div></div>
</div>
<div class="luck-grid">
<div class="luck-card best">
<div class="lab" id="luckBestLab">手气最佳</div>
<div class="name" id="luckBestName">-</div>
<div class="amt" id="luckBestAmt">-</div>
</div>
<div class="luck-card worst">
<div class="lab" id="luckWorstLab">手气最差</div>
<div class="name" id="luckWorstName">-</div>
<div class="amt" id="luckWorstAmt">-</div>
</div>
</div>
<div class="section">领取排行</div>
<table>
<thead><tr><th class="rank">#</th><th>昵称 / ID</th><th>金额</th><th>时间</th></tr></thead>
<tbody id="board"></tbody>
</table>
</div>
</main>
</div>
<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, 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,
autoBounceHistoryOnDisconnect: cfgBounceHistory.checked
};
}
function applyPreset(name){
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(); }
async function saveSettings(){
settings = await (await fetch('/api/mmp/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(readSettingsForm())})).json();
fillSettingsForm(); restartPagePoll(); cfgStatus.textContent = '已保存 ' + new Date().toLocaleTimeString();
}
function restartPagePoll(){ if(pagePollTimer) clearInterval(pagePollTimer); pagePollTimer=setInterval(loadData, Math.max(500, Number(settings.pagePollMs)||1000)); }
function setStatus(s){
statusFilter=s;
['fAll','fOpen','fDone'].forEach(id=>document.getElementById(id).classList.remove('active'));
document.getElementById(s==='all'?'fAll':s==='open'?'fOpen':'fDone').classList.add('active');
renderList();
}
function setTime(t){
timeFilter=t;
['tAll','t1','t3','t7','tCustom'].forEach(id=>document.getElementById(id).classList.remove('active'));
document.getElementById({all:'tAll','1':'t1','3':'t3','7':'t7',custom:'tCustom'}[t]).classList.add('active');
customRange.classList.toggle('open', t==='custom');
renderList();
}
function onFilterChange(){ keyword=(filterQ.value||'').trim().toLowerCase(); renderList(); }
function parseIssueMs(text){
const s=(text||'').trim(); if(!s) return null;
let m=s.match(/^(\d{2})\/(\d{2})\/(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?/);
if(m) return new Date(+m[3],+m[2]-1,+m[1],+(m[4]||0),+(m[5]||0),+(m[6]||0)).getTime();
m=s.match(/^(\d{4})-(\d{2})-(\d{2})/);
if(m) return new Date(+m[1],+m[2]-1,+m[3]).getTime();
const t=Date.parse(s); return Number.isNaN(t)?null:t;
}
function inTimeRange(p){
if(timeFilter==='all') return true;
const ms=parseIssueMs(p.issuedAt||p.latestAt||p.updatedAt||p.fetchedAt||'');
if(ms==null) return false;
const now=Date.now();
if(timeFilter==='1'||timeFilter==='3'||timeFilter==='7') return ms>=now-Number(timeFilter)*86400000;
if(timeFilter==='custom'){
const from=dateFrom.value, to=dateTo.value;
if(from){ const fromMs=new Date(from+'T00:00:00').getTime(); if(ms<fromMs) return false; }
if(to){ const toMs=new Date(to+'T00:00:00').getTime()+86400000-1; if(ms>toMs) return false; }
}
return true;
}
function filteredPackets(){
return packets.filter(p=>{
if(statusFilter==='open'&&p.finished) return false;
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,r.userId,r.poolId].join(' '))].join(' ').toLowerCase();
return blob.indexOf(keyword)>=0;
});
}
function money(v){ const n=Number(v); return Number.isFinite(n)?n.toFixed(2):String(v??'-'); }
function shortId(id){ const s=String(id||''); return (s.length>=8&&s.indexOf('-')>0)?s.slice(0,8):(s.slice(0,16)||'-'); }
function formatHiLo(prefix, row){
if(!row) return prefix + ' -';
const nick = row.nickname || row.bestNick || '-';
const amt = row.amountText || row.bestAmount || (row.amount!=null ? money(row.amount) : '');
const uid = row.userId || '';
if(uid) return `${prefix} ${esc(nick)} ${esc(amt)}<div class="pkt-uid">ID ${esc(uid)}</div>`;
return `${prefix} ${esc(nick)} ${esc(amt)}`.trim();
}
function hiLo(p){
return {
hi: p&&p.finished?'手气最佳':'目前最高',
lo: p&&p.finished?'手气最差':'目前最低'
};
}
async function loadData(){
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 + ' 个';
list.innerHTML='';
if(!view.length){ empty.style.display='block'; panel.style.display='none'; activeId=null; return; }
empty.style.display='none'; panel.style.display='block';
if(!activeId || !view.find(p=>p.packetId===activeId)) activeId=view[0].packetId;
for(const p of view){
const div=document.createElement('div');
div.className='pkt'+(p.packetId===activeId?' active':'');
div.onclick=()=>{ activeId=p.packetId; renderList(); };
const label = p.statusLabel || (p.finished ? '已领完' : '领取中');
const fully = label.indexOf('已全部领取') >= 0;
const expired = label.indexOf('已过期') >= 0;
const badgeClass = fully ? 'done' : (expired ? 'expired' : 'open');
const badge=`<span class="badge ${badgeClass}">${esc(label)}</span>`;
const hiShort = p.finished || fully ? '最佳' : '最高';
const loShort = p.finished || fully ? '最差' : '最低';
const best = (p.leaderboard && p.leaderboard[0]) || null;
const worst = (p.leaderboard && p.leaderboard.length > 1)
? p.leaderboard[p.leaderboard.length - 1] : null;
const bestLine = formatHiLo(hiShort, best || {nickname:p.bestNick, amountText:p.bestAmount, userId:p.bestUserId});
const worstLine = (p.claimantCount||0)<=1
? `${loShort} -`
: formatHiLo(loShort, worst || {nickname:p.worstNick, amountText:p.worstAmount, userId:p.worstUserId});
const timeLine = p.expiresAt
? `${esc(p.issuedAt||'时间未知')} · 到期 ${esc(p.expiresAt)}`
: esc(p.issuedAt||'时间未知');
const countLine = (p.totalCount > 0)
? `${p.claimedCount||p.claimantCount||0}/${p.totalCount}`
: `${p.claimantCount||0}`;
div.innerHTML=`
<div class="pkt-row">
<div style="min-width:0;flex:1">
<div class="pkt-name">${esc(p.sender||p.title||'红包')}</div>
${p.packetId ? `<div class="pkt-uid">ID ${esc(p.packetId)}</div>` : ''}
</div>
<div class="pkt-money">${money(p.sumClaimed??p.total??0)}</div>
</div>
<div class="pkt-meta">${badge}<span>${countLine} · ${timeLine}</span></div>
<div class="pkt-luck">
<div class="mini best">${bestLine}</div>
<div class="mini worst">${worstLine}</div>
</div>`;
list.appendChild(div);
}
renderActive();
}
function renderActive(){
const p=packets.find(x=>x.packetId===activeId); if(!p) return;
const labels=hiLo(p);
title.textContent = p.sender ? p.sender + ' 的红包' : (p.title || '红包详情');
titleMoney.textContent = 'RM ' + money(p.sumClaimed ?? p.total ?? 0);
const parts=[];
if(p.packetId) parts.push('ID '+p.packetId);
if(p.issuedAt) parts.push('发放 '+p.issuedAt);
if(p.expiresAt) parts.push('到期 '+p.expiresAt);
if(p.via) parts.push(p.via);
sub.textContent = parts.join(' · ');
cPeople.textContent = p.claimantCount || 0;
cSum.textContent = money(p.sumClaimed ?? 0);
cTotal.textContent = p.total || '-';
cStatus.innerHTML = (()=>{
const label = p.statusLabel || (p.finished ? '已领完' : '领取中');
const fully = label.indexOf('已全部领取') >= 0;
const expired = label.indexOf('已过期') >= 0;
const cls = fully ? 'done' : (expired ? 'expired' : 'open');
return `<span class="badge ${cls}">${esc(label)}</span>`;
})();
luckBestLab.textContent = labels.hi;
luckWorstLab.textContent = labels.lo;
const best = (p.leaderboard && p.leaderboard[0]) || null;
const worst = (p.leaderboard && p.leaderboard.length > 1)
? p.leaderboard[p.leaderboard.length - 1] : null;
if(best){
luckBestName.innerHTML = best.userId
? `${esc(best.nickname||'-')}<div class="pkt-uid">ID ${esc(best.userId)}</div>`
: esc(best.nickname||p.bestNick||'-');
luckBestAmt.textContent = best.amountText ? ('RM ' + best.amountText)
: (p.bestAmount ? ('RM ' + p.bestAmount) : '-');
} else {
luckBestName.textContent = p.bestNick || '-';
luckBestAmt.textContent = p.bestAmount ? ('RM ' + p.bestAmount) : '-';
}
if(worst && (p.claimantCount||0)>1){
luckWorstName.innerHTML = worst.userId
? `${esc(worst.nickname||'-')}<div class="pkt-uid">ID ${esc(worst.userId)}</div>`
: esc(worst.nickname||p.worstNick||'-');
luckWorstAmt.textContent = worst.amountText ? ('RM ' + worst.amountText)
: (p.worstAmount ? ('RM ' + p.worstAmount) : '-');
} else {
luckWorstName.textContent='-'; luckWorstAmt.textContent='-';
}
const rows=p.leaderboard||[];
board.innerHTML='';
if(!rows.length){
board.innerHTML='<tr><td colspan="4" style="text-align:center;color:#6b7280">暂无领取记录</td></tr>';
return;
}
const maxAmt = Math.max(...rows.map(r=>Number(r.amount)||0));
const minAmt = Math.min(...rows.map(r=>Number(r.amount)||0));
for(const row of rows){
const amt=Number(row.amount)||0;
const isBest=rows.length>1 && amt===maxAmt;
const isWorst=rows.length>1 && amt===minAmt && maxAmt!==minAmt;
const tr=document.createElement('tr');
if(isBest) tr.className='row-best';
if(isWorst) tr.className='row-worst';
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">${nickHtml}</td>
<td class="amt">${esc(row.amountText || money(row.amount))}</td>
<td>${esc(row.claimTime || '-')}</td>`;
board.appendChild(tr);
}
}
async function clearAll(){
if(!confirm('仅清空红包领取台数据?')) return;
await fetch('/api/mmp',{method:'DELETE'}); activeId=null; loadData();
}
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
loadSettings().then(loadData);
</script>
</body>
</html>

View File

@@ -2,7 +2,10 @@
"""notiMessage 本地调试转发服务 — 在 PC 浏览器查看手机抓取的消息。"""
import json
import os
import re
import threading
import time
from collections import deque
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -11,9 +14,96 @@ from urllib.parse import urlparse
HOST = "0.0.0.0"
PORT = 8765
MAX_MESSAGES = 500
SETTINGS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_settings.json")
PACKETS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_packets.json")
HOOK_STATUS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mmp_hook_status.json")
# 与 xposed-module / App MmpModuleInfo 同步
EXPECTED_MODULE_CODE = 5
EXPECTED_MODULE_NAME = "1.2.2"
_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}
_mmp_settings_lock = threading.Lock()
_DEFAULT_MMP_SETTINGS = {
"historyCooldownSec": 8,
"detailCooldownSec": 8,
"dedupSec": 3,
"detailGapMs": 80,
"pagePollMs": 1000,
"openHistoryIfNoTemplate": True,
"autoBounceHistoryOnDisconnect": True,
}
_mmp_settings = dict(_DEFAULT_MMP_SETTINGS)
def _load_mmp_settings():
global _mmp_settings
try:
if os.path.isfile(SETTINGS_PATH):
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
merged = dict(_DEFAULT_MMP_SETTINGS)
merged.update(data)
_mmp_settings = _normalize_mmp_settings(merged)
except Exception as e:
print("load mmp settings failed:", e)
def _save_mmp_settings():
try:
with open(SETTINGS_PATH, "w", encoding="utf-8") as f:
json.dump(_mmp_settings, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp settings failed:", e)
def _normalize_mmp_settings(raw):
out = dict(_DEFAULT_MMP_SETTINGS)
if not isinstance(raw, dict):
return out
try:
out["historyCooldownSec"] = max(1, min(300, int(raw.get("historyCooldownSec", out["historyCooldownSec"]))))
out["detailCooldownSec"] = max(1, min(300, int(raw.get("detailCooldownSec", out["detailCooldownSec"]))))
out["dedupSec"] = max(0, min(120, int(raw.get("dedupSec", out["dedupSec"]))))
out["detailGapMs"] = max(0, min(5000, int(raw.get("detailGapMs", out["detailGapMs"]))))
out["pagePollMs"] = max(500, min(30000, int(raw.get("pagePollMs", out["pagePollMs"]))))
out["openHistoryIfNoTemplate"] = bool(raw.get(
"openHistoryIfNoTemplate", out["openHistoryIfNoTemplate"]))
out["autoBounceHistoryOnDisconnect"] = bool(raw.get(
"autoBounceHistoryOnDisconnect", out["autoBounceHistoryOnDisconnect"]))
except (TypeError, ValueError):
pass
return out
def _get_mmp_settings():
with _mmp_settings_lock:
return dict(_mmp_settings)
def _set_mmp_settings(raw):
global _mmp_settings
with _mmp_settings_lock:
_mmp_settings = _normalize_mmp_settings(raw)
_save_mmp_settings()
return dict(_mmp_settings)
_load_mmp_settings()
def _now_iso():
@@ -32,11 +122,42 @@ def _add_message(payload):
item = dict(payload)
item["group"] = _resolve_group(payload)
item["receivedAt"] = _now_iso()
dedup_key = "|".join([
str(payload.get("source") or ""),
str(payload.get("packageName") or ""),
str(payload.get("title") or ""),
str(payload.get("content") or ""),
])
now = time.time()
is_mmp = _is_mmp_message(item)
content_for_mod = ""
with _lock:
_messages.appendleft(item)
item["id"] = len(_messages)
print("[{0}] [{1}] [{2}] {3} | {4}".format(
if is_mmp:
if (_mmp_last_dedup["key"] == dedup_key
and now - float(_mmp_last_dedup["ts"]) < 3.0):
if _mmp_messages:
return _mmp_messages[0]
_mmp_last_dedup["key"] = dedup_key
_mmp_last_dedup["ts"] = now
_mmp_messages.appendleft(item)
item["id"] = len(_mmp_messages)
channel = "MMP"
content_for_mod = item.get("content") or ""
else:
if (_last_dedup["key"] == dedup_key
and now - float(_last_dedup["ts"]) < 3.0):
if _messages:
return _messages[0]
_last_dedup["key"] = dedup_key
_last_dedup["ts"] = now
_messages.appendleft(item)
item["id"] = len(_messages)
channel = "MSG"
if content_for_mod:
_note_module_from_content(content_for_mod)
print("[{0}] [{1}] [{2}] [{3}] {4} | {5}".format(
_now_iso(),
channel,
item["group"],
payload.get("source", "?"),
payload.get("appName", payload.get("packageName", "")),
@@ -45,6 +166,16 @@ def _add_message(payload):
return item
def _clear_mmp_messages():
with _lock:
_mmp_messages.clear()
_mmp_synced.clear()
# 兼容:顺带清掉旧版混入通用队列的 MMP
keep = [m for m in _messages if not _is_mmp_message(m)]
_messages.clear()
_messages.extend(keep)
_save_mmp_synced()
def _json_response(handler, status, data):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
handler.send_response(status)
@@ -58,6 +189,8 @@ def _json_response(handler, status, data):
def _group_messages(messages):
groups = {}
for msg in messages:
if _is_mmp_message(msg):
continue
key = msg.get("group") or _resolve_group(msg)
if key not in groups:
groups[key] = {
@@ -78,6 +211,751 @@ def _group_messages(messages):
return result
def _is_mmp_message(msg):
source = (msg.get("source") or "").lower()
content = msg.get("content") or ""
title = msg.get("title") or ""
if "tng_mmp" in source or "xposed_tng_mmp" in source:
return True
if "[MMP统计]" in content or "Money Packet" in title:
return True
lower = content.lower()
return "receiverlist" in lower or ("claimedamount" in lower and "mmp" in lower)
def _normalize_money(value):
"""把 1.23 / RM1.23 / {"amount":"0.71","cent":"71"} 统一成 float 或 None。"""
if value is None:
return None
text = str(value).strip()
if not text or text.lower() == "null":
return None
text = text.replace("RM", "").replace("rm", "").strip()
if text.startswith("{") and "amount" in text:
try:
obj = json.loads(text)
if isinstance(obj, dict):
if obj.get("amount") not in (None, ""):
return float(str(obj["amount"]).replace(",", ""))
if obj.get("cent") not in (None, ""):
return int(str(obj["cent"])) / 100.0
except Exception:
return None
try:
return float(text.replace(",", ""))
except ValueError:
return None
def _parse_mmp_content(content):
"""解析 TngMoneyPacketHook 转发文本 → {meta, claims[]}。"""
text = (content or "").strip()
meta = {}
claims = []
if not text:
return meta, claims
lines = text.splitlines()
head = lines[0] if lines else ""
if head.startswith("[MMP统计]"):
head = head[len("[MMP统计]"):].strip()
# 新格式用 " | " 分隔;旧格式兼容空格拆 token
if " | " in head:
parts = [p.strip() for p in head.split(" | ") if p.strip()]
else:
parts = []
buf = ""
for part in head.split():
if buf:
buf += " " + part
if buf.count("{") <= buf.count("}"):
parts.append(buf)
buf = ""
continue
if "=" in part and part.split("=", 1)[1].startswith("{") and part.count("{") > part.count("}"):
buf = part
continue
parts.append(part)
if buf:
parts.append(buf)
for part in parts:
if "=" not in part:
continue
k, v = part.split("=", 1)
meta[k.strip()] = v.strip()
# 旧报文 sender=LIAO RUICHAO 被空格截断时,尝试从原文还原
if meta.get("sender") and "sender=" in head:
raw_sender = head.split("sender=", 1)[1]
for stop in (" | ", " total=", " via=", " group=", " packet=", " src="):
if stop in raw_sender:
raw_sender = raw_sender.split(stop, 1)[0]
break
raw_sender = raw_sender.strip()
if raw_sender and len(raw_sender) > len(meta.get("sender") or ""):
meta["sender"] = raw_sender
if "total" in meta:
total_num = _normalize_money(meta["total"])
meta["total"] = ("{0:.2f}".format(total_num) if total_num is not None else meta["total"])
# 发放时间issued= / createTime= / issuedAt=
for key in ("issued", "createTime", "issuedAt", "create"):
if meta.get(key):
meta["issued"] = meta[key]
break
for line in lines[1:]:
line = line.strip()
if not line or "->" not in line:
continue
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 = ""
amount_raw = right
if "(" in right and ")" in right and not right.startswith("{"):
amt, _, rest = right.partition("(")
amount_raw = amt.strip()
claim_time = rest.split(")", 1)[0].strip()
amount_num = _normalize_money(amount_raw)
if nick or user_id:
claims.append({
"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,
})
return meta, claims
def _claims_fingerprint(claims):
rows = []
for c in claims:
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 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[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 key in order:
b = buckets[key]
result.append({
"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"],
"claimTime": b.get("claimTime") or "",
})
result.sort(key=lambda x: x["amount"], reverse=True)
for i, row in enumerate(result, 1):
row["rank"] = i
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 []
return (
1 if it.get("finished") else 0,
1 if it.get("issuedAt") else 0,
len(board),
it.get("updatedAt") or it.get("fetchedAt") or it.get("latestAt") or "",
)
def _normalize_synced_packet(raw):
if not isinstance(raw, dict):
return None
pid = str(raw.get("packetId") or raw.get("packet") or "").strip()
if not pid:
return None
board = raw.get("leaderboard") or []
if not isinstance(board, list):
board = []
norm_board = []
for i, row in enumerate(board):
if not isinstance(row, dict):
continue
amt = row.get("amount")
if amt is None:
amt = _normalize_money(row.get("amountText"))
try:
amt = float(amt or 0)
except (TypeError, ValueError):
amt = 0.0
nick = (row.get("nickname") or "?").strip() or "?"
norm_board.append({
"nickname": nick,
"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 "",
"rank": int(row.get("rank") or (i + 1)),
})
norm_board.sort(key=lambda x: x["amount"], reverse=True)
for i, row in enumerate(norm_board, 1):
row["rank"] = i
best = norm_board[0] if norm_board else None
worst = norm_board[-1] if norm_board else None
sum_claimed = raw.get("sumClaimed")
try:
sum_claimed = float(sum_claimed) if sum_claimed is not None else round(
sum(x["amount"] for x in norm_board), 4)
except (TypeError, ValueError):
sum_claimed = round(sum(x["amount"] for x in norm_board), 4)
total = raw.get("total") or ""
total_num = _normalize_money(total)
if total_num is not None:
total = "{0:.2f}".format(total_num)
elif sum_claimed:
total = "{0:.2f}".format(sum_claimed)
issued = raw.get("issuedAt") or raw.get("issued") or ""
expires = raw.get("expiresAt") or raw.get("expire") or raw.get("expireTime") or ""
status = raw.get("status") or raw.get("activityStatus") or ""
finished = bool(raw.get("finished")) or _is_terminal_status(status) or _is_expire_passed(expires)
return {
"packetId": pid,
"title": raw.get("title") or "TNG 红包",
"sender": raw.get("sender") or "",
"group": raw.get("group") or "",
"total": total,
"via": raw.get("via") or "phone-sync",
"issuedAt": issued,
"expiresAt": expires,
"status": status,
"updatedAt": raw.get("updatedAt") or "",
"fetchedAt": raw.get("updatedAt") or raw.get("fetchedAt") or _now_iso(),
"latestAt": issued or raw.get("updatedAt") or "",
"finished": finished,
"snapshots": int(raw.get("snapshots") or 1),
"claimedCount": int(raw.get("claimedCount") or 0),
"totalCount": int(raw.get("totalCount") or 0),
"leaderboard": norm_board,
"claimantCount": len(norm_board) if norm_board else int(raw.get("claimantCount") or 0),
"sumClaimed": sum_claimed,
"bestNick": (best or {}).get("nickname") or "",
"bestAmount": (best or {}).get("amountText") or "",
"worstNick": (worst or {}).get("nickname") or "",
"worstAmount": (worst or {}).get("amountText") or "",
"fromSync": True,
}
def _load_mmp_synced():
global _mmp_synced
try:
if not os.path.isfile(PACKETS_PATH):
return
with open(PACKETS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
items = data.get("packets") if isinstance(data, dict) else data
if not isinstance(items, list):
return
synced = {}
for raw in items:
p = _normalize_synced_packet(raw)
if p:
synced[p["packetId"]] = p
with _lock:
_mmp_synced = synced
print("loaded mmp synced packets:", len(synced))
except Exception as e:
print("load mmp packets failed:", e)
def _save_mmp_synced():
try:
with _lock:
packets = list(_mmp_synced.values())
with open(PACKETS_PATH, "w", encoding="utf-8") as f:
json.dump({"packets": packets, "savedAt": _now_iso()}, f, ensure_ascii=False, indent=2)
except Exception as e:
print("save mmp packets failed:", e)
def _sync_mmp_packets(payload):
"""手机全量同步:合并进落盘镜像,电脑刷新 /mmp 即可看到。"""
items = []
if isinstance(payload, dict):
items = payload.get("packets") or []
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):
return {"ok": False, "error": "packets must be list", "count": 0}
merged = 0
with _lock:
for raw in items:
p = _normalize_synced_packet(raw)
if not p:
continue
key = p["packetId"]
old = _mmp_synced.get(key)
if old is None or _packet_score(p) >= _packet_score(old):
if old and old.get("finished"):
p["finished"] = True
if old and not p.get("issuedAt") and old.get("issuedAt"):
p["issuedAt"] = old["issuedAt"]
p["latestAt"] = p["issuedAt"] or p.get("latestAt") or ""
_mmp_synced[key] = p
merged += 1
total = len(_mmp_synced)
_save_mmp_synced()
return {"ok": True, "count": merged, "total": total}
def _mmp_packets():
with _lock:
# 独立队列 + 兼容旧版混入通用消息的 MMP
msgs = list(_mmp_messages) + [m for m in _messages if _is_mmp_message(m)]
# 每个红包只保留「最新一次完整领取榜」快照
packets = {}
for msg in msgs:
meta, claims = _parse_mmp_content(msg.get("content") or "")
if not claims:
continue
packet_id = meta.get("packet") or meta.get("packetId") or ""
if not packet_id or packet_id in ("unknown", "TNG 红包", "TNG Money Packet"):
packet_id = "红包-" + _claims_fingerprint(claims)[:48]
key = str(packet_id)
issued = meta.get("issued") or ""
finished = str(meta.get("done") or "").lower() in ("1", "true") \
or _is_terminal_status(meta.get("status") or "") \
or _is_expire_passed(meta.get("expire") or meta.get("expireTime") or "")
item = {
"packetId": key,
"title": msg.get("title") or "TNG 红包",
"sender": meta.get("sender") or "",
"group": meta.get("group") or "",
"total": meta.get("total") or "",
"via": meta.get("via") or "",
"issuedAt": issued,
"expiresAt": meta.get("expire") or meta.get("expireTime") or "",
"status": meta.get("status") or "",
"fetchedAt": msg.get("receivedAt") or "",
"latestAt": issued or (msg.get("receivedAt") or ""),
"finished": finished,
"snapshots": 1,
"claims": claims,
}
_apply_counts_from_meta(item, meta)
existing = packets.get(key)
def _score(it):
return (
1 if it.get("issuedAt") else 0,
len(it.get("claims") or []),
it.get("fetchedAt") or "",
)
if existing is None or _score(item) >= _score(existing):
if existing is not None:
item["snapshots"] = int(existing.get("snapshots") or 1) + 1
if not item.get("issuedAt") and existing.get("issuedAt"):
item["issuedAt"] = existing["issuedAt"]
item["latestAt"] = item["issuedAt"] or item.get("fetchedAt") or ""
if existing.get("finished"):
item["finished"] = True
packets[key] = item
else:
existing["snapshots"] = int(existing.get("snapshots") or 1) + 1
if not existing.get("issuedAt") and issued:
existing["issuedAt"] = issued
existing["latestAt"] = issued
if finished:
existing["finished"] = True
result = []
for p in packets.values():
ranked = _aggregate_claims(p.get("claims") or [])
p["leaderboard"] = ranked
p["claimantCount"] = len(ranked)
p["claimEventCount"] = len(p.get("claims") or [])
try:
p["sumClaimed"] = round(sum(x["amount"] for x in ranked), 4)
except Exception:
p["sumClaimed"] = 0
total_num = _normalize_money(p.get("total"))
if total_num is None or (p["sumClaimed"] > 0 and abs(total_num - p["sumClaimed"]) > 0.001
and total_num <= max((x["amount"] for x in ranked), default=0) + 1e-9):
p["total"] = "{0:.2f}".format(p["sumClaimed"])
elif total_num is not None:
p["total"] = "{0:.2f}".format(total_num)
best = ranked[0] if ranked else None
worst = ranked[-1] if ranked else None
p["bestNick"] = (best or {}).get("nickname") or ""
p["bestAmount"] = (best or {}).get("amountText") or ""
p["worstNick"] = (worst or {}).get("nickname") or ""
p["worstAmount"] = (worst or {}).get("amountText") or ""
if not p.get("claimedCount"):
p["claimedCount"] = len(ranked)
if p.get("totalCount") and int(p.get("claimedCount") or 0) >= int(p.get("totalCount") or 0):
p["finished"] = True
del p["claims"]
result.append(p)
# 合并手机全量同步落盘数据(电脑重启后仍可看)
with _lock:
synced_items = list(_mmp_synced.values())
by_id = {p["packetId"]: p for p in result}
for sp in synced_items:
key = sp.get("packetId")
if not key:
continue
cur = by_id.get(key)
if cur is None or _packet_score(sp) >= _packet_score(cur):
merged = dict(sp)
if cur and cur.get("finished"):
merged["finished"] = True
if cur and not merged.get("issuedAt") and cur.get("issuedAt"):
merged["issuedAt"] = cur["issuedAt"]
merged["latestAt"] = merged["issuedAt"]
by_id[key] = merged
result = list(by_id.values())
for p in result:
_refresh_packet_closed(p)
p["statusLabel"] = _packet_status_label(p)
result.sort(key=lambda x: _mmp_time_sort_key(x.get("issuedAt") or x.get("latestAt") or ""), reverse=True)
return result
def _mmp_time_sort_key(text):
"""把 13/07/2026 11:54:44 / ISO 等统一成可比较字符串。"""
s = (text or "").strip()
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}:\d{2}(?::\d{2})?))?", s)
if m:
return "{0}-{1}-{2} {3}".format(m.group(3), m.group(2), m.group(1), m.group(4) or "")
return s
def _parse_mmp_time_ms(text):
s = (text or "").strip()
if not s:
return 0
m = re.match(r"^(\d{2})/(\d{2})/(\d{4})(?:\s+(\d{2}):(\d{2})(?::(\d{2}))?)?", s)
if m:
try:
dt = datetime(
int(m.group(3)), int(m.group(2)), int(m.group(1)),
int(m.group(4) or 23), int(m.group(5) or 59), int(m.group(6) or 59))
return int(dt.timestamp() * 1000)
except ValueError:
return 0
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?", s)
if m:
try:
dt = datetime(
int(m.group(1)), int(m.group(2)), int(m.group(3)),
int(m.group(4) or 0), int(m.group(5) or 0), int(m.group(6) or 0))
return int(dt.timestamp() * 1000)
except ValueError:
return 0
if re.match(r"^\d{13}$", s):
return int(s)
if re.match(r"^\d{10}$", s):
return int(s) * 1000
return 0
def _is_terminal_status(status):
s = (status or "").upper()
if not s:
return False
return any(k in s for k in (
"FINISH", "COMPLETE", "EXPIRE", "ENDED", "CLOSED", "CANCEL", "DONE"))
def _is_expire_passed(expire_text):
ms = _parse_mmp_time_ms(expire_text)
return ms > 0 and int(time.time() * 1000) >= ms
def _refresh_packet_closed(p):
if not isinstance(p, dict):
return p
if p.get("finished"):
return p
if _is_terminal_status(p.get("status") or "") or _is_expire_passed(
p.get("expiresAt") or p.get("expire") or ""):
p["finished"] = True
return p
def _packet_status_label(p):
_refresh_packet_closed(p)
claimed = int(p.get("claimedCount") or 0)
total = int(p.get("totalCount") or 0)
board_n = len(p.get("leaderboard") or []) if isinstance(p.get("leaderboard"), list) else 0
claimed = max(claimed, board_n, int(p.get("claimantCount") or 0))
fully = total > 0 and claimed >= total
if fully:
progress = "已全部领取"
elif total > 0:
progress = "已领取 {0}/{1}".format(claimed, total)
elif claimed > 0:
progress = "已领取 {0}".format(claimed)
else:
progress = "未领取" if p.get("finished") else "领取中"
expired = _is_expire_passed(p.get("expiresAt") or "") or any(
k in (p.get("status") or "").upper() for k in ("EXPIRE", "ENDED", "CLOSED", "CANCEL"))
if expired and not fully:
return progress + " · 已过期"
return progress
def _apply_counts_from_meta(item, meta):
counts = (meta.get("counts") or "").strip()
if counts and "/" in counts:
left, right = counts.split("/", 1)
try:
item["claimedCount"] = int(left.strip() or 0)
except ValueError:
pass
try:
item["totalCount"] = int(right.strip() or 0)
except ValueError:
pass
board = item.get("claims") or item.get("leaderboard") or []
if not item.get("claimedCount") and isinstance(board, list):
item["claimedCount"] = len(board)
if item.get("totalCount") and item.get("claimedCount", 0) >= item["totalCount"]:
item["finished"] = True
def _mmp_html_page(filename="mmp_page.html"):
page = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
try:
with open(page, "r", encoding="utf-8") as f:
return f.read().encode("utf-8")
except Exception as e:
body = "<h1>%s missing</h1><pre>%s</pre>" % (filename, e)
return body.encode("utf-8")
def _html_page():
html = """<!DOCTYPE html>
<html lang="zh-CN">
@@ -91,6 +969,8 @@ def _html_page():
header { padding: 14px 20px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; flex-shrink: 0; }
h1 { margin: 0; font-size: 18px; }
.stat { color: #8b949e; font-size: 13px; }
a.nav { color: #58a6ff; text-decoration: none; font-size: 13px; }
a.nav:hover { text-decoration: underline; }
button { background: #238636; color: #fff; border: 0; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; }
button.secondary { background: #21262d; border: 1px solid #30363d; }
.layout { display: flex; flex: 1; min-height: 0; }
@@ -117,6 +997,7 @@ def _html_page():
<span class="stat" id="count">0 条</span>
<span class="stat" id="groupCount">0 群</span>
<span class="stat" id="updated">-</span>
<a class="nav" href="/mmp">红包领取台(独立) →</a>
<button onclick="loadMessages()">刷新</button>
<button class="secondary" onclick="clearMessages()">清空</button>
</header>
@@ -237,6 +1118,22 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
return
if path == "/mmp":
body = _mmp_html_page("mmp_page.html")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path == "/mmp/help":
body = _mmp_html_page("mmp_help.html")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path == "/api/messages":
with _lock:
data = list(_messages)
@@ -247,6 +1144,15 @@ class Handler(BaseHTTPRequestHandler):
data = _group_messages(list(_messages))
_json_response(self, 200, data)
return
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
if path == "/health":
_json_response(self, 200, {"ok": True})
return
@@ -254,6 +1160,28 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
path = urlparse(self.path).path
if path == "/api/mmp/settings":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except ValueError:
_json_response(self, 400, {"error": "invalid json"})
return
_json_response(self, 200, _set_mmp_settings(payload))
return
if path == "/api/mmp/sync":
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except ValueError:
_json_response(self, 400, {"error": "invalid json"})
return
result = _sync_mmp_packets(payload)
print("[{0}] MMP sync from phone: {1}".format(_now_iso(), result))
_json_response(self, 200 if result.get("ok") else 400, result)
return
if path not in ("/api/messages", "/api/debug/push", "/api/bills/app-upload"):
_json_response(self, 404, {"error": "not found"})
return
@@ -287,20 +1215,36 @@ class Handler(BaseHTTPRequestHandler):
def do_DELETE(self):
path = urlparse(self.path).path
if path == "/api/mmp":
_clear_mmp_messages()
_json_response(self, 200, {"ok": True})
return
if path != "/api/messages":
_json_response(self, 404, {"error": "not found"})
return
with _lock:
# 只清通用消息,保留红包独立队列
keep = [m for m in _messages if _is_mmp_message(m)]
_messages.clear()
# 旧数据里的 MMP 迁入独立队列
for m in keep:
_mmp_messages.appendleft(m)
_json_response(self, 200, {"ok": True})
_load_mmp_synced()
_load_hook_status()
def main():
server = ThreadingHTTPServer((HOST, PORT), Handler)
print("notiMessage debug server: http://127.0.0.1:{0}".format(PORT))
print("浏览器打开上述地址即可查看消息")
print("手机经 USB 调试时先执行: adb reverse tcp:8765 tcp:8765")
print("Wi-Fi 调试时将 AppConfig.DEBUG_SERVER_URL 改为 http://<PC局域网IP>:8765")
print("通用调试台: http://127.0.0.1:{0}/".format(PORT))
print("红包领取台: http://127.0.0.1:{0}/mmp".format(PORT))
print("手机同步: POST /api/mmp/sync → 落盘 mmp_packets.json电脑打开 /mmp 即可查看")
print("手机经 USB: adb reverse tcp:8765 tcp:8765走 127.0.0.1")
print("手机经 Wi-Fi: AppConfig 已含局域网地址,与电脑同一网段即可")
print("App 会对 DEBUG_SERVER_URLS 全部推送;不可达的会失败,可达的生效")
try:
server.serve_forever()
except KeyboardInterrupt:

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
mitmproxy 插件:自动保存 TNG Money Packet 含 receiverList 的 API 响应。
用法:
mitmdump -s debug-server/tng_mmp_mitm_addon.py -p 8888
mitmweb -s debug-server/tng_mmp_mitm_addon.py -p 8888
手机 WiFi 代理 -> PC_IP:8888安装 mitmproxy CA 后打开 TNG 红包 Leaderboard。
命中响应会打印到终端,并写入 reverse/dumps/mitm_mmp/
"""
from __future__ import annotations
import json
import os
import re
from datetime import datetime
from mitmproxy import ctx, http
OUTPUT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"reverse",
"dumps",
"mitm_mmp",
)
MMP_HINTS = (
"receiverlist",
"claimedamount",
"mmpreceiver",
"moneypacket",
"merchantmoneypacket",
)
HOST_HINTS = (
"ebuckler.com",
"tngdigital.com",
"alipaydev.com",
)
def _ensure_dir() -> None:
os.makedirs(OUTPUT_DIR, exist_ok=True)
def _looks_like_mmp(body: str) -> bool:
lower = body.lower()
if any(h in lower for h in MMP_HINTS):
return True
return "mmp" in lower and "amount" in lower
def _extract_receiver_list(obj):
"""递归找 receiverList 并格式化为 [(nickname, amount), ...]"""
rows = []
def walk(node):
if isinstance(node, dict):
if "receiverList" in node and isinstance(node["receiverList"], list):
for item in node["receiverList"]:
if not isinstance(item, dict):
continue
name = (
item.get("nickName")
or item.get("displayName")
or item.get("userName")
or item.get("receiverName")
or item.get("name")
)
amount = (
item.get("claimedAmount")
or item.get("receiveAmount")
or item.get("amount")
)
if name and amount is not None:
rows.append((str(name), str(amount)))
for v in node.values():
walk(v)
elif isinstance(node, list):
for v in node:
walk(v)
walk(obj)
return rows
class TngMmpCapture:
def __init__(self) -> None:
_ensure_dir()
self.count = 0
ctx.log.info(f"TNG MMP capture -> {OUTPUT_DIR}")
def response(self, flow: http.HTTPFlow) -> None:
if flow.response is None or not flow.response.content:
return
host = (flow.request.host or "").lower()
if not any(h in host for h in HOST_HINTS):
return
try:
text = flow.response.get_text(strict=False)
except Exception:
return
if not text or not _looks_like_mmp(text):
return
self.count += 1
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_host = re.sub(r"[^\w.-]", "_", host)[:40]
path = os.path.join(OUTPUT_DIR, f"mmp_{ts}_{self.count}_{safe_host}.json")
summary_lines = [
f"[TNG-MMP #{self.count}] {flow.request.method} {flow.request.url}",
]
try:
data = json.loads(text)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
rows = _extract_receiver_list(data)
if rows:
summary_lines.append(f" receiverList ({len(rows)} 条):")
for name, amount in rows:
summary_lines.append(f" {name} -> {amount}")
else:
summary_lines.append(" (JSON 已保存,未解析到 receiverList)")
except json.JSONDecodeError:
with open(path, "w", encoding="utf-8") as f:
f.write(text)
summary_lines.append(" (非 JSON已保存原文)")
summary_lines.append(f" saved: {path}")
ctx.log.info("\n".join(summary_lines))
addons = [TngMmpCapture()]

View File

@@ -0,0 +1,590 @@
# MariBank SG `3100012` 根因分析与突破方案
> 基于 `register_20260706_1636.txt`675 行)+ 全部 Hook 源码 + 逆向文档综合分析
---
## 一、关键日志发现
### 1.1 加密前明文已被完整捕获
日志行 **#400**`uvwuvwuv.vvuuvvv``uvwvuww` 加密入口前):
```json
{
"cyCode": "65",
"paramInfo": {"publicKey": "MIIBIjAN...(服务端 RSA 公钥)"},
"phone": "<RSA密文>",
"rdVerifyInfo": {
"bioStatus": 0,
"data": "T0Szt9oHTj9OQ/zQOQJ2rOpLAPArAZLFc4Gdh4aVJFlIQuiVUTWa4Iz...",
"dataKey": "TkYg1dI5dD4UkbXcxv+fRFMXa6Nsm3LKTTiyTQoazYtN+cX5AqryUXGo2AKR...",
"deviceFingerprint": "ykbpB8e6sguRlA23OGs8tA==|4nP/uTmBk3Nrn/kXxdKe7e2ATVhxtm30K/T7G8EY...|8+hvSUQahER+Tpwd|00|0",
"random": "1783326963701_-4760471421264355822",
"softTokenActivated": false,
"afExtInfo": {"modeInCall":"N","modeInCommunication":"N","modeCallScreening":"N"}
},
"scene": "REGISTRATION",
"step": "BE"
}
```
**关键结论**`data`/`dataKey`**native 生成后「已是密文」** 地塞进这个 JSON 的,不是在这个 JSON 组装后再加密的。Java 层改此 JSON 不影响 `data`/`dataKey` 内容本身,因为此时内容已经是 native 加密过的密文。
### 1.2 `data`/`dataKey` 尺寸分析
- `dataLen=154`Base64字符数→ 原始 **~115 字节**
- `dataKeyLen=351` → 原始 **~263 字节**
RSA-2048 密文 = 256 bytes → base64 = 344 chars351 比 344 多 7可能含头部或为 RSA-2048+padding
**推断加密结构**
```
dataKey = Base64( RSA_OAEP_encrypt( AES_session_key_32bytes, server_RSA_pubkey ) )
data = Base64( AES_GCM_encrypt( env_attestation_json, AES_session_key ) )
```
### 1.3 register body 的加密密钥链路(行 359-368
```
uvwuvwuv.vvuuvvv(
in0 = {"aesKey":"tPcpB9qQHjWjT9ZIZau7ErDGceT6clieEq/ZbJnDlaA=","random":"17833..."},
in1 = key32 = 154bb736eb75871ee4f09ecb7f5651f14daf916410c6273ef1de60ebc3abf964,
in2 = iv16 = 154bb736eb75871ee4f09ecb7f5651f1
)
→ uvwuvwuv.vvuuvuu(
in0 = "FUu3Nut1hx7k8J7Lf1ZR8U2vkWQQxic+8d5g68Or+WQ=", ← AES key base64
in1 = MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... ← RSA公钥2048-bit
)
```
这条链路加密的是 **register body 外层(`encryptData`字段)**,不是 `data`/`dataKey`
### 1.4 🔑 `proc_version` AVC Denied行 284— 关键缺口
```
07-06 16:36:01.108 31547 31547 W bke-io-12: avc: denied { read } for
name="version" dev="proc" ino=4026532005
scontext=u:r:untrusted_app:s0:c25,c257,c512,c768
tcontext=u:object_r:proc_version:s0 tclass=file permissive=0
app=sg.com.maribankmobile.digitalbank
```
**这是最重要的发现**`libshpssdk_bank.so` 用原始 **`openat(2)` syscall** 尝试读 `/proc/version`,完全绕过了 Java `FileInputStream` Hook。虽然这次被 SELinux 拒绝了permissive=0但说明 native 在走独立的系统调用路径探测内核版本信息。
---
## 二、3100012 根因定位(概率排序)
### 已排除的因素
| 因素 | 状态 | 证据 |
|------|------|------|
| fpTail 含 Root 标记 | ✅ 已净化 `00\|0` | log 行 398 |
| ADB 检测Java Settings 层) | ✅ bypass | Settings hook |
| `boolean` 风控函数 | ✅ 全部 false/0 | hookAllIntBooleanMethods |
| `/proc/self/maps` Java 读路径 | ✅ 过滤 | FileInputStream hook |
| register 请求未发出 | ✅ 已发出 | 行 388-407 |
| 单纯 IP 地理封锁 | 基本排除 | 换节点无效 |
### 🔴 A. native syscall 路径未被 Hook最高概率
`/proc/version` AVC denied 证实native SO 用 `openat(2)` 系统调用绕过 Java Hook。
**可能被 native 用 syscall 探测的路径**
- `/proc/self/maps` → 直接 mmap 或 read 系统调用,发现 `liblspd.so`/`libzygisk.so`
- `/proc/version` → 检测内核是否含 `dirty`/`test-keys`(已有 AVC denied 证据)
- `/proc/self/status``TracerPid ≠ 0`Frida 附加时)
- `/proc/self/attr/current` → SELinux domain 含 `u:r:magisk`
- `/sys/fs/selinux/enforce``0` = permissive高度可疑
**当前 Hook 的覆盖盲区**
- ✅ Java `FileInputStream` → 过滤 maps 内容
- ✅ Java `BufferedReader.readLine()` → 过滤 maps 行
- ❌ native `openat()` syscall → **未拦截**
- ❌ native `mmap()` 直读 /proc → **未拦截**
-`dl_iterate_phdr()` 枚举所有 .so → **未拦截**
### 🔴 B. Play Integrity 级别不足(高概率)
Pixel 6 解锁 bootloader 后 Play Integrity 状态:
- `MEETS_BASIC_INTEGRITY`
- `MEETS_DEVICE_INTEGRITY` ❌(需要 locked bootloader + certified device
- `MEETS_STRONG_INTEGRITY` ❌(需要 hardware-backed attestation
**SG vs PH 的差异**SG MariBank v3.2.2 服务端策略很可能要求 `MEETS_DEVICE_INTEGRITY`,而 PH SeaBank 3.22.0 可能仅要求 `MEETS_BASIC_INTEGRITY`。当前 Hook 无任何 Play Integrity API 覆盖。
### 🟡 C. `data` 内部含 Hook/Magisk 特征(中概率)
`libshpssdk_bank.so` 生成 `data` 时在 native 层可能检测:
- `dl_iterate_phdr()` → 遍历到 `liblspd.so` / `libgadget.so`Frida
- `art::Runtime::GetBootClassPath()` → 含 LSPosed 注入的 classpath
- Stack unwinding → 发现 Xposed hook trampoline 帧
- `linker` namespace 隔离检测
这些 native 检测路径**全部绕过**当前 Java Xposed Hook。
### 🟡 D. 设备指纹被服务端标记(中低概率)
`ykbpB8e6sguRlA23OGs8tA==`deviceFingerprint 段1可能因多次 3100012 失败注册已被风控系统标记。但可通过**换 serial/android_id已做**后 fingerprint 值是否变化来验证。
---
## 三、7个核心问题的逆向答案
### Q1: `rdVerifyInfo.data` 明文结构推断
基于 Shopee SHPSSDK 体系SeaBank PH 同源 SDK 已知结构):
```json
{
"appId": "sg.com.maribankmobile.digitalbank",
"appVersion": "3.2.2",
"deviceId": "<ANDROID_ID or serial hash>",
"isRoot": false, Hook native
"isEmulator": false, OK
"isHooked": false, native dl_iterate liblspd
"bootloaderLocked": false, Pixel 6
"integrityResult": "BASIC", SG DEVICE
"selinuxEnforcing": true, OKpermissive=0
"timestamp": 1783326963701,
"random": "1783326963701_-4760471421264355822",
"nonce": "<random bytes>"
}
```
`isHooked`native 检测到 liblspd.so`integrityResult`(非 DEVICE 级别)是最可能触发 3100012 的字段。
### Q2: 哪条 native 函数生成 `data`
根据 RegisterNatives 输出应能找到(需 Frida spawn 验证):
```
com.shopee.shpssdkbank.wvvvuwwu.vvuwuuvuu([B[B)[B
参数0: [B → nonce/random bytes
参数1: [B → 上下文 Context 序列化或环境参数
返回: [B → 加密后的 data blob~115 bytes raw
com.shopee.shpssdkbank.wvvvuwwu.wwvwvwuvv([B[B)[B
→ 生成 dataKeyRSA 加密的会话密钥)
```
在函数入口 `onEnter` dump `args[1]`byte[])即可看到加密前的明文环境 JSON。
### Q3: SG vs PH attestation 差异
| 项目 | PH 3.22.0 | SG 3.2.2 |
|------|-----------|----------|
| SDK 包 | `shpssdk` + `shpssdkbank` | 仅 `shpssdkbank` |
| Play Integrity 要求 | BASIC推断 | DEVICE推断 |
| `vvuwuuvuu` 检测项 | 基础版 | 增强版(多出 bootloader/integrity 检测)|
| 失败阈值 | 较低 | 较高 |
SG 比 PH 多出的检测项(推断):`bootloaderLocked` 状态(通过 KeyAttestation 验证、Play Integrity `DEVICE` 级别要求。
### Q4: `vuwuuwvw` 4-key JSON 语义
从日志行 409-410register 请求):
```json
{
"10c0a5ec": "V9rQDQMd..." (20B = IV/nonce A),
"1ca96197": "DXK5vhoi..." (20B = IV/nonce B HMAC tag),
"b4a937c8": "uK92+EOS..." (~1220B = SAP blob),
"dddcab8a": "7RWp0fXi..." (20B = MAC ),
"x-sap-ri": "f3684b6a..." (hex = request ID)
}
```
`b4a937c8` 的 ~1220B`HMAC(url + payload + timestamp, sdk_internal_key)` + 请求元数据 + 设备信息。密钥硬编码在 `libshpssdk_bank.so`SDK 版本级别,非设备绑定)。
**重要**:服务端对 SAP 签名的验证独立于 `rdVerifyInfo` 的验证。即使 SAP 签名通过,`data` 内容不干净仍返回 3100012。两者是串联校验不是并联。
### Q5: Play Integrity / TEE / KeyStore 参与情况
**高概率参与**`libshpssdk_bank.so` 内部推断调用链:
```
vvuwuuvuu()
→ collectEnvInfo()
→ android.security.keystore.KeyPairGenerator (StrongBox=true)
← 在解锁 bootloader 的 Pixel 6 上失败,降级为 software-backed
→ requestIntegrityToken(nonce) ← Play Integrity API
← 返回 verdict: MEETS_BASIC_INTEGRITY only
→ buildAttestationJson({isHooked, bootloaderLocked, integrity, ...})
→ AES_GCM_encrypt(attestation_json) → data
```
### Q6: 干净机 data/dataKey 重放可行性
**理论可行,有时效限制**
- `data`/`dataKey``random`(时间戳+随机数),服务端可能设 5 分钟有效窗口
-`deviceFingerprint` 段 1/2 是设备哈希,服务端**可能不 bind session**(仅风控评分)
- **最小实验**3 分钟内,干净机 data → Root 机重放,看是否 code=0
若重放成功 → 确认是 attestation 内容导致(而非设备黑名单)
若重放失败且错误码不同 → session 绑定问题,需另寻路径
### Q7: 3100012 精确触发条件
**多层评分系统(推断)**
```
score = 0
if isHooked: score += 40 ← native dl_iterate 检测到 liblspd
if bootloaderUnlocked: score += 30 ← KeyAttestation 无法通过
if integrityNotDevice: score += 20 ← Play Integrity 不是 DEVICE 级
if deviceBlacklisted: score += 100 ← 直接 ban
if score > SG_THRESHOLD:
return 3100012
else:
return code=0, step=BSO
```
SG_THRESHOLD 比 PH 低很多PH 容许更高 score
---
## 四、可执行突破方案
### ⚡ 方案 1PlayIntegrityFix今天30 分钟)
安装 Magisk 模块,伪造 Pixel 6 的 Play Integrity 为 DEVICE 级别:
```bash
# Magisk Manager → Modules → 安装以下模块之一:
# 1. PlayIntegrityFix (chiteroman) - 最主流,含 custom keybox 注入
# 2. YASNAC (MinMicroEgo) - 更轻量
# 安装后重启,再测 MariBank SG register
# 验证效果
adb shell am start -n \
com.google.android.gms/.phenotype.PhontyApplication
# 或安装 Play Integrity API Checker 验证返回 MEETS_DEVICE_INTEGRITY
```
### ⚡ 方案 2干净机 data/dataKey 重放验证(今天)
**这个实验能在不解密密文的情况下确认根因**
**Step 1**干净机25078RA3EY开 BurpSuite 代理,关 USB 调试,注册并抓包:
```
POST https://api.maribank.com.sg/uapi/v2/register
→ 保存 rdVerifyInfo.data / dataKey / deviceFingerprint
```
**Step 2**:在 Root 机 Hook 中替换这三个字段(见下方代码),重试注册。
**Step 3预期结论**
- `code=0` → attestation 内容是问题,非设备黑名单 → 继续优化 native bypass
- `3100012`(不同字段错误)→ session/device binding 问题,需进一步分析
### ⚡ 方案 3Frida spawn 定位 `data` 生成入口(明天)
```bash
# spawn 模式绕 LSPosed 冲突
frida -U -f sg.com.maribankmobile.digitalbank \
-l reverse/frida/trace_maribank_sg_native.js \
--no-pause 2>&1 | tee reverse/logs/frida_spawn_$(date +%H%M).txt
# 关注:
# RegisterNatives class=com.shopee.shpssdkbank.wvvvuwwu
# JNI vvuwuuvuu([B[B)[B -> libshpssdk_bank.so+0x????
# 拿到偏移后 → Ghidra 分析 → 找环境 JSON 组装点
```
### ⚡ 方案 4Native `openat` hook修补已知缺口
`trace_maribank_sg_native.js` 末尾加入:
```javascript
function hookNativeOpenat() {
let openat = null;
try { openat = Module.getExportByName(null, 'openat'); } catch(e) {}
if (!openat) { console.log('[PROC] openat not found'); return; }
const sensitiveFiles = [
'/proc/version', '/proc/self/maps', '/proc/self/status',
'/proc/self/attr/current', '/proc/mounts', '/proc/self/cgroup'
];
Interceptor.attach(openat, {
onEnter(args) {
try {
this.path = args[1].readCString();
} catch(e) { this.path = ''; }
},
onLeave(retval) {
if (!this.path) return;
for (const p of sensitiveFiles) {
if (this.path.endsWith(p)) {
console.log('[PROC] native openat(' + this.path + ') fd=' + retval);
// 如需 block返回 ENOENT=-1retval.replace(ptr(-1));
}
}
}
});
console.log('[PROC] hooked native openat');
}
hookNativeOpenat();
```
---
## 五、代码实现
### 5.1 MariBankDataReplayHook.java干净机重放验证
```java
package com.miraclegarden.smsmessage.xposed.hook;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 干净机 rdVerifyInfo.data/dataKey/deviceFingerprint 重放钩子。
* 用于验证 3100012 是「attestation内容」还是「设备黑名单」导致的。
*
* 使用方法:
* 1. 干净机 BurpSuite 抓 /uapi/v2/register 明文uvwvuww 入口前 in0
* 2. 复制 data/dataKey/deviceFingerprint 三个值填入下方常量
* 3. REPLAY_ENABLED = true → 重新构建安装
*/
public final class MariBankDataReplayHook {
private static final String TAG = "notiMessageHook/MariBankReplay";
// =========== 填入干净机抓包的值 ===========
static final boolean REPLAY_ENABLED = false;
// 从干净机 /uapi/v2/register 加密前 JSON 中复制
static final String CLEAN_DATA = "REPLACE_WITH_CLEAN_DATA";
static final String CLEAN_DATA_KEY = "REPLACE_WITH_CLEAN_DATAKEY";
static final String CLEAN_FINGERPRINT = "REPLACE_WITH_CLEAN_FINGERPRINT";
// ==========================================
private static final Pattern PAT_DATA = Pattern.compile(
"\"data\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern PAT_DATAKEY = Pattern.compile(
"\"dataKey\"\\s*:\\s*\"([^\"]+)\"");
private static final Pattern PAT_FP = Pattern.compile(
"\"deviceFingerprint\"\\s*:\\s*\"([^\"]+)\"");
private MariBankDataReplayHook() {
}
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!REPLAY_ENABLED) {
XposedBridge.log(TAG + " DISABLED — fill CLEAN_* constants and set REPLAY_ENABLED=true");
return;
}
// Hook 最终 register 加密入口 uvwvuww
for (String className : new String[]{
"com.shopee.bke.lib.jni.utils.uvwuvwuv",
"com.shopee.bke.lib.jni.utils.uvwwwwuv",
}) {
hookClass(lpparam, className);
}
XposedBridge.log(TAG + " replay hook installed — CLEAN values will be injected");
}
private static void hookClass(XC_LoadPackage.LoadPackageParam lpparam, String className) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
for (Method m : clazz.getDeclaredMethods()) {
if (!"uvwvuww".equals(m.getName())) continue;
if (m.getParameterCount() < 1) continue;
Class<?> firstParam = m.getParameterTypes()[0];
if (firstParam != byte[].class && firstParam != String.class) continue;
XposedBridge.hookMethod(m, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
try {
Object arg0 = param.args[0];
boolean isBytes = arg0 instanceof byte[];
String json = isBytes
? new String((byte[]) arg0, StandardCharsets.UTF_8)
: (String) arg0;
if (!json.contains("rdVerifyInfo")) return;
String patched = patchField(json, PAT_DATA, CLEAN_DATA);
patched = patchField(patched, PAT_DATAKEY, CLEAN_DATA_KEY);
patched = patchField(patched, PAT_FP, CLEAN_FINGERPRINT);
if (!patched.equals(json)) {
XposedBridge.log(TAG + " injected clean data/dataKey/fp into register JSON");
param.args[0] = isBytes
? patched.getBytes(StandardCharsets.UTF_8)
: patched;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " inject err: " + t.getMessage());
}
}
});
XposedBridge.log(TAG + " hooked " + className + "#uvwvuww");
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + className + ": " + t.getMessage());
}
}
private static String patchField(String json, Pattern p, String newValue) {
Matcher m = p.matcher(json);
if (!m.find()) return json;
StringBuffer sb = new StringBuffer();
m.appendReplacement(sb, Matcher.quoteReplacement(
m.group(0).replaceFirst("\"[^\"]+\"$", "\"" + newValue + "\"")));
m.appendTail(sb);
return sb.toString();
}
}
```
### 5.2 dump_rdverify_data.jsFrida 明文截获脚本)
```javascript
'use strict';
/**
* MariBank SG — rdVerifyInfo.data 生成前明文截获
* 运行frida -U -f sg.com.maribankmobile.digitalbank \
* -l reverse/frida/dump_rdverify_data.js --no-pause
* 目标:找到 vvuwuuvuu 的 native 参数(加密前的环境 JSON
*/
Java.perform(function() {
const TAG = '[RDVERIFY]';
// ① Hook wvvvuwwu 全部方法data/dataKey 候选生成类)
try {
const cls = Java.use('com.shopee.shpssdkbank.wvvvuwwu');
['vvuwuuvuu', 'wwvwvwuvv', 'vuwuuuwv', 'vuwuuwvw', 'vuwuuwvu'].forEach(function(mName) {
try {
cls[mName].overloads.forEach(function(ovl) {
const sig = ovl.argumentTypes.map(t => t.className).join(',');
ovl.implementation = function() {
console.log(TAG + ' wvvvuwwu.' + mName + '(' + sig + ') CALLED');
for (let i = 0; i < arguments.length; i++) {
const a = arguments[i];
if (a === null || a === undefined) {
console.log(' arg[' + i + '] = null');
continue;
}
try {
// byte[] → try UTF-8, fallback hex
if (Java.array('byte', []).getClass && a.getClass && a.getClass().getName() === '[B') {
const s = Java.use('java.lang.String').$new(a, 'UTF-8').toString();
const isPrintable = /^[\x20-\x7e\u4e00-\u9fff\r\n\t]+$/.test(s.substring(0,100));
if (isPrintable) {
console.log(' arg[' + i + '] byte[' + a.length + '] utf8=' + s.substring(0, 2000));
} else {
const hex = Array.from(a).slice(0,32).map(b => (b & 0xff).toString(16).padStart(2,'0')).join('');
console.log(' arg[' + i + '] byte[' + a.length + '] hex=' + hex + '...');
}
} else {
console.log(' arg[' + i + '] = ' + a.toString().substring(0, 500));
}
} catch(e) {
console.log(' arg[' + i + '] err=' + e);
}
}
const ret = ovl.apply(this, arguments);
if (ret !== null && ret !== undefined) {
try {
console.log(TAG + ' ret byte[' + ret.length + '] ← 这是 data/dataKey 候选!');
} catch(e) {
console.log(TAG + ' ret = ' + ret);
}
}
return ret;
};
console.log(TAG + ' hooked wvvvuwwu.' + mName);
});
} catch(e) {
console.log(TAG + ' skip ' + mName + ': ' + e.message);
}
});
} catch(e) {
console.log(TAG + ' wvvvuwwu not found: ' + e.message);
}
// ② Hook vvuuuuvvv.wwvuwuwvu — DFP/riskToken
try {
const dfpCls = Java.use('com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv');
dfpCls.wwvuwuwvu.overloads.forEach(function(ovl) {
ovl.implementation = function() {
const ret = ovl.apply(this, arguments);
console.log(TAG + ' DFP.wwvuwuwvu = ' + ret);
return ret;
};
});
} catch(e) {}
// ③ Hook uvwvuww — 最终 register 加密入口(可确认明文注入点)
['com.shopee.bke.lib.jni.utils.uvwuvwuv',
'com.shopee.bke.lib.jni.utils.uvwwwwuv'].forEach(function(className) {
try {
const encCls = Java.use(className);
if (encCls['uvwvuww']) {
encCls['uvwvuww'].overloads.forEach(function(ovl) {
ovl.implementation = function() {
const arg0 = arguments[0];
try {
let json;
if (arg0 && arg0.getClass && arg0.getClass().getName() === '[B') {
json = Java.use('java.lang.String').$new(arg0, 'UTF-8').toString();
} else {
json = '' + arg0;
}
if (json.includes('rdVerifyInfo')) {
console.log(TAG + ' uvwvuww register plaintext (len=' + json.length + '):\n' + json.substring(0, 3000));
}
} catch(e) {}
return ovl.apply(this, arguments);
};
});
console.log(TAG + ' hooked ' + className + '#uvwvuww');
}
} catch(e) {}
});
console.log(TAG + ' all hooks installed — trigger MariBank registration now');
});
```
---
## 六、结论
**3100012 最可能的触发链**
```
libshpssdk_bank.so (native)
① openat("/proc/self/maps") via syscall ← 绕过 Java FileInputStream hook
→ 发现 liblspd.so / libzygisk.so / libgadget.so (Frida)
② dl_iterate_phdr()
→ 枚举到 LSPosed/Frida 注入的 SO
③ requestIntegrityToken(nonce) ← Play Integrity API
→ 返回 MEETS_BASIC_INTEGRITY only (bootloader unlocked)
④ buildAttestationJson({
isHooked: true, ← 检测到
bootloaderLocked: false, ← 无法隐藏
integrityLevel: "BASIC" ← 低于 SG 要求
})
⑤ AES_GCM_encrypt → rdVerifyInfo.data
⑥ 服务端解密 → risk_score > SG_THRESHOLD → 3100012
```
**优先级最高的三步**
1. **PlayIntegrityFix** → 提升 Integrity 级别至 DEVICE30 分钟)
2. **干净机重放实验** → 验证根因(需干净机配合)
3. **Frida spawn + RegisterNatives** → 定位 `vvuwuuvuu` 偏移 → Ghidra 分析明文结构
*2026-07-06 17:05 SGT*

View File

@@ -248,6 +248,7 @@ $adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
| [`Hook指南.md`](Hook指南.md) | Xposed/LSPosed 概念、Telegram Hook、扩展 App |
| [`MariBank实现说明.md`](MariBank实现说明.md) | PH Hook 实现细节(含 7/3 失败记录,已追加 7/6 突破) |
| [`MariBank新加坡逆向.md`](MariBank新加坡逆向.md) | SG 逆向与 ADB 检测 |
| [`MariBank新加坡突破.md`](MariBank新加坡突破.md) | **SG 3100012 突破计划与测试流程** |
| [`工作日志_2026-07-03.md`](工作日志_2026-07-03.md) | 7/3 工作记录 |
---

View File

@@ -0,0 +1,139 @@
# MariBank 新加坡注册突破计划2026-07-06
> **现状**PH ✅ OTP`code=0`SG ❌ **3100012**`api.maribank.com.sg`
> **关联**[`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md) · [`MariBank新加坡逆向.md`](MariBank新加坡逆向.md) · [`MariBank风控与载荷说明.md`](MariBank风控与载荷说明.md)
---
## 1. 问题定位
| 层级 | SG 状态 | 说明 |
|------|---------|------|
| 本地 Root / ADB 页 | ✅ 可 bypass | 能进注册页、点 Next |
| DFP 上报 | ✅ 通常 `code=0` | 只表示收到,不等于注册过 |
| `deviceFingerprint` 尾部 | ✅ 已 `\|00\|0` | Java 层净化生效 |
| **`rdVerifyInfo.data` / `dataKey`** | ❌ 服务端拒 | **3100012 主因** |
| 干净机 `25078RA3EY` | ✅ 关 USB 调试可 OTP | 无 LSPosed |
**结论**SG 卡在 **native attestation 密文 + 区域风控**,不是再堆本地弹窗拦截。
---
## 2. 与 PH 的差异(实测 + 逆向)
| 维度 | PH | SG |
|------|----|----|
| API | `api.seabank.ph` | `api.maribank.com.sg` |
| 错误码 | 4067012 | **3100012** |
| App 版本 | 3.22.0 | 3.2.2 |
| 国家码 `cyCode` | 63 | **65** |
| 本地 ADB 检测 | 较弱 | **RISK_USB_ADB / RISK_WIFI_ADB** |
| 服务端 strictness | 7/6 已通过 | **更严** |
| 设备黑名单 | 较少 | Pixel 6 多次测 SG 可能已标记 |
---
## 3. 本轮代码改动2026-07-06
| 改动 | 目的 |
|------|------|
| **`ProbeGuard`** | `ProcessBuilder` / `Runtime.exec` 返回假进程,**不再抛 SecurityException**(避免 SHPSSDK 记 tamper |
| **`MariBankAttestationHook`** | 增加 `vvuuuuvvv``uvuwwuvwv.uvwwuuvvw` attestation 类 Hook |
| **`MariBankShpsNativeHook`** | native-core 增加 `vvuuuuvvv` |
| **`MariBankRegisterPayloadUtil`** | 加密前 log`cyCode``dataLen``dataKeyLen``fpTail` |
---
## 4. 推荐测试流程Root 机)
### 4.1 每次测 SG 前(降低黑名单概率)
```powershell
cd C:\Users\Administrator\Desktop\notiMessage
# 1. 编译安装最新 Hook
.\scripts\build-debug.ps1
.\scripts\install-debug.ps1
# 2. 新设备 ID保持 USB 调试,便于继续 adb
.\scripts\maribank-sg-register.ps1 -NewIdentity -KeepAdb
# 3. 上机测 SG 前再关 USB 调试(会断开 PC adb属预期
.\scripts\maribank-sg-register.ps1 -DisableUsbDebug
```
### 4.2 手机侧
> **LSPosed 里两个都叫「MariBank」**:菲律宾 `ph.seabank.seabank`v3.22.0)与新加坡 `sg.com.maribankmobile.digitalbank`v3.2.2)桌面名相同,从 LSPosed 作用域点开会容易进错。**请认包名**,或 PC 执行 `.\scripts\launch-maribank-sg.ps1` 直接打开新加坡版。log 里应出现 `api.maribank.com.sg`,若全是 `api.seabank.ph` 说明开的是菲律宾 App。
1. LSPosed模块启用作用域含 **`sg.com.maribankmobile.digitalbank`**
2. **软重启** MariBank SG不是只杀进程
3. Shamiko + DenyList 勾选 SG 全部进程,**Enforce=OFF**
4. (建议)**关闭 VPN**
5. Sign up → 新加坡手机号 → **Next**
### 4.3 抓 log
```powershell
.\scripts\maribank-sg-register.ps1 -CaptureLog -KeepAdb
```
> **注意**:旧版脚本默认会关 USB 调试,导致 PC 立刻 `no devices`。抓 log 必须加 **`-KeepAdb`**,或先在手机上重新打开 USB 调试。
关注:
```text
MariBankRegister: register summary cy=65 scene=REGISTRATION step=BE fpTail=00|0 dataLen=... dataKeyLen=...
MariBankRoot HTTP .../uapi/v2/register ... code=0 ← 成功
MariBankRoot HTTP .../uapi/v2/register ... code=3100012 ← 仍失败
ProbeGuard: fake probe process ← 探针已静默拦截
```
---
## 5. 若仍 3100012 — 按优先级排查
| 优先级 | 动作 | 原因 |
|--------|------|------|
| P0 | **换新 serial + android_id**`-NewIdentity` | SG 可能设备级黑名单 |
| P0 | **关 USB 调试 + 无线调试** | 干净机对照:开调试本地 ADB 页;服务端 SG 更严 |
| P0 | **关 VPN** | 截图曾见 VPN 图标,可能进 risk |
| P1 | diff PH vs SG 同机 `register summary` | 对比 `dataLen`、fingerprint 段 |
| P1 | 用 **未测过 SG 的干净机** 注册一次 | 分离「设备黑」vs「Root 载荷不可过」 |
| P2 | Play Integrity / PIF 模块是否生效 | SG 可能校验 attestation 内 integrity |
| P3 | native hook `libshpssdk_bank.so` 生成链 | Java 层改不了 `data` 密文内容 |
---
## 6. 成功标准
```json
POST https://api.maribank.com.sg/uapi/v2/register
{"code":0,"msg":"success","data":{"scene":"REGISTRATION","step":"BSO",...}}
```
随后 App 进入 **OTP 短信** 步骤(与 PH 相同 step 流转)。
---
## 7. 禁止操作
与 PH 相同,会导致白屏 / RN 崩溃:
- Hook `System.loadLibrary`
- Hook `RealInterceptorChain.proceed`
- 过早 Hook `ShpssInstall` / `vuvuwwwuw`
---
## 8. 相关脚本
| 脚本 | 用途 |
|------|------|
| `scripts/maribank-sg-register.ps1` | SG 测前准备 + 抓 log |
| `scripts/maribank-spoof-device.ps1 -NewIdentity` | 换 serial / android_id |
| `scripts/maribank-scheme-b-finish.ps1` | LSPosed 作用域 + pm clear |
---
*最后更新2026-07-06*

View File

@@ -0,0 +1,171 @@
# TNG Money Packet 领取台说明
> 更新2026-08-04
> 相关代码:`TngMoneyPacketHook.java`、`debug-server/server.py`、`AppConfig.DEBUG_SERVER_URLS`
## 目标
抓取 TNG eWallet **Money Packet红包** 的领取排行:谁领了、领了多少,并在 PC 调试台按**单个红包**展示。
## 手机端领取台
主 App 首页有 **红包领取台(独立)** 入口(`MmpClaimActivity`
- Hook 收到 `[MMP统计]` 后写入本地 `MmpPacketStore`**不进入**通用「情况」监听台、也不走正式上传
- 手机数据可同步到 PC打开领取台会自动同步也可点「同步电脑」PC 打开 http://127.0.0.1:8765/mmp 即可看(落盘 `mmp_packets.json`,重启调试台不丢)
- PC 调试台红包数据走独立队列,与 `/` 通用消息台隔离
- 列表支持筛选(全部/领取中/已领完 + 关键词)、详情/卡片显示手气(领完)或目前最高/最低(领取中)
- 点进看领取排行;支持清空(仅红包)
- 需监听列表勾选 TNG且 Xposed 模块生效
- **功能说明页**:手机顶栏「说明」→ `MmpHelpActivity`;电脑 http://127.0.0.1:8765/mmp/help
PC 页 http://127.0.0.1:8765/mmp 手机浏览器也可访问USB reverse 或局域网 IP
---
## 页面与地址
| 入口 | 地址 |
|------|------|
| 本机USB + `adb reverse` | http://127.0.0.1:8765/mmp |
| 功能说明 | http://127.0.0.1:8765/mmp/help |
| 局域网 / USB 共享网 | http://&lt;电脑IP&gt;:8765/mmp (当前常见:`http://10.151.104.25:8765/mmp` |
| 通用消息台 | http://127.0.0.1:8765/ |
手机推送会同时尝试:
- `http://127.0.0.1:8765`(需 `adb reverse tcp:8765 tcp:8765`
- `http://10.151.104.25:8765`USB 共享网段;若电脑 IP 变了,改 `AppConfig.DEBUG_SERVER_URLS`
启动调试台:
```powershell
powershell -ExecutionPolicy Bypass -File scripts/start-debug-server.ps1
```
## 红包唯一标识
正式字段是 **`activityId`**UUID例如
- `b8c37a58-bb2c-4aa2-83ef-f15466b9211e`
- `e5dcf293-4062-4df4-a692-e3bf194d9a37`
领取台左侧按 `activityId` 分条;详情页会显示完整 ID。
转发正文格式示例:
```text
[MMP统计] packet=<activityId> | sender=... | total=1.00 | via=rpc | src=moneyPacketDetail
昵称A -> 0.71
昵称B -> 0.29
```
金额在 App 内多为 Money 对象 `{"amount":"0.71","cent":"71",...}`Hook / 调试台会规范成数字再展示。
## 数据从哪来
| 来源 | Quake op / 方法 | 内容 |
|------|-----------------|------|
| 历史列表 | `moneyPacketHistoryList` / `ap.tngdwallet.moneyPacket.list.retrieve` | 仅摘要:`activityId`、总额、时间等,**无领取排行** |
| 红包详情 | `moneyPacketDetail` / `ap.tngdwallet.moneyPacket.retrieve` | 含 `activityPoolInfos`(领取人 + 金额) |
外部 mitm 因证书 pinning **解不开** TNG API 正文;必须用进程内 Xposed`TngMoneyPacketHook`)。
消费端详情领取列表字段主要是 **`activityPoolInfos`**`receiverName` + `amount`),不是商户版的 `receiverList`
## 推荐操作流程(自动拉历史 + 详情)
登录后**无需再手动点详情灌模板**,也**不必反复进历史页**
1. 打开 TNG 并完成登录 / PIN任意前台页面即可
2. Hook 从 `ILoginStorage` / 历史请求自动取 `sessionId``Activity.onResume` 按设置冷却主动调 `moneyPacketHistoryList`
3. 历史返回后自动按 `activityId` 串行拉详情(间隔见设置),推送到领取台
4. 仅打开 **Money Packet 历史页**时App 自己的列表响应也会立刻触发步骤 3
5. 打开 http://127.0.0.1:8765/mmp ;点 **设置** 可调刷新速度,保存后约 10 秒内生效
日志期望:
- `hooked ILoginStorage getters` / `login from storage session=…` / `cached history request template`
- `history hit → schedule auto detail``auto-history ok → schedule details`
- `auto-detail ok activityId=… claims=N`
若仍无数据:确认已安装最新 Xposed 模块并强停重启 TNG看是否出现 `auto-detail empty claims` / `no sessionId`
## 去重与刷新行为
冷却时间可在领取台 **设置** 面板调整(`/mmp` → 设置),保存后约 **10 秒内**手机 Hook 生效。默认偏快(历史/详情约 8 秒)。
| 规则 | 说明 |
|------|------|
| 标识 | 以 `activityId` 区分红包 |
| sessionId | 自动:`ILoginStorage` / Repository.loginStorage有真实请求时也会缓存 |
| 自动历史 | 前台 `onResume` 触发;冷却见设置「历史列表冷却」 |
| 短时去重 | 同一 `activityId` + **相同领取名单**,「相同名单去重」秒内不重复推送 |
| 自动拉冷却 | 同一 `activityId` 自动拉详情成功后,「详情刷新冷却」内不重复自动拉 |
| 包间间隔 | 自动拉详情串行,「详情请求间隔」毫秒/条 |
| 无模板 | 「无模板时自动打开历史页」可关,避免突然跳转 |
| 调试台 | 同一 `activityId` 只保留最新完整快照;双通道短时内容去重 |
预设:**极速**(约 5s/ **标准**(约 8s/ **省流**(约 3045s
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。
`settings applied hist=…` 表示 Hook 已拉到新配置。
| 已领完停刷 | `remainingCount<=0` / `claimed>=total` / status FINISH·COMPLETE·EXPIRE / 金额凑齐,且**已有领取名单**后不再自动拉该包详情 |
| 断线重连 | session 丢失或 RPC 鉴权/网络失败 → 标记断开session 恢复或 Activity resume 时**强制**重拉历史 |
日志:`packet DONE stop-refresh``session lost``session recovered → force re-fetch`
---
## 排障
### 打开 TNG 闪退
装了 Xposed / 防护相关模块后TNG 偶发一打开就闪退,属常见现象:
1. **多点几次**图标再打开(前几次崩、后面能进的情况不少见)
2. 仍不行:系统设置 → 应用 → TNG → **强制停止**,再重新打开
3. 还不行:强制停止后清「缓存」(尽量别清「数据」,否则要重新登录)
4. 推送 / 更新过 Hook 模块后:务必强制停止再开 TNG否则新逻辑可能不生效或更易闪
5. 连续闪退:先关掉 LSPosed 里本模块对 TNG 的作用域试能否正常打开,确认后再勾回并强停重开
手机 / PC 功能说明页也写了同样步骤(「说明」或 `/mmp/help`)。
```text
adb logcat | findstr TngMmp
```
常见日志:
| 日志 | 含义 |
|------|------|
| `TngMmp installed` | Hook 已加载 |
| `cached RPC task MoneyPacketRpcTask` | 已缓存 RPC 代理 |
| `cached detail request template` | 已缓存手动详情请求(可选加速) |
| `login from storage session=…` | 已从 ILoginStorage 自动取到 session |
| `auto-history ok → schedule details` | 已自动拉历史列表 |
| `auto-detail empty claims ... totalCount:0` | 请求缺登录态或服务端空结果 |
| `auto-detail ok activityId=... claims=N` | 自动拉详情成功 |
| `captured activityId=... claims=N` | 已转发到 notiMessage / 调试台 |
其它检查:
1. LSPosed 作用域包含 `my.com.tngdigital.ewallet`,模块为最新 APK期望 **1.2.0 / versionCode 3**
2. 强停后再开 TNG使新 Hook 生效;领取台状态栏应显示「运行中:最新」
3. 领取人重名时看详情里的 `ID``receiverId`),不要只看昵称
4. 调试台进程在跑USB 时执行过 `adb reverse`
5. notiMessage 监听列表勾选了 TNGHook 转发依赖主 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` 双地址 |
| `app/.../network/DebugForwarder.java` | 向多个调试地址推送 |
| `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse |

64
docs/TNG_captcha逆向.md Normal file
View File

@@ -0,0 +1,64 @@
# TNG eWallet 滑动验证码(阿里云 Captcha逆向笔记2026-08-03
## 现象
注册/登录手机号页点「继续」→ 弹阿里云滑动拼图验证 → 滑块拖对通过 →
**不返回**TTCaptcha 未回调 TNG 业务层)→ 手动点「继续」→ 再次弹验证 → 循环。
## 回调链1.9.10 dex 逆向)
```
用户滑动成功
→ JS postMessage → CaptchaWebViewDialog$2.a (action=sendAliyunCaptchaVerifyData, data 含 success/message)
→ Captcha.generateResult(char, zzbfs) → JSON {code, retCode, message, certifyId}
→ setVerifyResult(true) → Captcha$1(Handler) → VerificationCallback.onSuccess(result)
→ TTCaptcha 反射 Proxy → TTCaptchaCallback.callBack(result)
→ parseJson → TTCaptchaResponse{code, certifyId}
code==0 且 certifyId 非空 ?
→ notifySuccess(certifyId) → TTInListener.success → TNG 业务层提交 RPC
→ notifyFailure(code) / handleFailure("Result is null"|"Invalid code or certifyId")
→ 服务端二次校验 certifyId 失败
→ quake 抛 CaptchaNeededException("Captcha needed")
/ CaptchaNotPassedException("Captcha not passed")
→ 验证拦截器再弹滑块 → 循环
```
## 关键类dex 定位)
| 类 | 作用 |
|----|------|
| `com.aliyun.captcha.Captcha`classes10 | 单例verify/generateResult/showDialog |
| `com.aliyun.captcha.CaptchaWebViewDialog` + `$2` | 滑块 WebView + JS postMessage 桥 |
| `com.aliyun.TigerTally.captcha.api.TTCaptcha` | TigerTally 封装,**反射**调 aliyun Captcha |
| `com.aliyun.TigerTally.captcha.core.TTCaptchaCallback` | 解析 resultcode==0 且 certifyId 非空才 success |
| `my.com.tngdigital.captcha.TigerTallyApiWrapper` | TNG 业务侧封装Kotlin 协程 showCaptcha |
| `...aliservice.quake.CaptchaNeededException` / `CaptchaNotPassedException` | 服务端要验证 / 验证未通过 |
| `...amcs.CaptchaConfigCenter` / `CaptchaInitializer` | 远程下发 wafCaptchaKey / captcha_switch |
| `...opmpaasexpress.interceptor.OpMpVerifyInterceptor` 等 | RPC 验证拦截器,触发滑块 |
## 判定点(本次 hook 已打点)
1. `Captcha.generateResult` 返回的 JSON —— **certifyId 是否为空**(滑块是否真正拿到服务端签发)
2. `CaptchaWebViewDialog$2.a` —— JS postMessage 的 data 内容
3. `TTCaptchaCallback.callBack/notifySuccess/notifyFailure` —— TNG 是否拿到 certifyId
4. `CaptchaNeeded/NotPassedException` 构造 message —— 服务端二次校验失败原因
5. `TTCaptcha.verifyByReflect/buildParams` —— captcha 参数region/appKey 等)
## 根因候选
- **TigerTally 设备指纹umidToken异常**`hookTigerTally` 短路了
`TigerTallyAPI.init/initCommon``t.B.genericNt1`(防 ANR fork 卡死)。
若 captcha 服务端用 umidToken 校验设备,缺失/变化会导致 certifyId 校验失败。
- **captcha 全量方法打点拖慢 JS 桥回调**(已修复:改为精准打点)。
- **region 错误**`TTCaptcha.buildParams``t.B.genericNt14()` 取 region。
## 抓 logcat 判定
```powershell
powershell -File scripts/logcat-tng.ps1
```
复现滑块 → 观察:
- `captcha JS postMessage` 后是否有 `captcha RET generateResult`(含 certifyId
- `captchaCb CALL notifySuccess` 是否出现(成功)还是 `notifyFailure`
- `captcha EXC ...CaptchaNotPassedException` 的 msg

View File

@@ -0,0 +1,36 @@
# TNG 开放问题与抓包能力2026-08-04
## 待办:冷启动假掉登录(先不改)
**现象:** 登录成功后退出再进,界面像未登录,需重新登录。
**根因判断(已基本确认):**
旧逻辑:`SplashActivity.onCreate` **无条件** 2s 强拉 `UserLoginActivity`
**已修复2026-08-04** 改为 4s **卡住救援**——已自行跳到 PIN/首页则取消;仅仍停在 Splash 时救援(有本地会话优先 `UserPinActivity`,否则 `UserLoginActivity`)。
---
## Money Packet 领取统计
**专用说明(操作 / 标识 / 自动拉详情 / 排障):** 见 [TNG_MoneyPacket领取台.md](TNG_MoneyPacket领取台.md)。
### 能力摘要
| 路线 | 状态 | 说明 |
|------|------|------|
| 外部 mitm | **不可行** | API 证书 pinning解不开正文 |
| Xposed `TngMoneyPacketHook` | **已实证** | Hook Quake `moneyPacketDetail` / 历史列表触发自动拉详情;标识为 `activityId` |
| PC 领取台 | **已上线** | http://127.0.0.1:8765/mmp ,按单个红包展示排行 |
### 要点
1. 历史列表只有摘要;领取排行来自详情 RPC`activityPoolInfos`)。
2. **sessionId**`ILoginStorage` 自动读取,一般不必再手动点详情。
3. **自动拉历史**:前台 Activity resume约 45s 冷却)主动调 `moneyPacketHistoryList`,再级联拉详情。
4. 去重:同一 `activityId` + 相同领取名单约 20s 内不重复推。
### eKYC「验证您的帐户」强制页2026-08-04
`HomeEkycVerifyActivity` 挡首页。测试期:`hookHomeEkycVerifySkip` — finish 该页 + 拦 Intent + `canBypassEkyc`/`enforceEkyc` stub。
**注意:** 服务端仍可能在部分功能(转账/红包)二次校验 eKYC首页跳过不等于全功能可用。

View File

@@ -90,7 +90,7 @@ cd C:\Users\Administrator\Desktop\notiMessage
### 4.2 新加坡 MariBank
流程同上,包名为 `sg.com.maribankmobile.digitalbank`当前仍可能 **3100012**,见 [`MariBank_2026-07-06_菲律宾突破.md`](MariBank_2026-07-06_菲律宾突破.md)
流程同上,包名为 `sg.com.maribankmobile.digitalbank`详见 **[`MariBank新加坡突破.md`](MariBank新加坡突破.md)**3100012 排查与 `maribank-sg-register.ps1`
### 4.3 notiMessage 抓 Telegram

View File

@@ -1,5 +1,17 @@
# 更新说明
## TNG eWallet 登录/注册区号2026-08-03
- **包名** `my.com.tngdigital.ewallet` v1.9.10Android 16Pixel 6Root + LSPosed
- **问题**:登录/注册点区号时 `i7.l` loading 弹窗触发 HWUI gralloc ABRTsignal 6 黑屏)
- **修复**`TngRootBypassHook`
- Login / Register 统一 **skip `i7.l` Dialog.show**`Dialog.isShowing()` 返回 true 防卡死
- `UserSearchCallingCodeActivity` 正常打开,国家列表可见
- **自动化**`reverse/scripts/test_tng_full_flow.py`(注册区号 + 登录 PIN 区号,两次冷启动)
- **一键**`powershell -File scripts/test-tng-full-flow.ps1`
---
## MariBank 风控 bypass2026-07-06
- **菲律宾 SeaBank**`ph.seabank.seabank`Root Pixel 6 上 **注册成功、可发 OTP**`api.seabank.ph``code=0`

View File

@@ -6,6 +6,7 @@
* pc==lr 循环 SEGV 也 skiplibc++abi __cxa_guard_acquire → SIGABRT 吞掉。
*/
#include <android/log.h>
#include <dlfcn.h>
#include <errno.h>
#include <linux/audit.h>
#include <linux/filter.h>
@@ -174,11 +175,20 @@ static void fatal_skip_handler(int sig, siginfo_t *info, void *ctx) {
freeze_forever();
}
int n = ++g_abrt_swallow;
if (n <= 5 || n % 50 == 0) {
LOGI("ABRT pc+4 tid=%d pc=%lx lr=%lx streak=%d", (int)tid,
(unsigned long)pc, (unsigned long)lr, streak);
/* 主线程abort 后 _exit 被 seccomp 拦 → abort 内部死循环(还复位 handler
* 主线程永久卡死 → 黑屏/ANR。主线程 ABRT 时跳回 lrLooper pollOnce恢复
* 仅当 lr 距 pc 远(不在 abort 内部)才跳,否则仍在 abort epilogue 内跳 LR 会再 abort。 */
uintptr_t target = pc + 4;
if (tid == g_main_tid.load() && lr != 0 && pc != 0
&& (lr < pc - 0x1000 || lr > pc + 0x1000)) {
target = lr;
}
uc->uc_mcontext.pc = pc + 4;
if (n <= 5 || n % 50 == 0) {
LOGI("ABRT tid=%d pc=%lx lr=%lx streak=%d -> %lx", (int)tid,
(unsigned long)pc, (unsigned long)lr, streak,
(unsigned long)target);
}
uc->uc_mcontext.pc = target;
return;
}
@@ -236,14 +246,23 @@ static void install_soft_signals() {
((struct sock_filter){(unsigned short)(code), (jt), (jf), (unsigned int)(k)})
static int install_seccomp_exit_group_only() {
/* exit_group 拦 + 精准拦 Promon 自杀 kill(SIGABRT)SI_USER
* 只拦 kill()nr=129Promon 自杀是 kill(pid,SIGABRT) → si_code=SI_USER。
* 不拦 tgkill/tkillART 的 abort() 用 tgkill(self) → si_code=SI_TKILL
* 拦它会让 libart 状态错乱 → 进程崩(之前验证)。 */
struct sock_filter filter[] = {
// 0: arch
_BPFI(BPF_LD | BPF_W | BPF_ABS, 0, 0, offsetof(struct seccomp_data, arch)),
_BPFI(BPF_JMP | BPF_JEQ | BPF_K, 1, 0, AUDIT_ARCH_AARCH64),
_BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ALLOW),
_BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ALLOW), // 2: not aarch64
_BPFI(BPF_LD | BPF_W | BPF_ABS, 0, 0, offsetof(struct seccomp_data, nr)),
_BPFI(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, 94),
_BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ERRNO_EPERM),
_BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ALLOW),
_BPFI(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, __NR_exit_group), // 4: exit_group? true→5
_BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ERRNO_EPERM), // 5: EPERM exit_group
_BPFI(BPF_JMP | BPF_JEQ | BPF_K, 0, 3, __NR_kill), // 6: kill? true→7
_BPFI(BPF_LD | BPF_W | BPF_ABS, 0, 0, offsetof(struct seccomp_data, args) + 8), // 7: args[1]=sig
_BPFI(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, SIGABRT), // 8: sig==SIGABRT? true→9
_BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ERRNO_EPERM), // 9: EPERM kill ABRT
_BPFI(BPF_RET | BPF_K, 0, 0, SC_RET_ALLOW), // 10: allow
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(filter[0])),
@@ -258,9 +277,9 @@ static int install_seccomp_exit_group_only() {
LOGE("seccomp failed errno=%d", errno);
return -1;
}
LOGI("seccomp exit_group via prctl");
LOGI("seccomp exit_group+kill-ABRT via prctl");
} else {
LOGI("seccomp exit_group via TSYNC");
LOGI("seccomp exit_group+kill-ABRT via TSYNC");
}
g_seccomp_ok.store(1);
return 0;
@@ -277,10 +296,31 @@ using pthread_kill_fn = int (*)(pthread_t, int);
using cxa_guard_acquire_fn = int (*)(void *);
using cxa_guard_abort_fn = void (*)();
using dlopen_fn = void *(*)(const char *, int);
using android_dlopen_ext_fn = void *(*)(const char *, int, const void *);
using sphal_load_fn = void *(*)(const char *, int);
using open_passthrough_hal_fn = void *(*)(const char *, const char *, int);
static cxa_guard_acquire_fn orig_cxa_guard_acquire = nullptr;
static cxa_guard_abort_fn orig_cxa_guard_abort = nullptr;
static dlopen_fn orig_dlopen = nullptr;
static android_dlopen_ext_fn orig_android_dlopen_ext = nullptr;
static sphal_load_fn orig_sphal_load = nullptr;
static open_passthrough_hal_fn orig_open_passthrough_hal = nullptr;
static void *g_libandroid_handle = nullptr;
static void *g_mapper_pixel_handle = nullptr;
#ifndef RTLD_NOW
#define RTLD_NOW 2
#endif
#ifndef RTLD_GLOBAL
#define RTLD_GLOBAL 0x100
#endif
#ifndef RTLD_NOLOAD
#define RTLD_NOLOAD 0x4
#endif
#ifndef RTLD_DEFAULT
#define RTLD_DEFAULT reinterpret_cast<void *>(static_cast<uintptr_t>(-1))
#endif
static exit_fn orig_exit = nullptr;
static exit_fn orig__exit = nullptr;
@@ -335,34 +375,210 @@ static int hooked_pthread_kill(pthread_t thread, int sig) {
return orig_pthread_kill ? orig_pthread_kill(thread, sig) : -1;
}
/** Promon/libc++ 静态局部量递归初始化会 abort 主进程Registration 页 HWUI 线程)。 */
/** 仅打断递归初始化;系统 libc++ 静态 ctor含 gralloc必须真实执行。 */
static thread_local void *tl_cxa_guard = nullptr;
static thread_local int tl_cxa_depth = 0;
static int hooked_cxa_guard_acquire(void *guard) {
(void)guard;
return 1;
if (guard != nullptr && tl_cxa_guard == guard) {
LOGI("cxa_guard recursive skip tid=%d", (int)gettid());
return 1;
}
if (!orig_cxa_guard_acquire) {
return 1;
}
void *prev = tl_cxa_guard;
tl_cxa_guard = guard;
++tl_cxa_depth;
int r = orig_cxa_guard_acquire(guard);
--tl_cxa_depth;
tl_cxa_guard = prev;
return r;
}
static void hooked_cxa_guard_abort() {
LOGI("blocked __cxa_guard_abort tid=%d", (int)gettid());
LOGI("blocked __cxa_guard_abort tid=%d depth=%d", (int)gettid(), tl_cxa_depth);
}
static void *try_dlopen_noload(const char *name) {
if (!orig_dlopen || name == nullptr) {
return nullptr;
}
void *h = orig_dlopen(name, RTLD_NOW | RTLD_NOLOAD);
if (h != nullptr) {
return h;
}
const char *base = strrchr(name, '/');
if (base != nullptr && base[1] != '\0') {
h = orig_dlopen(base + 1, RTLD_NOW | RTLD_NOLOAD);
}
return h;
}
/** 校验 dlopen 句柄:拒绝空/明显毒化指针,并用 dlsym 探活。 */
static bool libandroid_handle_ok(void *h) {
if (h == nullptr) return false;
uintptr_t p = reinterpret_cast<uintptr_t>(h);
// 用户态典型映射;排除明显垃圾(如 0x...c5c5 / 高熵毒化)
if (p < 0x10000UL) return false;
if ((p & 0xffffUL) == 0xc5c5UL) return false;
void *sym = dlsym(h, "ANativeWindow_fromSurface");
if (sym == nullptr) {
sym = dlsym(h, "AAssetManager_fromJava");
}
return sym != nullptr;
}
static void preload_hwui_libs() {
if (!orig_dlopen) {
// PLT 尚未拿到 orig 时,用 libc 直调
orig_dlopen = reinterpret_cast<dlopen_fn>(dlsym(RTLD_DEFAULT, "dlopen"));
}
if (!orig_sphal_load) {
orig_sphal_load = reinterpret_cast<sphal_load_fn>(
dlsym(RTLD_DEFAULT, "android_load_sphal_library"));
}
if (g_libandroid_handle != nullptr && !libandroid_handle_ok(g_libandroid_handle)) {
LOGI("drop invalid cached libandroid %p", g_libandroid_handle);
g_libandroid_handle = nullptr;
}
if (g_libandroid_handle == nullptr && orig_dlopen) {
static const char *kPaths[] = {
"libandroid.so",
"/system/lib64/libandroid.so",
"/apex/com.android.runtime/lib64/libandroid.so",
};
for (const char *path : kPaths) {
void *h = orig_dlopen(path, RTLD_NOW | RTLD_GLOBAL);
if (h == nullptr) {
h = try_dlopen_noload(path);
}
if (libandroid_handle_ok(h)) {
g_libandroid_handle = h;
LOGI("preload libandroid ok path=%s -> %p", path, h);
break;
}
if (h != nullptr) {
LOGI("preload libandroid reject path=%s -> %p", path, h);
}
}
if (g_libandroid_handle == nullptr) {
LOGI("preload libandroid FAILED");
}
}
if (g_mapper_pixel_handle == nullptr) {
if (orig_sphal_load) {
g_mapper_pixel_handle = orig_sphal_load("mapper.pixel.so", RTLD_NOW);
LOGI("preload mapper via sphal -> %p", g_mapper_pixel_handle);
}
if (g_mapper_pixel_handle == nullptr && orig_dlopen) {
g_mapper_pixel_handle = orig_dlopen(
"/vendor/lib64/hw/mapper.pixel.so", RTLD_NOW | RTLD_GLOBAL);
LOGI("preload mapper via path -> %p", g_mapper_pixel_handle);
}
}
}
static void *hooked_dlopen(const char *name, int flags) {
void *handle = orig_dlopen ? orig_dlopen(name, flags) : nullptr;
if (handle != nullptr || name == nullptr) {
if (name == nullptr) {
return handle;
}
if (handle != nullptr) {
// 命名空间下偶发返回毒化非空句柄HWUI 随后 FATAL
if (strstr(name, "libandroid.so") != nullptr && !libandroid_handle_ok(handle)) {
LOGI("dlopen got bad handle %p for %s — recover", handle, name);
handle = nullptr;
} else {
if (strstr(name, "libandroid.so") != nullptr) {
g_libandroid_handle = handle;
}
return handle;
}
}
// 已映射库:命名空间下按名 dlopen 会失败RTLD_NOLOAD 可取回句柄
handle = try_dlopen_noload(name);
if (handle != nullptr) {
if (strstr(name, "libandroid.so") == nullptr || libandroid_handle_ok(handle)) {
LOGI("dlopen NOLOAD hit name=%s -> %p", name, handle);
if (strstr(name, "libandroid.so") != nullptr) {
g_libandroid_handle = handle;
}
return handle;
}
}
if (strstr(name, "libandroid.so") != nullptr) {
static const char *kFallbacks[] = {
if (g_libandroid_handle != nullptr && !libandroid_handle_ok(g_libandroid_handle)) {
LOGI("drop bad cached libandroid %p", g_libandroid_handle);
g_libandroid_handle = nullptr;
}
if (g_libandroid_handle != nullptr) {
LOGI("dlopen return cached libandroid %p (from %s)",
g_libandroid_handle, name);
return g_libandroid_handle;
}
static const char *kAndroidPaths[] = {
"/system/lib64/libandroid.so",
"/system/lib/libandroid.so",
"libandroid.so",
};
for (const char *path : kFallbacks) {
handle = orig_dlopen ? orig_dlopen(path, flags) : nullptr;
if (handle != nullptr) {
LOGI("dlopen miss name=%s flags=0x%x tid=%d — try fallback",
name, flags, (int)gettid());
for (const char *path : kAndroidPaths) {
handle = try_dlopen_noload(path);
if (handle == nullptr && orig_dlopen) {
handle = orig_dlopen(path, flags | RTLD_GLOBAL);
}
if (libandroid_handle_ok(handle)) {
g_libandroid_handle = handle;
LOGI("dlopen fallback %s -> %p (from %s)", path, handle, name);
return handle;
}
if (handle != nullptr) {
LOGI("dlopen fallback reject %s -> %p", path, handle);
}
}
LOGI("dlopen libandroid.so failed tid=%d", (int)gettid());
LOGI("dlopen fallback failed name=%s tid=%d", name, (int)gettid());
return nullptr;
}
if (strstr(name, "mapper.pixel") != nullptr
|| strstr(name, "mapper.") != nullptr) {
if (g_mapper_pixel_handle != nullptr) {
LOGI("dlopen return cached mapper %p (from %s)",
g_mapper_pixel_handle, name);
return g_mapper_pixel_handle;
}
if (orig_sphal_load) {
handle = orig_sphal_load("mapper.pixel.so", RTLD_NOW);
if (handle != nullptr) {
g_mapper_pixel_handle = handle;
LOGI("dlopen sphal mapper -> %p (from %s)", handle, name);
return handle;
}
}
if (orig_dlopen) {
handle = orig_dlopen("/vendor/lib64/hw/mapper.pixel.so",
flags | RTLD_GLOBAL);
if (handle != nullptr) {
g_mapper_pixel_handle = handle;
LOGI("dlopen path mapper -> %p (from %s)", handle, name);
return handle;
}
}
LOGI("dlopen mapper failed name=%s tid=%d", name, (int)gettid());
}
return handle;
}
static void *hooked_android_dlopen_ext(const char *name, int flags, const void *extinfo) {
void *handle = orig_android_dlopen_ext
? orig_android_dlopen_ext(name, flags, extinfo)
: nullptr;
if (handle != nullptr || name == nullptr) {
return handle;
}
if (strstr(name, "libandroid.so") != nullptr
|| strstr(name, "mapper") != nullptr) {
LOGI("android_dlopen_ext miss name=%s — try dlopen fallback", name);
return hooked_dlopen(name, flags);
}
return handle;
}
@@ -381,7 +597,7 @@ static bool find_lib_match(const char *suffix, const char *contains,
unsigned long inode = 0;
char path[512] = {};
int n = sscanf(line, "%lx-%lx %7s %llx %31s %lu %511[^\n]",
&start, &end, perms, &offset, &deststr, &inode, path);
&start, &end, perms, &offset, deststr, &inode, path);
if (n < 7 || inode == 0) continue;
char *p = path;
while (*p == ' ') ++p;
@@ -409,20 +625,164 @@ static bool find_lib_by_suffix(const char *suffix, dev_t *dev, ino_t *ino) {
return find_lib_match(suffix, nullptr, dev, ino);
}
static bool find_lib_contains(const char *needle, dev_t *dev, ino_t *ino) {
return find_lib_match(nullptr, needle, dev, ino);
}
static bool find_libc(dev_t *dev, ino_t *ino) {
return find_lib_by_suffix("libc.so", dev, ino);
}
/** 收集 maps 里所有匹配后缀的已加载库去重。Zygisk commit 前必须覆盖全部副本,
* 否则 libc++ 多副本(/system、/vendor、/apex时只 hook 一份,调用点仍走原生实现。 */
struct lib_devino {
dev_t dev;
ino_t ino;
};
static int find_all_lib_by_suffix(const char *suffix, lib_devino *out, int max) {
FILE *fp = fopen("/proc/self/maps", "r");
if (!fp) return 0;
char line[1024];
int n = 0;
while (fgets(line, sizeof(line), fp)) {
uintptr_t start = 0, end = 0;
char perms[8] = {};
unsigned long long offset = 0;
char deststr[32] = {};
unsigned long inode = 0;
char path[512] = {};
int got = sscanf(line, "%lx-%lx %7s %llx %31s %lu %511[^\n]",
&start, &end, perms, &offset, deststr, &inode, path);
if (got < 7 || inode == 0) continue;
char *p = path;
while (*p == ' ') ++p;
size_t plen = strlen(p);
size_t slen = strlen(suffix);
if (plen < slen || strcmp(p + plen - slen, suffix) != 0) continue;
unsigned maj = 0, min = 0;
if (sscanf(deststr, "%x:%x", &maj, &min) != 2) continue;
dev_t d = makedev(maj, min);
ino_t in = inode;
bool dup = false;
for (int i = 0; i < n; i++) {
if (out[i].dev == d && out[i].ino == in) {
dup = true;
break;
}
}
if (dup) continue;
if (n < max) {
out[n].dev = d;
out[n].ino = in;
n++;
}
}
fclose(fp);
return n;
}
static void register_plt(zygisk::Api *api, dev_t dev, ino_t ino,
const char *sym, void *hook, void **orig) {
if (!api || dev == 0 || ino == 0) return;
api->pltHookRegister(dev, ino, sym, hook, orig);
}
static void *hooked_sphal_load(const char *name, int flags) {
void *handle = orig_sphal_load ? orig_sphal_load(name, flags) : nullptr;
if (handle != nullptr || name == nullptr) {
return handle;
}
LOGI("sphal miss name=%s flags=0x%x — try cache/path", name, flags);
if (strstr(name, "mapper") != nullptr) {
if (g_mapper_pixel_handle != nullptr) {
LOGI("sphal return cached mapper %p", g_mapper_pixel_handle);
return g_mapper_pixel_handle;
}
if (orig_dlopen) {
handle = orig_dlopen("/vendor/lib64/hw/mapper.pixel.so",
RTLD_NOW | RTLD_GLOBAL);
if (handle != nullptr) {
g_mapper_pixel_handle = handle;
LOGI("sphal path mapper -> %p", handle);
return handle;
}
handle = orig_dlopen("mapper.pixel.so", RTLD_NOW | RTLD_GLOBAL);
if (handle != nullptr) {
g_mapper_pixel_handle = handle;
LOGI("sphal name mapper -> %p", handle);
return handle;
}
}
}
return nullptr;
}
static void *hooked_open_passthrough_hal(const char *interface, const char *instance,
int dlopen_flags) {
void *handle = orig_open_passthrough_hal
? orig_open_passthrough_hal(interface, instance, dlopen_flags)
: nullptr;
if (handle != nullptr) {
return handle;
}
LOGI("passthroughHal miss iface=%s inst=%s — try mapper path",
interface ? interface : "?", instance ? instance : "?");
if ((interface && strstr(interface, "mapper") != nullptr)
|| (instance && strstr(instance, "pixel") != nullptr)) {
if (g_mapper_pixel_handle != nullptr) {
return g_mapper_pixel_handle;
}
if (orig_sphal_load) {
handle = orig_sphal_load("mapper.pixel.so", RTLD_NOW);
}
if (handle == nullptr && orig_dlopen) {
handle = orig_dlopen("/vendor/lib64/hw/mapper.pixel.so",
RTLD_NOW | RTLD_GLOBAL);
}
if (handle != nullptr) {
g_mapper_pixel_handle = handle;
LOGI("passthroughHal mapper recovered -> %p", handle);
}
}
return handle;
}
static void register_dlopen_on_lib(zygisk::Api *api, const char *suffix) {
dev_t dev = 0;
ino_t ino = 0;
if (!find_lib_by_suffix(suffix, &dev, &ino)) {
return;
}
// 必须 hook 调用方 PLTlibhwui/libui只 hook libc 拦不到 HWUI 的 dlopen
register_plt(api, dev, ino, "dlopen",
(void *)hooked_dlopen, (void **)&orig_dlopen);
register_plt(api, dev, ino, "android_dlopen_ext",
(void *)hooked_android_dlopen_ext, (void **)&orig_android_dlopen_ext);
register_plt(api, dev, ino, "android_load_sphal_library",
(void *)hooked_sphal_load, (void **)&orig_sphal_load);
register_plt(api, dev, ino, "AServiceManager_openDeclaredPassthroughHal",
(void *)hooked_open_passthrough_hal,
(void **)&orig_open_passthrough_hal);
LOGI("dlopen PLT on %s", suffix);
}
/** 注册所有已加载 libc++ 副本的 __cxa_guard_acquire/abort。返回注册的副本数。 */
static int register_cxx_guard_hooks(zygisk::Api *api) {
if (!api) return 0;
lib_devino libs[8];
int n = find_all_lib_by_suffix("libc++.so", libs, 8);
if (n == 0) {
n = find_all_lib_by_suffix("libc++_shared.so", libs, 8);
}
for (int i = 0; i < n; i++) {
register_plt(api, libs[i].dev, libs[i].ino, "__cxa_guard_acquire",
(void *)hooked_cxa_guard_acquire, (void **)&orig_cxa_guard_acquire);
register_plt(api, libs[i].dev, libs[i].ino, "__cxa_guard_abort",
(void *)hooked_cxa_guard_abort, (void **)&orig_cxa_guard_abort);
}
if (n > 0) {
LOGI("cxx guard target libc++ copies=%d", n);
}
return n;
}
static void install_plt(zygisk::Api *api) {
if (!api) return;
dev_t dev = 0;
@@ -439,9 +799,25 @@ static void install_plt(zygisk::Api *api) {
register_plt(api, dev, ino, "pthread_kill",
(void *)hooked_pthread_kill, (void **)&orig_pthread_kill);
register_plt(api, dev, ino, "dlopen", (void *)hooked_dlopen, (void **)&orig_dlopen);
register_plt(api, dev, ino, "android_dlopen_ext",
(void *)hooked_android_dlopen_ext, (void **)&orig_android_dlopen_ext);
}
// HWUI / libui 直接 PLT→linker必须单独挂
register_dlopen_on_lib(api, "libhwui.so");
register_dlopen_on_lib(api, "libui.so");
register_dlopen_on_lib(api, "libandroid_runtime.so");
register_dlopen_on_lib(api, "libbinder_ndk.so");
register_dlopen_on_lib(api, "libvndksupport.so");
// libc++ cxa guard 必须在首次 commit 前注册Zygisk pltHookCommit 二次调用会失败,
// 导致 __cxa_guard_acquire 递归 abort 保护从未生效(主线程反复 SIGABRT → 黑屏)。
int cxx = register_cxx_guard_hooks(api);
bool ok = api->pltHookCommit();
LOGI("PLT commit=%d", ok ? 1 : 0);
LOGI("PLT commit=%d cxx_guard_copies=%d", ok ? 1 : 0, cxx);
if (ok && cxx > 0) {
g_cxx_plt.store(1);
}
// commit 后立刻预加载,抢在 Promon/命名空间收紧之前拿到句柄
preload_hwui_libs();
}
static void try_install_cxx_guard_plt() {
@@ -449,33 +825,33 @@ static void try_install_cxx_guard_plt() {
dev_t dev = 0;
ino_t ino = 0;
bool any = false;
if (find_lib_by_suffix("libc++_shared.so", &dev, &ino)
|| find_lib_contains("libc++", &dev, &ino)) {
register_plt(g_api, dev, ino, "__cxa_guard_acquire",
(void *)hooked_cxa_guard_acquire, (void **)&orig_cxa_guard_acquire);
register_plt(g_api, dev, ino, "__cxa_guard_abort",
(void *)hooked_cxa_guard_abort, (void **)&orig_cxa_guard_abort);
any = true;
}
// libc++ 副本已由首次 PLT commit 覆盖;此处仅补 libtngdigital_ewallet.so 自身 PLT。
if (find_lib_by_suffix("libtngdigital_ewallet.so", &dev, &ino)) {
register_plt(g_api, dev, ino, "__cxa_guard_acquire",
(void *)hooked_cxa_guard_acquire, (void **)&orig_cxa_guard_acquire);
register_plt(g_api, dev, ino, "__cxa_guard_abort",
(void *)hooked_cxa_guard_abort, (void **)&orig_cxa_guard_abort);
any = true;
LOGI("cxx guard target libtngdigital_ewallet");
}
if (!any) return;
if (g_api->pltHookCommit()) {
bool ok = g_api->pltHookCommit();
LOGI("cxx guard commit=%d", ok ? 1 : 0);
if (ok) {
g_cxx_plt.store(1);
LOGI("PLT cxx guards committed");
LOGI("PLT cxx guards committed (orig acquire + recursive skip)");
}
}
static void *phase_thread(void *) {
install_promon_segv_handler();
/* Promon 用 SVC exit_group 绕过 PLT必须 seccomp。延迟 400ms 避开最早的 fork/getprop。 */
usleep(400 * 1000);
install_seccomp_exit_group_only();
LOGI("seccomp armed @400ms ok=%d", g_seccomp_ok.load());
if (install_seccomp_exit_group_only() == 0) {
LOGI("seccomp exit_group armed @400ms");
} else {
LOGE("seccomp install failed");
}
for (int i = 0; i < 40; i++) {
usleep(1000 * 1000);
install_soft_signals();
@@ -491,7 +867,7 @@ static void *phase_thread(void *) {
static void install_all(zygisk::Api *api) {
g_api = api;
g_main_tid.store(gettid());
LOGI("install pid=%d main_tid=%d (PLT+cxx-guard+ABRT-pc+4+SEGV-skip+exit_group@400ms)",
LOGI("install pid=%d main_tid=%d (PLT+cxx-guard+ABRT-pc+4+SEGV-skip+seccomp@400ms)",
getpid(), (int)g_main_tid.load());
install_fatal_skip_handlers();
install_soft_signals();

View File

@@ -22,16 +22,15 @@ logcat 标签:`NativeEncrypt: loading JNI`、`CharacterCryptoManager`
## 运行
```powershell
# 1. 手机启动 frida-server (root)
adb push frida-server /data/local/tmp/
adb shell su -c 'chmod 755 /data/local/tmp/frida-server && /data/local/tmp/frida-server -D &'
# SG 专用 native trace推荐
.\scripts\run-frida-sg-native.ps1
# 2. PC 安装 frida-tools 后
#
cd reverse\frida
.\run-frida-trace.ps1 -Mode spawn
C:\Users\Administrator\AppData\Local\Programs\Python\Python312\python.exe run_frida_sg_native.py attach
# 3. App 内 Sign up → 输入号码 → Next
# 关注 [MB-TRACE] NativeEncryptWrapper / NativeEncryptUtils / HTTP .../register
# PH 旧脚本(勿用于 SG
.\run-frida-trace.ps1 -Mode spawn
```
建议测试时**暂时关闭 LSPosed 对 MariBank 的作用域**,避免与 Frida 冲突。

25
reverse/frida/mini_run.py Normal file
View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
import subprocess, time
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
def adb(*a): return subprocess.run([ADB, *a], capture_output=True, text=True)
def on_msg(m, d):
line = m.get("payload") if m.get("type") in ("send", "log") else str(m)
print(line, flush=True)
d = frida.get_usb_device(10)
adb("shell", "am", "force-stop", PKG); time.sleep(1)
pid = d.spawn([PKG])
s = d.attach(pid)
sc = s.create_script(open("mini_timer.js", encoding="utf-8").read())
sc.on("message", on_msg)
sc.load()
print("resume", pid, flush=True)
d.resume(pid)
for i in range(20):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
print("t=%d pid=%s" % (i+1, p or "DEAD"), flush=True)
if not p: break
try: s.detach()
except: pass

View File

@@ -0,0 +1,9 @@
"use strict";
console.log("[MINI] start");
let i = 0;
const t = setInterval(() => {
i++;
const m = Process.findModuleByName("libtiger_tally.so");
console.log(`[MINI] tick=${i} tiger_loaded=${!!m}`);
if (i > 15) clearInterval(t);
}, 1000);

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""Frida SG native trace — attach to sg.com.maribankmobile.digitalbank."""
import sys
import time
from datetime import datetime
from pathlib import Path
import frida
PKG = "sg.com.maribankmobile.digitalbank"
HERE = Path(__file__).resolve().parent
LOGS_DIR = HERE.parent / "logs" / "frida"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
SCRIPT = HERE / "trace_maribank_sg_native.js"
LOG = LOGS_DIR / ("sg_native_%s.log" % datetime.now().strftime("%Y%m%d_%H%M%S"))
def on_message(message, data):
mtype = message.get("type")
if mtype == "send":
line = message.get("payload")
elif mtype == "log":
line = message.get("payload", message.get("description", ""))
else:
line = str(message)
text = line if isinstance(line, str) else repr(line)
print(text, flush=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write(text + "\n")
if mtype == "error":
with open(str(LOG) + ".err", "a", encoding="utf-8") as f:
f.write(text + "\n")
def wait_for_process(device, pkg, timeout_sec=60):
deadline = time.time() + timeout_sec
while time.time() < deadline:
for app in device.enumerate_applications():
if app.identifier == pkg and app.pid and app.pid > 0:
return app.pid
for proc in device.enumerate_processes():
if proc.name == pkg:
return proc.pid
time.sleep(0.5)
return None
def launch_app(pkg):
import subprocess
adb = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
subprocess.run([adb, "shell", "am", "force-stop", pkg], check=False, capture_output=True)
time.sleep(1)
subprocess.run(
[
adb,
"shell",
"am",
"start",
"-n",
pkg + "/com.shopee.bke.digitalbank.ui.MainActivity",
],
check=False,
capture_output=True,
)
def ensure_frida_server():
import subprocess
adb = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
out = subprocess.run(
[adb, "shell", "su", "-c", "pgrep frida-server"],
capture_output=True,
text=True,
)
if out.stdout.strip():
return
subprocess.run(
[adb, "shell", "su", "-c", "/data/local/tmp/frida-server -D &"],
check=False,
capture_output=True,
)
time.sleep(2)
def main():
mode = "attach"
if len(sys.argv) > 1:
mode = sys.argv[1]
if not SCRIPT.is_file():
raise SystemExit("missing script: %s" % SCRIPT)
ensure_frida_server()
device = frida.get_usb_device(timeout=10)
source = SCRIPT.read_text(encoding="utf-8")
print("Package: %s" % PKG)
print("Script: %s" % SCRIPT)
print("Log: %s" % LOG)
print("")
print("IMPORTANT: keep LSPosed scope ENABLED for SG (bypasses ADB page while tracing)")
print("")
if mode == "spawn":
pid = device.spawn([PKG])
session = device.attach(pid)
else:
pid = wait_for_process(device, PKG, 3)
if pid is None:
print("Launching MariBank SG ...")
launch_app(PKG)
pid = wait_for_process(device, PKG, 90)
if pid is None:
raise SystemExit(
"SG MariBank not running — open app to Sign up page, then re-run"
)
print("Attach pid=%s" % pid)
session = device.attach(pid)
script = session.create_script(source)
script.on("message", on_message)
script.load()
if mode == "spawn":
device.resume(pid)
print("Spawn resumed, wait JVM 15s ...")
time.sleep(15)
else:
time.sleep(3)
print("Trace running. Sign up -> +65 -> Next. Ctrl+C to stop.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Stopping...")
session.detach()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
"""Spawn TNG eWallet with Frida native exit blockers."""
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import frida
PKG = "my.com.tngdigital.ewallet"
HERE = Path(__file__).resolve().parent
LOGS_DIR = HERE.parent / "logs" / "frida"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
SCRIPT = HERE / "trace_tng_native_exit.js"
LOG = LOGS_DIR / ("tng_native_%s.log" % datetime.now().strftime("%Y%m%d_%H%M%S"))
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
HOLD_SEC = 45
def on_message(message, data):
mtype = message.get("type")
if mtype == "send":
line = message.get("payload")
elif mtype == "log":
line = message.get("payload", "")
else:
line = str(message)
text = line if isinstance(line, str) else repr(line)
print(text, flush=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write(text + "\n")
def adb(*args):
return subprocess.run([ADB, *args], capture_output=True, text=True)
def ensure_frida_server():
out = adb("shell", "su", "-c", "pgrep -x frida-server")
if out.stdout.strip():
print("frida-server already running pid=%s" % out.stdout.strip())
return
print("starting frida-server ...")
adb("shell", "su", "-c", "pkill -9 frida-server; true")
# run in background via nohup-like
subprocess.Popen(
[ADB, "shell", "su", "-c", "/data/local/tmp/frida-server -D"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(2)
out = adb("shell", "su", "-c", "pgrep -x frida-server")
if not out.stdout.strip():
raise RuntimeError("frida-server failed to start")
print("frida-server pid=%s" % out.stdout.strip())
def main():
ensure_frida_server()
adb("shell", "am", "force-stop", PKG)
time.sleep(1)
device = frida.get_usb_device(10)
source = SCRIPT.read_text(encoding="utf-8")
print("Spawning %s ..." % PKG)
print("log=%s" % LOG)
pid = device.spawn([PKG])
session = device.attach(pid)
script = session.create_script(source)
script.on("message", on_message)
script.load()
print("script loaded, resume pid=%s" % pid)
device.resume(pid)
alive = 0
for i in range(HOLD_SEC):
time.sleep(1)
# check process still alive
out = adb("shell", "pidof", PKG)
pids = out.stdout.strip()
if not pids:
print("DEAD after %ss" % (i + 1))
break
alive = i + 1
if (i + 1) % 5 == 0:
print("alive %ss pid=%s" % (alive, pids))
else:
print("STABLE %ss pid=%s" % (HOLD_SEC, adb("shell", "pidof", PKG).stdout.strip()))
# dump activity focus
focus = adb("shell", "dumpsys", "activity", "activities")
for line in focus.stdout.splitlines():
if "tngdigital" in line.lower() and (
"mResumedActivity" in line
or "topResumedActivity" in line
or "UserLogin" in line
or "SecurityError" in line
or "Splash" in line
):
print("ACT: " + line.strip())
try:
session.detach()
except Exception:
pass
print("done alive=%ss log=%s" % (alive, LOG))
return 0 if alive >= 15 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""Compare: Xposed-only vs Frida-stealth-spawn."""
import subprocess
import time
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
STEALTH = r"""
"use strict";
function log(m){ send("[stealth] "+m); }
function findExport(mod, name) {
try {
var m = Process.getModuleByName(mod);
var a = m.findExportByName(name);
if (a) return a;
} catch (e) {}
try { return Module.getGlobalExportByName(name); } catch (e2) { return null; }
}
// Rename frida threads
try {
var pthread_setname = findExport("libc.so", "pthread_setname_np");
// also patch existing: best-effort via Java later
} catch (e) {}
// Hide maps
var markers = ["frida","gadget","linjector","gum-js","gmain","pool-frida","hluda"];
var tracked = {};
function hideLine(line) {
var l = (line||"").toLowerCase();
for (var i=0;i<markers.length;i++) if (l.indexOf(markers[i])>=0) return true;
return false;
}
function filterBuf(buf, len) {
try {
var t = buf.readUtf8String(len);
if (!t) return len;
var out = t.split("\n").filter(function(x){return !hideLine(x);}).join("\n");
var b = Memory.allocUtf8String(out);
var n = Math.min(len, out.length);
Memory.copy(buf, b, n);
return n;
} catch (e) { return len; }
}
var openat = findExport("libc.so","openat");
var readFn = findExport("libc.so","read");
if (openat) {
Interceptor.attach(openat, {
onEnter: function(args){ this.path = args[1].isNull()?null:args[1].readCString(); },
onLeave: function(retval){
var fd=retval.toInt32();
if (fd>=0 && this.path && (this.path.indexOf("maps")>=0 || this.path.indexOf("status")>=0 || this.path.indexOf("task")>=0))
tracked[fd]=this.path;
}
});
}
if (readFn) {
Interceptor.attach(readFn, {
onEnter: function(args){ this.fd=args[0].toInt32(); this.buf=args[1]; },
onLeave: function(retval){
var n=retval.toInt32();
if (n>0 && tracked[this.fd]) retval.replace(ptr(filterBuf(this.buf, n)));
}
});
}
// Patch libc exit_group SVC
var libc = Process.getModuleByName("libc.so");
libc.enumerateRanges("r-x").forEach(function(r){
for (var off=0; off+8<r.size; off+=4) {
var p=r.base.add(off);
var w;
try { w=p.readU32(); } catch(e){ return; }
if (w!==0xd2800bc8 && w!==0xd2800ba8 && w!==0x52800bc8 && w!==0x52800ba8) continue;
for (var j=4;j<=24;j+=4) {
var s=p.add(j);
try {
if (s.readU32()===0xd4000001) {
Memory.protect(s,4,"rwx");
s.writeU32(0xd65f03c0);
log("SVC->RET "+s);
}
} catch(e2){}
}
}
});
// Replace exit APIs
["_exit","exit","abort"].forEach(function(n){
var a=findExport("libc.so",n);
if(!a) return;
try {
Interceptor.replace(a, new NativeCallback(function(c){ log("block "+n+"("+c+")"); }, "void", ["int"]));
} catch(e){}
});
log("stealth ready");
"""
def adb(*a):
return subprocess.run([ADB, *a], capture_output=True, text=True)
def on_msg(m, _d):
print(m.get("payload", m), flush=True)
def wait_alive(sec, label):
for i in range(sec):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
act = ""
dump = adb("shell", "dumpsys", "activity", "activities").stdout
for line in dump.splitlines():
if "tngdigital" in line.lower() and any(
x in line for x in ("UserLogin", "SecurityError", "Splash", "topResumedActivity")
):
act = line.strip()[:120]
break
print("%s t=%ds pid=%s act=%s" % (label, i + 1, p or "DEAD", act), flush=True)
if not p:
return i + 1
return sec
def test_xposed_only():
print("=== Xposed-only (monkey) ===", flush=True)
adb("shell", "am", "force-stop", PKG)
adb("logcat", "-c")
time.sleep(0.3)
adb(
"shell",
"monkey",
"-p",
PKG,
"-c",
"android.intent.category.LAUNCHER",
"1",
)
alive = wait_alive(15, "XPOSED")
print("xposed_alive_sec", alive, flush=True)
for line in adb("logcat", "-d").stdout.splitlines():
if "TngRoot" in line and any(
x in line for x in ("install", "UserLogin", "short-circuit", "finishing", "blocked intent")
):
print(line[line.find("TngRoot") :][:180], flush=True)
if "Displayed" in line and "tngdigital" in line:
print(line.strip()[:200], flush=True)
if "exited cleanly" in line:
print(line.strip()[:160], flush=True)
def test_frida_stealth():
print("=== Frida stealth spawn ===", flush=True)
adb("shell", "am", "force-stop", PKG)
time.sleep(0.3)
d = frida.get_usb_device(10)
pid = d.spawn([PKG])
s = d.attach(pid)
sc = s.create_script(STEALTH)
sc.on("message", on_msg)
sc.load()
d.resume(pid)
alive = wait_alive(15, "FRIDA")
print("frida_alive_sec", alive, flush=True)
def main():
test_xposed_only()
test_frida_stealth()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
import subprocess
import time
from pathlib import Path
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
SCRIPT = Path(__file__).with_name("trace_tng_diag_exit.js").read_text(encoding="utf-8")
def adb(*a):
return subprocess.run([ADB, *a], capture_output=True, text=True)
def on_msg(m, d):
if m.get("type") == "send":
print(m["payload"], flush=True)
else:
print(m, flush=True)
def main():
adb("shell", "am", "force-stop", PKG)
time.sleep(0.5)
d = frida.get_usb_device(10)
pid = d.spawn([PKG])
s = d.attach(pid)
sc = s.create_script(SCRIPT)
sc.on("message", on_msg)
sc.load()
print("resume", pid, flush=True)
d.resume(pid)
for i in range(15):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
print("t=%ds pid=%s" % (i + 1, p or "DEAD"), flush=True)
if not p:
break
try:
s.detach()
except Exception:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
import subprocess
import time
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
def adb(*a):
return subprocess.run([ADB, *a], capture_output=True, text=True)
def main():
adb("shell", "am", "force-stop", PKG)
adb("logcat", "-c")
time.sleep(0.3)
d = frida.get_usb_device(10)
pid = d.spawn([PKG])
s = d.attach(pid)
sc = s.create_script('send("empty ok " + String(Process.id));')
sc.on("message", lambda m, _d: print(m, flush=True))
sc.load()
print("resume", pid, flush=True)
d.resume(pid)
for i in range(10):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
print("t=%d %s" % (i + 1, p or "DEAD"), flush=True)
if not p:
break
print("--- death lines ---", flush=True)
for line in adb("logcat", "-d").stdout.splitlines():
if "exited cleanly" in line or "has died" in line and "tngdigital" in line:
print(line[:240], flush=True)
if "TngRoot" in line and ("install" in line or "short-circuit" in line or "blocked" in line):
print(line[:240], flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""Resolve libc _exit real target and patch its SVC."""
import subprocess
import time
import frida
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
SCRIPT = r"""
"use strict";
function log(m){ send("[TNG-native] "+m); }
function findExport(mod, name) {
try {
var m = Process.getModuleByName(mod);
var a = m.findExportByName(name);
if (a) return a;
} catch (e) {}
try { return Module.getGlobalExportByName(name); } catch (e2) { return null; }
}
function resolveTrampoline(addr, name) {
// Follow simple ADRP+ADD+BR / LDR+BR patterns a few times
var cur = addr;
for (var depth = 0; depth < 5; depth++) {
var w0 = cur.readU32();
var w1 = cur.add(4).readU32();
// BR Xn: 0xD61F0000 | (Rn<<5)
if ((w1 & 0xfffffc1f) === 0xd61f0000) {
var rn = (w1 >> 5) & 0x1f;
// LDR Xn, [PC, #imm] : 0x58000000
if ((w0 & 0xff000000) === 0x58000000) {
var imm19 = (w0 >> 5) & 0x7ffff;
if (imm19 & 0x40000) imm19 -= 0x80000;
var targetPtr = cur.add(imm19 * 4);
var target = targetPtr.readPointer();
log(name + " trampoline LDR+BR -> " + target);
cur = target;
continue;
}
// ADRP Xn, page
if ((w0 & 0x9f000000) === 0x90000000) {
var rd = w0 & 0x1f;
var immhi = (w0 >> 5) & 0x7ffff;
var immlo = (w0 >> 29) & 0x3;
var imm = ((immhi << 2) | immlo) << 12;
if (imm & 0x100000000) imm = imm - 0x200000000;
var page = cur.and(ptr("0xfffffffffffff000")).add(imm);
// next might be LDR/ADD
var w2 = cur.add(8).readU32();
log(name + " ADRP page="+page+" rn="+rn+" rd="+rd+" w2="+w2.toString(16));
}
log(name + " BR X" + rn + " at " + cur + " (stop follow)");
break;
}
break;
}
return cur;
}
function patchSvcNear(addr, name, windowSize) {
var nop = [0x1f, 0x20, 0x03, 0xd5];
var n = 0;
for (var i = 0; i < windowSize; i += 4) {
try {
var p = addr.add(i);
if (p.readU32() !== 0xd4000001) continue;
Memory.protect(p, 4, "rwx");
// replace svc with: mov x0, x0; ret — or just nop and hope
// Better: movz x0, #0; ret so "exit" becomes return 0
// movz x0,#0 = 0xD2800000; ret = 0xD65F03C0
p.writeU32(0xd2800000); // movz x0, #0
if (i + 4 < windowSize) {
var p2 = addr.add(i + 4);
// only overwrite next if also svc/brk or nop pad — safer: write ret at svc place only via branch
}
// Just NOP the svc — caller may hang; use ret instead by overwriting svc with ret
p.writeU32(0xd65f03c0); // RET
n++;
log("patched SVC->RET @ " + p + " (" + name + "+" + i + ")");
} catch (e) {
log("patch fail: " + e);
}
}
return n;
}
function hookByPatchingLibcExit() {
var libc = Process.getModuleByName("libc.so");
// Scan entire libc for the classic exit_group sequence:
// mov x8, #94; mov x0, ...; svc #0 OR svc inside _exit impl
var count = 0;
var svcAddrs = [];
// Focused: exports that must lead to exit
["_exit", "exit"].forEach(function (n) {
var a = libc.findExportByName(n);
if (!a) return;
log(n + " export " + a);
// DebugSymbol / Instruction parse: find first BL/B to real impl
});
// Brute: scan libc executable for movz x8,#94 followed within 16 bytes by svc
var ranges = libc.enumerateRanges("r-x");
ranges.forEach(function (r) {
for (var off = 0; off + 8 < r.size; off += 4) {
var p = r.base.add(off);
var w;
try { w = p.readU32(); } catch (e) { return; }
// movz x8, #94 = 0xD2800BC8 ; movz w8,#94 = 0x52800BC8
// movz x8, #93 = 0xD2800BA8
if (w !== 0xd2800bc8 && w !== 0x52800bc8 && w !== 0xd2800ba8 && w !== 0x52800ba8) continue;
// look ahead for svc
for (var j = 4; j <= 24; j += 4) {
try {
if (p.add(j).readU32() === 0xd4000001) {
svcAddrs.push(p.add(j));
log("exit-seq movz@ " + p + " svc@ " + p.add(j));
}
} catch (e2) {}
}
}
});
svcAddrs.forEach(function (svc) {
try {
Memory.protect(svc, 4, "rwx");
// Replace svc with ret — turns exit into function return
svc.writeU32(0xd65f03c0);
count++;
log("SVC->RET " + svc);
} catch (e) {
log("SVC patch fail " + svc + ": " + e);
}
});
log("libc exit SVC patches=" + count);
}
function installEntryLog() {
["_exit", "exit", "abort"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
try {
Interceptor.attach(a, {
onEnter: function (args) {
log("ENTER " + n + "(" + args[0] + ")");
}
});
} catch (e) {}
});
}
// Also Interceptor.replace as backup
function installReplace() {
["_exit", "exit", "abort"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
try {
Interceptor.replace(a, new NativeCallback(function (code) {
log("REPLACED-HIT " + n + "(" + (code|0) + ")");
}, "void", ["int"]));
log("replaced " + n);
} catch (e) {
log("replace " + n + " fail: " + e);
}
});
}
log("pid=" + Process.id);
hookByPatchingLibcExit();
installReplace();
installEntryLog();
log("ready");
"""
def adb(*a):
return subprocess.run([ADB, *a], capture_output=True, text=True)
def on_msg(m, d):
print(m.get("payload", m), flush=True)
def main():
adb("shell", "am", "force-stop", PKG)
time.sleep(0.4)
device = frida.get_usb_device(10)
pid = device.spawn([PKG])
session = device.attach(pid)
script = session.create_script(SCRIPT)
script.on("message", on_msg)
script.load()
print("resume", pid, flush=True)
device.resume(pid)
for i in range(25):
time.sleep(1)
p = adb("shell", "pidof", PKG).stdout.strip()
print("t=%ds %s" % (i + 1, p or "DEAD"), flush=True)
if not p:
break
else:
print("STABLE", flush=True)
focus = adb("shell", "dumpsys", "activity", "activities").stdout
for line in focus.splitlines():
if "tngdigital" in line.lower() and any(
x in line for x in ("UserLogin", "SecurityError", "Splash", "mResumed", "topResumed")
):
print("ACT", line.strip()[:200], flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""Spawn TNG, observe TigerTally fread blocking (observe-only, no behavior change)."""
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
import frida
PKG = "my.com.tngdigital.ewallet"
HERE = Path(__file__).resolve().parent
LOGS_DIR = HERE.parent / "logs" / "frida"
LOGS_DIR.mkdir(parents=True, exist_ok=True)
SCRIPT = HERE / "trace_tng_tiger_fread.js"
LOG = LOGS_DIR / ("tng_tiger_fread_%s.log" % datetime.now().strftime("%Y%m%d_%H%M%S"))
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
HOLD_SEC = 45
def on_message(message, data):
mtype = message.get("type")
if mtype == "send":
line = message.get("payload")
elif mtype == "log":
line = message.get("payload", "")
else:
line = str(message)
text = line if isinstance(line, str) else repr(line)
print(text, flush=True)
with open(LOG, "a", encoding="utf-8") as f:
f.write(text + "\n")
def adb(*args):
return subprocess.run([ADB, *args], capture_output=True, text=True)
def ensure_frida_server():
out = adb("shell", "su", "-c", "pgrep -x frida-server")
if out.stdout.strip():
print("frida-server running pid=%s" % out.stdout.strip())
return
adb("shell", "su", "-c", "pkill -9 frida-server; true")
subprocess.Popen(
[ADB, "shell", "su", "-c", "/data/local/tmp/frida-server -D"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(2)
out = adb("shell", "su", "-c", "pgrep -x frida-server")
if not out.stdout.strip():
raise RuntimeError("frida-server failed to start")
print("frida-server pid=%s" % out.stdout.strip())
def main():
ensure_frida_server()
adb("shell", "am", "force-stop", PKG)
time.sleep(1)
device = frida.get_usb_device(10)
source = SCRIPT.read_text(encoding="utf-8")
print("spawn %s ..." % PKG)
print("log=%s" % LOG)
pid = device.spawn([PKG])
session = device.attach(pid)
script = session.create_script(source)
script.on("message", on_message)
script.load()
print("script loaded, resume pid=%s" % pid)
device.resume(pid)
alive = 0
for i in range(HOLD_SEC):
time.sleep(1)
out = adb("shell", "pidof", PKG)
pids = out.stdout.strip()
if not pids:
print("DEAD after %ss" % (i + 1))
break
alive = i + 1
if (i + 1) % 5 == 0:
print("alive %ss pid=%s" % (alive, pids))
else:
print("STABLE %ss pid=%s" % (HOLD_SEC, adb("shell", "pidof", PKG).stdout.strip()))
# 焦点 Activity
focus = adb("shell", "dumpsys", "activity", "activities")
for line in focus.stdout.splitlines():
if "tngdigital" in line.lower() and (
"mResumedActivity" in line or "topResumedActivity" in line
or "UserLogin" in line or "SecurityError" in line or "Splash" in line
):
print("ACT: " + line.strip())
try:
session.detach()
except Exception:
pass
print("done alive=%ss log=%s" % (alive, LOG))
return 0 if alive >= 15 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Spawn TNG and strace for exit syscalls (needs root)."""
import subprocess
import time
import sys
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
def adb(*args, timeout=30):
return subprocess.run([ADB, *args], capture_output=True, text=True, timeout=timeout)
def main():
adb("shell", "am", "force-stop", PKG)
time.sleep(0.5)
adb("logcat", "-c")
# start app
adb(
"shell",
"monkey",
"-p",
PKG,
"-c",
"android.intent.category.LAUNCHER",
"1",
)
time.sleep(0.4)
out = adb("shell", "pidof", PKG)
pid = out.stdout.strip().split()[0] if out.stdout.strip() else ""
if not pid:
print("no pid")
return 1
print("pid", pid)
# strace briefly
p = subprocess.Popen(
[
ADB,
"shell",
"su",
"-c",
f"timeout 8 strace -f -e trace=exit,exit_group,kill,tkill,tgkill,write -p {pid} 2>&1 | head -80",
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
try:
stdout, _ = p.communicate(timeout=15)
print(stdout)
except subprocess.TimeoutExpired:
p.kill()
print(p.stdout.read() if p.stdout else "timeout")
print("alive?", adb("shell", "pidof", PKG).stdout.strip())
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,634 @@
'use strict';
/**
* MariBank SG 3.2.2 — Java + native attestation / encrypt trace
* Package: sg.com.maribankmobile.digitalbank
*
* SG 差异: 无 utils.d / com.shopee.shpssdk.*,仅 shpssdkbank + uvwuvwuv
*/
const TAG = '[MB-NATIVE]';
const MAX_STR = 4000;
const MAX_BYTES_LOG = 8192;
const HOOKED_NATIVE_PTRS = {};
const JAVA_TARGETS = [
'com.shopee.shpssdkbank.wvvvuwwu',
'com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv',
'com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu',
'com.shopee.shpssdkbank.uwuvuvvww.wvvuuwvwu',
'com.shopee.shpssdkbank.uvuwwuvwv.uvwwuuvvw',
'com.shopee.bke.lib.jni.utils.uvwuvwuv',
'com.shopee.bke.lib.jni.utils.uvwwwwuv',
'com.shopee.shpssdkbank.SHPSSDK',
];
const SO_WATCH = [
'libshpssdk_bank.so',
'libshpssdk.so',
'libsdkutils.so',
'libbkutils.so',
];
function log(msg) {
send(TAG + ' ' + msg);
}
function jniFn(envPtr, index, ret, args) {
const funcs = envPtr.readPointer();
const addr = funcs.add(index * Process.pointerSize).readPointer();
if (!addr || addr.isNull()) return null;
return new NativeFunction(addr, ret, args);
}
function jniReadByteArray(envPtr, jarrayPtr) {
if (!jarrayPtr || jarrayPtr.isNull()) return null;
try {
const GetArrayLength = jniFn(envPtr, 171, 'int', ['pointer', 'pointer']);
const GetByteArrayElements = jniFn(envPtr, 184, 'pointer', ['pointer', 'pointer', 'pointer']);
const ReleaseByteArrayElements = jniFn(envPtr, 187, 'void', ['pointer', 'pointer', 'pointer', 'int']);
if (!GetArrayLength || !GetByteArrayElements || !ReleaseByteArrayElements) {
return jniReadByteArrayArt(envPtr, jarrayPtr);
}
const len = GetArrayLength(envPtr, jarrayPtr);
if (len <= 0 || len > MAX_BYTES_LOG) return { len: len, hex: '', text: '' };
const elems = GetByteArrayElements(envPtr, jarrayPtr, ptr(0));
if (!elems || elems.isNull()) return { len: len, hex: '', text: '' };
const raw = elems.readByteArray(Math.min(len, MAX_BYTES_LOG));
ReleaseByteArrayElements(envPtr, jarrayPtr, elems, 0);
return bytesToPreview(raw, len);
} catch (e) {
return { len: -1, hex: 'err:' + e, text: '' };
}
}
function bytesToPreview(raw, len) {
const arr = new Uint8Array(raw);
let text = '';
try {
text = String.fromCharCode.apply(null, arr);
if (text.indexOf('\u0000') >= 0 || !/^[\x20-\x7e\r\n\t\u4e00-\u9fff\u0100-\u024f]+$/.test(text.substring(0, Math.min(text.length, 200)))) {
text = '';
}
} catch (e) {
text = '';
}
if (text.length > MAX_STR) text = text.substring(0, MAX_STR) + '...';
return { len: len, hex: hexPreview(arr, 64), text: text, arr: arr };
}
function jniReadByteArrayArt(envPtr, jarrayPtr) {
const art = moduleByName('libart.so');
if (!art) return { len: -1, hex: 'err:no-art', text: '' };
let sym = null;
art.enumerateSymbols().forEach(function (s) {
if (sym) return;
if (s.name.indexOf('GetByteArrayRegion') >= 0 && s.name.indexOf('JNI') >= 0) {
sym = s.address;
}
});
if (!sym) return { len: -1, hex: 'err:no-GetByteArrayRegion', text: '' };
const GetLen = jniFn(envPtr, 171, 'int', ['pointer', 'pointer']);
const len = GetLen ? GetLen(envPtr, jarrayPtr) : 0;
if (len <= 0 || len > MAX_BYTES_LOG) return { len: len, hex: '', text: '' };
const buf = Memory.alloc(len);
const GetRegion = new NativeFunction(sym, 'void', ['pointer', 'pointer', 'int', 'int', 'pointer']);
GetRegion(envPtr, jarrayPtr, 0, len, buf);
return bytesToPreview(buf.readByteArray(len), len);
}
function jniReadJstring(envPtr, jstrPtr) {
if (!jstrPtr || jstrPtr.isNull()) return '';
try {
const GetStringUTFChars = jniFn(envPtr, 169, 'pointer', ['pointer', 'pointer', 'pointer']);
const ReleaseStringUTFChars = jniFn(envPtr, 170, 'void', ['pointer', 'pointer', 'pointer']);
if (!GetStringUTFChars || !ReleaseStringUTFChars) return '';
const chars = GetStringUTFChars(envPtr, jstrPtr, ptr(0));
if (!chars || chars.isNull()) return '';
const s = chars.readCString();
ReleaseStringUTFChars(envPtr, jstrPtr, chars);
return s || '';
} catch (e) {
return '';
}
}
function dumpNativeArgs(methodName, sig, envPtr, args) {
if (methodName === 'vuwuuwvw' && sig.indexOf('[B[B') >= 0) {
const a0 = jniReadByteArray(envPtr, args[2]);
const a1 = jniReadByteArray(envPtr, args[3]);
if (a0) log(' nat in0 len=' + a0.len + ' hex=' + a0.hex);
if (a1) log(' nat in1 len=' + a1.len + ' hex=' + a1.hex);
return;
}
if (methodName === 'uvwuuww') {
const plain = jniReadByteArray(envPtr, args[2]);
const key = jniReadJstring(envPtr, args[3]);
const flag = args[4] ? args[4].toInt32() : 0;
if (plain) {
log(' nat plain len=' + plain.len + ' hex=' + plain.hex);
if (plain.text) log(' nat plain utf8=' + plain.text);
}
if (key) log(' nat key=' + key + ' flag=' + flag);
return;
}
if (methodName === 'vuwuuuwv' && sig.indexOf('[B[B') >= 0) {
const a0 = jniReadByteArray(envPtr, args[2]);
const a1 = jniReadByteArray(envPtr, args[3]);
if (a0) log(' nat defense in0 len=' + a0.len + ' hex=' + a0.hex);
if (a1) log(' nat defense in1 len=' + a1.len + ' hex=' + a1.hex);
}
}
function hexPreview(arr, limit) {
if (!arr) return '';
const n = Math.min(arr.length, limit || 64);
let hex = '';
for (let i = 0; i < n; i++) {
const b = (arr[i] & 0xff).toString(16);
hex += (b.length === 1 ? '0' : '') + b;
}
if (arr.length > n) hex += '...(' + arr.length + ')';
return hex;
}
function dumpBytes(label, jobj) {
if (jobj === null || jobj === undefined) {
log(label + ' = null');
return;
}
try {
const arr = Java.cast(jobj, Java.use('[B'));
let text = '';
try {
text = Java.use('java.lang.String').$new(arr, 'UTF-8').toString();
} catch (e) {
text = '';
}
const printable = text.length > 0 && text.indexOf('\u0000') < 0;
if (printable && (text.indexOf('rdVerifyInfo') >= 0 || text.indexOf('REGISTRATION') >= 0
|| text.indexOf('deviceFingerprint') >= 0 || text.length < MAX_STR)) {
const show = text.length > MAX_STR ? text.substring(0, MAX_STR) + '...' : text;
log(label + ' byte[' + arr.length + '] utf8=' + show);
} else {
log(label + ' byte[' + arr.length + '] hex=' + hexPreview(arr, 48));
}
} catch (e) {
log(label + ' dump err=' + e);
}
}
function dumpJava(label, obj) {
if (obj === null || obj === undefined) {
log(label + ' = null');
return;
}
try {
const cls = obj.getClass().getName();
if (cls === '[B') {
dumpBytes(label, obj);
return;
}
if (cls === 'java.lang.String') {
const s = Java.cast(obj, Java.use('java.lang.String')).toString();
const show = s.length > MAX_STR ? s.substring(0, MAX_STR) + '...' : s;
log(label + ' String(' + s.length + ') ' + show);
return;
}
if (cls === '[Ljava.lang.String;') {
const arr = Java.cast(obj, Java.use('[Ljava.lang.String;'));
log(label + ' String[' + arr.length + ']');
for (let i = 0; i < arr.length; i++) dumpJava(label + '[' + i + ']', arr[i]);
return;
}
if (cls === '[[B') {
const outer = Java.cast(obj, Java.use('[[B'));
log(label + ' byte[][] len=' + outer.length);
for (let i = 0; i < outer.length; i++) dumpBytes(label + '[' + i + ']', outer[i]);
return;
}
log(label + ' ' + cls + ' = ' + obj.toString());
} catch (e) {
log(label + ' err=' + e);
}
}
function shouldLogRegisterText(s) {
if (!s) return false;
const low = s.toLowerCase();
return low.indexOf('register') >= 0 || low.indexOf('rdverifyinfo') >= 0
|| low.indexOf('datakey') >= 0 || low.indexOf('fingerprint') >= 0
|| low.indexOf('3100012') >= 0 || s.indexOf('|') >= 0;
}
/* ---------- native: dlopen + RegisterNatives ---------- */
function moduleExport(moduleName, symbol) {
if (typeof Module.getExportByName === 'function') {
try {
return Module.getExportByName(moduleName, symbol);
} catch (e) {
return null;
}
}
if (typeof Module.findExportByName === 'function') {
return Module.findExportByName(moduleName, symbol);
}
return null;
}
function moduleByName(name) {
if (typeof Process.getModuleByName === 'function') {
try {
return Process.getModuleByName(name);
} catch (e) {
return null;
}
}
if (typeof Process.findModuleByName === 'function') {
return Process.findModuleByName(name);
}
return null;
}
function moduleByAddress(addr) {
if (typeof Process.getModuleByAddress === 'function') {
try {
return Process.getModuleByAddress(addr);
} catch (e) {
return null;
}
}
if (typeof Process.findModuleByAddress === 'function') {
return Process.findModuleByAddress(addr);
}
return null;
}
function hookDlopen() {
const names = ['android_dlopen_ext', '__loader_android_dlopen_ext', 'dlopen'];
names.forEach(function (sym) {
const addr = moduleExport(null, sym);
if (!addr) return;
Interceptor.attach(addr, {
onEnter(args) {
try {
this.path = args[0].readCString();
} catch (e) {
this.path = '';
}
},
onLeave() {
if (!this.path) return;
SO_WATCH.forEach(function (so) {
if (this.path.indexOf(so) >= 0) log('dlopen ' + this.path);
}, this);
},
});
log('hooked ' + sym);
});
}
function findRegisterNatives() {
const art = moduleByName('libart.so');
if (!art) return null;
let found = null;
art.enumerateSymbols().forEach(function (sym) {
if (found) return;
const n = sym.name;
if (n.indexOf('RegisterNatives') >= 0
&& n.indexOf('CheckJNI') < 0
&& n.indexOf('art') >= 0) {
found = sym.address;
}
});
return found;
}
function hookNativePtr(className, methodName, sig, fnPtr) {
const key = fnPtr.toString();
if (HOOKED_NATIVE_PTRS[key]) return;
HOOKED_NATIVE_PTRS[key] = true;
const mod = moduleByAddress(fnPtr);
const modName = mod ? mod.name : '?';
const off = mod ? fnPtr.sub(mod.base) : fnPtr;
log('RegisterNatives HOOK ' + className + '.' + methodName + sig
+ ' @ ' + modName + '+0x' + off.toString(16));
try {
Interceptor.attach(fnPtr, {
onEnter(args) {
this.mname = methodName;
this.msig = sig;
this.env = args[0];
log('native>> ' + className + '.' + methodName + sig);
dumpNativeArgs(methodName, sig, this.env, args);
},
onLeave(retval) {
log('native<< ' + className + '.' + methodName + ' ret=' + retval);
},
});
} catch (e) {
log('Interceptor.attach fail ' + methodName + ': ' + e);
}
}
function resolveJClassName(jclassPtr) {
if (typeof Java === 'undefined' || !Java.available) {
return '';
}
let className = '';
const run = (typeof Java.performNow === 'function') ? Java.performNow : Java.perform;
try {
run(function () {
className = Java.cast(jclassPtr, Java.use('java.lang.Class')).getName();
});
} catch (e) {
className = '';
}
return className;
}
function isInterestingSo(modName) {
if (!modName) return false;
return modName.indexOf('shpssdk') >= 0
|| modName.indexOf('sdkutils') >= 0
|| modName.indexOf('bkutils') >= 0;
}
function hookRegisterNatives() {
const addr = findRegisterNatives();
if (!addr) {
log('RegisterNatives symbol not found');
return;
}
Interceptor.attach(addr, {
onEnter(args) {
const count = args[3].toInt32();
const methods = args[2];
const clazz = args[1];
const className = resolveJClassName(clazz) || '<unknown>';
const classHit = className.indexOf('shpssdk') >= 0
|| className.indexOf('jni.utils') >= 0
|| className.indexOf('bke.lib.jni') >= 0;
const ptrSize = Process.pointerSize;
let loggedClass = false;
for (let i = 0; i < count; i++) {
const base = methods.add(i * ptrSize * 3);
const name = base.readPointer().readCString();
const sig = base.add(ptrSize).readPointer().readCString();
const fnPtr = base.add(ptrSize * 2).readPointer();
const mod = moduleByAddress(fnPtr);
const modName = mod ? mod.name : '';
if (!classHit && !isInterestingSo(modName)) continue;
if (!loggedClass) {
log('RegisterNatives class=' + className + ' count=' + count);
loggedClass = true;
}
log(' JNI ' + name + sig + ' -> ' + fnPtr + ' (' + modName + ')');
hookNativePtr(className, name, sig, fnPtr);
}
},
});
log('hooked RegisterNatives @ ' + addr);
}
/* ---------- Java: hook static native + key methods ---------- */
function hookJavaMethod(className, clazz, methodName, isNative, retName, isStatic) {
try {
const overloads = clazz[methodName].overloads;
overloads.forEach(function (ovl) {
ovl.implementation = function () {
const args = [].slice.call(arguments);
log('Java>> ' + className + '.' + methodName
+ (isStatic ? ' static' : '')
+ (isNative ? ' native' : '') + ' args=' + args.length);
args.forEach(function (a, idx) { dumpJava(' in' + idx, a); });
const ret = ovl.apply(this, args);
if (retName === 'void') {
log('Java<< ' + methodName + ' void');
} else if (retName === '[B') {
dumpBytes(' out', ret);
} else if (retName === 'java.lang.String') {
dumpJava(' out', ret);
} else if (retName === '[[B') {
dumpJava(' out', ret);
} else if (retName === 'boolean' || retName === 'int' || retName === 'long') {
log(' out=' + ret);
} else {
dumpJava(' out', ret);
}
return ret;
};
});
log('hooked ' + className + '.' + methodName + ' overloads=' + overloads.length
+ (isNative ? ' native' : '') + (isStatic ? ' static' : ''));
return 1;
} catch (e) {
return 0;
}
}
function hookClassMethods(className, staticOnly, instanceOnly) {
let clazz;
try {
clazz = Java.use(className);
} catch (e) {
log('skip Java class ' + className + ': ' + e);
return 0;
}
const Modifier = Java.use('java.lang.reflect.Modifier');
const declared = clazz.class.getDeclaredMethods();
let hooked = 0;
for (let i = 0; i < declared.length; i++) {
const m = declared[i];
const isStatic = Modifier.isStatic(m.getModifiers());
if (staticOnly && !isStatic) continue;
if (instanceOnly && isStatic) continue;
const methodName = m.getName();
const isNative = Modifier.isNative(m.getModifiers());
const retName = m.getReturnType().getName();
hooked += hookJavaMethod(className, clazz, methodName, isNative, retName, isStatic);
}
return hooked;
}
function hookShpsSdkFacade() {
try {
const SHPSSDK = Java.use('com.shopee.shpssdkbank.SHPSSDK');
['getRiskToken', 'getRiskSync', 'requestDefense', 'assessRisk'].forEach(function (name) {
if (!SHPSSDK[name]) return;
SHPSSDK[name].overloads.forEach(function (ovl) {
ovl.implementation = function () {
const args = [].slice.call(arguments);
log('Java>> SHPSSDK.' + name);
args.forEach(function (a, idx) { dumpJava(' in' + idx, a); });
const ret = ovl.apply(this, args);
dumpJava(' out', ret);
return ret;
};
});
log('hooked SHPSSDK.' + name);
});
} catch (e) {
log('SHPSSDK facade skip: ' + e);
}
}
function hookOkHttp() {
try {
const RealCall = Java.use('okhttp3.RealCall');
RealCall.execute.implementation = function () {
const req = this.request();
const url = req.url().toString();
if (url.indexOf('register') >= 0 || url.indexOf('dfp') >= 0 || url.indexOf('uapi') >= 0) {
log('HTTP>> ' + req.method() + ' ' + url);
}
const resp = this.execute.call(this);
if (url.indexOf('register') >= 0) {
try {
const peek = resp.peekBody(Java.use('java.lang.Long').parseLong('1048576'));
log('HTTP<< register ' + peek.string());
} catch (e) {
log('HTTP<< register peek err=' + e);
}
}
return resp;
};
log('hooked OkHttp RealCall.execute');
} catch (e) {
log('OkHttp skip: ' + e);
}
}
function hookGsonRegister() {
try {
const Gson = Java.use('com.google.gson.Gson');
Gson.toJson.overload('java.lang.Object').implementation = function (obj) {
const ret = this.toJson(obj);
if (shouldLogRegisterText(ret)) {
const show = ret.length > MAX_STR ? ret.substring(0, MAX_STR) + '...' : ret;
log('Gson.toJson REGISTRATION len=' + ret.length + ' ' + show);
}
return ret;
};
log('hooked Gson.toJson');
} catch (e) {
log('Gson skip: ' + e);
}
}
function hookRiskTokenEntry() {
try {
const V = Java.use('com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv');
hookJavaMethod(
'com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv',
V, 'wwvuwuwvu', false, 'java.lang.String', true);
} catch (e) {
log('vvuuuuvvv skip: ' + e);
}
}
function hookEncryptHelper() {
hookClassMethods('com.shopee.bke.lib.jni.utils.uvwwwwuv', false, true);
}
function isAdbSettingKey(key) {
if (!key) return false;
const lower = key.toLowerCase();
return lower.indexOf('adb') >= 0
|| lower === 'development_settings_enabled'
|| lower.indexOf('wireless_debug') >= 0;
}
function hookAdbBypassJava() {
try {
const fakeInt = function (key) {
if (isAdbSettingKey(key)) {
log('fake Settings int ' + key + ' -> 0');
return 0;
}
return null;
};
const fakeStr = function (key) {
if (isAdbSettingKey(key)) {
log('fake Settings str ' + key + ' -> 0');
return '0';
}
return null;
};
['Global', 'Secure', 'System'].forEach(function (bucket) {
const Cls = Java.use('android.provider.Settings$' + bucket);
Cls.getInt.overloads.forEach(function (ovl) {
ovl.implementation = function () {
const key = arguments[1];
const f = fakeInt(String(key));
if (f !== null) return f;
return ovl.apply(this, arguments);
};
});
if (Cls.getString) {
Cls.getString.overloads.forEach(function (ovl) {
ovl.implementation = function () {
const key = arguments[1];
const f = fakeStr(String(key));
if (f !== null) return f;
return ovl.apply(this, arguments);
};
});
}
});
const SysProp = Java.use('android.os.SystemProperties');
SysProp.get.overload('java.lang.String').implementation = function (key) {
if (key === 'init.svc.adbd' || key === 'init.svc.adb_wifi') {
log('fake SystemProperties ' + key + ' -> stopped');
return 'stopped';
}
return this.get(key);
};
log('hooked ADB Settings/SystemProperties bypass');
} catch (e) {
log('ADB Java bypass skip: ' + e);
}
}
function installJavaHooks() {
hookAdbBypassJava();
let total = 0;
JAVA_TARGETS.forEach(function (cn) {
total += hookClassMethods(cn, true, false);
});
hookRiskTokenEntry();
hookEncryptHelper();
hookShpsSdkFacade();
hookOkHttp();
hookGsonRegister();
log('Java hooks installed methods=' + total + ' pid=' + Process.id);
log('READY SG — Sign up -> +65 -> Next (watch native>> / Gson / HTTP)');
}
function waitForJava(n) {
n = n || 0;
if (typeof Java === 'undefined' || !Java.available) {
if (n % 10 === 0) log('waiting Java.available attempt=' + n);
setTimeout(function () { waitForJava(n + 1); }, 500);
return;
}
Java.perform(function () {
installJavaHooks();
});
}
setImmediate(function () {
log('SG native trace loaded pid=' + Process.id);
hookDlopen();
hookRegisterNatives();
waitForJava(0);
});

View File

@@ -0,0 +1,74 @@
/**
* Diagnostic: only LOG exit-related calls, do not block.
*/
"use strict";
function log(msg) { send("[TNG-diag] " + msg); }
function findExport(mod, name) {
try {
var m = Process.findModuleByName(mod);
if (m) { var a = m.findExportByName(name); if (a) return a; }
} catch (e) {}
try { return Module.getGlobalExportByName(name); } catch (e2) { return null; }
}
function bt(ctx) {
try {
return Thread.backtrace(ctx, Backtracer.FUZZY).map(DebugSymbol.fromAddress).slice(0, 8).join(" <- ");
} catch (e) { return "?"; }
}
["_exit", "exit", "abort", "quick_exit"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
Interceptor.attach(a, {
onEnter: function (args) {
log("CALL " + n + "(" + args[0] + ") " + bt(this.context));
}
});
log("watch " + n + " @ " + a);
});
["kill", "tgkill", "raise"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
Interceptor.attach(a, {
onEnter: function (args) {
log("CALL " + n + "(" + args[0] + "," + args[1] + ") " + bt(this.context));
}
});
});
var sys = findExport("libc.so", "syscall");
if (sys) {
Interceptor.attach(sys, {
onEnter: function (args) {
var nr = args[0].toInt32();
if (nr === 93 || nr === 94 || nr === 129 || nr === 131) {
log("CALL syscall(" + nr + ") " + bt(this.context));
}
}
});
}
// count mprotect EXEC
var mp = findExport("libc.so", "mprotect");
if (mp) {
Interceptor.attach(mp, {
onEnter: function (args) {
if (args[2].toInt32() & 4) {
log("mprotect EXEC " + args[0] + " len=" + args[1]);
}
}
});
}
log("diag ready pid=" + Process.id);
setTimeout(function () {
var n = 0;
Process.enumerateRanges("r-x").forEach(function (r) {
var file = r.file ? r.file.path : "anon";
if (file.indexOf("/system") === 0 || file.indexOf("/apex") === 0) return;
n++;
log("RX " + file + " " + r.base + " +" + r.size);
});
log("app RX ranges=" + n);
}, 800);

View File

@@ -0,0 +1,282 @@
/**
* TNG — catch Promon exit after runtime code decrypt (mmap/mprotect RX).
* Frida 17 compatible.
*/
"use strict";
function log(msg) {
send("[TNG-native] " + msg);
}
function findExport(moduleName, name) {
try {
if (moduleName) {
var m = Process.findModuleByName(moduleName);
if (m) {
var a = m.findExportByName(name);
if (a) return a;
}
}
} catch (e) {}
try {
return Module.getGlobalExportByName(name);
} catch (e2) {
return null;
}
}
var patched = {};
function looksLikeExitSetup(addr) {
for (var i = 1; i <= 12; i++) {
try {
var w = addr.sub(i * 4).readU32();
var opc = w & 0xff800000;
if (opc === 0x52800000 || opc === 0xd2800000) {
var rd = w & 0x1f;
var imm = (w >> 5) & 0xffff;
if (rd === 8 && (imm === 93 || imm === 94)) return imm;
}
// mov x8, xN then earlier load — also catch svc after mov x0, #imm (exit code)
if ((w & 0xffe0ffff) === 0xaa0003e8) return 8; // mov x8, x0.. pattern loose
} catch (e) {}
}
return 0;
}
function patchRegion(base, size, tag) {
if (size <= 0 || size > 64 * 1024 * 1024) return;
var key = base + ":" + size;
if (patched[key]) return;
patched[key] = true;
var nop = [0x1f, 0x20, 0x03, 0xd5];
var n = 0;
var totalSvc = 0;
try {
// only scan 4-byte aligned by walking manually for reliability
var end = base.add(size - 4);
for (var p = base; p.compare(end) <= 0; p = p.add(4)) {
var w;
try {
w = p.readU32();
} catch (e) {
break;
}
if (w !== 0xd4000001) continue; // svc #0
totalSvc++;
var kind = looksLikeExitSetup(p);
if (!kind) continue;
try {
Memory.protect(p, 4, "rwx");
p.writeByteArray(nop);
n++;
log("patched exit SVC#" + kind + " @ " + p + " [" + tag + "]");
} catch (e2) {
log("patch err " + p + ": " + e2);
}
}
if (totalSvc > 0) {
log("region " + tag + " svc#0=" + totalSvc + " patched=" + n + " size=" + size);
}
} catch (e) {
log("scan err " + tag + ": " + e);
}
}
function scanAllExecutable(tag) {
Process.enumerateRanges("r-x").forEach(function (r) {
var file = r.file ? r.file.path : "anon";
// skip system libs except if anonymous / app
if (file.indexOf("/system/") === 0 || file.indexOf("/apex/") === 0) return;
if (file.indexOf("frida") >= 0) return;
patchRegion(r.base, r.size, tag + ":" + file);
});
}
function installLibcExitHooks() {
function blockExit(name, address) {
try {
Interceptor.replace(
address,
new NativeCallback(
function (code) {
log("BLOCKED " + name + "(" + (code | 0) + ")");
},
"void",
["int"]
)
);
log("replaced " + name);
} catch (e) {
Interceptor.attach(address, {
onEnter: function (args) {
log("BLOCKED(attach) " + name + "(" + args[0].toInt32() + ")");
while (true) Thread.sleep(60);
},
});
log("attached " + name);
}
}
["_exit", "exit", "abort", "quick_exit"].forEach(function (n) {
var a = findExport("libc.so", n);
if (a) blockExit(n, a);
});
["kill", "tgkill", "pthread_kill", "raise"].forEach(function (n) {
var a = findExport("libc.so", n);
if (!a) return;
Interceptor.attach(a, {
onEnter: function (args) {
var pid = args[0].toInt32();
var sig = args[1].toInt32();
if ((pid === Process.id || pid === 0 || pid === -1) &&
(sig === 9 || sig === 15 || sig === 6 || sig === 5)) {
log("BLOCKED " + n + " sig=" + sig);
args[1] = ptr(0);
}
},
});
});
var sys = findExport("libc.so", "syscall");
if (sys) {
Interceptor.attach(sys, {
onEnter: function (args) {
var nr = args[0].toInt32();
if (nr === 93 || nr === 94) {
log("BLOCKED syscall exit " + nr);
args[0] = ptr(-1);
}
},
});
}
log("libc hooks OK");
}
function installMprotectWatcher() {
var mprotect = findExport("libc.so", "mprotect");
var mmap = findExport("libc.so", "mmap");
if (mprotect) {
Interceptor.attach(mprotect, {
onEnter: function (args) {
this.addr = args[0];
this.len = args[1].toInt32();
this.prot = args[2].toInt32();
},
onLeave: function () {
// PROT_EXEC = 4
if (this.prot & 4) {
log("mprotect+EXEC " + this.addr + " len=" + this.len);
patchRegion(this.addr, this.len, "mprotect");
}
},
});
}
if (mmap) {
Interceptor.attach(mmap, {
onEnter: function (args) {
this.len = args[1].toInt32();
this.prot = args[2].toInt32();
},
onLeave: function (retval) {
if ((this.prot & 4) && !retval.isNull()) {
log("mmap+EXEC " + retval + " len=" + this.len);
patchRegion(retval, this.len, "mmap");
}
},
});
}
log("mprotect/mmap watchers OK");
}
function installMapsHide() {
var markers = ["frida", "gadget", "xposed", "lsposed", "vector", "zygisk", "magisk", "liblspd"];
var tracked = {};
function hide(line) {
var l = (line || "").toLowerCase();
for (var i = 0; i < markers.length; i++) if (l.indexOf(markers[i]) >= 0) return true;
return false;
}
function filter(buf, len) {
try {
var text = buf.readUtf8String(len);
if (!text) return len;
var out = text.split("\n").filter(function (x) { return !hide(x); }).join("\n");
var bytes = Memory.allocUtf8String(out);
var n = Math.min(len, out.length);
Memory.copy(buf, bytes, n);
return n;
} catch (e) {
return len;
}
}
var openat = findExport("libc.so", "openat");
var readFn = findExport("libc.so", "read");
if (openat) {
Interceptor.attach(openat, {
onEnter: function (args) {
this.path = args[1].isNull() ? null : args[1].readCString();
},
onLeave: function (retval) {
var fd = retval.toInt32();
if (fd >= 0 && this.path && this.path.indexOf("maps") >= 0) tracked[fd] = 1;
},
});
}
if (readFn) {
Interceptor.attach(readFn, {
onEnter: function (args) {
this.fd = args[0].toInt32();
this.buf = args[1];
},
onLeave: function (retval) {
var n = retval.toInt32();
if (n > 0 && tracked[this.fd]) retval.replace(ptr(filter(this.buf, n)));
},
});
}
log("maps hide OK");
}
function installJavaGuards() {
if (typeof Java === "undefined") {
setTimeout(installJavaGuards, 500);
return;
}
Java.perform(function () {
try {
Java.use("java.lang.System").exit.implementation = function (c) {
log("Java System.exit(" + c + ") blocked");
};
} catch (e) {}
try {
var R = Java.use("java.lang.Runtime");
R.exit.overload("int").implementation = function (c) {
log("Java Runtime.exit(" + c + ") blocked");
};
} catch (e) {}
try {
var P = Java.use("android.os.Process");
P.killProcess.implementation = function (pid) {
if (pid === P.myPid()) {
log("Java killProcess(self) blocked");
return;
}
return this.killProcess(pid);
};
} catch (e) {}
log("Java guards OK");
});
}
log("load pid=" + Process.id);
installLibcExitHooks();
installMapsHide();
installMprotectWatcher();
scanAllExecutable("boot");
setInterval(function () {
scanAllExecutable("tick");
}, 1000);
installJavaGuards();
log("ready");

View File

@@ -0,0 +1,147 @@
"use strict";
/*
* TNG eWallet — 定位 TigerTally(libtiger_tally.so) 启动期 fread 阻塞。观察不改行为。
*
* 背景(ANR 栈): CaptchaInitializer → TigerTallyAPI.init → t.B.genericNt1(native)
* → libtiger_tally.so (mNYjyzyN23) → fread → __sread → read 永远读不到数据
*/
const TIGER_SO = "libtiger_tally.so";
const LIBC = Process.getModuleByName("libc.so");
const readlink = new NativeFunction(
LIBC.findExportByName("readlink"), "long", ["pointer", "pointer", "ulong"]);
/* ---- Tiger 模块范围(热路径缓存,每 2s 刷新一次) ---- */
let tigerMod = null;
function refreshTiger() {
const m = Process.findModuleByName(TIGER_SO);
if (m) tigerMod = m;
return !!tigerMod;
}
function inTiger(addr) {
if (!addr) return false;
if (!tigerMod) return false;
return addr.compare(tigerMod.base) >= 0 && addr.compare(tigerMod.base.add(tigerMod.size)) < 0;
}
setInterval(() => { refreshTiger(); }, 2000);
function resolveFd(fd) {
try {
const link = Memory.allocUtf8String(`/proc/self/fd/${fd}`);
const out = Memory.alloc(256);
const n = readlink(link, out, 256);
if (n > 0) return out.readUtf8String(Math.min(n, 255));
} catch (e) { /* ignore */ }
return "?";
}
function fdKind(fd) {
const p = resolveFd(fd);
if (p.indexOf("socket:") === 0) return "SOCKET " + p;
if (p.indexOf("pipe:") === 0) return "PIPE " + p;
if (p.indexOf("anon_inode:") === 0) return "ANON " + p;
return p;
}
function threadName() {
try { return Process.getCurrentThreadName(); } catch (e) { return "?"; }
}
function fmtAddr(a) { return a ? a.toString(16) : "?"; }
const stats = {}; // tid -> info
function bump(fd, kind, ret) {
const tid = Process.getCurrentThreadId();
let s = stats[tid];
if (!s) { s = { name: threadName(), reads: 0, lastFd: fd, lastFdKind: kind, lastRet: ret }; stats[tid] = s; }
s.name = threadName();
s.reads++;
s.lastFd = fd;
s.lastFdKind = kind;
s.lastRet = ret;
}
/* ---- fread: FILE* 第4参数; bionic __sFILE._file 偏移约 18 ---- */
Interceptor.attach(LIBC.findExportByName("fread"), {
onEnter(args) {
const caller = this.returnAddress;
if (!inTiger(caller)) return;
const fp = args[3];
let fd = -1;
for (const off of [18, 16, 24, 20]) {
try { const v = fp.add(off).readU16(); if (v > 0 && v < 4096) { fd = v; break; } }
catch (e) { /* try next */ }
}
const kind = fd > 0 ? fdKind(fd) : "?";
bump(fd, kind, "pending");
console.log(`[FREAD] tid=${Process.getCurrentThreadId()} "${threadName()}" caller=${fmtAddr(caller)} fd=${fd} kind=${kind}`);
},
onLeave(ret) {
if (!inTiger(this.returnAddress)) return;
bump(-1, "", ret.toInt32());
console.log(`[FREAD-LEAVE] tid=${Process.getCurrentThreadId()} ret=${ret.toInt32()}`);
}
});
/* ---- read: 只观察调用者位于 libtiger_tally.so 的 ---- */
Interceptor.attach(LIBC.findExportByName("read"), {
onEnter(args) {
const caller = this.returnAddress;
if (!inTiger(caller)) return;
const fd = args[0].toInt32();
const kind = fdKind(fd);
bump(fd, kind, "?");
console.log(`[READ] tid=${Process.getCurrentThreadId()} "${threadName()}" caller=${fmtAddr(caller)} fd=${fd} kind=${kind}`);
},
onLeave(ret) {
if (!inTiger(this.returnAddress)) return;
const tid = Process.getCurrentThreadId();
const s = stats[tid];
const fd = s ? s.lastFd : -1;
const r = ret.toInt32();
if (s) s.lastRet = r;
if (r < 0) console.log(`[READ-RET] tid=${tid} fd=${fd} ret=${r} (blocked/error)`);
else if (r === 0) console.log(`[READ-EOF] tid=${tid} fd=${fd} ret=0 (EOF/closed)`);
else console.log(`[READ-RET] tid=${tid} fd=${fd} ret=${r}`);
}
});
/* ---- 周期 dump ---- */
function dumpThreads() {
try {
const dir = new File("/proc/self/task", "r");
const entries = dir.list();
dir.close();
let relevant = [];
for (const e of entries) {
let nm = "?";
try { const nf = new File(`/proc/self/task/${e}/comm`, "r"); nm = nf.readString().trim(); nf.close(); } catch (err) {}
const s = stats[e] || null;
const lower = nm.toLowerCase();
if (lower.indexOf("location") >= 0 || lower.indexOf("tally") >= 0 || s) {
let info = `tid=${e} "${nm}"`;
if (s) info += ` tigerReads=${s.reads} lastFd=${s.lastFd} kind=${s.lastFdKind} lastRet=${s.lastRet}`;
relevant.push(info);
}
}
console.log(`[DUMP] tiger-fread threads: ${relevant.length ? relevant.join(" | ") : "(none)"}`);
} catch (e) {
console.log(`[DUMP] failed: ${e}`);
}
}
setInterval(() => {
dumpThreads();
try {
const dir = new File("/proc/self/fd", "r");
const fds = dir.list();
dir.close();
let pipes = [], socks = [];
for (const f of fds) {
const kind = fdKind(parseInt(f, 10));
if (kind.indexOf("PIPE") === 0) pipes.push(f + ":" + kind.split(" ").slice(1).join(" "));
if (kind.indexOf("SOCKET") === 0) socks.push(f + ":" + kind.split(" ").slice(1).join(" "));
}
if (pipes.length) console.log(`[DUMP-FD] pipes: ${pipes.join(" | ")}`);
if (socks.length) console.log(`[DUMP-FD] sockets: ${socks.join(" | ")}`);
} catch (e) { /* ignore */ }
}, 3000);
console.log("[TIGER-FREAD] armed (observe-only)");

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Dump aliyun captcha method signatures from TNG APK."""
import zipfile
import re
import sys
apk = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_1.9.10_base.apk"
targets = [
b"Lcom/aliyun/captcha/Captcha;",
b"Lcom/aliyun/captcha/CaptchaWebViewDialog;",
b"Lcom/aliyun/captcha/Captcha$VerificationCallback;",
b"Lcom/aliyun/captcha/CaptchaWebViewDialog$CaptchaCompletionListener;",
b"Lcom/aliyun/captcha/a;",
b"Lcom/aliyun/captcha/b;",
b"Lcom/aliyun/captcha/c;",
]
# Method refs: Lclass;->name(args)ret
pat = re.compile(
rb"(Lcom/aliyun/captcha/[A-Za-z0-9_/$]+;)->([A-Za-z0-9_<>$]+)\(([^)]*)\)([A-Za-z0-9_/;$[\]-]+)"
)
found = {t.decode(): set() for t in targets}
with zipfile.ZipFile(apk) as z:
for n in z.namelist():
if not n.endswith(".dex"):
continue
data = z.read(n)
for m in pat.finditer(data):
clazz = m.group(1).decode()
if clazz not in found:
continue
name = m.group(2).decode()
args = m.group(3).decode()
ret = m.group(4).decode()
found[clazz].add(f"{name}({args}){ret}")
for clazz, methods in found.items():
print("====", clazz, "n=", len(methods))
for s in sorted(methods):
print(" ", s)

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
"""Scan TNG APK for CallingCode / country UI / HW-related classes."""
import re
import sys
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_base_scan.apk"
NEEDLES = [
b"UserSearchCallingCodeActivity",
b"CallingCode",
b"ll_country",
b"BottomSelect",
b"BottomSelectDialogFragment",
b"ftv_title",
b"i7.l",
b"enableHardwareAcceleration",
b"FLAG_HARDWARE_ACCELERATED",
]
def main():
z = zipfile.ZipFile(APK)
print("=== DEX STRING HITS ===")
for name in sorted(z.namelist()):
if not name.endswith(".dex"):
continue
data = z.read(name)
hits = [s.decode() for s in NEEDLES if s in data]
if hits:
print("%s -> %s" % (name, hits))
print("\n=== CLASS NAMES (CallingCode / BottomSelect / country) ===")
pat = re.compile(
rb"L[a-zA-Z0-9_$/]*(?:CallingCode|BottomSelect|Country|country)[a-zA-Z0-9_$/]*;"
)
found = set()
for name in sorted(z.namelist()):
if not name.endswith(".dex"):
continue
data = z.read(name)
for m in pat.findall(data):
found.add(m.decode("ascii", "ignore")[1:-1].replace("/", "."))
for c in sorted(found):
print(" ", c)
print("\n=== CONTEXT AROUND UserSearchCallingCodeActivity ===")
target = b"UserSearchCallingCodeActivity"
for name in sorted(z.namelist()):
if not name.endswith(".dex"):
continue
data = z.read(name)
start = 0
n = 0
while True:
idx = data.find(target, start)
if idx < 0:
break
s = max(0, idx - 80)
e = min(len(data), idx + len(target) + 120)
chunk = re.sub(rb"[^\x20-\x7e]+", b".", data[s:e])
print("[%s @%d] %s" % (name, idx, chunk.decode("ascii", "ignore")))
start = idx + 1
n += 1
if n >= 8:
break
# Manifest component
print("\n=== ANDROIDMANIFEST snippets ===")
try:
# binary manifest — just search utf16/utf8 remnants in apk
data = z.read("AndroidManifest.xml")
for key in (b"CallingCode", b"hardwareAccelerated", b"user.view"):
if key in data or key.decode().encode("utf-16le") in data:
print(" manifest contains", key)
except KeyError:
pass
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""Deeper scan: UserSearchCallingCodeActivity methods / Compose / launch."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_base_scan.apk"
def strings_near(data, needle, radius=200, limit=15):
out = []
start = 0
while len(out) < limit:
idx = data.find(needle, start)
if idx < 0:
break
s = max(0, idx - radius)
e = min(len(data), idx + len(needle) + radius)
chunk = re.sub(rb"[^\x20-\x7e]+", b".", data[s:e])
out.append(chunk.decode("ascii", "ignore"))
start = idx + 1
return out
def main():
z = zipfile.ZipFile(APK)
# Collect interesting strings from classes that have CallingCode
keys = [
b"CallingListScreen",
b"setContent",
b"ComposeView",
b"AbstractComposeView",
b"ComponentActivity",
b"getCallingCodeList",
b"CountryListRepository",
b"startActivity",
b"UserSearchCallingCodeActivity",
b"ll_country",
b"hardwareAccelerated",
b"RecyclerView",
b"LazyColumn",
b"androidx/compose",
]
for dex in sorted(n for n in z.namelist() if n.endswith(".dex")):
data = z.read(dex)
if b"UserSearchCallingCodeActivity" not in data and b"CallingListScreen" not in data and b"ll_country" not in data:
continue
print("\n========", dex, "========")
for k in keys:
if k in data:
print("HAS", k.decode())
if b"CallingListScreen" in data:
print("--- CallingListScreen ctx ---")
for c in strings_near(data, b"CallingListScreen", 120, 6):
print(" ", c[:240])
if b"ll_country" in data:
print("--- ll_country ctx ---")
for c in strings_near(data, b"ll_country", 100, 8):
print(" ", c[:240])
# Who references UserSearchCallingCodeActivity (launchers)
print("\n=== who references UserSearchCallingCodeActivity class desc ===")
desc = b"Lmy/com/tngdigital/user/view/UserSearchCallingCodeActivity;"
for dex in sorted(n for n in z.namelist() if n.endswith(".dex")):
data = z.read(dex)
count = data.count(desc)
if count:
print(dex, "count=", count)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,16 @@
import re
import zipfile
apk = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_1.9.10_base.apk"
z = zipfile.ZipFile(apk)
pat = re.compile(rb"Lcom/aliyun/captcha/[A-Za-z0-9_/$]+;")
found = set()
for n in z.namelist():
if not n.endswith(".dex"):
continue
data = z.read(n)
for m in pat.findall(data):
found.add(m.decode())
for c in sorted(found):
print(c)
print("total", len(found))

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""Compare CallingCode vs CountrySelect activities / intent extras."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\dumps\tng_base_scan.apk"
def near(data, needle, r=180, lim=12):
out = []
start = 0
while len(out) < lim:
i = data.find(needle, start)
if i < 0:
break
chunk = re.sub(rb"[^\x20-\x7e]+", b".", data[max(0, i - r): i + len(needle) + r])
out.append(chunk.decode("ascii", "ignore"))
start = i + 1
return out
def main():
z = zipfile.ZipFile(APK)
for dex in sorted(n for n in z.namelist() if n.endswith(".dex")):
data = z.read(dex)
if b"UserCountrySelectActivity" not in data and b"UserSearchCallingCodeActivity" not in data:
continue
print("\n====", dex, "====")
for n in (b"UserCountrySelectActivity", b"AbsCountrySelectActivity",
b"newIntent", b"CallingListScreen"):
if n in data:
print("HAS", n.decode())
for n in (b"UserSearchCallingCodeActivity", b"UserCountrySelectActivity"):
if n not in data:
continue
print("--", n.decode(), "--")
for c in near(data, n, 100, 6):
if "Hilt_" in c and ".java" in c:
continue
print(" ", c[:220])
print("\n=== calling/country intent-like strings ===")
seen = set()
for dex in sorted(n for n in z.namelist() if n.endswith(".dex")):
data = z.read(dex)
if b"CallingCode" not in data and b"CountrySelect" not in data:
continue
for m in re.findall(
rb"(?:EXTRA_|KEY_|arg_|ARG_)[A-Za-z0-9_]{2,40}|"
rb"[A-Za-z0-9_]{0,15}(?:calling_code|CallingCode|country_code|CountryCode|countryList)[A-Za-z0-9_]{0,20}",
data):
s = m.decode("ascii", "ignore")
if s not in seen and len(s) > 6:
seen.add(s)
print(" ", s)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
import zipfile
import re
z = zipfile.ZipFile(r"reverse/dumps/tng_1.9.10_base.apk")
names = [n for n in z.namelist() if n.endswith(".dex")]
# Find packages that look like Promon (short random package + few classes)
# Also search string markers
markers = [
b"promon",
b"Promon",
b"PROMON",
b"xwwqazamx",
b"Rooting",
b"jailbroken",
b"JailBroken",
b"W:16",
b"libtngdigital_ewallet",
]
for m in markers:
hits = 0
for n in names:
hits += z.read(n).count(m)
print(f"marker {m!r}: {hits}")
# Extract L.../...; type descriptors that contain 'promon' case-insensitive or weird short pkgs
pkg_re = re.compile(rb"L([a-z]{6,12})/([A-Za-z0-9_$]{1,20});")
pkg_counts = {}
for n in names:
data = z.read(n)
for m in pkg_re.findall(data):
pkg = m[0].decode("ascii", errors="ignore")
pkg_counts[pkg] = pkg_counts.get(pkg, 0) + 1
# Show rare short packages (likely obfuscated)
cands = [(p, c) for p, c in pkg_counts.items() if 5 <= c <= 500 and p.isalpha() and len(p) <= 12]
cands.sort(key=lambda x: -x[1])
print("\ncandidate obfuscated packages:")
for p, c in cands[:40]:
print(f" {p}: {c}")

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "tng" / "base.apk"
if not APK.exists():
APK = Path(__file__).resolve().parent.parent / "apks" / "tng" / "tng.apk"
acts = set()
ops = set()
xww = set()
tng = set()
with zipfile.ZipFile(str(APK)) as zf:
for name in zf.namelist():
if not name.endswith(".dex"):
continue
data = zf.read(name)
for m in re.finditer(rb"my/com/tngdigital/[a-zA-Z0-9_/]+Activity", data):
acts.add(m.group().decode().replace("/", "."))
for m in re.finditer(rb"com\.(?:abl|tngd|zoloz|alipayplus)\.[a-z0-9.]+", data):
s = m.group().decode("ascii", "ignore")
if any(k in s for k in ("wallet", "otp", "login", "register", "phone", "member", "jail", "customer", "pin", "mobile")):
ops.add(s)
for m in re.finditer(rb"xwwqazamx/[a-zA-Z0-9_]+", data):
xww.add(m.group().decode().replace("/", "."))
print("=== User flow Activities ===")
for a in sorted(acts):
if any(k in a for k in ("User", "Splash", "Guide", "Registration", "Login", "Otp", "Pin", "WebView", "Security")):
print(a)
print("\n=== Promon xwwqazamx classes (sample) ===")
for c in sorted(xww)[:40]:
print(c)
print(f"... total {len(xww)}")
print("\n=== RPC operationTypes (sample) ===")
for o in sorted(ops)[:50]:
print(o)

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
import json
import time
import urllib.parse
import urllib.request
BASE = "http://127.0.0.1:9090"
def wait_api(retries=15):
for _ in range(retries):
try:
with urllib.request.urlopen(BASE + "/proxies", timeout=3) as r:
return json.loads(r.read())
except Exception:
time.sleep(1)
raise SystemExit("clash api not ready")
def put_proxy(group: str, target: str):
enc_g = urllib.parse.quote(group, safe="")
req = urllib.request.Request(
BASE + "/proxies/" + enc_g,
data=json.dumps({"name": target}).encode(),
method="PUT",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=5) as r:
print("switched", group, "->", target, "status", r.status)
def main():
data = wait_api()
proxies = data.get("proxies", {})
group = None
for gname in ["🚀节点选择", "GLOBAL"]:
if gname in proxies:
group = gname
break
if not group:
raise SystemExit("selector group not found")
print("group=", group, "now=", proxies.get(group, {}).get("now"))
candidates = [
"🇸🇬狮城节点",
"🇸🇬AWS新加坡01 | 电信移动联通推荐",
"🇸🇬新加坡01 | 电信联通推荐",
"🇸🇬新加坡 | 高速专线-hy2",
]
target = next((c for c in candidates if c in proxies), None)
if not target:
for k in proxies:
if "新加坡" in k or "AWS新加坡" in k:
target = k
break
if not target:
raise SystemExit("no SG proxy found")
put_proxy(group, target)
enc_g = urllib.parse.quote(group, safe="")
with urllib.request.urlopen(BASE + "/proxies/" + enc_g, timeout=3) as r:
print("verify now=", json.loads(r.read()).get("now"))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Download TNG eWallet XAPK from Uptodown eAPI (arm64-v8a)."""
from __future__ import annotations
import json
import re
import subprocess
import sys
import urllib.request
import zipfile
from pathlib import Path
UA = "Mozilla/5.0 (Linux; Android 13) Chrome/120.0.0.0 Mobile Safari/537.36"
APP_CODE = "1000382462"
VERSION = "1.9.10"
ARCH = "arm64-v8a, armeabi-v7a, x86_64"
BASE = "https://touch-n-go-ewallet.en.uptodown.com"
OUT_XAPK = Path("reverse/dumps/tng_1.9.10.xapk")
OUT_DIR = Path("reverse/dumps/tng_xapk_extracted")
def fetch(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.read()
def fetch_text(url: str) -> str:
return fetch(url).decode("utf-8", "ignore")
def main() -> int:
versions = json.loads(fetch_text(f"{BASE}/android/apps/{APP_CODE}/versions/1"))
entry = next(x for x in versions["data"] if x["version"] == VERSION)
version_id = entry["versionURL"]["versionID"]
print(f"version {VERSION} fileID={entry['fileID']} kind={entry['kindFile']}")
dl_page = fetch_text(f"{BASE}/android/download/{version_id}")
m = re.search(r'class="button variants" data-version="(\d+)"', dl_page)
if not m:
print("variants data-version not found", file=sys.stderr)
return 1
data_version = m.group(1)
print("data_version", data_version)
files_json = json.loads(fetch_text(f"{BASE}/app/{APP_CODE}/version/{data_version}/files"))
content = files_json.get("content", "")
# parse variant rows from HTML fragment
rows = re.findall(
r'class="variant".*?data-file-id="(\d+)".*?<span>([^<]+)</span>',
content,
flags=re.S,
)
if not rows:
# fallback: any data-file-id near xapk
rows = re.findall(r'data-file-id="(\d+)"', content)
rows = [(rid, "?") for rid in rows]
print("variants", rows)
target_file_id = None
for fid, arch in rows:
if ARCH in arch or "arm64-v8a" in arch:
target_file_id = fid
print("pick", fid, arch)
break
if not target_file_id and rows:
target_file_id = rows[0][0]
print("fallback file_id", target_file_id)
if not target_file_id:
print("no file id", file=sys.stderr)
return 1
variant_page = fetch_text(f"{BASE}/android/download/{target_file_id}-x")
token_m = re.search(r'id="detail-download-button"[^>]*data-url="(-[^"]+)"', variant_page)
if not token_m:
token_m = re.search(r'data-url="(-[^"]+)"', variant_page)
if not token_m:
print("download token not found", file=sys.stderr)
return 1
token = token_m.group(1)
print("downloading XAPK...")
data = fetch(f"https://dw.uptodown.com/dwn/{token}")
OUT_XAPK.write_bytes(data)
print("saved", OUT_XAPK, "bytes", len(data))
with zipfile.ZipFile(OUT_XAPK) as z:
apks = [n for n in z.namelist() if n.endswith(".apk")]
print("apk splits", apks)
if not apks:
print("no apk inside xapk", file=sys.stderr)
return 1
if OUT_DIR.exists():
import shutil
shutil.rmtree(OUT_DIR)
OUT_DIR.mkdir(parents=True)
import zipfile as zf
with zf.ZipFile(OUT_XAPK) as z:
z.extractall(OUT_DIR)
apk_files = sorted(OUT_DIR.rglob("*.apk"))
adb = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
cmd = [adb, "install-multiple", "-r"] + [str(p) for p in apk_files]
print("install:", " ".join(cmd))
r = subprocess.run(cmd, capture_output=True, text=True)
print(r.stdout)
print(r.stderr)
return 0 if r.returncode == 0 else r.returncode
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,110 @@
"""Minimal DEX parser: dump methods for target classes."""
import struct
import zipfile
from pathlib import Path
TARGET = {
"Lcom/tngd/networksdk/common/NativeLib;",
"Lcom/tngd/networksdk/common/ApiSixSecretKeys;",
"Lmy/com/tngdigital/common/internal/libs/RetrieveFromNativeLibs;",
}
def uleb(data, i):
result = 0
shift = 0
while True:
b = data[i]
i += 1
result |= (b & 0x7F) << shift
if (b & 0x80) == 0:
break
shift += 7
return result, i
def parse_dex(data: bytes, label: str):
if data[:4] != b"dex\n":
return
string_ids_size, string_ids_off = struct.unpack_from("<II", data, 56)
type_ids_size, type_ids_off = struct.unpack_from("<II", data, 64)
proto_ids_size, proto_ids_off = struct.unpack_from("<II", data, 72)
field_ids_size, field_ids_off = struct.unpack_from("<II", data, 80)
method_ids_size, method_ids_off = struct.unpack_from("<II", data, 88)
class_defs_size, class_defs_off = struct.unpack_from("<II", data, 96)
def string_at(idx):
off = struct.unpack_from("<I", data, string_ids_off + idx * 4)[0]
size, p = uleb(data, off)
return data[p : p + size].decode("utf-8", "replace")
def type_at(idx):
return string_at(struct.unpack_from("<I", data, type_ids_off + idx * 4)[0])
def proto_at(idx):
shorty_idx, return_type_idx, parameters_off = struct.unpack_from(
"<III", data, proto_ids_off + idx * 12
)
ret = type_at(return_type_idx)
params = []
if parameters_off:
size = struct.unpack_from("<I", data, parameters_off)[0]
for i in range(size):
tidx = struct.unpack_from("<H", data, parameters_off + 4 + i * 2)[0]
params.append(type_at(tidx))
return ret, params
def method_at(idx):
class_idx, proto_idx, name_idx = struct.unpack_from(
"<HHI", data, method_ids_off + idx * 8
)
ret, params = proto_at(proto_idx)
return type_at(class_idx), string_at(name_idx), ret, params
print(f"\n===== {label} =====")
for c in range(class_defs_size):
class_idx, access_flags, superclass_idx, interfaces_off, source_file_idx, annotations_off, class_data_off, static_values_off = struct.unpack_from(
"<IIIIIIII", data, class_defs_off + c * 32
)
cname = type_at(class_idx)
if cname not in TARGET:
continue
print(f"\nCLASS {cname} access=0x{access_flags:x}")
if not class_data_off:
print(" (no class_data)")
continue
p = class_data_off
static_fields_size, p = uleb(data, p)
instance_fields_size, p = uleb(data, p)
direct_methods_size, p = uleb(data, p)
virtual_methods_size, p = uleb(data, p)
# skip fields
for _ in range(static_fields_size + instance_fields_size):
_, p = uleb(data, p)
_, p = uleb(data, p)
mid = 0
for kind, count in (("direct", direct_methods_size), ("virtual", virtual_methods_size)):
mid = 0
for _ in range(count):
diff, p = uleb(data, p)
access, p = uleb(data, p)
code_off, p = uleb(data, p)
mid += diff
cls, name, ret, params = method_at(mid)
flags = []
if access & 0x100:
flags.append("native")
if access & 0x8:
flags.append("static")
if access & 0x10000:
flags.append("constructor")
print(f" [{kind}] {' '.join(flags)} {name}({', '.join(params)}){ret} code=0x{code_off:x}")
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk")
with zipfile.ZipFile(apk) as z:
for n in z.namelist():
if n.endswith(".dex"):
data = z.read(n)
if any(t.encode() in data for t in ("NativeLib;", "ApiSixSecretKeys;", "RetrieveFromNativeLibs;")):
parse_dex(data, n)

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Race-window: dump TNG executable maps and hunt kill+SVC after launch."""
import re
import subprocess
import sys
import time
from pathlib import Path
ADB = r"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
PKG = "my.com.tngdigital.ewallet"
OUT = Path(__file__).resolve().parents[1] / "dumps" / "tng_runtime"
OUT.mkdir(parents=True, exist_ok=True)
# aarch64 movz x8/w8,#129/130/131 + svc #0
KILL_IMMS = [
bytes.fromhex("281080d2"),
bytes.fromhex("28108052"),
bytes.fromhex("481080d2"),
bytes.fromhex("48108052"),
bytes.fromhex("681080d2"),
bytes.fromhex("68108052"),
]
SVC0 = bytes.fromhex("010000d4")
EXIT_IMM = bytes.fromhex("c80b80d2") # movz x8,#94 exit_group
EXIT2 = bytes.fromhex("ba0b80d2") # movz x8,#93 exit
def adb(*args, check=False):
r = subprocess.run([ADB, *args], capture_output=True)
out = (r.stdout or b"") + (r.stderr or b"")
if check and r.returncode != 0:
raise RuntimeError(out.decode("utf-8", "ignore"))
return r.returncode, out.decode("utf-8", "replace")
def su(cmd):
return adb("shell", f"su -c '{cmd}'")
def main():
adb("shell", "am", "force-stop", PKG)
time.sleep(0.5)
adb("shell", "monkey", "-p", PKG, "-c", "android.intent.category.LAUNCHER", "1")
pid = None
for _ in range(40):
time.sleep(0.25)
_, out = adb("shell", "pidof", PKG)
toks = out.strip().split()
if toks:
pid = toks[0]
break
if not pid:
print("FAIL: no pid")
return 1
print(f"pid={pid}")
# pull maps
_, maps = su(f"cat /proc/{pid}/maps")
maps_path = OUT / f"maps_{pid}.txt"
maps_path.write_text(maps, encoding="utf-8", errors="replace")
print(f"maps -> {maps_path} lines={len(maps.splitlines())}")
targets = []
for line in maps.splitlines():
if "r-xp" not in line and "r-x" not in line:
# also rw-p with execute rarely; keep x
if "x" not in line.split()[1] if len(line.split()) > 1 else "":
continue
if "tngdigital" in line or "libnative" in line or "ewallet" in line.lower():
parts = line.split()
rng = parts[0]
start_s, end_s = rng.split("-")
start, end = int(start_s, 16), int(end_s, 16)
path = parts[-1] if len(parts) >= 6 else ""
targets.append((start, end, path, line))
print(f"target segments={len(targets)}")
for start, end, path, line in targets[:12]:
print(f" {hex(start)}-{hex(end)} {path}")
# dump via dd from /proc/pid/mem
remote = f"/data/local/tmp/tng_rt_{pid}.bin"
su(f"rm -f {remote}")
total = 0
for i, (start, end, path, _) in enumerate(targets):
size = end - start
if size <= 0 or size > 32 * 1024 * 1024:
continue
# append dump
cmd = (
f"dd if=/proc/{pid}/mem bs=4096 skip={start // 4096} "
f"count={(size + 4095) // 4096} 2>/dev/null >> {remote}"
)
# dd skip is in blocks from file start — wrong for /proc/pid/mem!
# Use busybox dd with seek on output and skip via python on device instead.
cmd = (
f"toybox dd if=/proc/{pid}/mem of={remote}.p{i} "
f"bs=1 skip={start} count={size} 2>/dev/null"
)
code, _ = su(cmd)
if code == 0:
total += size
print(f" dumped p{i} size={size} from {path}")
else:
# fallback: python on device
py = (
f"python3 -c \"import sys;f=open('/proc/{pid}/mem','rb');"
f"f.seek({start});d=f.read({size});open('{remote}.p{i}','wb').write(d)\""
)
code2, out2 = su(py)
if code2 == 0:
total += size
print(f" dumped p{i} via python size={size}")
else:
print(f" FAIL dump p{i}: {out2[:120]}")
# pull pieces and scan
local_dir = OUT / f"mem_{pid}"
local_dir.mkdir(exist_ok=True)
kill_hits = 0
exit_hits = 0
for i, (start, end, path, _) in enumerate(targets):
rem = f"{remote}.p{i}"
loc = local_dir / f"seg_{i}_{start:x}.bin"
code, _ = adb("shell", f"su -c 'test -f {rem} && echo OK'")
if "OK" not in _:
continue
adb("pull", rem, str(loc))
if not loc.exists():
continue
data = loc.read_bytes()
for imm in KILL_IMMS:
pos = 0
while True:
j = data.find(imm, pos)
if j < 0:
break
win = data[j : j + 36]
if SVC0 in win:
kill_hits += 1
delta = win.find(SVC0)
print(f"KILL+SVC seg{i} file+0x{j:x} va=0x{start+j:x} delta={delta} path={path}")
pos = j + 4
for imm in (EXIT_IMM, EXIT2):
pos = 0
while True:
j = data.find(imm, pos)
if j < 0:
break
win = data[j : j + 36]
if SVC0 in win:
exit_hits += 1
if exit_hits <= 15:
print(f"EXIT+SVC seg{i} file+0x{j:x} va=0x{start+j:x} path={path}")
pos = j + 4
# also count raw svc
print(f" seg{i} svc0={data.count(SVC0)} size={len(data)}")
print(f"DONE kill+svc={kill_hits} exit+svc={exit_hits} dumped_bytes~={total}")
_, alive = adb("shell", "pidof", PKG)
print(f"still alive? {alive.strip() or 'NO'}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for m in re.finditer(rb"Lcom/[^;]{0,160}CharacterCrypto[^;]{0,40};", d):
print(m.group().decode()[1:-1].replace("/", "."))
for m in re.finditer(rb"Lcom/[^;]{0,160}NativeEncrypt[^;]{0,40};", d):
print(m.group().decode()[1:-1].replace("/", "."))
# find class with getDfpByMMKV - search all Lcom paths and check if followed by getDfp in same method table is hard
# instead search for MMKV + dfp strings proximity
idx = d.find(b"getDfpByMMKV:")
if idx >= 0:
chunk = d[max(0, idx - 500) : idx + 500]
for m in re.finditer(rb"Lcom/[^;\x00]{5,200};", chunk):
print("near getDfpByMMKV:", m.group().decode()[1:-1].replace("/", "."))

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for m in re.finditer(rb"CharacterCrypto[\w$]{0,40}", d):
print(m.group().decode())
for m in re.finditer(rb"getDfp[\w$]{0,20}", d):
s = m.group().decode()
if s not in ("getDfp",):
print("method:", s)
# utils.d wrapper
for m in re.finditer(rb"Lcom/shopee/bke/lib/jni/utils/[\w$]{1,20};", d):
print(m.group().decode()[1:-1].replace("/", "."))

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for needle in [b"CharacterCryptoManager", b"CharacterCryptoManagerWrapper", b"NativeEncryptUtilsWrapper"]:
print("\n===", needle.decode(), "===")
for m in re.finditer(re.escape(needle) + rb"[\w$]{0,30}", d):
name = m.group().decode()
if "$" in name or name.endswith("Wrapper") or name.endswith("Manager"):
pass
for m in re.finditer(rb"Lcom/[^;]{0,200}" + re.escape(needle) + rb"[^;]{0,20};", d):
print(m.group().decode()[1:-1].replace("/", "."))
# also search for class ending with .d
for m in re.finditer(rb"Lcom/shopee/bke/lib/jni/utils/[a-z];", d):
print("short utils:", m.group().decode()[1:-1].replace("/", "."))

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needle = b"The dfp is empty in register scene"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
idx = data.find(needle)
ctx = data[max(0, idx - 2000) : idx + 2000]
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/[^;\x00]{5,200};", ctx)))
print("classes near register dfp empty:")
for c in classes:
print(c)
print("\nstrings:")
for m in re.finditer(rb"[\x20-\x7e]{6,100}", ctx):
s = m.group().decode()
if any(k in s.lower() for k in ("dfp", "register", "fingerprint", "empty", "scene", "monitor", "iv_")):
print(s)

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for needle in [b"getDfp", b"DeviceFingerprintResp", b"deviceFingerprint", b"/dfp/", b"dfp/v1"]:
print("\n===", needle.decode(), "===")
for m in re.finditer(re.escape(needle) + rb"[\x00-\xff]{0,80}", data):
chunk = data[m.start() : m.start() + 120]
s = re.sub(rb"[^\x20-\x7e]+", b"|", chunk).decode("ascii", "ignore")
print(s[:140])
break
# classes with fingerprint in name
fps = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/[^;\x00]{0,120}[Ff]ingerprint[^;\x00]{0,40};", data)))
print("\n=== fingerprint classes ===")
for c in fps[:30]:
print(c)
# RegisterViewModel methods - search string RegisterViewModel in dex
for m in re.finditer(rb"Lcom/shopee/bke/biz/user/viewmodel/RegisterViewModel[^;\x00]*;", data):
print("\nRegisterViewModel:", m.group().decode())

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
idx = d.find(b"getDfpByMMKV")
print("idx", idx)
window = d[max(0, idx - 20000) : idx + 20000]
classes = re.findall(rb"Lcom/[a-zA-Z0-9_$/]{5,200};", window)
unique = sorted(set(x.decode()[1:-1].replace("/", ".") for x in classes))
print("classes in 40k window:", len(unique))
for c in unique:
cl = c.lower()
if any(k in cl for k in ("crypto", "dfp", "finger", "device", "user", "jni", "utils", "manager", "wrapper", "register", "login")):
print(c)

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needles = [b"getDfp empty!", b"getDfp onError:", b"getDfpByMMKV:", b"The dfp is empty in register scene"]
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
all_classes = [m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/[\w$/]{5,160};", data)]
for needle in needles:
print("\n===", needle.decode(), "===")
idx = data.find(needle)
if idx < 0:
print("not found")
continue
window = data[max(0, idx - 8000) : idx + 8000]
nearby = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/[\w$/]{5,160};", window)))
for c in nearby:
if any(k in c.lower() for k in ("dfp", "finger", "device", "register", "user", "util", "manager", "helper", "repo", "data", "rn")):
print(" ", c)
dfp_classes = sorted(set(c for c in all_classes if "dfp" in c.lower() or "fingerprint" in c.lower()))
print("\n=== dfp/fingerprint class names ===")
for c in dfp_classes:
print(c)

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needle = b"getDfp empty!"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
idx = data.find(needle)
window = data[max(0, idx - 12000) : idx + 12000]
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/[^;\x00]{5,200};", window)))
print("all classes near getDfp empty (filtered):")
for c in classes:
cl = c.lower()
if any(k in cl for k in ("dfp", "finger", "shps", "bke", "jni", "utils", "sdk", "device", "monitor", "crypto", "register")):
print(c)
print("\nall com classes count:", len(classes))

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
for name in zf.namelist():
if not name.endswith((".json", ".jsbundle")):
continue
data = zf.read(name)
if b"currently unavailable" not in data and b"system is currently" not in data:
continue
text = data.decode("utf-8", errors="replace")
for m in re.finditer(r".{0,40}currently unavailable.{0,60}", text):
print(f"\n[{name}]")
print(m.group().replace("\n", " ")[:200])

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
for name in sorted(zf.namelist()):
low = name.lower()
if not name.endswith(".json"):
continue
if "user" not in low and "auth" not in low and "register" not in low and "ekyc" not in low:
continue
data = zf.read(name).decode("utf-8", errors="replace")
hits = []
for m in re.finditer(r'"[^"]+"\s*:\s*"[^"]{8,200}"', data):
s = m.group()
sl = s.lower()
if any(k in sl for k in ("unavailable", "register", "dfp", "phone", "otp", "system is")):
hits.append(s[:220])
if hits:
print("\n===", name, "===")
for h in hits[:40]:
print(h)

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import sqlite3
import sys
p = sys.argv[1] if len(sys.argv) > 1 else "clash_cache.db"
con = sqlite3.connect(p)
for (name,) in con.execute("SELECT name FROM sqlite_master WHERE type='table'"):
print("TABLE", name)
cols = [c[1] for c in con.execute(f"PRAGMA table_info({name})")]
print(" cols", cols)
for row in con.execute(f"SELECT * FROM {name} LIMIT 8"):
s = str(row)
print(" ", s[:300] + ("..." if len(s) > 300 else ""))

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/lib/safemode/[^;]{1,80};", d)))
print("safemode classes:", len(classes))
for c in classes[:30]:
print(c)
print("\nrisk classes:")
for m in re.finditer(rb"Lcom/shopee/bke/biz/base/risk/[^;]{1,40};", d):
print(m.group().decode()[1:-1].replace("/", "."))

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
patterns = [
rb"Lcom/shopee/bke/lib/safemode/[a-zA-Z$][\w$]{0,30};",
rb"Lcom/shopee/bke/lib/safemode/[a-z]+/[a-zA-Z$][\w$]{0,40};",
]
seen = set()
for pat in patterns:
for m in re.finditer(pat, d):
c = m.group().decode()[1:-1].replace("/", ".")
if c.startswith("com.shopee.bke.lib.safemode.R"):
continue
if c not in seen:
seen.add(c)
print(c)
print("\n--- short obfuscated bke classes (root/adb) ---")
for m in re.finditer(rb"Lcom/shopee/bke/[a-z]+/[a-z]{1,2};", d):
c = m.group().decode()[1:-1].replace("/", ".")
if "lib" in c or "safemode" in c or "risk" in c:
print(c)

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for pat in [
rb"Lcom/shopee/shpssdk/[\w$/]{3,100};",
rb"Lcom/shopee/shpssdkbank/[\w$/]{3,100};",
]:
cs = sorted(set(m.group().decode()[1:-1].replace("/", ".") for m in re.finditer(pat, d)))
print("\n", pat.decode(), len(cs))
for c in cs:
if "R" != c.split(".")[-1] or "$" in c:
print(" ", c)
for needle in [b"RISK_USB", b"RISK_WIFI", b"RISK_ROOT", b"RISK_HOOK", b"requestDefense"]:
print(needle.decode(), d.count(needle))

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
import re
import sys
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
if len(sys.argv) > 1:
APK = Path(sys.argv[1])
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for pkg in ("shpssdkbank", "shpssdk"):
pat = re.compile(rf"Lcom/shopee/{pkg}/[\w$]+;".encode())
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".") for m in pat.finditer(data)))
print(f"\n=== {pkg} ({len(classes)} classes) ===")
short = [c for c in classes if len(c.split(".")[-1]) <= 12 and "shpssdk" in c]
for c in short[:40]:
print(" ", c)

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""Parse vuwuuwvw attestation JSON from logcat or raw JSON file."""
import base64
import hashlib
import json
import re
import sys
from pathlib import Path
SUSPICIOUS = re.compile(
rb"(root|hook|xposed|lsposed|magisk|frida|substrate|emulator|debug|adb|"
rb"selinux|\bsu\b|/proc/|zygisk|riru|shamiko|tamper|integrity|"
rb"jailbreak|virtual|mock|proxy|vpn|developer)",
re.I,
)
KNOWN_FIELDS = [
"root", "hook", "xposed", "lsposed", "magisk", "frida", "adb", "debug",
"debuggable", "emulator", "simulator", "vpn", "proxy", "mock",
"selinux", "su", "supersu", "zygisk", "riru", "shamiko", "substrate",
"integrity", "safetynet", "playIntegrity", "deviceId", "androidId",
"serial", "fingerprint", "model", "brand", "manufacturer", "board",
"host", "tags", "type", "user", "display", "product", "hardware",
"usb", "wifi", "adb_enabled", "development_settings_enabled",
"RISK_ROOT", "RISK_HOOK", "RISK_USB_ADB", "RISK_WIFI_ADB", "RISK_ADB",
"RISK_EMULATOR", "RISK_DEBUG", "RISK_VPN", "RISK_PROXY", "RISK_MOCK",
"rdVerifyInfo", "deviceFingerprint", "data", "dataKey", "riskToken",
"isRoot", "isHook", "isDebug", "isAdb", "isEmulator", "isVirtual",
"tamper", "jailbreak", "bootloader", "verifiedbootstate", "vbmeta",
"init.svc.adbd", "/proc/self/maps", "RealInterceptorChain",
]
# keys seen in 16:29-16:30 Pixel6 logs (from vuwuuwvw head=...)
SAMPLE_KEYS = """
2535994b 3923d741 68e69650 37132b99 1c5681ce 324f4370 4ea521fa
2236b022 5a5532da 1309e885 1bb219c0 3ade7f65 3ade7f66 1c560a56 1c560a55
3d33c1b1 1854d9b1 21e5cca2 23a20fae 36f30e66 29e2320e 2652ab1c 122c5826
269b494b 22b1f08d 169b85f 1610b055 2baf3770 5bc1a01a 37132b99
""".split()
def md5_key(name: str) -> str:
return hashlib.md5(name.encode()).hexdigest()[:8]
def guess_keys(keys):
table = {md5_key(n): n for n in KNOWN_FIELDS}
out = []
for k in keys:
if k.lower() in table:
out.append((k, table[k.lower()]))
return out
def scan_value(path, val, hits):
if isinstance(val, str):
b = val.encode("utf-8", "replace")
m = SUSPICIOUS.search(b)
if m:
hits.append(f"{path} str hit={m.group().decode()} val={val[:120]}")
if re.fullmatch(r"[A-Za-z0-9+/=]+", val) and 8 <= len(val) <= 512:
try:
raw = base64.b64decode(val + "==="[: (4 - len(val) % 4) % 4])
if sum(32 <= c < 127 for c in raw) * 100 // max(len(raw), 1) >= 85:
inner = raw.decode("utf-8", "replace")
m2 = SUSPICIOUS.search(inner.encode())
if m2:
hits.append(f"{path} b64utf8 hit={m2.group().decode()} val={inner[:120]}")
else:
hx = raw[:32].hex()
hits.append(f"{path} b64 bin len={len(raw)} hex={hx}")
except Exception:
pass
elif isinstance(val, (int, float, bool)):
if val in (1, True):
hits.append(f"{path} ={val} (flag?)")
def parse_json(text, label=""):
obj = json.loads(text)
keys = sorted(obj.keys())
print(f"\n=== {label} keys={len(keys)} ===")
print("first keys:", keys[:12])
hits = []
for k in keys:
scan_value(k, obj[k], hits)
if hits:
print("SUSPICIOUS:")
for h in hits[:30]:
print(" ", h)
else:
print("no plain suspicious strings")
matched = guess_keys(keys)
if matched:
print("MD5 key guesses:")
for k, n in matched:
print(f" {k} => {n}")
return obj
def extract_from_log(path):
text = Path(path).read_text(encoding="utf-8", errors="replace")
# MariBankCapture chunked: [vuwuuwvw.out REGISTER] 1/N ...
chunks = {}
current = None
for line in text.splitlines():
if "vuwuuwvw.out REGISTER" in line or "vuwuuwvw.out]" in line:
m = re.search(r"\] (\d+)/(\d+) (.+)$", line)
if m:
idx, total, part = int(m.group(1)), int(m.group(2)), m.group(3)
key = (total, line.split("REGISTER")[0])
chunks.setdefault(key, {})[idx] = part
elif " len=" in line and " parts=" not in line:
m2 = re.search(r"\] len=\d+ (.+)$", line)
if m2:
current = m2.group(1)
elif "vuwuuwvw.out REGISTER] len=" in line and " parts=" not in line:
m2 = re.search(r"len=\d+ (.+)$", line)
if m2:
current = m2.group(1)
if current and current.startswith("{"):
return [current]
out = []
for parts in chunks.values():
if parts:
joined = "".join(parts[i] for i in sorted(parts))
if joined.startswith("{"):
out.append(joined)
# fallback: head= lines won't work for full JSON
return out
def main():
print("=== MD5 key table (known fields -> 8 hex) ===")
for name in KNOWN_FIELDS[:20]:
print(f" {md5_key(name):8s} {name}")
print(" ...")
print("\n=== sample keys from device logs ===")
matched = guess_keys(SAMPLE_KEYS)
if matched:
for k, n in matched:
print(f" {k} => {n}")
else:
print(" (no MD5 match — keys may use different hash algo)")
if len(sys.argv) > 1:
p = Path(sys.argv[1])
if p.suffix == ".json":
parse_json(p.read_text(encoding="utf-8"), p.name)
else:
for i, blob in enumerate(extract_from_log(p)):
parse_json(blob, f"log#{i+1}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needle = b"dfp is empty"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
idx = 0
while True:
idx = data.find(needle, idx)
if idx < 0:
break
ctx = data[max(0, idx - 400) : idx + 400]
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/[^;\x00]{5,160};", ctx)))
print("\n--- hit at", idx, "---")
for c in classes:
if any(k in c.lower() for k in ("user", "register", "dfp", "fingerprint", "viewmodel", "rn", "helper")):
print(" ", c)
# printable strings nearby
for m in re.finditer(rb"[\x20-\x7e]{4,80}", ctx):
s = m.group().decode()
if any(k in s.lower() for k in ("dfp", "register", "empty", "error", "unavailable", "fingerprint")):
print(" str:", s)
idx += len(needle)

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
d = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for kw in [
b"CharacterCrypto",
b"IV_Monitor",
b"getDfpByMMKV",
b"NativeEncryptUtilsWrapper",
b"dfp/v1/data/report",
b"com/shopee/bke/lib/jni/utils/d",
]:
print(kw.decode(), d.count(kw))
print("\ndfp-related classes:")
for m in re.finditer(rb"Lcom/[^;]{0,120}[Dd][Ff][Pp][^;]{0,40};", d):
print(m.group().decode()[1:-1].replace("/", "."))
print("\nMonitor classes:")
for m in re.finditer(rb"Lcom/[^;]{0,120}Monitor[^;]{0,40};", d):
s = m.group().decode()[1:-1].replace("/", ".")
if "bke" in s or "shps" in s:
print(s)

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for needle in [b"dfp is empty", b"getDfp", b"DfpManager", b"DeviceFingerprint"]:
print("\n===", needle.decode(), "count=", data.count(needle))
idx = 0
for _ in range(5):
idx = data.find(needle, idx)
if idx < 0:
break
ctx = data[max(0, idx - 120) : idx + 200]
for m in re.finditer(rb"Lcom/[^;\x00]{5,140};", ctx):
print(" ", m.group().decode()[1:-1].replace("/", "."))
idx += len(needle)

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
"""Find jni/utils and safemode classes across SG split dex files."""
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
with zipfile.ZipFile(str(APK)) as zf:
for dex in sorted(n for n in zf.namelist() if n.endswith(".dex")):
d = zf.read(dex)
needles = [
b"Lcom/shopee/bke/lib/jni/utils/",
b"Lcom/shopee/bke/lib/safemode/",
b"CharacterCrypto",
b"rdVerifyInfo",
]
if not any(n in d for n in needles):
continue
print("\n===", dex, "===")
for pat in [
rb"Lcom/shopee/bke/lib/jni/utils/[^;]{1,40};",
rb"Lcom/shopee/bke/lib/safemode/[^;]{1,60};",
rb"Lcom/shopee/bke/biz/base/risk/[^;]{1,40};",
]:
cs = sorted(
set(
m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(pat, d)
)
)
for c in cs:
if ".R" in c and c.endswith(".R"):
continue
if "$" in c or not c.endswith(".R"):
print(" ", c)

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
import re
import zipfile
from pathlib import Path
APKS = Path(__file__).resolve().parent.parent / "apks"
for name in ["maribank_sg_base.apk", "maribank_sg_arm64.apk"]:
p = APKS / name
if not p.exists():
print(name, "missing")
continue
with zipfile.ZipFile(p) as z:
dex = [n for n in z.namelist() if n.endswith(".dex")]
print("\n", name, "dex:", dex)
if not dex:
so = [n for n in z.namelist() if n.endswith(".so")][:5]
print(" native:", so)
continue
d = b"".join(z.read(n) for n in dex)
for needle in [
b"safemode.b",
b"safemode/catchs",
b"safemode/util",
b"USB_ADB",
b"RISK_USB",
b"lib/safemode/",
]:
print(" ", needle.decode(), d.count(needle))
classes = sorted(set(m.group().decode()[1:-1].replace("/", ".")
for m in re.finditer(rb"Lcom/shopee/bke/lib/safemode/[^;]{1,80};", d)))
for c in classes:
if not c.endswith(".R") and ".R$" not in c:
print(" ", c)

View File

@@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needles = [
b"system is currently unavailable",
b"currently unavailable",
b"Unexpected error occurred",
b"Please try again later",
b"3100012",
b"deviceFingerprint",
b"preCheck",
b"preRegister",
b"getDfp",
b"dfp is empty",
b"dfpReady",
b"isDfpReady",
b"IV_Monitor",
b"register scene",
b"GlobalAuthError",
b"ErrorFlowHelper",
]
with zipfile.ZipFile(str(APK)) as zf:
data = b"".join(
zf.read(n)
for n in zf.namelist()
if n.endswith((".dex", ".jsbundle", ".json"))
)
for n in needles:
print(n.decode(), data.count(n))
idx = data.find(b"currently unavailable")
if idx >= 0:
print("\ncontext:", data[max(0, idx - 100) : idx + 150])

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""Scan TNG eWallet APK for AppProtect / root detection artifacts."""
import re
import sys
import zipfile
KEYS = [
b"AppProtect", b"appprotect", b"vkey", b"V-Key", b"VKey", b"VGuard",
b"jailbroken", b"Jailbroken", b"Rooted Device", b"rooted device",
b"Close app", b"tngdigital", b"TNG eWallet", b"How to keep device safe",
b"enhanced our security", b"RootBeer", b"SafetyNet", b"PlayIntegrity",
b"detectRoot", b"isRooted", b"checkRoot", b"magisk", b"xposed", b"lsposed",
b"frida", b"emulator", b"su binary", b"/system/xbin/su",
]
CLASS_KEYS = [
b"safemode", b"SafeMode", b"appprotect", b"AppProtect", b"vkey", b"VKey",
b"vguard", b"VGuard", b"rooted", b"RootBeer", b"integrity", b"jailbreak",
b"security", b"RiskDevice", b"tamper", b"hook", b"frida",
]
def scan_strings(apk_path):
print("=== STRING SCAN: %s ===" % apk_path)
with zipfile.ZipFile(apk_path) as zf:
for name in sorted(zf.namelist()):
if not name.endswith(".dex"):
continue
data = zf.read(name)
hits = []
for key in KEYS:
start = 0
while True:
idx = data.find(key, start)
if idx < 0:
break
s = max(0, idx - 60)
e = min(len(data), idx + len(key) + 100)
chunk = re.sub(rb"[^\x20-\x7e]+", b" ", data[s:e])
chunk = chunk.decode("ascii", "ignore").strip()
if chunk and chunk not in hits:
hits.append(chunk)
start = idx + 1
if hits:
print("\n--- %s (%d hits) ---" % (name, len(hits)))
for h in sorted(set(hits))[:40]:
print(" ", h)
if len(hits) > 40:
print(" ... +%d more" % (len(hits) - 40))
def scan_classes(apk_path):
print("\n=== CLASS SCAN: %s ===" % apk_path)
with zipfile.ZipFile(apk_path) as zf:
for name in sorted(zf.namelist()):
if not name.endswith(".dex"):
continue
data = zf.read(name)
classes = set(re.findall(rb"L[a-zA-Z0-9_$/]+;", data))
hits = []
for raw in classes:
low = raw.lower()
if any(k.lower() in low for k in CLASS_KEYS):
s = raw.decode("ascii", "ignore")[1:-1].replace("/", ".")
hits.append(s)
if hits:
print("\n--- %s (%d classes) ---" % (name, len(hits)))
for h in sorted(set(hits))[:60]:
print(" ", h)
if len(hits) > 60:
print(" ... +%d more" % (len(hits) - 60))
def scan_native(apk_path):
print("\n=== NATIVE LIB SCAN: %s ===" % apk_path)
with zipfile.ZipFile(apk_path) as zf:
for name in sorted(zf.namelist()):
if not name.endswith(".so"):
continue
data = zf.read(name)
lib = name.split("/")[-1]
found = []
for key in KEYS + [b"libvos", b"libvkey", b"libvguard", b"libappprotect"]:
if key.lower() in data.lower():
found.append(key.decode("ascii", "ignore"))
if found:
print(" %s: %s" % (lib, ", ".join(sorted(set(found)))))
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else "reverse/apks/tng/base.apk"
scan_strings(path)
scan_classes(path)
scan_native(path)

View File

@@ -0,0 +1,18 @@
"""Dump xwwqazamx bl/w/A method refs from TNG dex."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk"
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for cls in ["Lxwwqazamx/bl;", "Lxwwqazamx/w;", "Lxwwqazamx/W;", "Lxwwqazamx/A;"]:
print("===", cls, "===")
refs = sorted(set(re.findall(cls.encode() + rb"->[^\x00]{1,80}", data)))
for r in refs[:30]:
print(r.decode("ascii", "ignore"))
print()
print("=== lifecycle on xwwqazamx/w ===")
for m in sorted(set(re.findall(rb"Lxwwqazamx/w;->on[A-Za-z]+", data))):
print(m.decode())

View File

@@ -0,0 +1,25 @@
"""Scan TNG AppSecurityManager callbacks and bl methods."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk"
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
keys = [
b"handleRootingCallback", b"handleEmulatorCallback", b"handleHookingCallback",
b"handleMalwareCallback", b"onBlockStaticCheck", b"addIntoQueue",
b"ForceExit", b"exitApplication", b"startForceExit", b"Lxwwqazamx/bl;",
]
for k in keys:
i = data.find(k)
if i < 0:
continue
print("---", k.decode(), "---")
s = re.sub(rb"[^\x20-\x7e]+", b" ", data[max(0, i - 150) : i + len(k) + 200])
print(s.decode("ascii", "ignore")[:400])
print()
print("=== bl method refs ===")
for m in sorted(set(re.findall(rb"Lxwwqazamx/bl;->[a-zA-Z0-9_$<>\[\]]+", data))):
print(m.decode())

View File

@@ -0,0 +1,37 @@
"""Scan TNG dex for Promon exit paths and ActivityThread refs."""
import re
import sys
import zipfile
apk = sys.argv[1] if len(sys.argv) > 1 else r"C:\Users\Administrator\AppData\Local\Temp\tng_base.apk"
with zipfile.ZipFile(apk) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
patterns = [
rb"handleExitApplication",
rb"System;->exit",
rb"Runtime;->exit",
rb"Process;->killProcess",
rb"Runtime;->halt",
rb"xwwqazamx/bl",
rb"xwwqazamx/w",
rb"xwwqazamx/W",
rb"addIntoQueue",
rb"handleRootingCallback",
rb"startForceExit",
]
for pat in patterns:
hits = len(re.findall(pat, data))
print(f"{pat.decode('utf-8', 'ignore')}: {hits}")
print("\n=== xwwqazamx class names (sample) ===")
classes = sorted(set(m.group().decode("utf-8", "ignore") for m in re.finditer(rb"Lxwwqazamx/[A-Za-z0-9_$]+;", data)))
for c in classes[:60]:
print(c)
print(f"... total {len(classes)}")
for pat in [b"startForceExit", b"ForceExit", b"openSecurityUrl", b"Lxwwqazamx/w;", b"Lxwwqazamx/bl;->"]:
print("---", pat.decode())
hits = sorted(set(m.group().decode("utf-8", "ignore") for m in re.finditer(pat + rb"[^\x00]{0,100}", data)))
for h in hits[:20]:
print(h)

View File

@@ -0,0 +1,72 @@
"""Find System.exit(10) / killProcess callers and nearby strings in TNG DEX."""
import re
import zipfile
from pathlib import Path
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk")
with zipfile.ZipFile(apk) as z:
dex_blobs = [(n, z.read(n)) for n in z.namelist() if n.endswith(".dex")]
needles = [
b"finishAllActivityAndKillApp",
b"UnhandledEvent detected",
b"AppSecurityManager: UnhandledEvent",
b"openSecurityUrl",
b"startForceExitCountdown",
b"ForceExitCountdown",
b"killProcess",
b"SecurityForceExit",
b"handleExitApplication",
b"exitApplication",
b"Jailbroken/Rooted",
b"Detected by AppProtect",
]
print("=== string hits ===")
for name, data in dex_blobs:
for n in needles:
c = data.count(n)
if c:
print(f"{name}: {n.decode(errors='ignore')} x{c}")
# Find UTF-16 / UTF-8 contexts around exit-related
print("\n=== contexts near 'exit' security strings ===")
for name, data in dex_blobs:
for m in re.finditer(rb"[\x20-\x7e]{0,30}(exit|KillApp|killApp|ForceExit|Unhandled)[\x20-\x7e]{0,80}", data):
s = m.group().decode("ascii", "ignore")
if any(k in s.lower() for k in ("force", "kill", "unhandled", "security", "promon", "root")):
print(f"{name}: {s}")
# Smali-ish type refs
print("\n=== type refs ===")
patterns = [
rb"Lmy/com/tngdigital/common/internal/_ContextKt;",
rb"Lmy/com/tngdigital/common/security/model/UnhandledEvent;",
rb"Lxwwqazamx/W;",
rb"Lxwwqazamx/bl;",
rb"Landroid/os/Process;->killProcess",
rb"Ljava/lang/System;->exit",
rb"Ljava/lang/Runtime;->exit",
]
for name, data in dex_blobs:
for pat in patterns:
hits = len(re.findall(pat, data))
if hits:
print(f"{name}: {pat.decode(errors='ignore')} x{hits}")
# Look for const/16 near exit - hard in raw dex; instead find methods that mention exit code strings
print("\n=== classes near ForceExit / KillApp strings ===")
for name, data in dex_blobs:
for pat in [b"finishAllActivityAndKillApp", b"UnhandledEvent detected", b"startForceExitCountdownIfNeeded"]:
i = 0
while True:
j = data.find(pat, i)
if j < 0:
break
# scan backwards for L...; class descriptor within 2KB
window = data[max(0, j - 2048):j]
classes = re.findall(rb"L[\w/$]+;", window)
if classes:
print(f"{name} @{j} near {pat.decode()}: ...{classes[-5:]}")
i = j + 1

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Deeper TNG reverse: Exiting/Report, TigerTally API, kill-SVC in SO."""
from pathlib import Path
import re
import struct
ROOT = Path(__file__).resolve().parents[2]
APK_DIR = ROOT / "reverse" / "apks" / "tng"
SO = APK_DIR / "libtngdigital_ewallet.so"
def load_dexes():
files = sorted(APK_DIR.glob("classes*.dex"))
if not files:
# try extracted under other layouts
files = sorted((ROOT / "reverse" / "apks").rglob("tng*/classes*.dex"))
return files
def near(data, needle, before=40, after=80):
out = []
for m in re.finditer(re.escape(needle), data):
s = max(0, m.start() - before)
e = min(len(data), m.end() + after)
chunk = data[s:e]
printable = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
out.append(printable)
return out
def main():
dexes = load_dexes()
print(f"dex count={len(dexes)}")
all_data = b""
for d in dexes:
data = d.read_bytes()
all_data += data
hits = []
for k in [b"Exiting:", b"Exiting", b"Report", b"W: 16", b"W:16",
b"TigerTallyAPI", b"ttInit", b"collect", b"killProcess",
b"SIGABRT", b"abort(", b"tgkill"]:
c = data.count(k)
if c:
hits.append(f"{k.decode('latin1')}x{c}")
if hits:
print(f"{d.name}: {', '.join(hits)}")
print("\n=== near Exiting ===")
for s in near(all_data, b"Exiting")[:15]:
print(" ", s)
print("\n=== TigerTallyAPI method refs ===")
for m in sorted(set(re.findall(rb"Lcom/aliyun/TigerTally/TigerTallyAPI;->[A-Za-z0-9_<>$]+", all_data))):
print(" ", m.decode())
print("\n=== TigerTally t/ classes ===")
for m in sorted(set(re.findall(rb"Lcom/aliyun/TigerTally/[a-z]/[A-Za-z0-9_/$]*;", all_data))):
print(" ", m.decode())
print("\n=== xwwqazamx/bl method refs ===")
for m in sorted(set(re.findall(rb"Lxwwqazamx/bl;->[A-Za-z0-9_<>$]+", all_data)))[:40]:
print(" ", m.decode())
print("\n=== Process.killProcess / Runtime.exit refs near Promon ===")
for pat in [rb"Landroid/os/Process;->killProcess", rb"Ljava/lang/System;->exit",
rb"Ljava/lang/Runtime;->exit", rb"Ljava/lang/Runtime;->halt"]:
print(pat.decode(), "count=", len(re.findall(pat, all_data)))
if SO.exists():
so = SO.read_bytes()
print(f"\n=== SO {SO.name} size={len(so)} ===")
# movz x8,#129 = D2801028 LE
patterns = {
"movz_x8_129": bytes.fromhex("281080d2"),
"movz_w8_129": bytes.fromhex("28108052"),
"movz_x8_130": bytes.fromhex("481080d2"),
"movz_x8_131": bytes.fromhex("681080d2"),
"svc0": bytes.fromhex("010000d4"),
"brk0": bytes.fromhex("000020d4"),
}
for name, pat in patterns.items():
print(f" {name}: {so.count(pat)}")
# find movz kill + nearby svc within 32 bytes
kill_imm = [bytes.fromhex(x) for x in ("281080d2", "28108052", "481080d2", "681080d2")]
svc = bytes.fromhex("010000d4")
found = 0
for imm in kill_imm:
start = 0
while True:
i = so.find(imm, start)
if i < 0:
break
window = so[i:i+36]
if svc in window:
found += 1
if found <= 20:
off = window.find(svc)
print(f" kill+svc @ file+0x{i:x} svc_delta={off}")
start = i + 4
print(f" kill+svc pairs (packed): {found}")
else:
print(f"\nSO missing: {SO}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
import re
import zipfile
APK = "reverse/apks/tng/base.apk"
KEYS = [
b"AppProtect", b"JailBroken", b"jailbroken", b"isRooted", b"isJailbroken",
b"VKey", b"Promon", b"promon", b"APSE", b"Close app", b"Rooted Device",
b"enhanced our security", b"How to keep device safe", b"Detected by",
]
CLASS_NEEDLES = [
b"JailBroken", b"AppProtect", b"ApSecurity", b"Promon", b"VKey", b"VGuard",
b"RootDetect", b"DeviceRisk", b"SecurityInitializer", b"libAPSE",
]
def main():
with zipfile.ZipFile(APK) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
print("=== KEY STRINGS ===")
for k in KEYS:
idx = 0
while True:
idx = data.find(k, idx)
if idx < 0:
break
s = max(0, idx - 80)
e = min(len(data), idx + len(k) + 120)
chunk = re.sub(rb"[^\x20-\x7e]+", b" ", data[s:e]).decode("ascii", "ignore")
print(f"[{k.decode()}] {chunk.strip()}")
idx += 1
print("\n=== KEY CLASSES ===")
classes = set(re.findall(rb"L[a-zA-Z0-9_$/]+;", data))
hits = []
for raw in classes:
if any(n in raw for n in CLASS_NEEDLES):
hits.append(raw.decode()[1:-1].replace("/", "."))
for h in sorted(set(hits)):
print(h)
FLOW_KEYS = [
b"Detected by", b"Close app", b"AppSecurityManager", b"RootEvent", b"RootI18n",
b"PromonError", b"showJailBrokenAlert", b"onShowPopupDisable", b"isJailBroken",
b"detectJailBroken", b"APSecuritySdk", b"no/promon/shield", b"HookingFrameworks",
b"How to keep device safe", b"onBlockStaticCheck",
]
def scan_flow():
with zipfile.ZipFile(APK) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
print("\n=== FLOW STRINGS ===")
for k in FLOW_KEYS:
idx = data.find(k)
if idx < 0:
continue
s = max(0, idx - 80)
e = min(len(data), idx + len(k) + 120)
chunk = re.sub(rb"[^\x20-\x7e]+", b" ", data[s:e]).decode("ascii", "ignore")
print(f"[{k.decode()}] {chunk.strip()}")
if __name__ == "__main__":
main()
scan_flow()

View File

@@ -0,0 +1,53 @@
"""Find NativeLib / loadLibrary targets in TNG APK."""
import re
import zipfile
from pathlib import Path
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk")
split = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\split_config.arm64_v8a.apk")
with zipfile.ZipFile(apk) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
print("base dex files:", [n for n in z.namelist() if n.endswith(".dex")])
print("base lib entries:", [n for n in z.namelist() if "lib/" in n][:40])
print("\nsplit libs:")
with zipfile.ZipFile(split) as z:
libs = [n for n in z.namelist() if n.endswith(".so")]
for n in libs:
print(" ", n)
# strings related to NativeLib
needles = [
b"NativeLib",
b"tngd.networksdk",
b"RetrieveFromNativeLibs",
b"getApiSixSecretKeys",
b"networksdk",
b"libtng",
b"loadLibrary",
]
print("\n=== string hits ===")
for n in needles:
hits = list(re.finditer(n, data))
print(f"{n!r}: {len(hits)}")
for h in hits[:5]:
ctx = data[max(0, h.start()-30):h.end()+80]
ctx = bytes(c if 32 <= c < 127 else 46 for c in ctx)
print(" ", ctx)
# library name candidates near NativeLib
print("\n=== lib name-like strings near 'NativeLib' / networksdk ===")
for m in re.finditer(rb"[\x20-\x7e]{4,60}", data):
s = m.group().decode()
if "network" in s.lower() or "tngd" in s.lower() or s.startswith("lib") and "tng" in s.lower():
if len(s) < 80:
print(" ", s)
# specific: System.loadLibrary argument often stored as short string without lib/ prefix
print("\n=== candidate loadLibrary short names ===")
cands = set(re.findall(rb"[\x00]([A-Za-z0-9_]{3,40})[\x00]", data))
for c in sorted(cands):
s = c.decode()
if any(k in s.lower() for k in ("tng", "network", "native", "promon", "shield", "apse")):
print(" ", s)

View File

@@ -0,0 +1,19 @@
"""Find NativeLib method signatures / loadLibrary name via dex string proximity."""
import re
import zipfile
from pathlib import Path
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk")
with zipfile.ZipFile(apk) as z:
for name in z.namelist():
if not name.endswith(".dex"):
continue
data = z.read(name)
if b"NativeLib" not in data and b"native-lib" not in data:
continue
print("===", name, "===")
for pat in [b"NativeLib", b"native-lib", b"getApiSixSecretKeys", b"RetrieveFromNativeLibs", b"Lcom/tngd/networksdk"]:
for m in re.finditer(pat, data):
ctx = data[max(0, m.start()-60):m.end()+100]
ctx = bytes(c if 32 <= c < 127 else 46 for c in ctx)
print(pat.decode(), "@", m.start(), ":", ctx.decode())

View File

@@ -0,0 +1,16 @@
import re
import sys
import zipfile
apk = sys.argv[1] if len(sys.argv) > 1 else r"C:\Users\Administrator\AppData\Local\Temp\tng_base.apk"
with zipfile.ZipFile(apk) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for cls in ["xwwqazamx/bl", "xwwqazamx/W", "xwwqazamx/a", "JNICLibrary", "hzchengdun"]:
pattern = cls.encode("utf-8") + rb"[^\x00]{0,120}"
hits = sorted(set(
m.group().decode("utf-8", "ignore") for m in re.finditer(pattern, data)
))
print(f"\n=== {cls} ({len(hits)} strings) ===")
for h in hits[:40]:
print(h)

View File

@@ -0,0 +1,18 @@
"""Find SecurityErrorActivity onCreate signature."""
import re
import zipfile
APK = r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\base.apk"
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
for pat in [
b"SecurityErrorActivity",
b"launchProcessNextSecurityState",
b"addIntoQueueAndLaunch",
b"SecurityErrorBaseActivity;->onCreate",
]:
print("===", pat.decode(), "===")
for m in sorted(set(re.findall(pat + rb"[^\x00]{0,120}", data))):
print(m.decode("ascii", "ignore")[:150])
print()

View File

@@ -0,0 +1,61 @@
"""Scan libtngdigital_ewallet.so for SVC / exit patterns."""
from pathlib import Path
import struct
SO = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\libtngdigital_ewallet.so")
if not SO.exists():
# try from split apk
import zipfile
apk = Path(r"C:\Users\Administrator\Desktop\notiMessage\reverse\apks\tng\split_config.arm64_v8a.apk")
if apk.exists():
with zipfile.ZipFile(apk) as z:
for n in z.namelist():
if n.endswith("libtngdigital_ewallet.so"):
SO.write_bytes(z.read(n))
print("extracted from", apk, "->", SO)
break
data = SO.read_bytes()
print("size", len(data), SO)
svc = b"\x01\x00\x00\xd4"
idxs = []
start = 0
while True:
i = data.find(svc, start)
if i < 0:
break
idxs.append(i)
start = i + 4
print("total svc#0:", len(idxs))
# movz x8,#93 = d2 80 0b a8 ; movz x8,#94 = d2 80 0b c8 (LE)
# bytes LE: A8 0B 80 D2 / C8 0B 80 D2
exit_setups = [
(b"\xa8\x0b\x80\xd2", 93), # movz x8, #93
(b"\xc8\x0b\x80\xd2", 94), # movz x8, #94
(b"\xa8\x0b\x80\x52", 93), # movz w8, #93
(b"\xc8\x0b\x80\x52", 94), # movz w8, #94
]
for pat, nr in exit_setups:
c = data.count(pat)
print(f"movz *8,#{nr} pattern count={c}")
print("\nSVC with nearby exit setup (lookback 32 bytes):")
hits = 0
for i in idxs[:2000]:
window = data[max(0, i - 32) : i]
for pat, nr in exit_setups:
if pat in window:
print(f" off=0x{i:x} exit_group/exit via #{nr}")
hits += 1
break
print("hits", hits)
# also search brk
brk = b"\x00\x00\x20\xd4" # brk #0
print("brk#0 count", data.count(brk))
# string refs
for s in [b"_exit", b"exit_group", b"abort", b"frida", b"/proc/self/maps", b"xposed"]:
print(s, "->", data.find(s))

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
from pathlib import Path
DIR = Path(__file__).resolve().parents[1] / "apks" / "tng"
KILL = [bytes.fromhex(x) for x in (
"281080d2", "28108052", "481080d2", "48108052", "681080d2", "68108052",
)]
SVC = bytes.fromhex("010000d4")
EXIT = [bytes.fromhex(x) for x in ("c80b80d2", "ba0b80d2")] # exit_group, exit
for so in sorted(DIR.glob("lib*.so")):
data = so.read_bytes()
pairs = 0
for imm in KILL:
i = 0
while True:
j = data.find(imm, i)
if j < 0:
break
if SVC in data[j:j + 36]:
pairs += 1
if pairs <= 10:
print(f"{so.name} KILL+SVC @0x{j:x} d={data[j:j+36].find(SVC)}")
i = j + 4
ep = 0
for imm in EXIT:
i = 0
while True:
j = data.find(imm, i)
if j < 0:
break
if SVC in data[j:j + 36]:
ep += 1
if ep <= 8:
print(f"{so.name} EXIT+SVC @0x{j:x}")
i = j + 4
print(
f"{so.name}: size={len(data)} svc0={data.count(SVC)} "
f"kill+svc={pairs} exit+svc={ep} "
f"abort={data.count(b'abort')} kill={data.count(b'kill')}"
)

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
"""TNG reverse notes helper — ForceExit / abort / Promon suicide map."""
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parents[1] / "apks" / "tng" / "base.apk"
SO = Path(__file__).resolve().parents[1] / "apks" / "tng" / "libtngdigital_ewallet.so"
def main():
with zipfile.ZipFile(APK) as z:
data = b"".join(z.read(n) for n in z.namelist() if n.endswith(".dex"))
print("=== suicide ladder (from runtime + static) ===")
print("1) Promon root hit -> openSecurityUrl Rooting FAQ (Xposed blocks)")
print("2) xwwqazamx.W -> KillApplicationHandler (Xposed blocks)")
print("3) native exit_group(1) OR SIGABRT SI_USER via libc abort/raise/tgkill")
print("4) AppSecurityManager.startForceExitCountdown* / addIntoQueueAndLaunch")
print()
print("=== ForceExit-related descriptors ===")
for m in sorted(set(re.findall(rb"L[A-Za-z0-9_/$]*ForceExit[A-Za-z0-9_/$]*;", data))):
print(m.decode())
print("\n=== AppSecurityManager log strings (detection events) ===")
for m in re.finditer(rb"AppSecurityManager: [A-Za-z][^\x00]{5,80}", data):
s = m.group().decode("utf-8", "ignore")
if any(k in s for k in ("Root", "Hook", "Emulator", "Force", "Unhandled", "Navigat")):
print(s)
if SO.exists():
raw = SO.read_bytes()
print("\n=== SO imports of interest ===")
for s in (b"abort", b"raise", b"tgkill", b"kill", b"exit"):
print(s.decode(), "at", hex(raw.find(s)) if raw.find(s) >= 0 else None)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""Scan TNG DEX for Aliyun TigerTally / abort / SI_USER suicide helpers."""
import re
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parents[1] / "apks" / "tng" / "base.apk"
KEYS = [
b"TigerTally",
b"aliyun",
b"Aliyun",
b"com/aliyun/TigerTally",
b"UnhandledEvent detected",
b"finishAllActivityAndKillApp",
b"trackUnhandledEvent",
b"SI_USER",
b"raise",
b"SIGABRT",
b"pthread_kill",
b"dispatchUncaughtException",
b"AppProtect",
b"promon",
b"xwwqazamx",
]
def main():
with zipfile.ZipFile(APK) as z:
entries = [(n, z.read(n)) for n in z.namelist() if n.endswith(".dex")]
print("=== key hits ===")
for name, data in entries:
for k in KEYS:
c = data.count(k)
if c:
print(f"{name}: {k.decode('utf-8','ignore')} x{c}")
print("\n=== TigerTally class descriptors ===")
all_data = b"".join(d for _, d in entries)
classes = sorted(set(re.findall(rb"Lcom/aliyun/TigerTally/[A-Za-z0-9_/$]*;", all_data)))
for c in classes[:80]:
print(c.decode())
print("total", len(classes))
print("\n=== nearby strings TigerTally ===")
for m in re.finditer(rb"TigerTally[\x20-\x7e]{0,60}", all_data):
print(m.group().decode("ascii", "ignore"))
print("\n=== finishAll / Unhandled contexts ===")
for pat in [
b"finishAllActivityAndKillApp",
b"UnhandledEvent detected",
b"trackUnhandledEvent",
b"dispatchUncaughtException",
]:
idx = all_data.find(pat)
if idx < 0:
continue
ctx = all_data[max(0, idx - 40) : idx + len(pat) + 80]
printable = "".join(chr(b) if 32 <= b < 127 else "." for b in ctx)
print(pat.decode(), "=>", printable)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
import re
import zipfile
APK = "reverse/apks/tng/base.apk"
KEYS = [
b"36616543382169", b"support.tngdigital", b"Rooting", b"How to keep device safe",
b"showJailBroken", b"openUrl", b"openBrowser", b"launchUrl", b"ACTION_VIEW",
b"RootI18n", b"SecurityError", b"startChrome", b"IntentDispatcher",
]
def main():
with zipfile.ZipFile(APK) as zf:
data = b"".join(zf.read(n) for n in zf.namelist() if n.endswith(".dex"))
for k in KEYS:
idx = 0
while True:
idx = data.find(k, idx)
if idx < 0:
break
s = max(0, idx - 80)
e = min(len(data), idx + len(k) + 120)
chunk = re.sub(rb"[^\x20-\x7e]+", b" ", data[s:e]).decode("ascii", "ignore")
print(f"[{k.decode()}] {chunk.strip()}")
idx += 1
print("\n=== classes with Root/security ===")
classes = set(re.findall(rb"Lmy/com/tngdigital/common/security[^;]+;", data))
for c in sorted(classes):
s = c.decode()[1:-1].replace("/", ".")
if any(x in s.lower() for x in ["root", "error", "jail", "shield", "promon"]):
print(s)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
import zipfile
from pathlib import Path
APK = Path(__file__).resolve().parent.parent / "apks" / "maribank_sg_base.apk"
needles = [
b"The system is currently unavailable",
b"dfp is empty",
b"getDfp",
b"preCheck",
b"register",
b"Sign up with mobile",
b"msg_ekyc_singpass_service_error",
b"general_error",
b"GlobalAuth",
]
with zipfile.ZipFile(str(APK)) as zf:
bundles = [n for n in zf.namelist() if n.endswith(".jsbundle")]
print("bundles:", len(bundles))
for name in bundles:
data = zf.read(name)
hits = [n.decode() for n in needles if n in data]
if hits:
print(name, hits)

View File

@@ -0,0 +1,466 @@
#!/usr/bin/env python3
"""TNG 全流程自动化:注册区号 + 登录 PIN/区号;两次冷启动,互不干扰。"""
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
PKG = "my.com.tngdigital.ewallet"
SPLASH = f"{PKG}/.ui.SplashActivity"
ADB = ["adb"]
TNG_LOG = re.compile(
r"TngRoot|F HWUI|GraphicBuffer|Runtime abort|signal 6|exited due to signal"
r"|UserSearchCallingCode|UserLogin|UserRegistration|SecurityError"
r"|Dialog\.show|skip loading|registration flow",
re.I,
)
CRASH_LOG = re.compile(
r"signal 6|Runtime aborting|F HWUI|F GraphicBuffer|gralloc-mapper is missing",
re.I,
)
def adb(*args, timeout=60):
r = subprocess.run(ADB + list(args), capture_output=True, text=True, timeout=timeout, errors="ignore")
return r.returncode, (r.stdout or "") + (r.stderr or "")
def is_tng_foreground():
_, out = adb("shell", "dumpsys", "activity", "activities")
for line in out.splitlines():
if "topResumedActivity=" in line:
return PKG in line
return False
def bring_tng_foreground():
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
if is_tng_foreground():
return
adb("shell", "am", "start", "-n", SPLASH)
time.sleep(2)
def dump_ui(path="/sdcard/tng_ui.xml", retries=2):
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
for _ in range(retries):
adb("shell", "uiautomator", "dump", path)
code, xml = adb("shell", "cat", path)
if code == 0 and xml.strip().startswith("<?xml"):
try:
return ET.fromstring(xml)
except ET.ParseError:
pass
time.sleep(1)
return None
def center(bounds_str):
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str or "")
if not m:
return None
x1, y1, x2, y2 = map(int, m.groups())
return (x1 + x2) // 2, (y1 + y2) // 2
def find_pin_login_row(root):
candidates = []
for node in root.iter("node"):
t = node.get("text") or ""
if "忘记" in t:
continue
pt = center(node.get("bounds"))
if pt is None:
continue
# 登录方式页 PIN 行 y≈600780
if 600 <= pt[1] <= 780 and (
"PIN" in t.upper() or "6位数" in t or "6位" in t or (len(t) >= 4 and "PIN" in t)
):
candidates.append((pt[1], node))
if not candidates:
return None
candidates.sort(key=lambda x: x[0])
return candidates[0][1]
def wait_phone_page(timeout=20):
return wait_for(lambda: is_phone_page(dump_ui()), timeout=timeout, interval=1.5, desc="phone page")
def find_nodes(root, **kwargs):
out = []
for node in root.iter("node"):
text = (node.get("text") or "").strip()
rid = node.get("resource-id") or ""
cls = node.get("class") or ""
clickable = node.get("clickable") == "true"
ok = True
if "text_contains" in kwargs and kwargs["text_contains"] not in text:
ok = False
if "text_excludes" in kwargs:
for ex in kwargs["text_excludes"]:
if ex in text:
ok = False
if "rid_contains" in kwargs and kwargs["rid_contains"] not in rid:
ok = False
if "class_contains" in kwargs and kwargs["class_contains"] not in cls:
ok = False
if kwargs.get("clickable") and not clickable:
ok = False
if ok:
out.append(node)
return out
def tap_node(node, label=""):
pt = center(node.get("bounds"))
if not pt:
print(f" skip tap {label}: bad bounds")
return False
x, y = pt
print(f" tap {label!r} at {x},{y}")
adb("shell", "input", "tap", str(x), str(y))
return True
def find_country_control(root, min_y=0, max_y=9999):
nodes = find_nodes(root, rid_contains="ll_country", clickable=True)
for node in nodes:
pt = center(node.get("bounds"))
if pt and min_y <= pt[1] <= max_y:
return node
nodes = find_nodes(root, rid_contains="tv_left", clickable=True)
for node in nodes:
pt = center(node.get("bounds"))
if pt and min_y <= pt[1] <= max_y:
return node
return None
def find_register_btn(root):
for node in root.iter("node"):
if node.get("clickable") != "true":
continue
t = node.get("text") or ""
if ("注册" in t or "Register" in t.lower()) and "已经" not in t and "已注" not in t:
return node
pt = center(node.get("bounds"))
if pt and 1050 < pt[1] < 1220 and 430 < pt[0] < 660:
return node
return None
def is_phone_page(root):
return bool(find_nodes(root, rid_contains="userContinueBtn"))
def is_register_page(root):
return bool(
find_nodes(root, rid_contains="ftv_register_title")
or find_nodes(root, rid_contains="userRegisterContinueBtn")
)
def has_country_list(root):
if root is None:
return False
for node in root.iter("node"):
t = node.get("text") or ""
if any(k in t for k in ("Malaysia", "Singapore", "Australia", "China", "+86", "+61")):
return True
return False
def pid():
code, out = adb("shell", "pidof", PKG)
return out.strip() if code == 0 and out.strip() else ""
def top_activity():
_, out = adb("shell", "dumpsys", "activity", "activities")
for line in out.splitlines():
if PKG not in line:
continue
if "topResumedActivity=" in line:
m = re.search(r"/([^/}\s]+)", line)
return m.group(1) if m else "?"
for line in out.splitlines():
if "ResumedActivity:" in line and PKG in line:
m = re.search(r"/([^/}\s]+)", line)
if m:
return m.group(1)
return "?"
def wait_for(fn, timeout=35, interval=1.5, desc=""):
deadline = time.time() + timeout
while time.time() < deadline:
if fn():
return True
time.sleep(interval)
print(f" timeout: {desc}")
return False
def wait_ui_widgets(timeout=30):
"""等待 Compose 控件出现在 dumpSplash WebView 退场后)。"""
def ready():
root = dump_ui()
if root is None:
return False
return bool(
find_pin_login_row(root)
or find_register_btn(root)
or is_phone_page(root)
or find_nodes(root, rid_contains="ftv_content")
or find_nodes(root, rid_contains="ftv_register_title")
)
return wait_for(ready, timeout=timeout, interval=2, desc="login/register widgets")
def wait_login_method(timeout=40):
def ready():
if not pid():
return False
if not is_tng_foreground():
bring_tng_foreground()
return "UserLoginActivity" in top_activity()
ok = wait_for(ready, timeout=timeout, interval=2, desc="UserLoginActivity ready")
if ok:
wait_ui_widgets(timeout=25)
return ok
def wait_login_method_register(timeout=40):
return wait_login_method(timeout=timeout)
def wait_country_list(timeout=30):
def activity_has_country_list():
act = top_activity()
if "UserSearchCallingCode" in act:
return True
_, out = adb("shell", "dumpsys", "activity", "activities")
return "UserSearchCallingCodeActivity" in out and PKG in out
def ready():
if activity_has_country_list():
return True
root = dump_ui()
return has_country_list(root)
return wait_for(ready, timeout=timeout, interval=1.5, desc="country list")
def cold_start(clear_log=False):
if clear_log:
adb("logcat", "-c")
adb("shell", "am", "force-stop", PKG)
time.sleep(3)
adb("shell", "input", "keyevent", "KEYCODE_WAKEUP")
adb("shell", "am", "start", "-W", "-n", SPLASH)
ok = wait_for(
lambda: bool(pid()) and "UserLoginActivity" in top_activity(),
timeout=60,
interval=2,
desc="UserLoginActivity after cold start",
)
if not ok:
return False
bring_tng_foreground()
time.sleep(3)
return True
def cold_start_with_retry(clear_log=False, attempts=3):
for i in range(attempts):
if cold_start(clear_log and i == 0):
return True
print(f" cold start retry {i + 1}/{attempts}")
adb("shell", "am", "force-stop", PKG)
time.sleep(5)
return bool(pid()) and "UserLoginActivity" in top_activity()
def tap_pin_row(root):
if root is None:
print(" PIN fallback (no dump) 561,677")
adb("shell", "input", "tap", "561", "677")
return
pin_text = find_pin_login_row(root)
if pin_text is None:
if find_nodes(root, rid_contains="ftv_content") or find_nodes(root, rid_contains="ftv_title"):
print(" PIN layout fallback 561,677")
adb("shell", "input", "tap", "561", "677")
return
print(" PIN fallback tap 540,677")
adb("shell", "input", "tap", "540", "677")
return
pt = center(pin_text.get("bounds"))
print(f" tap PIN at {pt[0]},{pt[1]}")
adb("shell", "input", "tap", str(pt[0]), str(pt[1]))
def collect_logs(n=3000):
_, out = adb("shell", "logcat", "-d", "-t", str(n))
return [ln for ln in out.splitlines() if TNG_LOG.search(ln)]
def collect_crashes(n=4000):
_, out = adb("shell", "logcat", "-d", "-t", str(n))
return [ln for ln in out.splitlines() if CRASH_LOG.search(ln) and "digital.ewallet" in ln]
def step(name, fn):
print(f"\n=== {name} ===")
bring_tng_foreground()
ok = fn()
print(f" pid={pid() or 'DEAD'} activity={top_activity()}")
return ok and bool(pid())
def wait_register_page(timeout=20):
def ready():
root = dump_ui()
if root is None or not is_register_page(root):
return False
ctrl = find_country_control(root, min_y=680)
if ctrl is None:
return False
pt = center(ctrl.get("bounds"))
# 注册页 ll_country 中心 y 通常 > 680
return pt is not None and pt[1] >= 680
ok = wait_for(ready, timeout=timeout, interval=1.5, desc="register page settled")
if ok:
time.sleep(1)
return ok
def flow_register():
time.sleep(5)
if not cold_start_with_retry():
return False
if not wait_login_method_register():
print(" register entry not visible")
return False
root = dump_ui()
reg = find_register_btn(root)
if reg is None:
print(" register fallback 540,1137")
adb("shell", "input", "tap", "540", "1137")
else:
tap_node(reg, "register")
if not wait_register_page():
return False
root2 = dump_ui()
target = find_country_control(root2, min_y=680)
if target is None:
print(" no country control on register page")
return False
tap_node(target, "register country")
if not wait_country_list(timeout=25):
return False
root3 = dump_ui()
if root3 is not None:
for node in root3.iter("node"):
t = node.get("text") or ""
if "Malaysia" in t or "Singapore" in t:
print(f" country UI: {t[:40]}")
break
logs = collect_logs(300)
for ln in logs:
if "skip loading" in ln or "UserSearchCallingCode" in ln:
print(f" log: {ln[:120]}")
break
return True
def flow_login():
if not cold_start_with_retry(clear_log=True):
return False
if not wait_login_method():
return False
root = dump_ui()
if is_phone_page(root):
print(" already on phone page")
else:
for attempt in range(2):
root = dump_ui()
if root is None:
time.sleep(2)
continue
if is_phone_page(root):
print(" phone page ready")
break
tap_pin_row(root)
if wait_phone_page(timeout=15):
break
print(f" phone page retry {attempt + 1}/2")
time.sleep(2)
else:
return False
root2 = dump_ui()
target = find_country_control(root2, min_y=450, max_y=650)
if target is None:
print(" no country control on phone page")
return False
tap_node(target, "login country")
if not wait_country_list():
return False
root3 = dump_ui()
if root3 is not None:
for node in root3.iter("node"):
t = node.get("text") or ""
if "Malaysia" in t or "Singapore" in t:
print(f" country UI: {t[:40]}")
break
logs = collect_logs(300)
for ln in logs:
if "skip loading" in ln:
print(f" log: {ln[:120]}")
break
return "UserSearchCallingCode" in top_activity() or has_country_list(root3)
def main():
adb("shell", "svc", "power", "stayon", "true")
adb("logcat", "-c")
adb("shell", "am", "force-stop", PKG)
time.sleep(5)
# 先登录后注册:首次冷启动最稳定
results = [
("login_country", step("A. login PIN → country list", flow_login)),
("register_country", step("B. register → country list", flow_register)),
]
crashes = collect_crashes()
p = pid()
print("\n=== SUMMARY ===")
for name, ok in results:
print(f" {'PASS' if ok else 'FAIL'} {name}")
print(f" pid={p or 'DEAD'} activity={top_activity()}")
print(f" crashes={len(crashes)}")
if crashes:
for ln in crashes[-5:]:
print(" ", ln[:150])
print("\n=== TngRoot (last 20) ===")
for ln in collect_logs(2000)[-20:]:
if "TngRoot" in ln:
print(ln[:180])
ok = bool(p) and all(r[1] for r in results)
if crashes and not ok:
print(" (crash lines may include prior process; see pid)")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

10
scripts/_tng_anr_dump.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/system/bin/sh
ANR=/data/anr/anr_2026-07-31-13-30-08-628
echo "=== header ==="
su -c "head -40 $ANR"
echo "=== main tid ==="
su -c "grep -n '\"main\"' $ANR | head -5"
echo "=== main block ==="
su -c "awk '/\"main\" prio/{p=1} p{print} p&&/^$/{c++} c>=2{exit}' $ANR" | head -80
echo "=== cpu ==="
su -c "grep -A20 'CPU usage' $ANR | head -30"

View File

@@ -0,0 +1,6 @@
#!/system/bin/sh
ANR=/data/anr/anr_2026-07-31-13-30-08-628
echo "=== RenderThread ==="
su -c "awk '/\"RenderThread\"/{p=1} p{print} p&&/^\"/{if(!/RenderThread/){exit}}' $ANR" | head -60
echo "=== Quake / promon threads ==="
su -c "grep -E '^\"|libtng|Quake|xwwq|promon|tiger' $ANR | head -80"

View File

@@ -0,0 +1,22 @@
#!/system/bin/sh
# 抓到 getprop 子进程瞬间 dump 其 syscall/status验证 exit_group 被 seccomp 卡住)
PKG="my.com.tngdigital.ewallet"
am force-stop "$PKG" 2>/dev/null
sleep 1
am start -n "$PKG/.ui.SplashActivity" 2>/dev/null
i=0
while [ $i -lt 200 ]; do
i=$((i+1))
for gp in $(pidof getprop 2>/dev/null); do
ppid=$(awk '/^PPid/{print $2}' /proc/$gp/status 2>/dev/null)
pp=$(tr '\0' ' ' < /proc/$ppid/cmdline 2>/dev/null)
echo "GETPROP pid=$gp ppid=$ppid pp=[$pp]"
echo " seccomp: $(grep -i seccomp /proc/$gp/status 2>/dev/null | tr '\n' ' ')"
echo " syscall: $(cat /proc/$gp/syscall 2>/dev/null)"
echo " wchan: $(cat /proc/$gp/wchan 2>/dev/null) state=$(awk '/^State/{print $2}' /proc/$gp/status 2>/dev/null)"
done
sleep 0.05
done
echo "=== done ==="

24
scripts/_tng_pin_check.sh Normal file
View File

@@ -0,0 +1,24 @@
#!/system/bin/sh
logcat -c
input keyevent KEYCODE_WAKEUP
settings put system screen_off_timeout 600000
am force-stop my.com.tngdigital.ewallet
sleep 1
monkey -p my.com.tngdigital.ewallet -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1
i=0
while [ $i -lt 40 ]; do
sleep 1
i=$((i+1))
p=$(pidof my.com.tngdigital.ewallet)
if [ -z "$p" ]; then
echo "t=${i}s DEAD"
break
fi
if [ $((i % 5)) -eq 0 ]; then
echo "t=${i}s pid=$p"
fi
done
echo "=== focus ==="
dumpsys window 2>/dev/null | grep -E 'mCurrentFocus|mFocusedApp' | head -4
echo "=== key ==="
logcat -d 2>/dev/null | grep -E 'TngExitGuard|Displayed.*User|UserPin|UserLogin|seccomp|caught sig=|exited cleanly|Fatal signal|ANR in my.com.tng' | tail -40

View File

@@ -0,0 +1,39 @@
#!/system/bin/sh
# 抓 TigerTally fread 阻塞的 pipe 写端进程。自动启动 TNG 并连续扫描。
PKG="my.com.tngdigital.ewallet"
am force-stop "$PKG" 2>/dev/null
sleep 1
am start -n "$PKG/.ui.SplashActivity" 2>/dev/null
i=0
while [ $i -lt 40 ]; do
i=$((i+1))
pid=""
for p in $(pidof "$PKG"); do
if [ "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | tr -d ' ')" = "$PKG" ]; then pid=$p; break; fi
done
if [ -z "$pid" ]; then sleep 0.5; continue; fi
for d in /proc/$pid/task/*/; do
t="${d%/}"
s=$(cat "$t/syscall" 2>/dev/null)
case "$s" in
63*)
c=$(cat "$t/comm" 2>/dev/null)
fd=$(echo "$s" | awk '{print $2}')
fd=$((fd))
tgt=$(readlink "/proc/$pid/fd/$fd" 2>/dev/null)
echo "[$i] pid=$pid TID=${t##*/} comm=$c wchan=$(cat "$t/wchan" 2>/dev/null) syscall=$s"
echo "[$i] fd=$fd -> $tgt"
ino=$(echo "$tgt" | sed 's/.*\[//; s/\]//')
if [ -n "$ino" ]; then
echo "[$i] writer-search inode=$ino:"
find /proc/[0-9]*/fd -lname "*$ino*" 2>/dev/null
fi
;;
esac
done
sleep 0.5
done
echo "=== scan done ==="

View File

@@ -0,0 +1,65 @@
#!/system/bin/sh
# 抓 TigerTally fread 阻塞的 pipe 写端进程,命中后立即 dump 该进程身份/冻结状态。
PKG="my.com.tngdigital.ewallet"
am force-stop "$PKG" 2>/dev/null
sleep 1
am start -n "$PKG/.ui.SplashActivity" 2>/dev/null
i=0
while [ $i -lt 60 ]; do
i=$((i+1))
pid=""
for p in $(pidof "$PKG"); do
if [ "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | tr -d ' ')" = "$PKG" ]; then pid=$p; break; fi
done
if [ -z "$pid" ]; then sleep 0.5; continue; fi
for d in /proc/$pid/task/*/; do
t="${d%/}"
s=$(cat "$t/syscall" 2>/dev/null)
case "$s" in
63*)
c=$(cat "$t/comm" 2>/dev/null)
case "$c" in
*pool*|*Tiger*|*tiger*|*tally*|*Tally*)
fd=$(echo "$s" | awk '{print $2}')
fd=$((fd))
tgt=$(readlink "/proc/$pid/fd/$fd" 2>/dev/null)
echo "[$i] MAIN pid=$pid TID=${t##*/} comm=$c wchan=$(cat "$t/wchan" 2>/dev/null)"
echo "[$i] MAIN fd=$fd -> $tgt"
ino=$(echo "$tgt" | sed 's/.*\[//; s/\]//')
[ -z "$ino" ] && continue
echo "[$i] writer-search inode=$ino:"
for wp in $(find /proc/[0-9]*/fd -lname "*$ino*" 2>/dev/null); do
echo "[$i] $wp"
done
# dump 所有非主进程端点
for wp in $(find /proc/[0-9]*/fd -lname "*$ino*" 2>/dev/null); do
wproc=$(echo "$wp" | cut -d/ -f3)
[ "$wproc" = "$pid" ] && continue
wfd=$(echo "$wp" | cut -d/ -f5)
echo "[$i] WRITER proc=$wproc fd=$wfd"
echo "[$i] cmdline: $(tr '\0' ' ' < /proc/$wproc/cmdline 2>/dev/null)"
echo "[$i] comm: $(cat /proc/$wproc/comm 2>/dev/null) state=$(cat /proc/$wproc/stat 2>/dev/null | awk '{print $3}')"
cg=$(cat /proc/$wproc/cgroup 2>/dev/null | grep -v freezer | head -1)
echo "[$i] cgroup: $cg"
# cgroup v2 freezer
cgpath=$(echo "$cg" | sed 's/^[0-9]*://')
if [ -f "/sys/fs/cgroup${cgpath}/cgroup.freeze" ]; then
echo "[$i] cgroup.freeze=$(cat /sys/fs/cgroup${cgpath}/cgroup.freeze 2>/dev/null)"
fi
echo "[$i] threads(wchan):"
for td in /proc/$wproc/task/*/; do
ttn=${td%/}
echo "[$i] ${ttn##*/} $(cat $ttn/comm 2>/dev/null) $(cat $ttn/wchan 2>/dev/null)"
done
done
;;
esac
;;
esac
done
sleep 0.4
done
echo "=== scan done ==="

46
scripts/_tng_proc_scan.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/system/bin/sh
# TNG eWallet — 启动期 /proc 扫描:定位阻塞在 read 的 TigerTally 线程及其 fd 目标。
# 用法: adb shell su -c 'sh /data/local/tmp/_tng_proc_scan.sh [loop_seconds]'
PKG="my.com.tngdigital.ewallet"
LOOP="${1:-1}" # 持续扫描秒数(默认 1 秒抓一次快照)
# 选主进程cmdline 恰为包名,排除 :tools / :goacqowmmt 等子进程)
main_pid=""
for p in $(pidof "$PKG"); do
if [ "$(tr '\0' ' ' < /proc/$p/cmdline 2>/dev/null | tr -d ' ')" = "$PKG" ]; then
main_pid="$p"
break
fi
done
[ -z "$main_pid" ] && { echo "NO-MAIN-PROC pidof=$(pidof $PKG)"; exit 1; }
echo "=== main pid=$main_pid ==="
i=0
while [ $i -lt "$LOOP" ]; do
i=$((i+1))
echo "--- scan #$i ---"
# 1) 所有可疑线程的状态
for tid in $(ls /proc/$main_pid/task 2>/dev/null); do
comm=$(cat /proc/$main_pid/task/$tid/comm 2>/dev/null)
case "$comm" in
*pool*|*location*|*tally*|*Tiger*|*tiger*)
syscall=$(cat /proc/$main_pid/task/$tid/syscall 2>/dev/null)
wchan=$(cat /proc/$main_pid/task/$tid/wchan 2>/dev/null)
stat=$(cat /proc/$main_pid/task/$tid/stat 2>/dev/null | awk '{print $3}')
echo "TID=$tid comm=$comm state=$stat syscall=[$syscall] wchan=$wchan"
;;
esac
done
# 2) 所有管道/套接字 fdTigerTally 握手候选)
for fd in /proc/$main_pid/fd/*; do
tgt=$(readlink "$fd" 2>/dev/null)
case "$tgt" in
*pipe:*|*socket:*|*anon_inode:*)
echo "FD=$(basename $fd) -> $tgt"
;;
esac
done
[ $i -lt "$LOOP" ] && sleep 1
done
echo "=== done ==="

View File

@@ -14,6 +14,7 @@ scopes = [
"au.com.up.money",
"au.com.suncorp.marketplace",
"au.com.bank86400",
"my.com.tngdigital.ewallet",
]
shutil.copy2(db_path, db_path + ".bak")

View File

@@ -0,0 +1,35 @@
# 解压 XAPK 并通过 adb install-multiple 安装 TNG
param(
[Parameter(Mandatory = $true)]
[string]$XapkPath
)
$ErrorActionPreference = "Stop"
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
if (-not (Test-Path $XapkPath)) {
Write-Host "文件不存在: $XapkPath" -ForegroundColor Red
exit 1
}
$extractDir = Join-Path ([IO.Path]::GetDirectoryName($XapkPath)) "tng_xapk_extracted"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
New-Item -ItemType Directory -Path $extractDir | Out-Null
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $XapkPath), $extractDir)
$apks = Get-ChildItem $extractDir -Filter "*.apk" -Recurse | Sort-Object Name
if ($apks.Count -eq 0) {
Write-Host "XAPK 内未找到 apk 文件" -ForegroundColor Red
exit 1
}
Write-Host "找到 $($apks.Count) 个 APK开始安装..." -ForegroundColor Cyan
$apkArgs = @("install-multiple", "-r") + ($apks | ForEach-Object { $_.FullName })
& $adb @apkArgs
if ($LASTEXITCODE -eq 0) {
Write-Host "TNG 安装成功" -ForegroundColor Green
& $adb shell monkey -p my.com.tngdigital.ewallet -c android.intent.category.LAUNCHER 1
} else {
Write-Host "安装失败exit=$LASTEXITCODE" -ForegroundColor Red
}

View File

@@ -0,0 +1,87 @@
# Launch MariBank Singapore (not PH SeaBank)
param(
[switch]$ClearData,
[switch]$StopPh,
[switch]$ColdStart,
[int]$WaitSeconds = 0
)
$ErrorActionPreference = "Stop"
$PkgSg = "sg.com.maribankmobile.digitalbank"
$PkgPh = "ph.seabank.seabank"
$Activity = "com.shopee.bke.digitalbank.ui.MainActivity"
function Resolve-AdbPath {
$candidates = @(
(Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"),
"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
)
foreach ($path in $candidates) {
if (Test-Path $path) { return $path }
}
$cmd = Get-Command adb -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
$adb = Resolve-AdbPath
if (-not $adb) {
Write-Host "adb not found." -ForegroundColor Red
exit 1
}
$devices = & $adb devices 2>&1 | Where-Object { $_ -match "\tdevice$" }
if (-not $devices) {
Write-Host "No authorized adb device." -ForegroundColor Red
exit 1
}
Write-Host "=== Launch MariBank SINGAPORE ===" -ForegroundColor Cyan
Write-Host "Package: $PkgSg (v3.2.2)"
Write-Host ""
Write-Host "[Required] After Xposed module update: LSPosed -> scope SG pkg -> re-optimize / force-stop / launch" -ForegroundColor Yellow
Write-Host " Without soft reboot, old hooks may cause BLANK-PAGE white screen." -ForegroundColor Yellow
Write-Host ""
Write-Host "Package: sg.com.maribankmobile.digitalbank (NOT ph.seabank.seabank)" -ForegroundColor Yellow
Write-Host ""
if ($StopPh) {
& $adb shell am force-stop $PkgPh | Out-Null
}
if ($ClearData) {
Write-Host "Clearing SG app data..."
& $adb shell pm clear $PkgSg | Out-Null
}
if ($ColdStart) {
Write-Host "Cold start: force-stop SG (first screen may stay white 30-60s)" -ForegroundColor Cyan
& $adb shell am force-stop $PkgSg | Out-Null
Start-Sleep -Seconds 2
} else {
Write-Host "Warm start (recommended). Use -ColdStart for cold start." -ForegroundColor Cyan
}
& $adb shell am start -n "$PkgSg/$Activity"
Write-Host ""
Write-Host "MariBank SG launched." -ForegroundColor Green
Write-Host "RN may show BLANK-PAGE for ~25-45s before welcome screen."
Write-Host "If white screen > 1 min: LSPosed soft reboot SG, then retry -ColdStart"
Write-Host "Verify API: adb logcat -d | Select-String api.maribank.com.sg"
if ($WaitSeconds -gt 0) {
Write-Host ""
Write-Host "Waiting ${WaitSeconds}s for UI..." -ForegroundColor Cyan
Start-Sleep -Seconds $WaitSeconds
& $adb shell uiautomator dump /sdcard/ui_launch_check.xml 2>&1 | Out-Null
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $adb pull /sdcard/ui_launch_check.xml "$env:TEMP\ui_launch_check.xml" 2>&1 | Out-Null
$ErrorActionPreference = $prevEap
if (Test-Path "$env:TEMP\ui_launch_check.xml") {
$xml = Get-Content "$env:TEMP\ui_launch_check.xml" -Raw
$blank = $xml -match "BLANK-PAGE"
Write-Host ("BLANK-PAGE=" + $blank)
}
}

View File

@@ -11,5 +11,5 @@ if ($Clear) {
Write-Host " .\scripts\logcat-maribank.ps1"
exit 0
}
& $adb logcat -d 2>&1 | Select-String -Pattern "MariBankRoot|MariBankNative" |
Select-String -Pattern "HTTP|outbound|register|faked|finish adb|blocked|ErrorFlow|RegisterViewModel|skip error|late app|late native|assessRisk|risk callback"
& $adb logcat -d 2>&1 | Select-String -Pattern "MariBankRoot|MariBankNative|MariBankDfp|MariBankEncrypt|MariBankCapture" |
Select-String -Pattern "HTTP|outbound|register|dfp/v1|dfp is empty|3100012|4067|faked|finish adb|blocked|ErrorFlow|RegisterViewModel|skip error|late app|late native|assessRisk|risk callback"

View File

@@ -0,0 +1,58 @@
# MariBank SG 一键:编译 → 安装模块 → 换 ID → 清数据 → 启动 SG
# 用法:
# .\scripts\maribank-sg-all-in.ps1
# .\scripts\maribank-sg-all-in.ps1 -DisableUsbDebug # 测前关 USB 调试(会断 adb
# .\scripts\maribank-sg-all-in.ps1 -SkipBuild # 仅换 ID + 启动
param(
[switch]$SkipBuild,
[switch]$DisableUsbDebug,
[switch]$KeepAdb,
[switch]$ColdStart
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Write-Host "=== MariBank SG All-In ===" -ForegroundColor Cyan
if (-not $SkipBuild) {
Write-Host "`n[1/5] Build debug APKs..." -ForegroundColor Cyan
& "$ProjectRoot\scripts\build-debug.ps1"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "`n[2/5] Install APKs..." -ForegroundColor Cyan
& "$ProjectRoot\scripts\install-debug.ps1"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
} else {
Write-Host "`n[1-2/5] Skip build/install (-SkipBuild)" -ForegroundColor Yellow
}
Write-Host "`n[3/5] New device identity + clear MariBank SG..." -ForegroundColor Cyan
& "$ProjectRoot\scripts\maribank-spoof-device.ps1" -NewIdentity -ClearMariBank
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "`n[4/5] LSPosed 必做PC 无法代劳)" -ForegroundColor Yellow
Write-Host " 1. LSPosed -> 模块 -> notiMessage 启用"
Write-Host " 2. 作用域勾选: sg.com.maribankmobile.digitalbank"
Write-Host " 3. 对该包: 重新优化 -> 强行停止 -> 启动(等价软重启)"
Write-Host " 4. Shamiko DenyList 含 SG 全部进程Enforce=OFF"
Write-Host ""
Read-Host "完成 LSPosed 软重启后按 Enter 继续" | Out-Null
$launchArgs = @("-StopPh")
if ($ColdStart) { $launchArgs += "-ColdStart" }
Write-Host "`n[5/5] Launch MariBank SG..." -ForegroundColor Cyan
& "$ProjectRoot\scripts\launch-maribank-sg.ps1" @launchArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if ($DisableUsbDebug -and -not $KeepAdb) {
Write-Host "`n=== Disable USB debugging (SG stricter) ===" -ForegroundColor Cyan
& "$ProjectRoot\scripts\maribank-sg-register.ps1" -DisableUsbDebug
} else {
Write-Host "`n=== Ready to test ===" -ForegroundColor Green
Write-Host "Phone: Sign up -> +65 -> Next"
Write-Host "Log: .\scripts\maribank-sg-register.ps1 -CaptureLog -KeepAdb"
Write-Host " .\scripts\logcat-maribank.ps1"
Write-Host "Strict test (no adb): .\scripts\maribank-sg-all-in.ps1 -SkipBuild -DisableUsbDebug"
}

View File

@@ -0,0 +1,153 @@
# MariBank SG register test — new identity, optional log capture
param(
[switch]$NewIdentity,
[switch]$CaptureLog,
[switch]$DumpLog,
[switch]$InstallModule,
[switch]$DisableUsbDebug,
[switch]$KeepAdb,
[switch]$All
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$Pkg = "sg.com.maribankmobile.digitalbank"
function Resolve-AdbPath {
$candidates = @(
(Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"),
"C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
)
foreach ($path in $candidates) {
if (Test-Path $path) { return $path }
}
$cmd = Get-Command adb -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
$adb = Resolve-AdbPath
if (-not $adb) {
Write-Host "adb not found. Install Android SDK platform-tools or add adb to PATH." -ForegroundColor Red
exit 1
}
function Get-AdbDevicesText {
& $adb devices 2>&1 | Out-String
}
function Test-AdbAuthorized {
$lines = & $adb devices 2>&1 | Where-Object { $_ -match "\tdevice$" }
return [bool]$lines
}
function Invoke-AdbShell([string]$cmd) {
& $adb shell $cmd 2>&1
}
function Show-AdbHelp {
param([string]$DevicesText)
Write-Host "`nadb devices output:" -ForegroundColor Yellow
Write-Host $DevicesText
Write-Host @"
1. USB
USB + 线
2. 线 / USB / MTP
3. USB
4. PC adb kill-server && adb start-server && adb devices
5. log .\scripts\maribank-sg-register.ps1 -CaptureLog -KeepAdb
"@ -ForegroundColor Cyan
}
Write-Host "=== MariBank SG Register Test ===" -ForegroundColor Cyan
Write-Host "adb: $adb"
if ($All) {
$allArgs = @()
if ($DisableUsbDebug -and -not $KeepAdb) { $allArgs += "-DisableUsbDebug" }
if ($KeepAdb) { $allArgs += "-KeepAdb" }
& "$ProjectRoot\scripts\maribank-sg-all-in.ps1" @allArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not $CaptureLog) { exit 0 }
}
& $adb start-server 2>&1 | Out-Null
$devicesText = Get-AdbDevicesText
if (-not (Test-AdbAuthorized)) {
Write-Host "No authorized adb device." -ForegroundColor Red
Show-AdbHelp -DevicesText $devicesText
exit 1
}
Write-Host $devicesText
if ($NewIdentity -or $InstallModule) {
$spoofArgs = @()
if ($NewIdentity) { $spoofArgs += "-NewIdentity" }
if ($InstallModule) { $spoofArgs += "-InstallModule" }
& "$ProjectRoot\scripts\maribank-spoof-device.ps1" @spoofArgs -ClearMariBank
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
$shouldDisableAdb = $DisableUsbDebug -and -not $KeepAdb
if ($shouldDisableAdb) {
Write-Host "`n=== Disable USB / wireless debugging (SG stricter) ===" -ForegroundColor Cyan
Write-Host "WARNING: PC adb will disconnect after this. Re-enable USB debug on phone to connect again." -ForegroundColor Yellow
Invoke-AdbShell "settings put global adb_enabled 0" | Out-Null
Invoke-AdbShell "settings put global development_settings_enabled 0" | Out-Null
Invoke-AdbShell "settings put secure adb_wifi_enabled 0" | Out-Null
Invoke-AdbShell "su -c 'resetprop init.svc.adbd stopped; resetprop persist.sys.adb_enable 0'" 2>$null | Out-Null
} elseif (-not $KeepAdb) {
Write-Host "`n=== Skip disabling USB debug (default) ===" -ForegroundColor Cyan
Write-Host "Use -DisableUsbDebug when ready to test SG without PC adb; use -KeepAdb with -CaptureLog."
}
if (-not $CaptureLog) {
Write-Host "`n=== Clear SG app + verify IDs ===" -ForegroundColor Cyan
Invoke-AdbShell "pm clear $Pkg" | Out-Null
Invoke-AdbShell "su -c 'getprop ro.serialno; settings get secure android_id'"
}
Write-Host ""
Write-Host "=== LSPosed: pick the correct MariBank package ===" -ForegroundColor Yellow
Write-Host " SG: sg.com.maribankmobile.digitalbank (v3.2.2, api.maribank.com.sg)"
Write-Host " PH: ph.seabank.seabank (v3.22.0, api.seabank.ph)"
Write-Host " Launch SG: .\scripts\launch-maribank-sg.ps1"
Write-Host ""
Write-Host "Phone: LSPosed -> soft reboot $Pkg"
Write-Host "Then: MariBank SG -> Sign up -> +65 phone -> Next"
if ($CaptureLog -or $DumpLog) {
if ($CaptureLog) {
Write-Host ""
Write-Host "=== logcat cleared; tap Next then press Enter ===" -ForegroundColor Cyan
& $adb logcat -c
Write-Host "Tap Next on phone, then press Enter..."
Read-Host | Out-Null
} else {
Write-Host ""
Write-Host "=== dump current logcat (no clear) ===" -ForegroundColor Cyan
}
Write-Host ""
Write-Host "--- register / dfp / attestation ---" -ForegroundColor Cyan
$lines = & $adb logcat -d | Select-String "MariBankCapture|MariBankRegister|MariBankEncrypt|MariBankAttest|MariBankDfp|MariBankNative|MariBankRoot HTTP|ProbeGuard|3100012|4067012|OTP_SMS|register summary|dfp/v1|code=0|Gson REGISTRATION|uapi/v2/register|attestation hooks|native-core"
if ($lines) {
$lines
} else {
Write-Host "(no matches)" -ForegroundColor Yellow
Write-Host "Likely: Enter pressed before Next, or register API not sent yet."
Write-Host "You are on phone screen? Tap Next, wait for loading, then run:"
Write-Host " .\scripts\maribank-sg-register.ps1 -DumpLog -KeepAdb"
}
} else {
Write-Host "`nLog after Next:" -ForegroundColor Cyan
Write-Host " .\scripts\maribank-sg-register.ps1 -CaptureLog -KeepAdb"
Write-Host " .\scripts\maribank-sg-register.ps1 -DumpLog -KeepAdb # no clear, dump now"
}
Write-Host ""
Write-Host "Doc: docs/MariBank新加坡突破.md" -ForegroundColor Green

32
scripts/pull-tng-apk.ps1 Normal file
View File

@@ -0,0 +1,32 @@
# 从已安装 TNG 的手机 pull 完整 split APK供另一台 adb install-multiple
$ErrorActionPreference = "Stop"
$adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
$pkg = "my.com.tngdigital.ewallet"
$outDir = Join-Path (Split-Path -Parent $PSScriptRoot) "reverse\dumps\tng_splits"
$paths = & $adb shell pm path $pkg 2>$null
if (-not $paths) {
Write-Host "设备未安装 $pkg" -ForegroundColor Red
Write-Host "请先在已装 TNG 的手机(如 Pixel 6上 USB 调试连接。"
exit 1
}
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
Remove-Item "$outDir\*.apk" -Force -ErrorAction SilentlyContinue
$i = 0
foreach ($line in $paths) {
if ($line -match "package:(.+)") {
$remote = $Matches[1].Trim()
$name = Split-Path $remote -Leaf
if ($name -eq "base.apk") { $local = Join-Path $outDir "base.apk" }
else { $local = Join-Path $outDir $name }
Write-Host "Pull $remote -> $local"
& $adb pull $remote $local
$i++
}
}
Write-Host "`n已 pull $i 个 APK 到 $outDir" -ForegroundColor Green
Write-Host "安装到另一台手机:"
Write-Host " adb install-multiple -r $outDir\*.apk"

View File

@@ -0,0 +1,48 @@
# MariBank SG Frida native attestation trace
# Usage:
# .\scripts\run-frida-sg-native.ps1
# .\scripts\run-frida-sg-native.ps1 -Spawn
param(
[switch]$Attach,
[switch]$SkipLsposedHint
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$FridaDir = Join-Path $ProjectRoot "reverse\frida"
$Py312 = "C:\Users\Administrator\AppData\Local\Programs\Python\Python312\python.exe"
$Adb = Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"
if (-not (Test-Path $Adb)) {
$Adb = "C:\Users\Administrator\AppData\Local\Android\Sdk\platform-tools\adb.exe"
}
Write-Host "=== MariBank SG Frida Native Trace ===" -ForegroundColor Cyan
Write-Host "Package: sg.com.maribankmobile.digitalbank"
Write-Host "Script: reverse\frida\trace_maribank_sg_native.js"
Write-Host ""
if (-not $SkipLsposedHint) {
Write-Host "[Required before trace]" -ForegroundColor Yellow
Write-Host " 1. LSPosed -> KEEP scope ENABLED for sg.com.maribankmobile.digitalbank"
Write-Host " (module bypasses ADB page; disabling scope shows ADB Detected screen)"
Write-Host " 2. Soft reboot SG app (force-stop then reopen)"
Write-Host " 3. frida-server running: .\scripts\install-frida.ps1 -StartServer"
Write-Host ""
Read-Host "Done? Press Enter to continue" | Out-Null
}
& $Adb devices
& $Adb shell "su -c 'pgrep frida-server || /data/local/tmp/frida-server -D &'" 2>&1 | Out-Null
Start-Sleep -Seconds 1
if (-not (Test-Path $Py312)) {
Write-Host "Python 3.12 not found at $Py312" -ForegroundColor Red
Write-Host "Run: .\scripts\install-frida.ps1"
exit 1
}
$mode = if ($Attach) { "attach" } else { "spawn" }
Write-Host "Mode: $mode (default spawn — open Sign up after app starts)" -ForegroundColor Cyan
Write-Host ""
& $Py312 (Join-Path $FridaDir "run_frida_sg_native.py") $mode

View File

@@ -0,0 +1,41 @@
# 启动 mitmproxy 抓 TNG Money Packet API需先: pip install mitmproxy
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$Addon = Join-Path $ProjectRoot "debug-server\tng_mmp_mitm_addon.py"
function Get-LanIp {
$ip = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.IPAddress -notlike "127.*" -and $_.InterfaceAlias -notlike "*Loopback*" } |
Select-Object -First 1 -ExpandProperty IPAddress
if ($ip) { return $ip }
return "127.0.0.1"
}
$mitm = Get-Command mitmweb -ErrorAction SilentlyContinue
if (-not $mitm) {
$mitm = Get-Command mitmdump -ErrorAction SilentlyContinue
}
if (-not $mitm) {
Write-Host "未找到 mitmproxy。请先安装:" -ForegroundColor Yellow
Write-Host " pip install mitmproxy" -ForegroundColor Cyan
exit 1
}
$pcIp = Get-LanIp
Write-Host ""
Write-Host "=== TNG Money Packet 抓包 ===" -ForegroundColor Cyan
Write-Host "1. 手机与 PC 同一 WiFi" -ForegroundColor White
Write-Host "2. 手机 WiFi 代理: 手动 $pcIp 端口 8888" -ForegroundColor Green
Write-Host "3. 手机浏览器打开 http://mitm.it 安装证书 (Android)" -ForegroundColor White
Write-Host "4. TNG 登录 -> 进群 -> 打开红包 Leaderboard" -ForegroundColor White
Write-Host "5. 命中响应保存到 reverse/dumps/mitm_mmp/" -ForegroundColor White
Write-Host ""
Write-Host "若 TNG 报网络错误,可能是证书 pinning见 docs 说明。" -ForegroundColor Yellow
Write-Host ""
if ($mitm.Name -eq "mitmweb") {
Write-Host "Web UI: http://127.0.0.1:8081" -ForegroundColor Cyan
& mitmweb -s $Addon -p 8888 --web-host 127.0.0.1
} else {
& mitmdump -s $Addon -p 8888
}

View File

@@ -0,0 +1,20 @@
# TNG eWallet 全流程自动化:注册/登录 → 点区号 → 国家列表
# 依赖adb、设备已 Root + LSPosed + notiMessage xposed-module 已勾选 TNG
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
Write-Host "== build xposed-module ==" -ForegroundColor Cyan
& .\gradlew :xposed-module:assembleDebug
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "== install ==" -ForegroundColor Cyan
adb install -r xposed-module\build\outputs\apk\debug\xposed-module-debug.apk
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
adb shell am force-stop my.com.tngdigital.ewallet
Start-Sleep -Seconds 3
Write-Host "== full flow test ==" -ForegroundColor Cyan
python reverse/scripts/test_tng_full_flow.py
exit $LASTEXITCODE

View File

@@ -0,0 +1,51 @@
# TNG eWallet bypass: install xposed + LSPosed scope + optional clear app data
param(
[switch]$ClearTng,
[switch]$SkipBuild
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Set-Location $ProjectRoot
$sdk = "C:\Users\Administrator\AppData\Local\Android\Sdk"
$adb = Join-Path $sdk "platform-tools\adb.exe"
if (-not (Test-Path $adb)) { $adb = "adb" }
if (-not $SkipBuild) {
& "$ProjectRoot\scripts\build-debug.ps1"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
$xposedApk = Join-Path $ProjectRoot "xposed-module\build\outputs\apk\debug\xposed-module-debug.apk"
Write-Host "=== Install Xposed module ===" -ForegroundColor Cyan
& $adb install -r -t -g $xposedApk
if ($LASTEXITCODE -ne 0) {
& $adb install -r -t -g --bypass-low-target-sdk-block $xposedApk
}
Write-Host "=== Configure LSPosed scope (incl. TNG) ===" -ForegroundColor Cyan
$apkPath = (& $adb shell pm path com.miraclegarden.smsmessage.xposed) -replace '^package:', ''
$apkPath = $apkPath.Trim()
& $adb shell "su -c 'cp /data/adb/lspd/config/modules_config.db /sdcard/Download/modules_config.db; rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm'"
$db = Join-Path $env:TEMP "modules_config_tng_finish.db"
& $adb pull /sdcard/Download/modules_config.db $db
python "$ProjectRoot\scripts\configure-lsposed.py" $db $apkPath
& $adb push $db /sdcard/Download/modules_config.db
& $adb shell "su -c 'cp /sdcard/Download/modules_config.db /data/adb/lspd/config/modules_config.db; rm -f /data/adb/lspd/config/modules_config.db-wal /data/adb/lspd/config/modules_config.db-shm; chmod 660 /data/adb/lspd/config/modules_config.db'"
Write-Host "=== Phone checklist ===" -ForegroundColor Yellow
Write-Host " 1. TNG must NOT be in Magisk DenyList (DenyList blocks Vector/LSPosed injection)"
Write-Host " 2. MariBank/Seabank may stay on DenyList; Shamiko + ProcMaps hook hide root for them"
Write-Host " 3. LSPosed: notiMessage Xposed enabled, scope includes my.com.tngdigital.ewallet"
Write-Host " 4. LSPosed soft reboot / zygote restart after scope update"
Write-Host " 5. Disable USB debugging before test (recommended)"
if ($ClearTng) {
Write-Host "=== Clear TNG data ===" -ForegroundColor Cyan
& $adb shell "su -c 'pm clear my.com.tngdigital.ewallet'"
}
& $adb shell "am force-stop my.com.tngdigital.ewallet"
Write-Host ""
Write-Host "Done. Launch TNG eWallet, then run: .\scripts\logcat-tng.ps1" -ForegroundColor Green

View File

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

View File

@@ -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 = 5;
public static final String MODULE_VERSION_NAME = "1.2.2";
public static final String SOURCE_XPOSED_SQLITE = "xposed_sqlite";
public static final String SOURCE_XPOSED_WECHAT = "xposed_wechat";
public static final String SOURCE_XPOSED_TELEGRAM = "xposed_telegram";
@@ -18,6 +25,7 @@ public final class HookBridge {
public static final String SOURCE_XPOSED_SUNCORP_NOTIFY = "xposed_suncorp_notify";
public static final String SOURCE_XPOSED_UBANK = "xposed_ubank";
public static final String SOURCE_XPOSED_UBANK_NOTIFY = "xposed_ubank_notify";
public static final String SOURCE_XPOSED_TNG_MMP = "xposed_tng_mmp";
private HookBridge() {
}

View File

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

View File

@@ -3,6 +3,7 @@ package com.miraclegarden.smsmessage.xposed;
import com.miraclegarden.smsmessage.xposed.hook.MariBankRootBypassHook;
import com.miraclegarden.smsmessage.xposed.hook.MariBankShpsNativeHook;
import com.miraclegarden.smsmessage.xposed.hook.SuncorpBankMessageHook;
import com.miraclegarden.smsmessage.xposed.hook.TngMoneyPacketHook;
import com.miraclegarden.smsmessage.xposed.hook.TngRootBypassHook;
import com.miraclegarden.smsmessage.xposed.hook.SqliteMessageHook;
import com.miraclegarden.smsmessage.xposed.hook.TelegramMessageHook;
@@ -66,6 +67,7 @@ public class MainHook implements IXposedHookLoadPackage {
if (TngRootBypassHook.isTargetPackage(lpparam.packageName)) {
TngRootBypassHook.install(lpparam);
TngMoneyPacketHook.install(lpparam);
return;
}

View File

@@ -3,6 +3,8 @@ package com.miraclegarden.smsmessage.xposed.hook;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
@@ -17,25 +19,70 @@ public final class MariBankAttestationHook {
private static final String TAG = "notiMessageHook/MariBankAttest";
private static volatile boolean installed = false;
private static volatile boolean probesInstalled = false;
private static volatile boolean loadClassWatcherInstalled = false;
private static final Set<String> HOOKED_ATTESTATION_CLASSES = new HashSet<>();
private MariBankAttestationHook() {
}
public static void installLate(XC_LoadPackage.LoadPackageParam lpparam) {
if (installed) {
return;
public static int installLate(XC_LoadPackage.LoadPackageParam lpparam) {
if (!probesInstalled) {
hookEnvironmentProbes(lpparam);
probesInstalled = true;
}
installed = true;
installLoadClassWatcher(lpparam);
RootBypassHelper.hookSecurityClass(lpparam, "com.shopee.bke.biz.base.risk.a");
int n = 0;
n += hookAttestationClass(lpparam, "com.shopee.shpssdkbank.wvvvuwwu");
n += hookAttestationClass(lpparam, "com.shopee.shpssdk.wvvvuwwu");
n += hookAttestationClass(lpparam,
"com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu");
for (String className : MariBankRegionProfile.shpsAttestationCoreClasses(lpparam.packageName)) {
n += hookAttestationClass(lpparam, className);
}
n += hookKnownAttestationMethods(lpparam);
hookEnvironmentProbes(lpparam);
XposedBridge.log(TAG + " attestation hooks=" + n);
if (n > 0) {
XposedBridge.log(TAG + " attestation hooks=" + n
+ " region=" + MariBankRegionProfile.label(lpparam.packageName)
+ " classes=" + HOOKED_ATTESTATION_CLASSES.size());
}
return n;
}
private static void installLoadClassWatcher(XC_LoadPackage.LoadPackageParam lpparam) {
if (loadClassWatcherInstalled) {
return;
}
loadClassWatcherInstalled = true;
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (name == null) {
return;
}
if (name.contains("shpssdk")
|| name.contains("CharacterCrypto")
|| name.contains("bke.biz.base.risk")) {
if (MariBankRegionProfile.isSingapore(lpparam.packageName)
&& MariBankRegionProfile.isShpssdkLegacyPackage(name)) {
return;
}
installLate(lpparam);
}
}
});
XposedBridge.log(TAG + " ClassLoader attestation watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip ClassLoader watcher: " + t.getMessage());
}
}
/** 逆向确认的 attestation / requestDefense 桥接方法。 */
@@ -44,7 +91,11 @@ public final class MariBankAttestationHook {
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vvuwuuvuu");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "wwvwvwuvv");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vuwuuuwv");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vuwuuwvw");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vuwuuwvvw");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.wvvvuwwu", "vuwuuwvu");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv", "wuvwuvwwu");
count += hookMethodByName(lpparam, "com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv", "wwvuwuwvu");
return count;
}
@@ -69,6 +120,9 @@ public final class MariBankAttestationHook {
private static int hookAttestationClass(
XC_LoadPackage.LoadPackageParam lpparam, String className) {
if (HOOKED_ATTESTATION_CLASSES.contains(className)) {
return 0;
}
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
@@ -80,6 +134,9 @@ public final class MariBankAttestationHook {
count++;
}
}
if (count > 0) {
HOOKED_ATTESTATION_CLASSES.add(className);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip class " + className + ": " + t.getMessage());
}
@@ -87,9 +144,28 @@ public final class MariBankAttestationHook {
}
private static boolean hookAttestationMethod(String className, Method method) {
final String methodName = method.getName();
final boolean isVuwuuwvw = "vuwuuwvw".equals(methodName);
Class<?> rt = method.getReturnType();
if (rt == String.class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
private byte[][] vuwuInputs;
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (!isVuwuuwvw) {
return;
}
vuwuInputs = new byte[param.args.length][];
for (int i = 0; i < param.args.length; i++) {
if (param.args[i] instanceof byte[]) {
byte[] b = (byte[]) param.args[i];
vuwuInputs[i] = b;
XposedBridge.log(TAG + " >> vuwuuwvw in" + i + " len=" + b.length);
}
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
@@ -97,6 +173,9 @@ public final class MariBankAttestationHook {
return;
}
String s = (String) result;
if (isVuwuuwvw) {
MariBankAttestationJsonUtil.logVuwuuwvwCall(vuwuInputs, s);
}
String sanitized = sanitizeAttestationString(s);
if (!sanitized.equals(s)) {
param.setResult(sanitized);
@@ -215,12 +294,9 @@ public final class MariBankAttestationHook {
if (pb == null || pb.command() == null) {
return;
}
String joined = String.join(" ", pb.command()).toLowerCase();
if (joined.contains(" su") || joined.startsWith("su")
|| joined.contains("magisk") || joined.contains("which su")
|| joined.contains("getprop ro.debuggable")) {
XposedBridge.log(TAG + " blocked ProcessBuilder: " + joined);
throw new SecurityException("blocked root probe");
if (ProbeGuard.isBlockedCommand(pb.command())) {
param.setResult(ProbeGuard.fakeFailedProcess(
String.join(" ", pb.command())));
}
}
}

View File

@@ -0,0 +1,251 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.util.Base64;
import org.json.JSONArray;
import org.json.JSONObject;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.robv.android.xposed.XposedBridge;
/**
* 解析 {@code vuwuuwvw} 返回的 obfuscated-key JSONkey 多为 8 位 hex
*/
final class MariBankAttestationJsonUtil {
private static final String TAG = "notiMessageHook/MariBankAttestJson";
private static final Pattern HEX_KEY = Pattern.compile("^[0-9a-fA-F]{8}$");
private static final Pattern SUSPICIOUS = Pattern.compile(
"(root|hook|xposed|lsposed|magisk|frida|substrate|emulator|debug|adb|"
+ "selinux|su\\b|/proc/|zygisk|riru|shamiko|tamper|integrity|"
+ "jailbreak|virtual|mock|proxy|vpn|developer)",
Pattern.CASE_INSENSITIVE);
private MariBankAttestationJsonUtil() {
}
static void logVuwuuwvwCall(byte[][] inputs, String output) {
if (inputs != null) {
for (int i = 0; i < inputs.length; i++) {
if (inputs[i] != null && inputs[i].length > 0) {
MariBankCaptureUtil.logBytes("vuwuuwvw.in" + i, inputs[i]);
}
}
}
if (output == null || output.isEmpty()) {
return;
}
boolean registerLike = isRegisterAttestationCall(inputs, output);
String label = registerLike ? "vuwuuwvw.out REGISTER" : "vuwuuwvw.out";
MariBankCaptureUtil.logText(label, output);
parseAndSummarize(output, registerLike);
}
static boolean isRegisterAttestationCall(byte[][] inputs, String output) {
if (inputs != null) {
for (byte[] in : inputs) {
if (in != null && in.length >= 200 && in.length <= 400) {
return true;
}
}
}
return output != null && output.length() >= 2200 && output.length() <= 2500;
}
static void parseAndSummarize(String json, boolean highlight) {
try {
JSONObject obj = new JSONObject(json);
List<String> keys = new ArrayList<>();
Iterator<String> it = obj.keys();
while (it.hasNext()) {
keys.add(it.next());
}
Collections.sort(keys);
XposedBridge.log(TAG + " keys=" + keys.size()
+ (highlight ? " [REGISTER-LIKE]" : "")
+ " sample=" + keys.subList(0, Math.min(6, keys.size())));
List<String> hits = new ArrayList<>();
for (String key : keys) {
Object val = obj.get(key);
scanValue(key, val, hits);
}
if (!hits.isEmpty()) {
XposedBridge.log(TAG + " SUSPICIOUS count=" + hits.size());
for (int i = 0; i < Math.min(hits.size(), 24); i++) {
XposedBridge.log(TAG + " " + hits.get(i));
}
} else {
XposedBridge.log(TAG + " no plain suspicious strings in values");
}
logKeyGuesses(keys);
} catch (Throwable t) {
XposedBridge.log(TAG + " parse fail: " + t.getMessage()
+ " head=" + json.substring(0, Math.min(120, json.length())));
}
}
private static void scanValue(String key, Object val, List<String> hits) {
if (val instanceof String) {
String s = (String) val;
noteIfSuspicious(key, "str", s, hits);
if (s.length() >= 8 && s.length() <= 512 && looksBase64(s)) {
byte[] decoded = tryBase64(s);
if (decoded != null) {
String inner = new String(decoded, StandardCharsets.UTF_8);
if (isMostlyPrintable(inner)) {
noteIfSuspicious(key, "b64utf8", inner, hits);
} else {
noteIfSuspicious(key, "b64hex", hexPreview(decoded, 32), hits);
}
}
}
return;
}
if (val instanceof Number || val instanceof Boolean) {
String s = String.valueOf(val);
if ("1".equals(s) || "true".equalsIgnoreCase(s)) {
hits.add(key + " =" + s + " (flag?)");
}
return;
}
if (val instanceof JSONObject) {
JSONObject nested = (JSONObject) val;
Iterator<String> it = nested.keys();
while (it.hasNext()) {
String nk = it.next();
try {
scanValue(key + "." + nk, nested.get(nk), hits);
} catch (Throwable ignored) {
}
}
return;
}
if (val instanceof JSONArray) {
JSONArray arr = (JSONArray) val;
for (int i = 0; i < arr.length(); i++) {
try {
scanValue(key + "[" + i + "]", arr.get(i), hits);
} catch (Throwable ignored) {
}
}
}
}
private static void noteIfSuspicious(String key, String kind, String text, List<String> hits) {
if (text == null || text.isEmpty()) {
return;
}
Matcher m = SUSPICIOUS.matcher(text);
if (m.find()) {
String show = text.length() > 120 ? text.substring(0, 120) + "..." : text;
hits.add(key + " " + kind + " hit=" + m.group().toLowerCase(Locale.ROOT) + " val=" + show);
}
}
private static void logKeyGuesses(List<String> keys) {
Set<String> candidates = knownFieldNames();
List<String> matched = new ArrayList<>();
for (String key : keys) {
if (!HEX_KEY.matcher(key).matches()) {
continue;
}
String lower = key.toLowerCase(Locale.ROOT);
for (String name : candidates) {
if (hashKey(name).equals(lower)) {
matched.add(key + "=>" + name);
}
}
}
if (!matched.isEmpty()) {
XposedBridge.log(TAG + " key guesses: " + matched);
}
}
private static Set<String> knownFieldNames() {
Set<String> names = new LinkedHashSet<>();
String[] base = {
"root", "hook", "xposed", "lsposed", "magisk", "frida", "adb", "debug",
"debuggable", "emulator", "simulator", "vpn", "proxy", "mock",
"selinux", "su", "supersu", "zygisk", "riru", "shamiko", "substrate",
"integrity", "safetynet", "playIntegrity", "deviceId", "androidId",
"serial", "fingerprint", "model", "brand", "manufacturer", "board",
"host", "tags", "type", "user", "display", "product", "hardware",
"usb", "wifi", "adb_enabled", "development_settings_enabled",
"RISK_ROOT", "RISK_HOOK", "RISK_USB_ADB", "RISK_WIFI_ADB", "RISK_ADB",
"RISK_EMULATOR", "RISK_DEBUG", "RISK_VPN", "RISK_PROXY", "RISK_MOCK",
"rdVerifyInfo", "deviceFingerprint", "data", "dataKey", "riskToken",
"isRoot", "isHook", "isDebug", "isAdb", "isEmulator", "isVirtual",
"tamper", "jailbreak", "bootloader", "verifiedbootstate", "vbmeta",
"init.svc.adbd", "/proc/self/maps", "RealInterceptorChain",
};
Collections.addAll(names, base);
return names;
}
private static String hashKey(String name) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] dig = md.digest(name.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 4; i++) {
sb.append(String.format(Locale.ROOT, "%02x", dig[i] & 0xff));
}
return sb.toString();
} catch (Throwable t) {
return "";
}
}
private static boolean looksBase64(String s) {
return s.matches("^[A-Za-z0-9+/=]+$");
}
private static byte[] tryBase64(String s) {
try {
return Base64.decode(s, Base64.DEFAULT);
} catch (Throwable t) {
return null;
}
}
private static boolean isMostlyPrintable(String s) {
if (s == null || s.isEmpty()) {
return false;
}
int ok = 0;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c >= 0x20 && c < 0x7f) {
ok++;
}
}
return ok * 100 / s.length() >= 85;
}
private static String hexPreview(byte[] b, int max) {
if (b == null) {
return "";
}
int n = Math.min(b.length, max);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(String.format(Locale.ROOT, "%02x", b[i] & 0xff));
}
if (b.length > n) {
sb.append("...");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,139 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.util.Base64;
import java.nio.charset.StandardCharsets;
import de.robv.android.xposed.XposedBridge;
/**
* 分段 logcat 输出,避免单条超过 ~4KB 被截断。
*/
final class MariBankCaptureUtil {
static final String TAG = "notiMessageHook/MariBankCapture";
private static final int CHUNK = 3500;
private static final int HEX_PREVIEW = 96;
private MariBankCaptureUtil() {
}
static void logText(String section, String text) {
if (text == null) {
XposedBridge.log(TAG + " [" + section + "] null");
return;
}
if (text.length() <= CHUNK) {
XposedBridge.log(TAG + " [" + section + "] len=" + text.length() + " " + text);
return;
}
int parts = (text.length() + CHUNK - 1) / CHUNK;
XposedBridge.log(TAG + " [" + section + "] len=" + text.length() + " parts=" + parts);
for (int i = 0; i < parts; i++) {
int start = i * CHUNK;
int end = Math.min(start + CHUNK, text.length());
XposedBridge.log(TAG + " [" + section + "] " + (i + 1) + "/" + parts + " "
+ text.substring(start, end));
}
}
static void logBytes(String section, byte[] bytes) {
if (bytes == null) {
XposedBridge.log(TAG + " [" + section + "] null");
return;
}
String utf8 = tryUtf8(bytes);
if (isMostlyPrintable(utf8)) {
logText(section + " utf8", utf8);
} else {
XposedBridge.log(TAG + " [" + section + "] byte[" + bytes.length + "] hex="
+ hexPreview(bytes) + " b64=" + base64(bytes));
}
}
static void logArg(String section, Object arg) {
if (arg == null) {
XposedBridge.log(TAG + " [" + section + "] null");
return;
}
if (arg instanceof String) {
logText(section, (String) arg);
return;
}
if (arg instanceof byte[]) {
logBytes(section, (byte[]) arg);
return;
}
if (arg instanceof String[]) {
String[] arr = (String[]) arg;
XposedBridge.log(TAG + " [" + section + "] String[" + arr.length + "]");
for (int i = 0; i < arr.length; i++) {
logText(section + "[" + i + "]", arr[i]);
}
return;
}
if (arg instanceof byte[][]) {
byte[][] arr = (byte[][]) arg;
XposedBridge.log(TAG + " [" + section + "] byte[][] len=" + arr.length);
for (int i = 0; i < arr.length; i++) {
logBytes(section + "[" + i + "]", arr[i]);
}
return;
}
XposedBridge.log(TAG + " [" + section + "] " + arg.getClass().getName()
+ " = " + String.valueOf(arg));
}
static boolean isBankUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
String lower = url.toLowerCase();
return lower.contains("maribank.com")
|| lower.contains("seabank.ph")
|| lower.contains("/uapi/");
}
private static String tryUtf8(byte[] bytes) {
try {
return new String(bytes, StandardCharsets.UTF_8);
} catch (Throwable t) {
return "";
}
}
static boolean isMostlyPrintable(String s) {
if (s == null || s.isEmpty()) {
return false;
}
int printable = 0;
int sample = Math.min(s.length(), 512);
for (int i = 0; i < sample; i++) {
char c = s.charAt(i);
if (c == '\n' || c == '\r' || c == '\t' || (c >= 32 && c < 127)) {
printable++;
}
}
return printable * 100 / sample >= 85;
}
private static String hexPreview(byte[] bytes) {
int n = Math.min(bytes.length, HEX_PREVIEW);
StringBuilder sb = new StringBuilder(n * 2);
for (int i = 0; i < n; i++) {
sb.append(String.format("%02x", bytes[i] & 0xff));
}
if (bytes.length > n) {
sb.append("...");
}
return sb.toString();
}
private static String base64(byte[] bytes) {
try {
return Base64.encodeToString(bytes, Base64.NO_WRAP);
} catch (Throwable t) {
return "<b64 err>";
}
}
}

View File

@@ -0,0 +1,209 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
/**
* DFPdevice fingerprint初始化诊断register 前若 dfp 为空会弹
* "The system is currently unavailable",且不会发 /uapi/v2/register。
*/
public final class MariBankDfpHook {
private static final String TAG = "notiMessageHook/MariBankDfp";
private static final Set<String> HOOKED_CLASSES = new HashSet<>();
private static volatile boolean loadClassWatcherInstalled = false;
private static volatile boolean registerVmHooked = false;
private MariBankDfpHook() {
}
public static void installLate(XC_LoadPackage.LoadPackageParam lpparam) {
hookRegisterViewModel(lpparam);
hookKnownDfpClasses(lpparam);
installLoadClassWatcher(lpparam);
}
private static void hookRegisterViewModel(XC_LoadPackage.LoadPackageParam lpparam) {
if (registerVmHooked) {
return;
}
String className = "com.shopee.bke.biz.user.viewmodel.RegisterViewModel";
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
registerVmHooked = true;
for (Method method : clazz.getDeclaredMethods()) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String args = param.args == null ? "[]" : Arrays.toString(param.args);
if (args.length() > 800) {
args = args.substring(0, 800) + "...";
}
XposedBridge.log(TAG + " RegisterViewModel."
+ method.getName() + " args=" + args);
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (result == null) {
return;
}
String rs = String.valueOf(result);
if (rs.length() > 600) {
rs = rs.substring(0, 600) + "...";
}
if (rs.isEmpty()
|| rs.toLowerCase().contains("dfp")
|| rs.toLowerCase().contains("error")
|| rs.toLowerCase().contains("unavailable")
|| rs.contains("3100012")
|| rs.contains("4067")) {
XposedBridge.log(TAG + " RegisterViewModel."
+ method.getName() + " result=" + rs);
}
}
});
}
XposedBridge.log(TAG + " RegisterViewModel hooked methods="
+ clazz.getDeclaredMethods().length);
} catch (Throwable t) {
XposedBridge.log(TAG + " skip RegisterViewModel: " + t.getMessage());
}
}
/** 启动时类可能已加载,先尝试已知命名。 */
private static void hookKnownDfpClasses(XC_LoadPackage.LoadPackageParam lpparam) {
String[] candidates = {
"com.shopee.bke.lib.jni.security.CharacterCryptoManager",
"com.shopee.bke.lib.jni.security.CharacterCryptoManagerWrapper",
"com.shopee.bke.lib.jni.utils.NativeEncryptUtilsWrapper",
};
for (String cn : candidates) {
tryHookDfpClass(lpparam, cn);
}
}
private static void installLoadClassWatcher(XC_LoadPackage.LoadPackageParam lpparam) {
if (loadClassWatcherInstalled) {
return;
}
loadClassWatcherInstalled = true;
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (name == null) {
return;
}
if (name.contains("CharacterCrypto")
|| name.contains("NativeEncryptUtils")
|| (name.contains("dfp") && name.contains("bke"))) {
tryHookDfpClass(lpparam, name);
}
}
});
XposedBridge.log(TAG + " ClassLoader dfp watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip ClassLoader watcher: " + t.getMessage());
}
}
private static void tryHookDfpClass(XC_LoadPackage.LoadPackageParam lpparam, String className) {
if (HOOKED_CLASSES.contains(className)) {
return;
}
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int count = 0;
for (Method method : clazz.getDeclaredMethods()) {
String mn = method.getName();
if (!mn.startsWith("getDfp") && !mn.contains("Fingerprint")) {
continue;
}
if (method.getReturnType() != String.class) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
String s = result instanceof String ? (String) result : null;
if (s == null || s.isEmpty()) {
XposedBridge.log(TAG + " EMPTY " + className + "."
+ method.getName() + " → register may fail");
} else {
String tail = s.length() > 80
? s.substring(0, 40) + "..." + s.substring(s.length() - 20)
: s;
XposedBridge.log(TAG + " " + className + "."
+ method.getName() + " len=" + s.length() + " val=" + tail);
}
}
});
count++;
}
if (count > 0) {
HOOKED_CLASSES.add(className);
XposedBridge.log(TAG + " hooked " + count + " dfp getters in " + className);
}
} catch (Throwable ignored) {
}
}
/** 拦截 dfp 空检查日志源(含 register scene 文案的类方法)。 */
public static void hookDfpEmptyGuards(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"android.util.Log",
lpparam.classLoader,
"e",
String.class,
String.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String msg = param.args[1] instanceof String ? (String) param.args[1] : null;
if (msg == null) {
return;
}
String lower = msg.toLowerCase();
if (lower.contains("dfp is empty")
|| lower.contains("getdfp empty")
|| lower.contains("getdfp onerror")) {
XposedBridge.log(TAG + " Log.e: " + msg);
logStack();
}
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " skip Log.e dfp guard: " + t.getMessage());
}
}
private static void logStack() {
for (StackTraceElement frame : Thread.currentThread().getStackTrace()) {
String cn = frame.getClassName();
if (cn.startsWith("com.shopee.bke") || cn.contains("CharacterCrypto")) {
XposedBridge.log(TAG + " at " + cn + "." + frame.getMethodName());
}
}
}
}

View File

@@ -0,0 +1,153 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.util.concurrent.atomic.AtomicInteger;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
/**
* MariBank / SeaBank HTTP 全量抓包(仅 OkHttp 头 + 响应体)。
* 加密明文 / attestation 由 {@link MariBankSdkUtilsHook} 负责,勿在此重复 Hook Gson/native。
*/
public final class MariBankFullCaptureHook {
private static final String TAG = MariBankCaptureUtil.TAG;
private static final AtomicInteger SEQ = new AtomicInteger();
private static volatile boolean installed = false;
private MariBankFullCaptureHook() {
}
public static void installLate(XC_LoadPackage.LoadPackageParam lpparam) {
if (installed) {
return;
}
installed = true;
int n = hookOkHttpRealCall(lpparam);
XposedBridge.log(TAG + " http capture hooks=" + n);
}
private static int hookOkHttpRealCall(XC_LoadPackage.LoadPackageParam lpparam) {
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
try {
Object request = XposedHelpers.getObjectField(param.thisObject, "originalRequest");
if (request == null) {
return;
}
captureRequest(request);
} catch (Throwable t) {
XposedBridge.log(TAG + " req capture err: " + t.getMessage());
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (!"execute".equals(param.method.getName())) {
return;
}
try {
Object response = param.getResult();
if (response != null) {
captureResponse(response);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " resp capture err: " + t.getMessage());
}
}
};
int count = 0;
for (String className : new String[]{
"okhttp3.RealCall", "okhttp3.internal.connection.RealCall"
}) {
try {
XposedHelpers.findAndHookMethod(className, lpparam.classLoader, "execute", hook);
XposedHelpers.findAndHookMethod(className, lpparam.classLoader, "enqueue",
"okhttp3.Callback", hook);
count += 2;
} catch (Throwable ignored) {
}
}
return count;
}
private static void captureRequest(Object request) {
Object urlObj = XposedHelpers.callMethod(request, "url");
String url = urlObj != null ? String.valueOf(urlObj) : "?";
if (!MariBankCaptureUtil.isBankUrl(url)) {
return;
}
int seq = SEQ.incrementAndGet();
String method = (String) XposedHelpers.callMethod(request, "method");
XposedBridge.log(TAG + " >>> #" + seq + " " + method + " " + url);
logHeaders("reqHdr #" + seq, XposedHelpers.callMethod(request, "headers"));
}
private static void captureResponse(Object response) {
try {
Object request = XposedHelpers.callMethod(response, "request");
Object urlObj = request != null ? XposedHelpers.callMethod(request, "url") : null;
String url = urlObj != null ? String.valueOf(urlObj) : "?";
if (!MariBankCaptureUtil.isBankUrl(url)) {
return;
}
int code = (int) XposedHelpers.callMethod(response, "code");
XposedBridge.log(TAG + " <<< HTTP " + code + " " + url);
logHeaders("respHdr", XposedHelpers.callMethod(response, "headers"));
try {
Object peek = XposedHelpers.callMethod(response, "peekBody", 256L * 1024L);
if (peek != null) {
Object ctObj = XposedHelpers.callMethod(peek, "contentType");
String contentType = ctObj != null ? String.valueOf(ctObj).toLowerCase() : "";
if (contentType.contains("pdf") || contentType.contains("octet-stream")) {
long size = 0L;
try {
size = (long) XposedHelpers.callMethod(peek, "contentLength");
} catch (Throwable ignored) {
}
XposedBridge.log(TAG + " <<< respBody skipped binary ct="
+ contentType + " len=" + size + " " + url);
} else {
String body = (String) XposedHelpers.callMethod(peek, "string");
if (body != null && !body.isEmpty()) {
if (body.length() > 512
&& !MariBankCaptureUtil.isMostlyPrintable(body)) {
XposedBridge.log(TAG + " <<< respBody skipped non-text len="
+ body.length() + " " + url);
} else {
MariBankCaptureUtil.logText("respBody", body);
}
}
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " peekBody err: " + t.getMessage());
}
} catch (Throwable t) {
XposedBridge.log(TAG + " captureResponse err: " + t.getMessage());
}
}
private static void logHeaders(String section, Object headers) {
if (headers == null) {
return;
}
try {
int size = (int) XposedHelpers.callMethod(headers, "size");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < size; i++) {
String name = (String) XposedHelpers.callMethod(headers, "name", i);
String value = (String) XposedHelpers.callMethod(headers, "value", i);
sb.append(name).append(": ").append(value).append("\n");
}
MariBankCaptureUtil.logText(section, sb.toString().trim());
} catch (Throwable t) {
XposedBridge.log(TAG + " [" + section + "] headers err: " + t.getMessage());
}
}
}

View File

@@ -0,0 +1,123 @@
package com.miraclegarden.smsmessage.xposed.hook;
/**
* PH vs SG MariBank 差异配置(基于 reverse/apks 扫描,非「换包名即相同」)。
* <p>
* SG 3.2.2dex 内无 {@code com.shopee.shpssdk.*}、无 {@code safemode.b/catchs.a/util.c}、
* 无 {@code jni.utils.d};风控仅 {@code shpssdkbank}。
*/
public final class MariBankRegionProfile {
private MariBankRegionProfile() {
}
public static boolean isSingapore(String packageName) {
return MariBankRootBypassHook.PACKAGE_SG.equals(packageName);
}
public static boolean isPhilippines(String packageName) {
return MariBankRootBypassHook.PACKAGE.equals(packageName);
}
public static String label(String packageName) {
if (isSingapore(packageName)) {
return "SG";
}
if (isPhilippines(packageName)) {
return "PH";
}
return "?";
}
/** PH SafeMode boolean 类SG base/split dex 均无,勿 findClass。 */
public static String[] safeModeBooleanClasses(String packageName) {
if (isPhilippines(packageName)) {
return new String[]{
"com.shopee.bke.lib.safemode.b",
"com.shopee.bke.lib.safemode.catchs.a",
"com.shopee.bke.lib.safemode.util.c",
};
}
return new String[0];
}
/** 两区共有或 SG 专用 risk 类classes3.dex。 */
public static String[] sharedRiskClasses() {
return new String[]{"com.shopee.bke.biz.base.risk.a"};
}
/** SHPSSDK Java 入口SG 仅 bank 包。 */
public static String[] shpsSdkClasses(String packageName) {
if (isSingapore(packageName)) {
return new String[]{"com.shopee.shpssdkbank.SHPSSDK"};
}
return new String[]{
"com.shopee.shpssdk.SHPSSDK",
"com.shopee.shpssdkbank.SHPSSDK",
};
}
public static String[] shpsAssessRiskClasses(String packageName) {
if (isSingapore(packageName)) {
return new String[]{"com.shopee.shpssdkbank.SPSAssessRisk"};
}
return new String[]{
"com.shopee.shpssdkbank.SPSAssessRisk",
"com.shopee.shpssdk.SPSAssessRisk",
};
}
public static String[] shpsCallbackAdapterClasses(String packageName) {
if (isSingapore(packageName)) {
return new String[]{"com.shopee.shpssdkbank.SPSCallbackAdapter"};
}
return new String[]{
"com.shopee.shpssdk.SPSCallbackAdapter",
"com.shopee.shpssdkbank.SPSCallbackAdapter",
};
}
/** attestation / native-coreSG 无 com.shopee.shpssdk.wvvvuwwu。 */
public static String[] shpsAttestationCoreClasses(String packageName) {
if (isSingapore(packageName)) {
return new String[]{
"com.shopee.shpssdkbank.wvvvuwwu",
"com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu",
"com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv",
"com.shopee.shpssdkbank.uwuvuvvww.wvvuuwvwu",
"com.shopee.shpssdkbank.uvuwwuvwv.uvwwuuvvw",
};
}
return new String[]{
"com.shopee.shpssdkbank.wvvvuwwu",
"com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu",
"com.shopee.shpssdkbank.uwuvuvvww.vvuuuuvvv",
"com.shopee.shpssdkbank.uwuvuvvww.wvvuuwvwu",
"com.shopee.shpssdk.wvvvuwwu",
};
}
/** 加密 JNIPH 有 utils.d 包装SG 仅 uvwuvwuv + uvwwwwuvclasses6.dex。 */
public static boolean hasEncryptWrapperD(String packageName) {
return isPhilippines(packageName);
}
public static boolean hasEncryptHelperUvwwwwuv(String packageName) {
return isSingapore(packageName);
}
/** late hooks 完成条件SG 不依赖 PH safemode booleanHooks。 */
public static boolean isLateHooksReady(
String packageName, int booleanHooks, int encryptHooks, int attestHooks) {
if (isSingapore(packageName)) {
return encryptHooks > 0 || attestHooks > 0;
}
return booleanHooks > 0 || encryptHooks > 0 || attestHooks > 0;
}
public static boolean isShpssdkLegacyPackage(String className) {
return className != null
&& className.startsWith("com.shopee.shpssdk.")
&& !className.startsWith("com.shopee.shpssdkbank.");
}
}

View File

@@ -66,6 +66,45 @@ final class MariBankRegisterPayloadUtil {
out = sb.toString();
XposedBridge.log(TAG + " deviceFingerprint sanitized in register JSON");
}
logRegisterSummary(out);
return out;
}
private static void logRegisterSummary(String json) {
if (json == null) {
return;
}
String cy = extractJsonString(json, "cyCode");
String scene = extractJsonString(json, "scene");
String step = extractJsonString(json, "step");
Matcher fp = DEVICE_FINGERPRINT.matcher(json);
String fpTail = fp.find() ? MariBankRiskTokenUtil.tail(fp.group(1)) : "?";
int dataLen = lengthOfJsonString(json, "\"data\"");
int dataKeyLen = lengthOfJsonString(json, "\"dataKey\"");
XposedBridge.log(TAG + " register summary cy=" + cy + " scene=" + scene
+ " step=" + step + " fpTail=" + fpTail
+ " dataLen=" + dataLen + " dataKeyLen=" + dataKeyLen);
}
private static String extractJsonString(String json, String key) {
Pattern p = Pattern.compile("\"" + Pattern.quote(key) + "\"\\s*:\\s*\"([^\"]*)\"");
Matcher m = p.matcher(json);
return m.find() ? m.group(1) : "?";
}
private static int lengthOfJsonString(String json, String key) {
int idx = json.indexOf(key);
if (idx < 0) {
return -1;
}
int start = json.indexOf('"', idx + key.length());
if (start < 0) {
return -1;
}
int end = json.indexOf('"', start + 1);
if (end < 0) {
return -1;
}
return end - start - 1;
}
}

View File

@@ -4,6 +4,8 @@ import android.app.Activity;
import android.app.Dialog;
import android.content.ContentResolver;
import android.content.res.Resources;
import android.os.Handler;
import android.os.Looper;
import android.os.Process;
import android.view.View;
import android.view.ViewGroup;
@@ -15,7 +17,9 @@ import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
@@ -63,13 +67,6 @@ public final class MariBankRootBypassHook {
private static final long FINISH_BURST_WINDOW_MS = 800L;
private static final int FINISH_BURST_THRESHOLD = 2;
private static final String[] SAFE_MODE_CLASSES = {
"com.shopee.bke.lib.safemode.b",
"com.shopee.bke.lib.safemode.catchs.a",
"com.shopee.bke.lib.safemode.util.c",
"com.shopee.bke.biz.base.risk.a",
};
private static final String[] ERROR_FLOW_CLASSES = {
"com.shopee.bke.biz.user.errorcodehandler.a",
"com.shopee.bke.biz.user.errorcodehandler.b",
@@ -77,18 +74,33 @@ public final class MariBankRootBypassHook {
"com.shopee.bke.biz.user.viewmodel.RegisterViewModel",
};
private static String[] booleanHookTargets(XC_LoadPackage.LoadPackageParam lpparam) {
String[] ph = MariBankRegionProfile.safeModeBooleanClasses(lpparam.packageName);
String[] shared = MariBankRegionProfile.sharedRiskClasses();
String[] out = new String[ph.length + shared.length];
System.arraycopy(ph, 0, out, 0, ph.length);
System.arraycopy(shared, 0, out, ph.length, shared.length);
return out;
}
private MariBankRootBypassHook() {
}
private static volatile boolean deferredHooksInstalled = false;
private static volatile boolean lateAppHooksInstalled = false;
private static volatile boolean lateHooksComplete = false;
private static volatile boolean lateHooksCoreInstalled = false;
private static volatile boolean lateHandlerRetriesDone = false;
private static volatile int lateHookAttempts = 0;
private static final int MAX_LATE_HOOK_ATTEMPTS = 10;
private static final Set<String> HOOKED_SAFE_MODE_CLASSES = new HashSet<>();
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!isTargetPackage(lpparam.packageName)) {
return;
}
XposedBridge.log(TAG + " install for " + lpparam.packageName);
XposedBridge.log(TAG + " install for " + lpparam.packageName
+ " region=" + MariBankRegionProfile.label(lpparam.packageName));
hookAntiSuicide(lpparam);
hookAntiSoftCrash(lpparam);
scheduleAppHooks(lpparam);
@@ -110,7 +122,7 @@ public final class MariBankRootBypassHook {
@Override
protected void afterHookedMethod(MethodHookParam param) {
MariBankShpsNativeHook.installLateNativeHooks(lpparam);
installLateAppHooks(lpparam);
scheduleLateAppHooks(lpparam);
}
};
try {
@@ -125,34 +137,159 @@ public final class MariBankRootBypassHook {
lpparam.classLoader,
"onCreate",
afterOnCreate);
hookMainActivityForLateHooks(lpparam);
XposedBridge.log(TAG + " waiting attachBaseContext + onCreate for app hooks");
} catch (Throwable t) {
XposedBridge.log(TAG + " BkeApplication hook failed, install now: " + t.getMessage());
installDeferredHooks(lpparam);
MariBankShpsNativeHook.installDeferred(lpparam);
MariBankShpsNativeHook.installLateNativeHooks(lpparam);
installLateAppHooks(lpparam);
scheduleLateAppHooks(lpparam);
}
}
/** onCreate 之后补装:此时 classes11 / SHPSSDK 与 RN SO 均已就绪。 */
private static void installLateAppHooks(XC_LoadPackage.LoadPackageParam lpparam) {
if (lateAppHooksInstalled) {
/** SG split-dex 在 onCreate 时类可能未加载MainActivity 再试。 */
private static void hookMainActivityForLateHooks(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"com.shopee.bke.digitalbank.ui.MainActivity",
lpparam.classLoader,
"onResume",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
scheduleLateAppHooks(lpparam);
}
}
);
} catch (Throwable t) {
XposedBridge.log(TAG + " skip MainActivity.onResume retry: " + t.getMessage());
}
}
private static void scheduleLateAppHooks(XC_LoadPackage.LoadPackageParam lpparam) {
tryInstallLateAppHooks(lpparam, false);
}
/** onCreate 之后补装SG 3.2.2 split dex 常需延迟重试。 */
private static void tryInstallLateAppHooks(XC_LoadPackage.LoadPackageParam lpparam) {
tryInstallLateAppHooks(lpparam, false);
}
private static void tryInstallLateAppHooks(
XC_LoadPackage.LoadPackageParam lpparam, boolean fromSplitDex) {
if (lateHooksComplete) {
return;
}
lateAppHooksInstalled = true;
int hooked = 0;
for (String className : SAFE_MODE_CLASSES) {
hooked += hookAllBooleanChecks(lpparam, className);
if (!lateHooksCoreInstalled) {
lateHooksCoreInstalled = true;
hookShpsRisk(lpparam);
hookShpsToken(lpparam);
hookErrorFlowLogging(lpparam);
MariBankFullCaptureHook.installLate(lpparam);
}
hooked += hookShpsRisk(lpparam);
hookShpsToken(lpparam);
hookErrorFlowLogging(lpparam);
MariBankSdkUtilsHook.installLate(lpparam);
MariBankAttestationHook.installLate(lpparam);
XposedBridge.log(TAG + " late app hooks installed, booleanHooks=" + hooked);
int booleanHooks = 0;
for (String className : booleanHookTargets(lpparam)) {
booleanHooks += hookAllBooleanChecks(lpparam, className);
}
int encryptHooks = MariBankSdkUtilsHook.installLate(lpparam);
int attestHooks = MariBankAttestationHook.installLate(lpparam);
MariBankDfpHook.installLate(lpparam);
MariBankDfpHook.hookDfpEmptyGuards(lpparam);
if (MariBankRegionProfile.isLateHooksReady(
lpparam.packageName, booleanHooks, encryptHooks, attestHooks)) {
lateHooksComplete = true;
XposedBridge.log(TAG + " late app hooks ready region="
+ MariBankRegionProfile.label(lpparam.packageName)
+ " booleanHooks=" + booleanHooks
+ " encryptHooks=" + encryptHooks + " attestHooks=" + attestHooks
+ " attempts=" + lateHookAttempts
+ (fromSplitDex ? " viaSplitDex" : ""));
return;
}
if (fromSplitDex) {
XposedBridge.log(TAG + " split-dex class loaded, safemode still pending encryptHooks="
+ encryptHooks + " attestHooks=" + attestHooks);
return;
}
if (lateHandlerRetriesDone) {
return;
}
lateHookAttempts++;
if (lateHookAttempts >= MAX_LATE_HOOK_ATTEMPTS) {
lateHandlerRetriesDone = true;
XposedBridge.log(TAG + " late handler retries done (split-dex may load later) attempts="
+ lateHookAttempts + " encryptHooks=" + encryptHooks
+ " attestHooks=" + attestHooks);
return;
}
long delay = Math.min(400L * lateHookAttempts, 2500L);
XposedBridge.log(TAG + " late hooks retry #" + lateHookAttempts + " in " + delay + "ms");
new Handler(Looper.getMainLooper()).postDelayed(
() -> tryInstallLateAppHooks(lpparam, false), delay);
}
private static boolean isLateHookTargetClass(String name, String packageName) {
if (name == null) {
return false;
}
for (String cn : MariBankRegionProfile.safeModeBooleanClasses(packageName)) {
if (cn.equals(name)) {
return true;
}
}
for (String cn : MariBankRegionProfile.sharedRiskClasses()) {
if (cn.equals(name)) {
return true;
}
}
for (String prefix : ERROR_FLOW_CLASSES) {
if (name.equals(prefix) || name.startsWith(prefix.substring(0, prefix.lastIndexOf('.') + 1))) {
return true;
}
}
return name.startsWith("com.shopee.bke.lib.safemode.")
|| name.equals("com.shopee.bke.lib.jni.utils.uvwuvwuv")
|| name.equals("com.shopee.bke.lib.jni.utils.uvwwwwuv")
|| (MariBankRegionProfile.hasEncryptWrapperD(packageName)
&& name.equals("com.shopee.bke.lib.jni.utils.d"))
|| name.startsWith("com.shopee.shpssdkbank.")
|| (!MariBankRegionProfile.isSingapore(packageName)
&& name.startsWith("com.shopee.shpssdk."));
}
/** SG split-dexsafemode / utils.d 延迟加载时在 loadClass 补装 Hook。 */
private static void hookClassLoaderSplitDex(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (isLateHookTargetClass(name, lpparam.packageName)) {
tryInstallLateAppHooks(lpparam, true);
}
}
}
);
XposedBridge.log(TAG + " ClassLoader.loadClass split-dex watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip ClassLoader.loadClass watcher: " + t.getMessage());
}
}
private static void installDeferredHooks(XC_LoadPackage.LoadPackageParam lpparam) {
@@ -165,10 +302,14 @@ public final class MariBankRootBypassHook {
hookRootDialogBlock(lpparam);
hookAdbBypass(lpparam);
hookNetworkLogging(lpparam);
hookClassLoaderSplitDex(lpparam);
XposedBridge.log(TAG + " early app hooks installed");
}
private static int hookAllBooleanChecks(XC_LoadPackage.LoadPackageParam lpparam, String className) {
if (HOOKED_SAFE_MODE_CLASSES.contains(className)) {
return 0;
}
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
@@ -197,6 +338,7 @@ public final class MariBankRootBypassHook {
count++;
}
if (count > 0) {
HOOKED_SAFE_MODE_CLASSES.add(className);
XposedBridge.log(TAG + " hooked " + count + " checks in " + className);
}
} catch (Throwable t) {
@@ -480,11 +622,7 @@ public final class MariBankRootBypassHook {
/** SHPSSDK 风控:仅 Hook 返回 boolean 的实例方法。 */
private static int hookShpsRisk(XC_LoadPackage.LoadPackageParam lpparam) {
int count = 0;
String[] riskClasses = {
"com.shopee.shpssdkbank.SPSAssessRisk",
"com.shopee.shpssdk.SPSAssessRisk",
};
for (String className : riskClasses) {
for (String className : MariBankRegionProfile.shpsAssessRiskClasses(lpparam.packageName)) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
@@ -519,11 +657,7 @@ public final class MariBankRootBypassHook {
*/
private static void hookShpsToken(XC_LoadPackage.LoadPackageParam lpparam) {
final String contextClass = "android.content.Context";
String[] shpsSdkClasses = {
"com.shopee.shpssdk.SHPSSDK",
"com.shopee.shpssdkbank.SHPSSDK",
};
for (String className : shpsSdkClasses) {
for (String className : MariBankRegionProfile.shpsSdkClasses(lpparam.packageName)) {
hookEmptyRiskList(lpparam, className, "getRiskSync", contextClass);
hookEmptyRiskList(lpparam, className, "getExtRiskSync", contextClass);
hookRiskAsyncCallback(lpparam, className, "getRiskAsync", contextClass,
@@ -601,11 +735,7 @@ public final class MariBankRootBypassHook {
} catch (Throwable ignored) {
}
String[] assessRiskClasses = {
"com.shopee.shpssdkbank.SPSAssessRisk",
"com.shopee.shpssdk.SPSAssessRisk",
};
for (String className : assessRiskClasses) {
for (String className : MariBankRegionProfile.shpsAssessRiskClasses(lpparam.packageName)) {
try {
XposedHelpers.findAndHookMethod(
className,
@@ -622,11 +752,7 @@ public final class MariBankRootBypassHook {
}
}
String[] callbackAdapters = {
"com.shopee.shpssdk.SPSCallbackAdapter",
"com.shopee.shpssdkbank.SPSCallbackAdapter",
};
for (String className : callbackAdapters) {
for (String className : MariBankRegionProfile.shpsCallbackAdapterClasses(lpparam.packageName)) {
try {
XposedHelpers.findAndHookMethod(
className,
@@ -1285,8 +1411,11 @@ public final class MariBankRootBypassHook {
XposedBridge.invokeOriginalMethod(
param.method, param.thisObject, new Object[]{buffer});
String content = (String) XposedHelpers.callMethod(buffer, "readUtf8");
String sanitized = MariBankRiskTokenUtil.sanitizeAllInText(content);
String url = CURRENT_REQUEST_URL.get();
if (!content.isEmpty() && url != null && MariBankCaptureUtil.isBankUrl(url)) {
MariBankCaptureUtil.logText("reqBody wire " + url, content);
}
String sanitized = MariBankRiskTokenUtil.sanitizeAllInText(content);
if (url != null && url.contains("/register") && !content.isEmpty()) {
int show = Math.min(content.length(), 400);
XposedBridge.log(TAG + " register body(raw): "
@@ -1633,6 +1762,19 @@ public final class MariBankRootBypassHook {
XposedBridge.hookMethod(method, fakeDisabled);
}
}
// getInt(cr, key, def) — 部分 SDK 走三参数重载
if ("getInt".equals(target[1])) {
try {
XposedHelpers.findAndHookMethod(
clazz,
"getInt",
ContentResolver.class,
String.class,
int.class,
fakeDisabled);
} catch (Throwable ignored) {
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip Settings hook " + target[0] + ": " + t.getMessage());
}

View File

@@ -16,26 +16,124 @@ import de.robv.android.xposed.callbacks.XC_LoadPackage;
public final class MariBankSdkUtilsHook {
private static final String TAG = "notiMessageHook/MariBankEncrypt";
private static final int MAX_LOG = 2000;
private static final String WRAPPER = "com.shopee.bke.lib.jni.utils.d";
private static final String NATIVE_ENCRYPT = "com.shopee.bke.lib.jni.utils.uvwuvwuv";
private static final String SG_ENCRYPT_HELPER = "com.shopee.bke.lib.jni.utils.uvwwwwuv";
private static volatile boolean installed = false;
private static volatile boolean encryptHooksInstalled = false;
private static volatile boolean gsonHookInstalled = false;
private static volatile boolean encryptLoadClassWatcherInstalled = false;
private static volatile String installedForPackage;
private MariBankSdkUtilsHook() {
}
/** BkeApplication.onCreate 之后安装libsdkutils 已加载)。 */
public static void installLate(XC_LoadPackage.LoadPackageParam lpparam) {
if (installed) {
public static int installLate(XC_LoadPackage.LoadPackageParam lpparam) {
installEncryptLoadClassWatcher(lpparam);
if (encryptHooksInstalled && lpparam.packageName.equals(installedForPackage)) {
return 0;
}
int n = 0;
if (MariBankRegionProfile.hasEncryptWrapperD(lpparam.packageName)) {
n += hookEncryptWrapper(lpparam);
}
n += hookNativeEncryptUtils(lpparam);
if (MariBankRegionProfile.hasEncryptHelperUvwwwwuv(lpparam.packageName)) {
n += hookEncryptHelperClass(lpparam, SG_ENCRYPT_HELPER, "uvwwwwuv");
}
if (n > 0) {
encryptHooksInstalled = true;
installedForPackage = lpparam.packageName;
}
if (!gsonHookInstalled) {
int g = hookGsonRegister(lpparam);
if (g > 0) {
gsonHookInstalled = true;
n += g;
}
}
if (n > 0) {
XposedBridge.log(TAG + " late encrypt hooks=" + n + " region="
+ MariBankRegionProfile.label(lpparam.packageName));
}
return n;
}
private static void installEncryptLoadClassWatcher(XC_LoadPackage.LoadPackageParam lpparam) {
if (encryptLoadClassWatcherInstalled) {
return;
}
installed = true;
int n = hookEncryptWrapper(lpparam);
n += hookNativeEncryptUtils(lpparam);
n += hookGsonRegister(lpparam);
XposedBridge.log(TAG + " late encrypt hooks=" + n);
encryptLoadClassWatcherInstalled = true;
final String pkg = lpparam.packageName;
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (NATIVE_ENCRYPT.equals(name)
|| SG_ENCRYPT_HELPER.equals(name)
|| (MariBankRegionProfile.hasEncryptWrapperD(pkg)
&& WRAPPER.equals(name))) {
installLate(lpparam);
}
}
});
XposedBridge.log(TAG + " ClassLoader encrypt watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip encrypt ClassLoader watcher: " + t.getMessage());
}
}
private static int hookEncryptHelperClass(
XC_LoadPackage.LoadPackageParam lpparam, String className, String logLabel) {
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
if (Modifier.isStatic(method.getModifiers())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " >> " + logLabel + "." + method.getName());
for (int i = 0; i < param.args.length; i++) {
Object sanitized = sanitizeArg(param.args[i]);
if (sanitized != param.args[i]) {
param.args[i] = sanitized;
}
logArg(" in" + i, param.args[i]);
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (result instanceof byte[]) {
logArg(" out", result);
} else if (result instanceof String) {
logArg(" out", result);
}
}
});
count++;
}
if (count > 0) {
XposedBridge.log(TAG + " hooked " + logLabel + " methods=" + count);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + logLabel + ": " + t.getMessage());
}
return count;
}
private static int hookEncryptWrapper(XC_LoadPackage.LoadPackageParam lpparam) {
@@ -143,7 +241,7 @@ public final class MariBankSdkUtilsHook {
json = sanitized;
}
if (MariBankRegisterPayloadUtil.isRegistrationPayload(json)) {
XposedBridge.log(TAG + " Gson REGISTRATION: " + truncate(json));
MariBankCaptureUtil.logText("Gson REGISTRATION plaintext", json);
}
}
}
@@ -195,7 +293,7 @@ public final class MariBankSdkUtilsHook {
if (arg instanceof String) {
String s = (String) arg;
if (s.length() > 4 || s.contains("|") || looksLikeRegisterJson(s)) {
XposedBridge.log(TAG + label + " String(" + s.length() + ") " + truncate(s));
MariBankCaptureUtil.logText(label, s);
}
return;
}
@@ -207,19 +305,9 @@ public final class MariBankSdkUtilsHook {
} catch (Throwable t) {
text = "<bin>";
}
if (text.contains("|") || looksLikeRegisterJson(text) || bytes.length < 512) {
XposedBridge.log(TAG + label + " byte[" + bytes.length + "] " + truncate(text));
if (text.contains("|") || looksLikeRegisterJson(text) || bytes.length < 8192) {
MariBankCaptureUtil.logBytes(label, bytes);
}
}
}
private static String truncate(String s) {
if (s == null) {
return "";
}
if (s.length() <= MAX_LOG) {
return s;
}
return s.substring(0, MAX_LOG) + "...";
}
}

View File

@@ -72,7 +72,9 @@ public final class MariBankShpsNativeHook {
}
private static volatile boolean deferredInstalled = false;
private static volatile boolean lateNativeInstalled = false;
private static volatile boolean nativeBridgeInstalled = false;
private static volatile boolean nativeLoadClassWatcherInstalled = false;
private static final Set<String> HOOKED_NATIVE_CORE_CLASSES = new HashSet<>();
/**
* 勿 Hook负责 SoUtils.loadSoLibrary / libshpssdk_bank.so 加载Hook 会导致 SO 找不到。
@@ -104,13 +106,45 @@ public final class MariBankShpsNativeHook {
/** BkeApplication.onCreate 之后RN / shpssdk SO 已加载,再装 native 桥接 Hook。 */
public static void installLateNativeHooks(XC_LoadPackage.LoadPackageParam lpparam) {
if (lateNativeInstalled) {
if (!nativeBridgeInstalled) {
nativeBridgeInstalled = true;
hookShpsNativeBridge(lpparam);
installNativeLoadClassWatcher(lpparam);
}
hookShpsNativeCore(lpparam);
}
private static void installNativeLoadClassWatcher(XC_LoadPackage.LoadPackageParam lpparam) {
if (nativeLoadClassWatcherInstalled) {
return;
}
lateNativeInstalled = true;
hookShpsNativeBridge(lpparam);
hookShpsNativeCore(lpparam);
XposedBridge.log(TAG + " late native hooks installed for " + lpparam.packageName);
nativeLoadClassWatcherInstalled = true;
try {
XposedHelpers.findAndHookMethod(
ClassLoader.class,
"loadClass",
String.class,
boolean.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.getThrowable() != null) {
return;
}
String name = (String) param.args[0];
if (name != null && name.contains("shpssdk")) {
if (MariBankRegionProfile.isSingapore(lpparam.packageName)
&& MariBankRegionProfile.isShpssdkLegacyPackage(name)) {
return;
}
hookShpsNativeCore(lpparam);
}
}
});
XposedBridge.log(TAG + " ClassLoader native-core watcher OK");
} catch (Throwable t) {
XposedBridge.log(TAG + " skip native ClassLoader watcher: " + t.getMessage());
}
}
/** native 直接读 /proc/self/maps 查 hook 库;过滤内容。 */
@@ -251,10 +285,7 @@ public final class MariBankShpsNativeHook {
/** 保留 requestDefense 执行(生成 x-sap-fixme仅净化返回值中的 risk 字段。 */
private static void hookRequestDefense(XC_LoadPackage.LoadPackageParam lpparam) {
for (String className : new String[]{
"com.shopee.shpssdkbank.SHPSSDK",
"com.shopee.shpssdk.SHPSSDK",
}) {
for (String className : MariBankRegionProfile.shpsSdkClasses(lpparam.packageName)) {
try {
XposedHelpers.findAndHookMethod(
className,
@@ -403,21 +434,19 @@ public final class MariBankShpsNativeHook {
* SHPSSDK 核心 native 桥wvvvuwwu.wwvwvwuvv / vvuwuuvuu 等直接生成 risk 数据。
*/
private static void hookShpsNativeCore(XC_LoadPackage.LoadPackageParam lpparam) {
String[] coreClasses = {
"com.shopee.shpssdkbank.wvvvuwwu",
"com.shopee.shpssdkbank.uwuvuvvww.uvwuuuuuw.vvvvuwwvu",
"com.shopee.shpssdkbank.uwuvuvvww.wvvuuwvwu",
"com.shopee.shpssdk.wvvvuwwu",
};
int total = 0;
for (String className : coreClasses) {
for (String className : MariBankRegionProfile.shpsAttestationCoreClasses(lpparam.packageName)) {
total += hookNativeCoreClass(lpparam, className);
}
XposedBridge.log(TAG + " native-core total hooks=" + total);
XposedBridge.log(TAG + " native-core total hooks=" + total + " region="
+ MariBankRegionProfile.label(lpparam.packageName));
}
private static int hookNativeCoreClass(
XC_LoadPackage.LoadPackageParam lpparam, String className) {
if (HOOKED_NATIVE_CORE_CLASSES.contains(className)) {
return 0;
}
int count = 0;
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
@@ -425,9 +454,6 @@ public final class MariBankShpsNativeHook {
if (!Modifier.isStatic(method.getModifiers())) {
continue;
}
if (Modifier.isNative(method.getModifiers())) {
continue;
}
Class<?> rt = method.getReturnType();
if (rt == String.class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@@ -443,12 +469,41 @@ public final class MariBankShpsNativeHook {
if (!sanitized.equals(s)) {
param.setResult(sanitized);
XposedBridge.log(TAG + " core String sanitized "
+ className + "#" + method.getName()
+ (Modifier.isNative(method.getModifiers()) ? " (native)" : ""));
}
} else if (s.length() > 40) {
String sanitized = MariBankRegisterPayloadUtil.isRegistrationPayload(s)
? MariBankRegisterPayloadUtil.sanitizeRegistrationJson(s)
: MariBankRiskTokenUtil.sanitizeAllInText(s);
if (!sanitized.equals(s)) {
param.setResult(sanitized);
XposedBridge.log(TAG + " core register String sanitized "
+ className + "#" + method.getName());
}
}
}
});
count++;
} else if (rt == byte[].class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (!(result instanceof byte[])) {
return;
}
byte[] bytes = (byte[]) result;
byte[] out = MariBankRegisterPayloadUtil.sanitizeRegistrationBytes(bytes);
if (out != bytes) {
param.setResult(out);
XposedBridge.log(TAG + " core byte[] sanitized "
+ className + "#" + method.getName()
+ (Modifier.isNative(method.getModifiers()) ? " (native)" : ""));
}
}
});
count++;
} else if (rt == boolean.class || rt == Boolean.class
|| rt == int.class || rt == Integer.class) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@@ -465,7 +520,8 @@ public final class MariBankShpsNativeHook {
}
}
if (count > 0) {
XposedBridge.log(TAG + " hooked " + count + " core natives in " + className);
HOOKED_NATIVE_CORE_CLASSES.add(className);
XposedBridge.log(TAG + " hooked " + count + " core methods in " + className);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip core " + className + ": " + t.getMessage());

View File

@@ -0,0 +1,77 @@
package com.miraclegarden.smsmessage.xposed.hook;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import java.util.Locale;
import de.robv.android.xposed.XposedBridge;
/**
* 拦截 root 探测命令时返回「空 / 失败」假进程,避免抛异常被 SHPSSDK 记为 tamper。
*/
public final class ProbeGuard {
private static final String TAG = "notiMessageHook/ProbeGuard";
private ProbeGuard() {
}
public static boolean isBlockedCommand(String command) {
if (command == null || command.isEmpty()) {
return false;
}
String lower = command.toLowerCase(Locale.US);
return lower.contains(" magisk")
|| lower.contains("/su")
|| lower.startsWith("su")
|| lower.contains("which su")
|| lower.contains("getprop ro.debuggable")
|| lower.contains("busybox")
|| lower.equals("su");
}
public static boolean isBlockedCommand(List<String> commands) {
if (commands == null || commands.isEmpty()) {
return false;
}
return isBlockedCommand(String.join(" ", commands));
}
public static Process fakeFailedProcess(String reason) {
XposedBridge.log(TAG + " fake probe process: " + reason);
return new FakeProcess();
}
private static final class FakeProcess extends Process {
@Override
public OutputStream getOutputStream() {
return OutputStream.nullOutputStream();
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(new byte[0]);
}
@Override
public InputStream getErrorStream() {
return new ByteArrayInputStream(new byte[0]);
}
@Override
public int waitFor() {
return 1;
}
@Override
public int exitValue() {
return 1;
}
@Override
public void destroy() {
}
}
}

View File

@@ -0,0 +1,2838 @@
package com.miraclegarden.smsmessage.xposed.hook;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import com.miraclegarden.smsmessage.xposed.HookBridge;
import com.miraclegarden.smsmessage.xposed.HookForwarder;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;
/**
* TNG eWallet Money Packet群红包领取统计。
* 从 HTTP 响应 JSON / Gson 反序列化结果中提取 receiverList昵称 + 金额),转发到 notiMessage。
*/
public final class TngMoneyPacketHook {
private static final String TAG = "notiMessageHook/TngMmp";
private static final String PACKAGE = TngRootBypassHook.PACKAGE;
private static final int DEDUP_SIZE = 256;
private static final ArrayDeque<String> RECENT_KEYS = new ArrayDeque<>();
private static final HashSet<String> RECENT_SET = new HashSet<>();
/** activityId → 上次转发时间 */
private static final java.util.HashMap<String, Long> RECENT_PACKET_AT = new java.util.HashMap<>();
/** activityId → 领取指纹 */
private static final java.util.HashMap<String, String> RECENT_PACKET_CLAIMS = new java.util.HashMap<>();
/** 同一 activityId + 相同领取名单短时不重复推(可由领取台设置覆盖) */
private static final long DEFAULT_PACKET_DEDUP_MS = 3_000L;
private static final ThreadLocal<String> CURRENT_REQUEST_URL = new ThreadLocal<>();
/** 历史列表触发的自动拉详情:已排队 / 上次成功时间 */
private static final java.util.HashSet<String> AUTO_DETAIL_PENDING = new java.util.HashSet<>();
private static final java.util.HashMap<String, Long> AUTO_DETAIL_AT = new java.util.HashMap<>();
private static final long DEFAULT_DETAIL_COOLDOWN_MS = 8_000L;
/** activityId → 历史列表里的发放时间 createTime */
private static final java.util.HashMap<String, String> ACTIVITY_ISSUE_TIME =
new java.util.HashMap<>();
/** 已成功拿到领取名单的 activityId */
private static final java.util.HashSet<String> PACKETS_WITH_DATA = new java.util.HashSet<>();
/** 已领完且已有数据 → 不再自动刷详情 */
private static final java.util.HashSet<String> COMPLETED_PACKETS = new java.util.HashSet<>();
/** 自动拉历史列表冷却(无需打开历史页;可由领取台设置覆盖) */
private static final long DEFAULT_HISTORY_COOLDOWN_MS = 8_000L;
private static final long DEFAULT_DETAIL_GAP_MS = 80L;
private static final long SETTINGS_POLL_MS = 10_000L;
/** 与 AppConfig.DEBUG_SERVER_URLS 对齐 */
private static final String[] SETTINGS_BASE_URLS = {
"http://127.0.0.1:8765",
"http://10.151.104.25:8765",
};
private static volatile long sHistoryCooldownMs = DEFAULT_HISTORY_COOLDOWN_MS;
private static volatile long sDetailCooldownMs = DEFAULT_DETAIL_COOLDOWN_MS;
private static volatile long sPacketDedupMs = DEFAULT_PACKET_DEDUP_MS;
private static volatile long sDetailGapMs = DEFAULT_DETAIL_GAP_MS;
private static volatile boolean sOpenHistoryIfNoTemplate = true;
/** 断线/拉历史失败时:自动进一下历史页再返回,重建模板与连接 */
private static volatile 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;
/** 本线程正在主动拉历史,避免 Quake afterHook 再 schedule 一遍 */
private static final ThreadLocal<Boolean> AUTO_HISTORY_SELF = new ThreadLocal<>();
private static volatile Object sMoneyPacketRepository;
private static volatile Object sMoneyPacketRpcTask;
private static volatile ClassLoader sAppClassLoader;
private static volatile String sCachedUserId;
private static volatile String sCachedSessionId;
private static volatile String sCachedLoginId;
/** 真实详情/历史请求模板(有则克隆;无则用 ILoginStorage 自造) */
private static volatile Object sTemplateDetailRequest;
private static volatile Object sTemplateHistoryRequest;
/** 最近一次带登录态的请求(历史/详情),用于给自造详情请求补 session */
private static volatile Object sLoginDonorRequest;
private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper());
private static final java.util.concurrent.ExecutorService AUTO_DETAIL_EXEC =
java.util.concurrent.Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "TngMmp-auto-detail");
t.setDaemon(true);
return t;
});
private TngMoneyPacketHook() {
}
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!TngRootBypassHook.isTargetPackage(lpparam.packageName)) {
return;
}
sAppClassLoader = lpparam.classLoader;
hookOkHttpUrl(lpparam);
hookResponseBody(lpparam);
hookGsonFromJson(lpparam);
hookQuakeRpc(lpparam);
hookLoginStorage(lpparam);
hookActivityResumeForAutoHistory(lpparam);
startSettingsPoller();
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 秒内生效 */
private static void startSettingsPoller() {
Thread t = new Thread(() -> {
while (true) {
try {
pollMmpSettings(true);
Thread.sleep(SETTINGS_POLL_MS);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
} catch (Throwable ignored) {
try {
Thread.sleep(SETTINGS_POLL_MS);
} catch (InterruptedException ie2) {
Thread.currentThread().interrupt();
return;
}
}
}
}, "TngMmp-settings");
t.setDaemon(true);
t.start();
}
private static void pollMmpSettings(boolean force) {
long now = System.currentTimeMillis();
if (!force && now - sLastSettingsPollAt < SETTINGS_POLL_MS) {
return;
}
sLastSettingsPollAt = now;
for (String base : SETTINGS_BASE_URLS) {
if (tryApplySettingsFromUrl(base + "/api/mmp/settings")) {
return;
}
}
}
private static boolean tryApplySettingsFromUrl(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(2500);
conn.setReadTimeout(2500);
conn.setRequestMethod("GET");
conn.setUseCaches(false);
int code = conn.getResponseCode();
if (code != 200) {
return false;
}
StringBuilder sb = new StringBuilder();
try (BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
sb.append(line);
}
}
JSONObject json = new JSONObject(sb.toString());
long historyMs = Math.max(1, json.optInt("historyCooldownSec", 8)) * 1000L;
long detailMs = Math.max(1, json.optInt("detailCooldownSec", 8)) * 1000L;
long dedupMs = Math.max(0, json.optInt("dedupSec", 3)) * 1000L;
long gapMs = Math.max(0, json.optInt("detailGapMs", 80));
boolean openHistory = json.optBoolean("openHistoryIfNoTemplate", true);
boolean bounceHistory = json.optBoolean("autoBounceHistoryOnDisconnect", true);
boolean changed = historyMs != sHistoryCooldownMs
|| detailMs != sDetailCooldownMs
|| dedupMs != sPacketDedupMs
|| gapMs != sDetailGapMs
|| 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;
} catch (Throwable ignored) {
return false;
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
private static void hookOkHttpUrl(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"okhttp3.Request$Builder",
lpparam.classLoader,
"build",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
try {
Object url = XposedHelpers.callMethod(param.getResult(), "url");
if (url != null) {
CURRENT_REQUEST_URL.set(String.valueOf(url));
}
} catch (Throwable ignored) {
}
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " Request.Builder.build hook failed: " + t.getMessage());
}
}
private static void hookResponseBody(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"okhttp3.ResponseBody",
lpparam.classLoader,
"string",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String body = (String) param.getResult();
String url = CURRENT_REQUEST_URL.get();
CURRENT_REQUEST_URL.remove();
if (!isMmpPayload(url, body)) {
return;
}
forwardParsedJson(body, url, "http");
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " ResponseBody.string hook failed: " + t.getMessage());
}
}
private static void hookGsonFromJson(XC_LoadPackage.LoadPackageParam lpparam) {
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object result = param.getResult();
if (result == null) {
return;
}
String className = result.getClass().getName();
if (!isMmpModelClass(className)) {
return;
}
try {
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
return;
}
forwardParsedJson(json, className, "gson");
} catch (Throwable t) {
XposedBridge.log(TAG + " gson capture failed: " + t.getMessage());
}
}
};
try {
XposedHelpers.findAndHookMethod(
"com.google.gson.Gson",
lpparam.classLoader,
"fromJson",
String.class,
Class.class,
hook);
} catch (Throwable t) {
XposedBridge.log(TAG + " Gson.fromJson(Class) hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
"com.google.gson.Gson",
lpparam.classLoader,
"fromJson",
String.class,
java.lang.reflect.Type.class,
hook);
} catch (Throwable ignored) {
}
}
private static boolean isMmpPayload(String url, String body) {
if (TextUtils.isEmpty(body) || body.length() < 24) {
return false;
}
String lower = body.toLowerCase(Locale.US);
boolean jsonHit = lower.contains("receiverlist")
|| lower.contains("\"claimedamount\"")
|| lower.contains("mmpreceiver")
|| (lower.contains("nick") && lower.contains("amount") && lower.contains("mmp"));
if (jsonHit) {
return true;
}
if (TextUtils.isEmpty(url)) {
return false;
}
String urlLower = url.toLowerCase(Locale.US);
return urlLower.contains("mmp") || urlLower.contains("moneypacket");
}
private static boolean isMmpModelClass(String className) {
if (TextUtils.isEmpty(className)) {
return false;
}
String n = className;
return n.contains("MmpDetail")
|| n.contains("MmpClaim")
|| n.contains("MmpReceiver")
|| n.contains("MmpLeaderboard")
|| n.contains("MoneyPacketDetail")
|| n.contains("MoneyPacketClaim")
|| n.contains("MoneyPacketLeader")
|| n.contains("MoneyPacketDoClaim")
|| n.contains("ClaimResultDto")
|| n.contains("ClaimDto")
|| n.contains("ReceiverList")
|| n.contains("packetReceiver");
}
private static void hookQuakeRpc(XC_LoadPackage.LoadPackageParam lpparam) {
// TNG 封装 + Quake 底层都挂,避免历史/详情只走其中一条链路时漏抓
String[] rpcClasses = {
"my.com.tngdigital.common.aliservice.quake.TngdRpcInvocationHandlerHost",
"com.alipay.imobile.network.quake.rpc.RpcInvocationHandler",
};
XC_MethodHook hook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (param.hasThrowable() || param.getResult() == null) {
return;
}
cacheMoneyPacketRpcTask(param.args);
cacheLoginFromInvokeArgs(param.args);
String op = extractMoneyPacketRpcOp(param.args);
if (op == null) {
return;
}
XposedBridge.log(TAG + " rpc op=" + op
+ " via=" + param.thisObject.getClass().getSimpleName());
Object result = param.getResult();
// 历史列表:无领取排行,但可用 activityId 自动拉详情
if (isHistoryRpcOp(op)) {
Object historyReq = extractRpcRequestArg(param.args);
if (historyReq != null) {
sTemplateHistoryRequest = cloneRequestShallow(historyReq);
sLoginDonorRequest = historyReq;
cacheLoginFromRequestObject(historyReq);
XposedBridge.log(TAG + " cached history request template"
+ " session=" + abbreviate(stringField(historyReq,
"sessionId", "getSessionId")));
}
// 无论是否自己触发的历史请求,都先缓存发放时间
cacheIssueTimesFromHistory(result);
if (Boolean.TRUE.equals(AUTO_HISTORY_SELF.get())) {
return;
}
XposedBridge.log(TAG + " history hit → schedule auto detail");
scheduleAutoDetailsFromHistory(result);
return;
}
if (!isDetailRpcOp(op)) {
return;
}
String className = result.getClass().getName();
XposedBridge.log(TAG + " rpc hit op=" + op + " type=" + className);
try {
Object detailReq = extractRpcRequestArg(param.args);
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
json = tryFastjson(result);
}
if (TextUtils.isEmpty(json)) {
XposedBridge.log(TAG + " rpc serialize empty op=" + op);
return;
}
MmpSnapshot probe = parseSnapshot(json);
enrichSnapshotFromJavaResult(result, probe);
if (probe != null && (!probe.claims.isEmpty() || snapshotHasPacketMeta(probe))) {
if (detailReq != null) {
sTemplateDetailRequest = cloneRequestShallow(detailReq);
sLoginDonorRequest = detailReq;
cacheLoginFromRequestObject(detailReq);
XposedBridge.log(TAG + " cached detail request template"
+ " userId=" + stringField(detailReq, "userId", "getUserId")
+ " session=" + abbreviate(stringField(detailReq,
"sessionId", "getSessionId"))
+ " page=" + stringField(detailReq, "page", "getPage")
+ " max=" + stringField(detailReq, "maxResult", "getMaxResult"));
}
forwardSnapshot(probe, op, "rpc");
} else {
String snippet = json.length() > 500 ? json.substring(0, 500) + "..." : json;
XposedBridge.log(TAG + " rpc no-claims op=" + op + " body=" + snippet);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " rpc capture failed: " + t.getMessage());
}
}
};
for (String className : rpcClasses) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!"invoke".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, hook);
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked RPC " + className + " invoke=" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip RPC " + className + ": " + t.getMessage());
}
}
hookMoneyPacketRepository(lpparam);
}
/** DI 创建 Repository 时立刻缓存 task + login不必等用户进红包页 */
private static void hookMoneyPacketRepository(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> repoCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.repo.MoneyPacketRepositoryImpl",
lpparam.classLoader);
XposedBridge.hookAllConstructors(repoCls, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
captureRepoInstance(param.thisObject, "ctor");
}
});
// Dagger 可能先无参构造再注入字段;在业务方法入口再捕获一次
for (String methodName : new String[]{
"getMoneyPacketList", "getMoneyPacketDetail", "doClaim", "preClaim"}) {
try {
for (Method m : repoCls.getDeclaredMethods()) {
if (!methodName.equals(m.getName())) {
continue;
}
XposedBridge.hookMethod(m, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
captureRepoInstance(param.thisObject, "method:" + methodName);
}
});
}
} catch (Throwable ignored) {
}
}
XposedBridge.log(TAG + " hooked MoneyPacketRepositoryImpl");
} catch (Throwable t) {
XposedBridge.log(TAG + " repo hook skip: " + t.getMessage());
}
}
private static void captureRepoInstance(Object repo, String reason) {
if (repo == null) {
return;
}
try {
sMoneyPacketRepository = repo;
Object task = null;
try {
task = XposedHelpers.getObjectField(repo, "task");
} catch (Throwable ignored) {
}
if (task != null && sMoneyPacketRpcTask == null) {
sMoneyPacketRpcTask = task;
XposedBridge.log(TAG + " cached RPC task from repo (" + reason + ")");
}
Object loginStorage = null;
try {
loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
} catch (Throwable ignored) {
}
applyLoginStorage(loginStorage);
if (sMoneyPacketRpcTask != null && !TextUtils.isEmpty(sCachedSessionId)) {
scheduleAutoHistory("repo-" + reason);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " captureRepo fail: " + t.getMessage());
}
}
/** 与 App 一致:发 RPC 前走 Repository.addNetworkHandle否则易 RpcApiException / 空结果 */
private static void prepareRpcRequest(Object req) {
if (req == null) {
return;
}
Object repo = sMoneyPacketRepository;
if (repo == null) {
repo = obtainMoneyPacketRepository(sAppClassLoader);
if (repo != null) {
sMoneyPacketRepository = repo;
}
}
if (repo == null) {
XposedBridge.log(TAG + " prepareRpc skip: no repository");
return;
}
try {
// addNetworkHandle$default(repo, request, processor=null, onError=null, mask=3, marker=null)
XposedHelpers.callStaticMethod(repo.getClass(), "addNetworkHandle$default",
repo, req, null, null, 3, null);
XposedBridge.log(TAG + " prepareRpc addNetworkHandle$default ok");
return;
} catch (Throwable t) {
XposedBridge.log(TAG + " addNetworkHandle$default fail: " + t.getMessage());
}
try {
XposedHelpers.callMethod(repo, "addNetworkHandle", req, null, null);
XposedBridge.log(TAG + " prepareRpc addNetworkHandle ok");
} catch (Throwable t) {
XposedBridge.log(TAG + " addNetworkHandle fail: " + t.getMessage());
}
}
private static boolean isHistoryRpcOp(String op) {
if (TextUtils.isEmpty(op)) {
return false;
}
String lower = op.toLowerCase(Locale.US);
return lower.contains("historylist")
|| lower.contains("history")
|| lower.contains("list.retrieve")
|| lower.equals("moneypackethistorylist");
}
/** 只要详情/领取查询,历史列表走 auto-detail */
private static boolean isDetailRpcOp(String op) {
if (TextUtils.isEmpty(op) || isHistoryRpcOp(op)) {
return false;
}
String lower = op.toLowerCase(Locale.US);
return lower.contains("detail")
|| lower.contains("retrieve")
|| lower.contains("claim.result")
|| lower.contains("leaderboard")
|| lower.contains("moneypacketdetail");
}
/** InvocationHandler.invoke(proxy, method, args) → 缓存 MoneyPacketRpcTask 代理 */
private static void cacheMoneyPacketRpcTask(Object[] args) {
if (args == null || args.length == 0) {
return;
}
try {
Object proxy = args[0];
if (proxy == null || !java.lang.reflect.Proxy.isProxyClass(proxy.getClass())) {
return;
}
if (sMoneyPacketRpcTask != null) {
return;
}
for (Class<?> iface : proxy.getClass().getInterfaces()) {
String name = iface.getName();
if (name.endsWith("MoneyPacketRpcTask") || name.contains("MoneyPacketRpc")) {
sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task " + name);
scheduleAutoHistory("rpc-task-cached");
return;
}
}
try {
proxy.getClass().getMethod("moneyPacketDetail",
XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
sAppClassLoader));
sMoneyPacketRpcTask = proxy;
XposedBridge.log(TAG + " cached RPC task via moneyPacketDetail method");
scheduleAutoHistory("rpc-task-cached");
} catch (Throwable ignored) {
}
} catch (Throwable ignored) {
}
}
/** 从历史/详情请求里偷登录态,供自动拉详情复用 */
private static void cacheLoginFromInvokeArgs(Object[] args) {
cacheLoginFromRequestObject(extractRpcRequestArg(args));
}
private static void cacheLoginFromRequestObject(Object req) {
if (req == null) {
return;
}
String userId = stringField(req, "userId", "getUserId");
String sessionId = stringField(req, "sessionId", "getSessionId");
String loginId = stringField(req, "loginId", "getLoginId");
if (!TextUtils.isEmpty(userId)) {
sCachedUserId = userId;
}
if (!TextUtils.isEmpty(sessionId)) {
sCachedSessionId = sessionId;
}
if (!TextUtils.isEmpty(loginId)) {
sCachedLoginId = loginId;
}
}
private static String abbreviate(String s) {
if (TextUtils.isEmpty(s)) {
return "-";
}
return s.length() <= 8 ? s : s.substring(0, 4) + "" + s.substring(s.length() - 4);
}
private static Object cloneRequestShallow(Object src) {
if (src == null) {
return null;
}
try {
Object copy = src.getClass().getDeclaredConstructor().newInstance();
copyAllFields(src, copy);
return copy;
} catch (Throwable t) {
// Kotlin data class 可能无无参构造,直接复用引用(自动拉取在单线程)
return src;
}
}
private static void copyAllFields(Object src, Object dst) {
Class<?> c = src.getClass();
while (c != null && c != Object.class) {
for (java.lang.reflect.Field f : c.getDeclaredFields()) {
if (f.getName().contains("$")) {
continue;
}
try {
f.setAccessible(true);
f.set(dst, f.get(src));
} catch (Throwable ignored) {
}
}
c = c.getSuperclass();
}
}
private static void applyActivityToRequest(Object req, String activityId, String senderUserId) {
try {
XposedHelpers.setObjectField(req, "activityId", activityId);
} catch (Throwable ignored) {
trySet(req, "setActivityId", activityId);
}
try {
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
} catch (Throwable ignored) {
trySet(req, "setSenderUserId", senderUserId);
}
// 尽量一次拉全领取名单(避免默认 20 条截断)
bumpDetailPageSize(req);
}
private static void bumpDetailPageSize(Object req) {
if (req == null) {
return;
}
try {
XposedHelpers.setIntField(req, "page", 0);
} catch (Throwable ignored) {
}
try {
XposedHelpers.setIntField(req, "maxResult", 200);
} catch (Throwable ignored) {
try {
XposedHelpers.setObjectField(req, "maxResult", 200);
} catch (Throwable ignored2) {
}
}
}
private static Object extractRpcRequestArg(Object[] args) {
if (args == null) {
return null;
}
// InvocationHandler.invoke(proxy, method, Object[] methodArgs)
if (args.length >= 3 && args[2] instanceof Object[]) {
Object[] methodArgs = (Object[]) args[2];
if (methodArgs.length > 0 && methodArgs[0] != null) {
String n = methodArgs[0].getClass().getName();
if (n.contains("Request") || n.contains("moneypacket") || n.contains("MoneyPacket")) {
return methodArgs[0];
}
}
}
for (Object arg : args) {
if (arg == null || arg instanceof Method || arg instanceof String) {
continue;
}
if (arg instanceof Object[]) {
continue;
}
String n = arg.getClass().getName();
if (n.contains("Request") && (n.contains("MoneyPacket") || n.contains("moneypacket")
|| n.contains("Mmp"))) {
return arg;
}
}
return null;
}
private static void scheduleAutoDetailsFromHistory(Object historyResult) {
if (historyResult == null) {
return;
}
pollMmpSettings(false);
// 解析放调用线程;真正 RPC 必须丢到主线程Quake/登录态常绑主线程)
final List<String[]> jobs;
try {
jobs = extractHistoryJobs(historyResult);
} catch (Throwable t) {
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
return;
}
XposedBridge.log(TAG + " auto-detail jobs=" + jobs.size());
if (jobs.isEmpty()) {
return;
}
long delayMs = 0L;
for (String[] job : jobs) {
final String activityId = job[0];
final String senderUserId = job[1];
final String createTime = job.length > 2 ? job[2] : null;
if (!TextUtils.isEmpty(createTime)) {
synchronized (ACTIVITY_ISSUE_TIME) {
ACTIVITY_ISSUE_TIME.put(activityId, createTime);
}
}
long now = System.currentTimeMillis();
long detailCooldown = sDetailCooldownMs;
if (COMPLETED_PACKETS.contains(activityId)) {
continue;
}
// job[3]=remainingCount, job[4]=claimedCount/totalCount hint, job[5]=status
String remainHint = job.length > 3 ? job[3] : null;
String countHint = job.length > 4 ? job[4] : null;
String statusHint = job.length > 5 ? job[5] : null;
if (isFinishedFlags(remainHint, countHint, statusHint)
&& PACKETS_WITH_DATA.contains(activityId)) {
COMPLETED_PACKETS.add(activityId);
XposedBridge.log(TAG + " skip completed(history) id=" + activityId);
continue;
}
synchronized (AUTO_DETAIL_AT) {
Long last = AUTO_DETAIL_AT.get(activityId);
if (last != null && now - last < detailCooldown) {
continue;
}
if (!AUTO_DETAIL_PENDING.add(activityId)) {
continue;
}
}
final long thisDelay = delayMs;
delayMs += Math.max(0L, sDetailGapMs);
AUTO_DETAIL_EXEC.execute(() -> {
try {
if (thisDelay > 0) {
Thread.sleep(thisDelay);
}
boolean ok = fetchDetailByActivityId(activityId, senderUserId);
if (ok) {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_AT.put(activityId, System.currentTimeMillis());
}
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
synchronized (AUTO_DETAIL_AT) {
AUTO_DETAIL_PENDING.remove(activityId);
}
}
});
}
}
private static List<String[]> extractHistoryJobs(Object historyResult) {
List<String[]> jobs = new ArrayList<>();
try {
Object infos = null;
try {
infos = XposedHelpers.callMethod(historyResult, "getParticipantInfos");
} catch (Throwable ignored) {
}
if (infos == null) {
try {
infos = XposedHelpers.getObjectField(historyResult, "participantInfos");
} catch (Throwable ignored) {
}
}
if (!(infos instanceof Iterable)) {
return jobs;
}
for (Object info : (Iterable<?>) infos) {
if (info == null) {
continue;
}
String activityId = stringField(info, "activityId", "getActivityId");
String senderUserId = stringField(info, "senderUserId", "getSenderUserId");
String createTime = stringField(info, "createTime", "getCreateTime");
if (looksLikeActivityId(activityId) && !TextUtils.isEmpty(senderUserId)) {
String remain = firstNonEmpty(
stringField(info, "remainingCount", "getRemainingCount"),
stringField(info, "remainCount", "getRemainCount"));
String claimed = stringField(info, "claimedCount", "getClaimedCount");
String totalCnt = stringField(info, "totalCount", "getTotalCount");
String countHint = "";
if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
countHint = nullToEmpty(claimed) + "/" + nullToEmpty(totalCnt);
}
String status = firstNonEmpty(
stringField(info, "activityStatus", "getActivityStatus"),
stringField(info, "participantStatus", "getParticipantStatus"),
stringField(info, "status", "getStatus"),
boolFieldText(info, "isFinished", "isFinished"));
jobs.add(new String[]{
activityId,
senderUserId,
TextUtils.isEmpty(createTime) ? "" : createTime,
nullToEmpty(remain),
countHint,
nullToEmpty(status)
});
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " extract history jobs failed: " + t.getMessage());
}
return jobs;
}
private static String stringField(Object obj, String field, String getter) {
try {
Object v = XposedHelpers.callMethod(obj, getter);
if (v != null) {
return String.valueOf(v);
}
} catch (Throwable ignored) {
}
try {
Object v = XposedHelpers.getObjectField(obj, field);
if (v != null) {
return String.valueOf(v);
}
} catch (Throwable ignored) {
}
return null;
}
private static String boolFieldText(Object obj, String field, String getter) {
try {
Object v = XposedHelpers.callMethod(obj, getter);
if (v instanceof Boolean) {
return Boolean.TRUE.equals(v) ? "FINISHED" : null;
}
} catch (Throwable ignored) {
}
try {
Object v = XposedHelpers.getObjectField(obj, field);
if (v instanceof Boolean && Boolean.TRUE.equals(v)) {
return "FINISHED";
}
} catch (Throwable ignored) {
}
return null;
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}
/** remaining / claimed/total / status 任一表明已领完或已结束 */
private static boolean isFinishedFlags(String remain, String claimedSlashTotal, String status) {
if (!TextUtils.isEmpty(remain)) {
try {
if (Integer.parseInt(remain.trim()) <= 0) {
return true;
}
} catch (NumberFormatException ignored) {
}
}
if (!TextUtils.isEmpty(claimedSlashTotal) && claimedSlashTotal.contains("/")) {
String[] parts = claimedSlashTotal.split("/", 2);
try {
int claimed = Integer.parseInt(parts[0].trim());
int total = Integer.parseInt(parts[1].trim());
if (total > 0 && claimed >= total) {
return true;
}
} catch (NumberFormatException ignored) {
}
}
if (!TextUtils.isEmpty(status)) {
String s = status.toUpperCase(Locale.US);
// TNG ActivityStatus: ACTIVE/INIT/FINISHED/ENDED/CLOSED/CANCELLED
if (s.contains("FINISH") || s.contains("COMPLETE") || s.contains("EXPIRE")
|| s.contains("ENDED") || s.contains("CLOSED") || s.contains("CANCEL")
|| s.contains("DONE") || "TRUE".equals(s)) {
return true;
}
}
return false;
}
private static boolean evaluateFinished(MmpSnapshot snapshot) {
if (snapshot == null) {
return false;
}
if (isFinishedFlags(snapshot.remainingCount,
buildCountHint(snapshot.claimedCount, snapshot.totalCount),
snapshot.activityStatus)) {
return true;
}
if (isExpireTimePassed(snapshot.expireTime)) {
return true;
}
// 已领金额 ≈ 总额
Double claimedAmt = parseMoneyDouble(snapshot.claimedAmountText);
Double totalAmt = parseMoneyDouble(snapshot.totalAmount);
if (claimedAmt == null && snapshot.claims != null && !snapshot.claims.isEmpty()) {
double sum = 0;
for (ClaimLine line : snapshot.claims) {
Double a = parseMoneyDouble(line.amount);
if (a != null) {
sum += a;
}
}
claimedAmt = sum;
}
if (claimedAmt != null && totalAmt != null && totalAmt > 0
&& Math.abs(claimedAmt - totalAmt) < 0.02) {
return true;
}
return false;
}
/** expireTime 形如 05/08/2026 14:06:56 或 ISO已过则视为结束 */
private static boolean isExpireTimePassed(String expireTime) {
long ms = parseIssueTimeMs(expireTime);
return ms > 0 && System.currentTimeMillis() >= ms;
}
private static long parseIssueTimeMs(String text) {
if (TextUtils.isEmpty(text)) {
return 0L;
}
String s = text.trim();
try {
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("^(\\d{2})/(\\d{2})/(\\d{4})(?:\\s+(\\d{2}):(\\d{2})(?::(\\d{2}))?)?")
.matcher(s);
if (m.find()) {
java.util.Calendar c = java.util.Calendar.getInstance();
c.set(Integer.parseInt(m.group(3)),
Integer.parseInt(m.group(2)) - 1,
Integer.parseInt(m.group(1)),
m.group(4) != null ? Integer.parseInt(m.group(4)) : 23,
m.group(5) != null ? Integer.parseInt(m.group(5)) : 59,
m.group(6) != null ? Integer.parseInt(m.group(6)) : 59);
c.set(java.util.Calendar.MILLISECOND, 999);
return c.getTimeInMillis();
}
if (s.matches("^\\d{13}$")) {
return Long.parseLong(s);
}
if (s.matches("^\\d{10}$")) {
return Long.parseLong(s) * 1000L;
}
// 2026-08-05T14:06:56 / 2026-08-05 14:06:56
java.util.regex.Matcher iso = java.util.regex.Pattern
.compile("^(\\d{4})-(\\d{2})-(\\d{2})[ T](\\d{2}):(\\d{2})(?::(\\d{2}))?")
.matcher(s);
if (iso.find()) {
java.util.Calendar c = java.util.Calendar.getInstance();
c.set(Integer.parseInt(iso.group(1)),
Integer.parseInt(iso.group(2)) - 1,
Integer.parseInt(iso.group(3)),
Integer.parseInt(iso.group(4)),
Integer.parseInt(iso.group(5)),
iso.group(6) != null ? Integer.parseInt(iso.group(6)) : 0);
c.set(java.util.Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
} catch (Throwable ignored) {
}
return 0L;
}
private static String buildCountHint(String claimed, String total) {
if (TextUtils.isEmpty(claimed) && TextUtils.isEmpty(total)) {
return null;
}
return nullToEmpty(claimed) + "/" + nullToEmpty(total);
}
private static Double parseMoneyDouble(String raw) {
if (TextUtils.isEmpty(raw)) {
return null;
}
try {
String n = normalizeMoneyText(raw);
if (TextUtils.isEmpty(n)) {
return null;
}
return Double.parseDouble(n);
} catch (Throwable ignored) {
return null;
}
}
private static void rememberPacketData(String packetId, MmpSnapshot snapshot, Object result) {
if (snapshot == null) {
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) && hasClaims) {
PACKETS_WITH_DATA.add(id);
}
} else if (hasClaims) {
PACKETS_WITH_DATA.add(id);
}
if (result != null
&& TextUtils.isEmpty(snapshot.remainingCount)
&& TextUtils.isEmpty(snapshot.claimedCount)
&& TextUtils.isEmpty(snapshot.activityStatus)) {
enrichSnapshotFromJavaResult(result, snapshot);
}
if (!snapshot.finished) {
snapshot.finished = evaluateFinished(snapshot);
}
if (snapshot.finished && hasClaims && !TextUtils.isEmpty(id)) {
if (COMPLETED_PACKETS.add(id)) {
XposedBridge.log(TAG + " packet DONE stop-refresh id=" + id
+ " claims=" + snapshot.claims.size()
+ " remain=" + snapshot.remainingCount
+ " status=" + snapshot.activityStatus);
}
}
}
private static void onSessionObserved(String sid) {
boolean has = !TextUtils.isEmpty(sid) && !"null".equalsIgnoreCase(sid);
if (has) {
sCachedSessionId = sid;
if (sSessionDisconnected) {
sSessionDisconnected = false;
XposedBridge.log(TAG + " session recovered → force re-fetch");
scheduleAutoHistoryForced("session-recovered");
}
sHadSession = true;
} else if (sHadSession) {
if (!sSessionDisconnected) {
sSessionDisconnected = true;
XposedBridge.log(TAG + " session lost / disconnected");
}
}
}
private static void noteSessionAlive() {
if (!TextUtils.isEmpty(sCachedSessionId)) {
sHadSession = true;
if (sSessionDisconnected) {
sSessionDisconnected = false;
XposedBridge.log(TAG + " connection alive again");
}
}
}
private static void tryRefreshLogin(ClassLoader cl) {
try {
Object ls = obtainLoginStorage(cl);
if (ls != null) {
applyLoginStorage(ls);
}
} catch (Throwable ignored) {
}
}
private static void markMaybeDisconnected(Throwable t, String where) {
String msg = t == null ? "" : String.valueOf(t.getMessage());
if (TextUtils.isEmpty(msg) && t != null) {
msg = t.getClass().getSimpleName();
}
String low = msg.toLowerCase(Locale.US);
if (low.contains("session") || low.contains("login") || low.contains("auth")
|| low.contains("unauthorized") || low.contains("timeout")
|| low.contains("unable to resolve") || low.contains("failed to connect")
|| low.contains("network") || low.contains("disconnect")
|| low.contains("socket") || low.contains("ssl")
|| msg.contains("未登录") || msg.contains("非法") || msg.contains("登录")
|| msg.contains("过期")) {
sSessionDisconnected = true;
XposedBridge.log(TAG + " disconnect suspected @" + where + ": " + msg);
tryRefreshLogin(sAppClassLoader);
}
}
private static void notePossibleDisconnectFromEmpty(Object result, String activityId) {
// 新发红包无人领取时 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
+ " → treat as possible disconnect");
sSessionDisconnected = true;
tryRefreshLogin(sAppClassLoader);
}
}
/** 详情有 summaryACTIVE / 有总额)但领取名单为空:刚发出去、还没人领 */
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);
Object task = sMoneyPacketRpcTask;
if (cl == null || task == null) {
XposedBridge.log(TAG + " auto-detail skip: no rpc task yet, id=" + activityId);
return false;
}
try {
Class<?> reqCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketDetailRequest",
cl);
Object req;
if (sTemplateDetailRequest != null
&& reqCls.isInstance(sTemplateDetailRequest)) {
req = cloneRequestShallow(sTemplateDetailRequest);
applyActivityToRequest(req, activityId, senderUserId);
XposedBridge.log(TAG + " auto-detail using template request id=" + activityId);
} else {
req = newDetailRequest(reqCls, activityId, senderUserId);
if (req == null) {
XposedBridge.log(TAG + " auto-detail newRequest failed id=" + activityId);
return false;
}
}
fillLoginOnRequest(req, cl);
prepareRpcRequest(req);
XposedBridge.log(TAG + " auto-detail req id=" + activityId
+ " userId=" + stringField(req, "userId", "getUserId")
+ " session=" + abbreviate(stringField(req, "sessionId", "getSessionId"))
+ " loginId=" + abbreviate(stringField(req, "loginId", "getLoginId"))
+ " page=" + stringField(req, "page", "getPage")
+ " max=" + stringField(req, "maxResult", "getMaxResult"));
if (TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
XposedBridge.log(TAG + " auto-detail skip: no sessionId id=" + activityId
+ " (ILoginStorage 尚未就绪,等登录后 Activity resume 会重试)");
return false;
}
Object result = XposedHelpers.callMethod(task, "moneyPacketDetail", req);
if (result == null) {
XposedBridge.log(TAG + " auto-detail null result id=" + activityId);
return false;
}
String json = buildJsonFromObject(result);
if (TextUtils.isEmpty(json)) {
json = tryFastjson(result);
}
if (TextUtils.isEmpty(json)) {
XposedBridge.log(TAG + " auto-detail empty json id=" + activityId);
return false;
}
MmpSnapshot probe = parseSnapshot(json);
enrichSnapshotFromJavaResult(result, probe);
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"));
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");
XposedBridge.log(TAG + " auto-detail ok activityId=" + activityId
+ " claims=" + probe.claims.size()
+ " issued=" + probe.issueTime
+ (COMPLETED_PACKETS.contains(activityId) ? " DONE" : ""));
noteSessionAlive();
return true;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-detail fail id=" + activityId + ": " + t.getMessage());
markMaybeDisconnected(t, "auto-detail");
return false;
}
}
private static Object newDetailRequest(Class<?> reqCls, String activityId, String senderUserId) {
// 优先: (activityId, senderUserId, loadTime, page, maxResult) — maxResult 拉大以尽量一次取全
try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200);
} catch (Throwable ignored) {
}
try {
return XposedHelpers.newInstance(reqCls, activityId, senderUserId, null, 0, 200,
31, null);
} catch (Throwable ignored) {
}
try {
Object req = reqCls.getDeclaredConstructor().newInstance();
XposedHelpers.setObjectField(req, "activityId", activityId);
XposedHelpers.setObjectField(req, "senderUserId", senderUserId);
bumpDetailPageSize(req);
return req;
} catch (Throwable t) {
XposedBridge.log(TAG + " newDetailRequest error: " + t.getMessage());
return null;
}
}
private static void fillLoginOnRequest(Object req, ClassLoader cl) {
ensureRpcTaskAndLogin(cl);
// 0) 从最近真实请求拷贝登录相关字段(历史页打开后即可,无需点详情)
Object donor = sLoginDonorRequest;
if (donor == null) {
donor = sTemplateHistoryRequest;
}
if (donor == null) {
donor = sTemplateDetailRequest;
}
if (donor != null && donor != req) {
copyLoginFields(donor, req);
cacheLoginFromRequestObject(donor);
}
// 1) 再用缓存的登录态覆盖
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
try {
if (!TextUtils.isEmpty(sCachedUserId)) {
XposedHelpers.setObjectField(req, "userId", sCachedUserId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
}
if (!TextUtils.isEmpty(sCachedLoginId)) {
XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
}
} catch (Throwable ignored) {
}
if (!TextUtils.isEmpty(stringField(req, "userId", "getUserId"))
&& !TextUtils.isEmpty(stringField(req, "sessionId", "getSessionId"))) {
return;
}
// 2) 从 Repository.loginStorage / ILoginStorage 再读一遍
Object loginStorage = obtainLoginStorage(cl);
if (loginStorage == null) {
return;
}
applyLoginStorage(loginStorage);
trySet(req, "setUserId", sCachedUserId);
trySet(req, "setSessionId", sCachedSessionId);
trySet(req, "setLoginId", sCachedLoginId);
try {
if (!TextUtils.isEmpty(sCachedUserId)) {
XposedHelpers.setObjectField(req, "userId", sCachedUserId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedHelpers.setObjectField(req, "sessionId", sCachedSessionId);
}
if (!TextUtils.isEmpty(sCachedLoginId)) {
XposedHelpers.setObjectField(req, "loginId", sCachedLoginId);
}
} catch (Throwable ignored) {
}
}
private static void copyLoginFields(Object src, Object dst) {
if (src == null || dst == null) {
return;
}
String[] keys = {"userId", "sessionId", "loginId", "securityId", "apiName"};
for (String key : keys) {
try {
Object v = null;
try {
v = XposedHelpers.getObjectField(src, key);
} catch (Throwable ignored) {
}
if (v == null) {
continue;
}
try {
XposedHelpers.setObjectField(dst, key, v);
} catch (Throwable ignored) {
}
String setter = "set" + Character.toUpperCase(key.charAt(0)) + key.substring(1);
trySet(dst, setter, v);
} catch (Throwable ignored) {
}
}
// extParams 等 Map 也尽量带上
try {
Object ext = XposedHelpers.getObjectField(src, "extParams");
if (ext != null) {
XposedHelpers.setObjectField(dst, "extParams", ext);
}
} catch (Throwable ignored) {
}
}
private static void trySet(Object req, String setter, Object value) {
if (value == null) {
return;
}
try {
XposedHelpers.callMethod(req, setter, value);
} catch (Throwable ignored) {
}
}
/** 被动缓存App 任何地方读 session 时同步到本 Hook */
private static void hookLoginStorage(XC_LoadPackage.LoadPackageParam lpparam) {
// 不能 hook 接口抽象方法;只挂实现类
try {
Class<?> impl = XposedHelpers.findClass(
"my.com.tngdigital.user.model.CacheLoginStorageImpl", lpparam.classLoader);
XC_MethodHook cacheHook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object v = param.getResult();
String name = param.method.getName();
if ("getSessionId".equals(name)) {
onSessionObserved(v == null ? "" : String.valueOf(v));
} else if (v == null || TextUtils.isEmpty(String.valueOf(v))) {
return;
} else if ("getAccountId".equals(name)) {
sCachedUserId = String.valueOf(v);
} else if ("getLoginId".equals(name)) {
sCachedLoginId = String.valueOf(v);
}
}
};
for (String m : new String[]{"getSessionId", "getAccountId", "getLoginId"}) {
XposedBridge.hookAllMethods(impl, m, cacheHook);
}
XposedBridge.log(TAG + " hooked CacheLoginStorageImpl getters");
} catch (Throwable t) {
XposedBridge.log(TAG + " CacheLoginStorageImpl hook skip: " + t.getMessage());
}
}
/** 前台 Activity 恢复时自动拉历史列表(再级联详情),无需手动进红包历史页 */
private static void hookActivityResumeForAutoHistory(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"android.app.Activity",
lpparam.classLoader,
"onResume",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
try {
Object act = param.thisObject;
if (act == null) {
return;
}
String pkg = String.valueOf(
XposedHelpers.callMethod(act, "getPackageName"));
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");
} else {
scheduleAutoHistory("activity-resume");
}
} catch (Throwable ignored) {
}
}
});
XposedBridge.log(TAG + " hooked Activity.onResume for auto history");
} catch (Throwable t) {
XposedBridge.log(TAG + " Activity.onResume hook skip: " + t.getMessage());
}
}
private static void scheduleAutoHistory(String reason) {
scheduleAutoHistoryInternal(reason, false);
}
/** 断线恢复后忽略冷却,立刻重拉历史/详情 */
private static void scheduleAutoHistoryForced(String reason) {
scheduleAutoHistoryInternal(reason, true);
}
private static void scheduleAutoHistoryInternal(String reason, boolean force) {
pollMmpSettings(false);
long now = System.currentTimeMillis();
if (sAutoHistoryRunning) {
return;
}
if (!force && now - sLastAutoHistoryAt < sHistoryCooldownMs) {
return;
}
sAutoHistoryRunning = true;
final String why = reason;
AUTO_DETAIL_EXEC.execute(() -> {
try {
// 等登录/Hilt/Network 就绪;不要在主线程阻塞等 RPC
Thread.sleep(why != null && why.startsWith("repo") ? 1500L : 800L);
boolean ok = fetchHistoryListAuto(why);
if (ok) {
sLastAutoHistoryAt = System.currentTimeMillis();
noteSessionAlive();
reportHookAlive("auto-history-ok");
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
} finally {
sAutoHistoryRunning = false;
}
});
}
/** 主动调 moneyPacketHistoryList结果走现有 scheduleAutoDetailsFromHistory */
private static boolean fetchHistoryListAuto(String reason) {
ClassLoader cl = sAppClassLoader;
ensureRpcTaskAndLogin(cl);
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)) {
Object ls = obtainLoginStorage(cl);
if (ls != null) {
applyLoginStorage(ls);
}
}
if (TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " auto-history skip: no sessionId yet (" + reason + ")");
if (sHadSession) {
sSessionDisconnected = true;
}
maybeBounceHistoryPage("no-session");
return false;
}
try {
Class<?> reqCls = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.data.rpc.MoneyPacketHistoryListRequest",
cl);
// 1) 已有官方请求模板(首次打开历史页后会缓存)→ 静默复用
if (sTemplateHistoryRequest != null
&& reqCls.isInstance(sTemplateHistoryRequest)) {
Object req = cloneRequestShallow(sTemplateHistoryRequest);
XposedBridge.log(TAG + " auto-history using template (" + reason + ")");
fillLoginOnRequest(req, cl);
prepareRpcRequest(req);
AUTO_HISTORY_SELF.set(Boolean.TRUE);
Object result;
try {
result = callHistoryListRpc(task, req);
} finally {
AUTO_HISTORY_SELF.remove();
}
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 && !sAutoBounceHistoryOnDisconnect) {
XposedBridge.log(TAG + " auto-history no template, openHistory disabled ("
+ reason + ")");
return false;
}
XposedBridge.log(TAG + " auto-history no template → open HistoryActivity (" + reason + ")");
openMoneyPacketHistoryActivity(true);
return false;
} catch (Throwable t) {
XposedBridge.log(TAG + " auto-history fail: " + t.getMessage());
markMaybeDisconnected(t, "auto-history");
maybeBounceHistoryPage("history-exception");
return false;
}
}
private static boolean historyHasJobs(Object historyResult) {
return !extractHistoryJobs(historyResult).isEmpty();
}
/** 断线/失败时短进历史页再建连(有冷却,避免刷屏) */
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();
intent.setClassName(PACKAGE,
"my.com.tngdigital.funding.moneypacket.create.ui.MoneyPacketHistoryActivity");
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
| android.content.Intent.FLAG_ACTIVITY_NO_ANIMATION);
ctx.startActivity(intent);
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;
}
}
private static void ensureRpcTaskAndLogin(ClassLoader cl) {
if (cl == null) {
return;
}
if (sMoneyPacketRpcTask == null || TextUtils.isEmpty(sCachedSessionId)) {
Object repo = obtainMoneyPacketRepository(cl);
if (repo != null) {
if (sMoneyPacketRpcTask == null) {
try {
Object task = XposedHelpers.getObjectField(repo, "task");
if (task != null) {
sMoneyPacketRpcTask = task;
XposedBridge.log(TAG + " cached RPC task from repository");
}
} catch (Throwable ignored) {
}
}
try {
Object loginStorage = XposedHelpers.getObjectField(repo, "loginStorage");
applyLoginStorage(loginStorage);
} catch (Throwable ignored) {
}
}
}
if (TextUtils.isEmpty(sCachedSessionId)) {
applyLoginStorage(obtainLoginStorage(cl));
}
}
private static void applyLoginStorage(Object loginStorage) {
if (loginStorage == null) {
return;
}
try {
Object accountId = XposedHelpers.callMethod(loginStorage, "getAccountId");
Object sessionId = XposedHelpers.callMethod(loginStorage, "getSessionId");
Object loginId = XposedHelpers.callMethod(loginStorage, "getLoginId");
if (accountId != null && !TextUtils.isEmpty(String.valueOf(accountId))) {
sCachedUserId = String.valueOf(accountId);
}
if (sessionId != null && !TextUtils.isEmpty(String.valueOf(sessionId))) {
onSessionObserved(String.valueOf(sessionId));
} else if (sHadSession) {
onSessionObserved("");
}
if (loginId != null && !TextUtils.isEmpty(String.valueOf(loginId))) {
sCachedLoginId = String.valueOf(loginId);
}
if (!TextUtils.isEmpty(sCachedSessionId)) {
XposedBridge.log(TAG + " login from storage session="
+ abbreviate(sCachedSessionId)
+ " userId=" + sCachedUserId);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " applyLoginStorage skip: " + t.getMessage());
}
}
private static Object obtainLoginStorage(ClassLoader cl) {
try {
Object repo = obtainMoneyPacketRepository(cl);
if (repo != null) {
try {
Object ls = XposedHelpers.getObjectField(repo, "loginStorage");
if (ls != null) {
return ls;
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
// Hilt尝试常见 EntryPoint 上返回 ILoginStorage 的无参方法
try {
Context ctx = getContext();
if (ctx == null || cl == null) {
return null;
}
String[] entryPoints = {
"my.com.tngdigital.member.di.MemberEntryPoint",
"my.com.tngdigital.user.di.UserEntryPoint",
"my.com.tngdigital.common.di.CommonEntryPoint",
};
Class<?> entryPointsCls = Class.forName("dagger.hilt.EntryPoints", false, cl);
for (String epName : entryPoints) {
try {
Class<?> epCls = Class.forName(epName, false, cl);
Object ep = XposedHelpers.callStaticMethod(
entryPointsCls, "get", ctx.getApplicationContext(), epCls);
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length != 0) {
continue;
}
if (m.getReturnType().getName().contains("ILoginStorage")) {
Object ls = m.invoke(ep);
if (ls != null) {
return ls;
}
}
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
private static Object obtainMoneyPacketRepository(ClassLoader cl) {
Context ctx = getContext();
if (ctx == null || cl == null) {
return null;
}
Context appCtx = ctx.getApplicationContext() != null ? ctx.getApplicationContext() : ctx;
Class<?> entryPoint;
try {
entryPoint = XposedHelpers.findClass(
"my.com.tngdigital.funding.moneypacket.common.di.MoneyPacketEntryPoint", cl);
} catch (Throwable t) {
XposedBridge.log(TAG + " MoneyPacketEntryPoint missing: " + t.getMessage());
return null;
}
// 1) EntryPointAccessors.fromApplicationHilt Android 推荐)
try {
Class<?> accessors = XposedHelpers.findClass(
"dagger.hilt.android.EntryPointAccessors", cl);
Object ep = XposedHelpers.callStaticMethod(
accessors, "fromApplication", appCtx, entryPoint);
Object repo = invokeRepository(ep);
if (repo != null) {
return repo;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " EntryPointAccessors fail: " + t.getMessage());
}
// 2) EntryPoints.get
try {
Class<?> entryPoints = XposedHelpers.findClass("dagger.hilt.EntryPoints", cl);
Object ep = XposedHelpers.callStaticMethod(
entryPoints, "get", appCtx, entryPoint);
Object repo = invokeRepository(ep);
if (repo != null) {
return repo;
}
} catch (Throwable t) {
XposedBridge.log(TAG + " EntryPoints.get fail: " + t.getMessage());
}
return null;
}
private static Object invokeRepository(Object ep) {
if (ep == null) {
return null;
}
try {
return XposedHelpers.callMethod(ep, "repository");
} catch (Throwable ignored) {
}
for (Method m : ep.getClass().getMethods()) {
if (m.getParameterTypes().length == 0
&& m.getReturnType().getName().contains("MoneyPacketRepository")) {
try {
return m.invoke(ep);
} catch (Throwable ignored) {
}
}
}
return null;
}
private static String extractMoneyPacketRpcOp(Object[] args) {
if (args == null) {
return null;
}
for (Object arg : args) {
if (arg instanceof Method) {
String name = ((Method) arg).getName().toLowerCase(Locale.US);
if (name.contains("moneypacket") || name.contains("mmp")) {
return ((Method) arg).getName();
}
} else if (arg instanceof String) {
String text = (String) arg;
String lower = text.toLowerCase(Locale.US);
if (lower.contains("moneypacket") || lower.contains(".mmp")
|| lower.contains("tngdwallet.money")) {
return text;
}
}
}
return null;
}
private static String tryFastjson(Object obj) {
try {
Class<?> json = Class.forName("com.alibaba.fastjson.JSON", false,
obj.getClass().getClassLoader());
Object out = XposedHelpers.callStaticMethod(json, "toJSONString", obj);
return out != null ? String.valueOf(out) : null;
} catch (Throwable ignored) {
return null;
}
}
private static void forwardParsedJson(String json, String sourceHint, String channel) {
MmpSnapshot snapshot = parseSnapshot(json);
if (snapshot == null || snapshot.claims.isEmpty()) {
return;
}
forwardSnapshot(snapshot, sourceHint, channel);
}
private static void forwardSnapshot(MmpSnapshot snapshot, String sourceHint, String channel) {
if (snapshot == null) {
return;
}
if (snapshot.claims.isEmpty() && !snapshotHasPacketMeta(snapshot)) {
return;
}
dedupeClaims(snapshot);
snapshot.applyCachedIssueTime();
rememberPacketData(snapshot.packetId, snapshot, null);
if (!shouldForwardPacket(snapshot)) {
return;
}
Context context = getContext();
if (context == null) {
XposedBridge.log(TAG + " skip forward: no context, claims="
+ snapshot.claims.size());
return;
}
String title = TextUtils.isEmpty(snapshot.title) ? "TNG 红包" : snapshot.title;
if (!TextUtils.isEmpty(snapshot.packetId) && snapshot.packetId.length() >= 8) {
title = title + " · " + snapshot.packetId.substring(0, 8);
}
String content = snapshot.formatForForward(channel, sourceHint);
HookForwarder.forward(context, PACKAGE, title, content, HookBridge.SOURCE_XPOSED_TNG_MMP);
XposedBridge.log(TAG + " captured activityId=" + snapshot.packetId
+ " claims=" + snapshot.claims.size()
+ " issued=" + snapshot.issueTime
+ (snapshot.finished ? " DONE" : "")
+ " via " + channel);
}
/** 直接从 Java 结果对象读 gmtModified / createTime不依赖 JSON 字段是否序列化成功 */
private static void enrichSnapshotFromJavaResult(Object result, MmpSnapshot snapshot) {
if (result == null || snapshot == null) {
return;
}
try {
Object pools = null;
try {
pools = XposedHelpers.callMethod(result, "getActivityPoolInfos");
} catch (Throwable ignored) {
}
if (pools == null) {
try {
pools = XposedHelpers.getObjectField(result, "activityPoolInfos");
} catch (Throwable ignored) {
}
}
if (pools instanceof Iterable) {
for (Object info : (Iterable<?>) pools) {
if (info == null) {
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"),
stringField(info, "createTime", "getCreateTime"));
String amount = null;
try {
Object money = XposedHelpers.callMethod(info, "getAmount");
if (money != null) {
amount = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
if (TextUtils.isEmpty(amount)) {
amount = normalizeMoneyText(stringField(info, "amount", "getAmount"));
}
if (TextUtils.isEmpty(name) && TextUtils.isEmpty(receiverId)) {
continue;
}
if (TextUtils.isEmpty(name)) {
name = "用户";
}
boolean matched = false;
for (ClaimLine line : snapshot.claims) {
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);
}
}
}
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) {
if (TextUtils.isEmpty(snapshot.senderName)) {
snapshot.senderName = stringField(summary, "senderName", "getSenderName");
}
if (TextUtils.isEmpty(snapshot.packetId)) {
snapshot.packetId = stringField(summary, "activityId", "getActivityId");
}
if (TextUtils.isEmpty(snapshot.totalAmount)) {
try {
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
if (money != null) {
snapshot.totalAmount = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
}
snapshot.remainingCount = firstNonEmpty(
stringField(summary, "remainingCount", "getRemainingCount"),
stringField(result, "remainingCount", "getRemainingCount"));
snapshot.claimedCount = firstNonEmpty(
stringField(summary, "claimedCount", "getClaimedCount"),
stringField(result, "claimedCount", "getClaimedCount"));
snapshot.totalCount = firstNonEmpty(
stringField(summary, "totalCount", "getTotalCount"),
stringField(result, "totalCount", "getTotalCount"));
snapshot.activityStatus = firstNonEmpty(
stringField(summary, "activityStatus", "getActivityStatus"),
stringField(result, "activityStatus", "getActivityStatus"),
boolFieldText(summary, "isFinished", "isFinished"),
boolFieldText(result, "isFinished", "isFinished"));
snapshot.expireTime = firstNonEmpty(
stringField(summary, "expireTime", "getExpireTime"),
stringField(result, "expireTime", "getExpireTime"),
stringField(summary, "expiryTime", "getExpiryTime"),
stringField(result, "expiryTime", "getExpiryTime"));
if (TextUtils.isEmpty(snapshot.claimedAmountText)) {
try {
Object money = XposedHelpers.callMethod(summary, "getClaimedAmount");
if (money != null) {
snapshot.claimedAmountText = normalizeMoneyText(String.valueOf(
XposedHelpers.callMethod(money, "getAmount")));
}
} catch (Throwable ignored) {
}
}
}
snapshot.applyCachedIssueTime();
snapshot.finished = evaluateFinished(snapshot);
} catch (Throwable t) {
XposedBridge.log(TAG + " enrich times failed: " + t.getMessage());
}
}
private static void cacheIssueTimesFromHistory(Object historyResult) {
if (historyResult == null) {
return;
}
try {
for (String[] job : extractHistoryJobs(historyResult)) {
if (job.length > 2 && looksLikeActivityId(job[0]) && !TextUtils.isEmpty(job[2])) {
synchronized (ACTIVITY_ISSUE_TIME) {
ACTIVITY_ISSUE_TIME.put(job[0], job[2]);
}
}
}
XposedBridge.log(TAG + " cached issue times size=" + ACTIVITY_ISSUE_TIME.size());
} catch (Throwable t) {
XposedBridge.log(TAG + " cache issue times failed: " + t.getMessage());
}
}
private static void dedupeClaims(MmpSnapshot snapshot) {
if (snapshot == null || snapshot.claims.size() <= 1) {
return;
}
java.util.LinkedHashMap<String, ClaimLine> map = new java.util.LinkedHashMap<>();
for (ClaimLine line : snapshot.claims) {
if (line == null) {
continue;
}
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(key, line);
continue;
}
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;
}
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();
snapshot.claims.addAll(map.values());
}
private static MmpSnapshot parseSnapshot(String json) {
try {
JSONObject root = new JSONObject(json.trim());
return parseSnapshot(root, null);
} catch (Throwable t) {
return null;
}
}
private static MmpSnapshot parseSnapshot(JSONObject root, MmpSnapshot base) {
MmpSnapshot snapshot = base != null ? base : new MmpSnapshot();
boolean rootPass = base == null;
// 领取行不再回填 meta其它嵌套节点可补 packetId/sender/total
if (rootPass || parseClaimLine(root) == null) {
fillMeta(root, snapshot);
}
JSONArray receiverList = findReceiverList(root);
if (receiverList != null) {
for (int i = 0; i < receiverList.length(); i++) {
JSONObject item = receiverList.optJSONObject(i);
if (item == null) {
continue;
}
ClaimLine line = parseClaimLine(item);
if (line != null) {
snapshot.claims.add(line);
}
}
}
Iterator<String> keys = root.keys();
while (keys.hasNext()) {
String key = keys.next();
Object val = root.opt(key);
if (val instanceof JSONObject) {
parseSnapshot((JSONObject) val, snapshot);
} else if (val instanceof JSONArray) {
JSONArray arr = (JSONArray) val;
for (int i = 0; i < arr.length(); i++) {
Object elem = arr.opt(i);
if (elem instanceof JSONObject) {
ClaimLine line = parseClaimLine((JSONObject) elem);
if (line != null) {
snapshot.claims.add(line);
}
parseSnapshot((JSONObject) elem, snapshot);
}
}
}
}
if (rootPass && TextUtils.isEmpty(snapshot.packetId) && !snapshot.claims.isEmpty()) {
snapshot.packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
}
return snapshot;
}
private static JSONArray findReceiverList(JSONObject obj) {
Iterator<String> keys = obj.keys();
while (keys.hasNext()) {
String key = keys.next();
Object val = obj.opt(key);
if (val instanceof JSONArray && isReceiverListKey(key)) {
return (JSONArray) val;
}
if (val instanceof JSONObject) {
JSONArray nested = findReceiverList((JSONObject) val);
if (nested != null) {
return nested;
}
}
}
return null;
}
private static boolean isReceiverListKey(String key) {
if (TextUtils.isEmpty(key)) {
return false;
}
String k = key.toLowerCase(Locale.US);
return "receiverlist".equals(k)
|| "leaderboardlist".equals(k)
|| "leaderboard".equals(k)
|| "rankinglist".equals(k)
|| "claimlist".equals(k)
|| "claimers".equals(k)
|| "packetreceiverlist".equals(k)
|| "receivers".equals(k)
|| "activitypoolinfos".equals(k)
|| k.contains("receiverlist")
|| k.contains("leaderboard")
|| k.contains("activitypool");
}
private static void fillMeta(JSONObject obj, MmpSnapshot snapshot) {
// activityId 是红包唯一标识UUID优先于其它 id 字段
putIfPresent(obj, snapshot, "activityId", "packetId", "mmpId", "moneyPacketId",
"packetCode", "fundOrderId", "orderNo", "bizNo", "bizOrderId");
putIfPresent(obj, snapshot, "groupId", "chatId", "conversationId");
putIfPresent(obj, snapshot, "groupName", "chatName", "conversationName");
putIfPresent(obj, snapshot, "senderName", "senderNickName", "operatorName", "creatorName");
putIfPresent(obj, snapshot, "totalAmount", "packetAmount", "packetTotalAmount",
"totalPacketAmount", "originalAmount");
putIfPresent(obj, snapshot, "createTime", "createdTime", "gmtCreate", "issueTime",
"issuedTime", "sendTime", "packetCreateTime");
if (TextUtils.isEmpty(snapshot.title)) {
String merchant = firstNonEmpty(
obj.optString("merchantName", null),
obj.optString("shopName", null));
if (!TextUtils.isEmpty(merchant)) {
snapshot.title = merchant;
}
}
}
private static void putIfPresent(JSONObject obj, MmpSnapshot snapshot, String... keys) {
for (String key : keys) {
if (!obj.has(key)) {
continue;
}
String val = readFieldText(obj, key);
if (TextUtils.isEmpty(val) || "null".equalsIgnoreCase(val)) {
continue;
}
String keyLower = key.toLowerCase(Locale.US);
if (keyLower.contains("createtime") || keyLower.contains("created")
|| keyLower.contains("gmtcreate") || keyLower.contains("issue")
|| keyLower.contains("sendtime")) {
if (TextUtils.isEmpty(snapshot.issueTime) && !looksLikeMoneyJson(val)) {
snapshot.issueTime = val;
}
} else if (keyLower.equals("activityid") || keyLower.contains("packet")
|| keyLower.contains("mmp") || keyLower.contains("order")
|| keyLower.contains("biz") || keyLower.equals("packetcode")) {
// 已有 UUID 形态的 activityId 时不覆盖
if (TextUtils.isEmpty(snapshot.packetId) && !looksLikeMoneyJson(val)) {
snapshot.packetId = val;
} else if (!TextUtils.isEmpty(snapshot.packetId)
&& keyLower.equals("activityid")
&& looksLikeActivityId(val)) {
snapshot.packetId = val;
}
} else if (keyLower.contains("group") || keyLower.contains("chat")
|| keyLower.contains("conversation")) {
if (keyLower.contains("name")) {
if (TextUtils.isEmpty(snapshot.title)) {
snapshot.title = val;
}
} else if (TextUtils.isEmpty(snapshot.groupId)) {
snapshot.groupId = val;
}
} else if (keyLower.contains("sender") || keyLower.contains("operator")
|| keyLower.contains("creator")) {
if (TextUtils.isEmpty(snapshot.senderName) && !looksLikeMoneyJson(val)) {
snapshot.senderName = val;
}
} else if (keyLower.contains("total") || keyLower.contains("packetamount")
|| keyLower.contains("original")) {
String money = normalizeMoneyText(val);
if (TextUtils.isEmpty(snapshot.totalAmount) && !TextUtils.isEmpty(money)) {
snapshot.totalAmount = money;
}
}
}
}
private static boolean looksLikeActivityId(String val) {
if (TextUtils.isEmpty(val)) {
return false;
}
// b8c37a58-bb2c-4aa2-83ef-f15466b9211e
return val.length() >= 32 && val.indexOf('-') > 0;
}
private static boolean looksLikeClaimRow(JSONObject obj) {
return parseClaimLine(obj) != null;
}
private static ClaimLine parseClaimLine(JSONObject obj) {
String name = firstNonEmpty(
readFieldText(obj, "nickName"),
readFieldText(obj, "nickname"),
readFieldText(obj, "displayName"),
readFieldText(obj, "userName"),
readFieldText(obj, "receiverName"),
readFieldText(obj, "receiverAccountName"),
readFieldText(obj, "accountName"),
readFieldText(obj, "participantName"),
readFieldText(obj, "name"));
String amount = firstNonEmpty(
normalizeMoneyText(readFieldText(obj, "claimedAmount")),
normalizeMoneyText(readFieldText(obj, "receiveAmount")),
normalizeMoneyText(readFieldText(obj, "receiverClaimedAmount")),
normalizeMoneyText(readFieldText(obj, "claimAmount")),
normalizeMoneyText(readFieldText(obj, "amount")));
if (TextUtils.isEmpty(name) || TextUtils.isEmpty(amount)) {
return null;
}
if (name.length() > 64 || looksLikeMoneyJson(name)) {
return null;
}
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"),
readFieldText(obj, "receiveTime"),
readFieldText(obj, "gmtClaim"),
readFieldText(obj, "gmtModified"),
readFieldText(obj, "gmtModify"),
readFieldText(obj, "gmtCreate"),
readFieldText(obj, "createTime"),
readFieldText(obj, "time"));
return line;
}
/** 读取字段:兼容 Money 对象 / 嵌套 JSON / 普通字符串。 */
private static String readFieldText(JSONObject obj, String key) {
if (obj == null || TextUtils.isEmpty(key) || !obj.has(key)) {
return null;
}
Object raw = obj.opt(key);
if (raw == null || raw == JSONObject.NULL) {
return null;
}
if (raw instanceof JSONObject) {
return normalizeMoneyText(((JSONObject) raw).toString());
}
String text = String.valueOf(raw).trim();
if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
return null;
}
if (looksLikeMoneyJson(text)) {
return normalizeMoneyText(text);
}
return text;
}
private static boolean looksLikeMoneyJson(String text) {
if (TextUtils.isEmpty(text)) {
return false;
}
String t = text.trim();
return t.startsWith("{") && t.contains("\"amount\"");
}
private static String normalizeMoneyText(String text) {
if (TextUtils.isEmpty(text) || "null".equalsIgnoreCase(text)) {
return null;
}
String t = text.trim().replace("RM", "").trim();
if (looksLikeMoneyJson(t)) {
try {
JSONObject money = new JSONObject(t);
String amount = money.optString("amount", null);
if (!TextUtils.isEmpty(amount) && !"null".equalsIgnoreCase(amount)) {
return amount.trim();
}
String cent = money.optString("cent", null);
if (!TextUtils.isEmpty(cent) && !"null".equalsIgnoreCase(cent)) {
try {
return String.format(Locale.US, "%.2f", Integer.parseInt(cent) / 100.0);
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return null;
}
// 纯数字或 1.00
try {
return String.format(Locale.US, "%.2f", Double.parseDouble(t.replace(",", "")));
} catch (Throwable ignored) {
return t;
}
}
private static String buildJsonFromObject(Object root) {
JSONObject json = objectToJson(root, new HashSet<Integer>(), 0);
return json != null ? json.toString() : null;
}
private static JSONObject objectToJson(Object obj, Set<Integer> visited, int depth) {
if (obj == null || depth > 6) {
return null;
}
if (obj instanceof String || obj instanceof Number || obj instanceof Boolean) {
JSONObject wrap = new JSONObject();
try {
wrap.put("value", obj);
} catch (Throwable ignored) {
}
return wrap;
}
int identity = System.identityHashCode(obj);
if (visited.contains(identity)) {
return null;
}
visited.add(identity);
JSONObject out = new JSONObject();
Class<?> clazz = obj.getClass();
if (clazz.isArray()) {
JSONArray arr = new JSONArray();
int len = Array.getLength(obj);
for (int i = 0; i < len; i++) {
Object elem = Array.get(obj, i);
JSONObject child = objectToJson(elem, visited, depth + 1);
if (child != null) {
arr.put(child);
}
}
try {
out.put("array", arr);
} catch (Throwable ignored) {
}
return out;
}
if (obj instanceof Iterable) {
JSONArray arr = new JSONArray();
for (Object elem : (Iterable<?>) obj) {
JSONObject child = objectToJson(elem, visited, depth + 1);
if (child != null) {
arr.put(child);
}
}
try {
out.put("receiverList", arr);
} catch (Throwable ignored) {
}
return out;
}
for (Field field : clazz.getDeclaredFields()) {
String fname = field.getName();
if (fname.contains("$") || fname.startsWith("CREATOR")) {
continue;
}
try {
field.setAccessible(true);
Object val = field.get(obj);
if (val == null) {
continue;
}
if (isPrimitiveLike(val)) {
out.put(fname, String.valueOf(val));
} else if (val instanceof Iterable || val.getClass().isArray()) {
JSONArray arr = new JSONArray();
if (val instanceof Iterable) {
for (Object elem : (Iterable<?>) val) {
putJsonValue(arr, elem, visited, depth + 1);
}
} else {
int len = Array.getLength(val);
for (int i = 0; i < len; i++) {
putJsonValue(arr, Array.get(val, i), visited, depth + 1);
}
}
out.put(fname, arr);
} else if (val.getClass().getName().startsWith("my.com.tngdigital")
|| val.getClass().getName().contains("Mmp")) {
JSONObject child = objectToJson(val, visited, depth + 1);
if (child != null) {
out.put(fname, child);
}
} else if (isPrimitiveLikeViaGetter(obj, fname)) {
out.put(fname, String.valueOf(val));
}
} catch (Throwable ignored) {
}
}
return out.length() > 0 ? out : null;
}
private static void putJsonValue(JSONArray arr, Object elem, Set<Integer> visited, int depth) {
if (elem == null) {
return;
}
if (isPrimitiveLike(elem)) {
arr.put(String.valueOf(elem));
return;
}
JSONObject child = objectToJson(elem, visited, depth);
if (child != null) {
arr.put(child);
}
}
private static boolean isPrimitiveLike(Object val) {
return val instanceof String
|| val instanceof Number
|| val instanceof Boolean
|| val instanceof Character;
}
private static boolean isPrimitiveLikeViaGetter(Object obj, String fieldName) {
try {
String suffix = fieldName.substring(0, 1).toUpperCase(Locale.US) + fieldName.substring(1);
for (String prefix : new String[]{"get", "is"}) {
try {
Method m = obj.getClass().getMethod(prefix + suffix);
Class<?> rt = m.getReturnType();
return rt == String.class || Number.class.isAssignableFrom(rt)
|| rt == boolean.class || rt == Boolean.class;
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
}
return false;
}
private static boolean shouldForwardPacket(MmpSnapshot snapshot) {
String packetId = snapshot.packetId;
if (TextUtils.isEmpty(packetId)) {
packetId = "fp" + Integer.toHexString(snapshot.dedupKey().hashCode());
snapshot.packetId = packetId;
}
String claimsFp = snapshot.claimsFingerprint();
long now = System.currentTimeMillis();
long dedupMs = sPacketDedupMs;
synchronized (RECENT_PACKET_AT) {
Long lastAt = RECENT_PACKET_AT.get(packetId);
String lastClaims = RECENT_PACKET_CLAIMS.get(packetId);
if (lastAt != null && lastClaims != null
&& claimsFp.equals(lastClaims)
&& now - lastAt < dedupMs) {
XposedBridge.log(TAG + " skip dup activityId=" + packetId
+ " ageMs=" + (now - lastAt));
return false;
}
RECENT_PACKET_AT.put(packetId, now);
RECENT_PACKET_CLAIMS.put(packetId, claimsFp);
// 顺带清理过期项,防止无限涨
if (RECENT_PACKET_AT.size() > DEDUP_SIZE) {
java.util.Iterator<java.util.Map.Entry<String, Long>> it =
RECENT_PACKET_AT.entrySet().iterator();
while (it.hasNext()) {
java.util.Map.Entry<String, Long> e = it.next();
if (now - e.getValue() > Math.max(dedupMs, 1L) * 3) {
String k = e.getKey();
it.remove();
RECENT_PACKET_CLAIMS.remove(k);
}
}
}
}
return true;
}
private static boolean remember(String key) {
if (TextUtils.isEmpty(key)) {
return false;
}
if (RECENT_SET.contains(key)) {
return false;
}
RECENT_SET.add(key);
RECENT_KEYS.addLast(key);
while (RECENT_KEYS.size() > DEDUP_SIZE) {
String old = RECENT_KEYS.removeFirst();
RECENT_SET.remove(old);
}
return true;
}
private static String firstNonEmpty(String... values) {
if (values == null) {
return null;
}
for (String v : values) {
if (!TextUtils.isEmpty(v) && !"null".equalsIgnoreCase(v)) {
return v;
}
}
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);
Object app = XposedHelpers.callStaticMethod(activityThread, "currentApplication");
if (app instanceof Context) {
return (Context) app;
}
} catch (Throwable ignored) {
}
return null;
}
private static final class MmpSnapshot {
String packetId;
String groupId;
String title;
String senderName;
String totalAmount;
/** 发放时间(历史 createTime 或详情里的 createTime */
String issueTime;
String remainingCount;
String claimedCount;
String totalCount;
String activityStatus;
String expireTime;
String claimedAmountText;
boolean finished;
final List<ClaimLine> claims = new ArrayList<>();
String dedupKey() {
StringBuilder sb = new StringBuilder();
sb.append(TextUtils.isEmpty(packetId) ? "?" : packetId).append('|');
sb.append(claimsFingerprint());
return sb.toString();
}
String claimsFingerprint() {
StringBuilder sb = new StringBuilder();
for (ClaimLine line : claims) {
sb.append(line.nickname).append('=').append(line.amount).append(';');
}
return sb.toString();
}
void applyCachedIssueTime() {
if (!TextUtils.isEmpty(issueTime) || TextUtils.isEmpty(packetId)) {
return;
}
synchronized (ACTIVITY_ISSUE_TIME) {
String cached = ACTIVITY_ISSUE_TIME.get(packetId);
if (!TextUtils.isEmpty(cached)) {
issueTime = cached;
}
}
}
String formatForForward(String channel, String sourceHint) {
applyCachedIssueTime();
if (!finished) {
finished = evaluateFinished(this);
}
// 详情无发放时间时,用最早领取时间兜底(仍优于拉取时间)
if (TextUtils.isEmpty(issueTime)) {
for (ClaimLine line : claims) {
if (!TextUtils.isEmpty(line.claimTime)) {
if (TextUtils.isEmpty(issueTime)
|| line.claimTime.compareTo(issueTime) < 0) {
issueTime = line.claimTime;
}
}
}
}
StringBuilder sb = new StringBuilder();
sb.append("[MMP统计]");
if (!TextUtils.isEmpty(packetId)) {
sb.append(" packet=").append(sanitizeMeta(packetId));
}
if (!TextUtils.isEmpty(groupId)) {
sb.append(" | group=").append(sanitizeMeta(groupId));
}
if (!TextUtils.isEmpty(senderName)) {
sb.append(" | sender=").append(sanitizeMeta(senderName));
}
if (!TextUtils.isEmpty(totalAmount)) {
sb.append(" | total=").append(sanitizeMeta(totalAmount));
}
if (!TextUtils.isEmpty(issueTime)) {
sb.append(" | issued=").append(sanitizeMeta(issueTime));
}
if (!TextUtils.isEmpty(expireTime)) {
sb.append(" | expire=").append(sanitizeMeta(expireTime));
}
if (!TextUtils.isEmpty(activityStatus)) {
sb.append(" | status=").append(sanitizeMeta(activityStatus));
}
String claimed = claimedCount;
String totalCnt = totalCount;
if (TextUtils.isEmpty(claimed) && claims != null) {
claimed = String.valueOf(claims.size());
}
if (!TextUtils.isEmpty(claimed) || !TextUtils.isEmpty(totalCnt)) {
sb.append(" | counts=").append(sanitizeMeta(nullToEmpty(claimed)))
.append('/').append(sanitizeMeta(nullToEmpty(totalCnt)));
}
if (finished) {
sb.append(" | done=1");
}
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;
sb.append(" | src=").append(sanitizeMeta(src));
}
sb.append("\n");
for (ClaimLine line : claims) {
sb.append(line.nickname).append(" -> ").append(line.amount);
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();
}
private static String sanitizeMeta(String value) {
if (value == null) {
return "";
}
// 避免空格拆坏 meta竖线是分隔符
return value.replace('|', '/').trim();
}
}
private static final class ClaimLine {
String nickname;
String receiverId;
String poolId;
String amount;
String claimTime;
}
}

View File

@@ -13,14 +13,20 @@ import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Process;
import android.view.Window;
import android.view.WindowManager;
import android.os.Message;
import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.Locale;
import java.util.Set;
import java.util.WeakHashMap;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
@@ -42,11 +48,6 @@ public final class TngRootBypassHook {
private static final String SECURITY_ERROR_ACTIVITY =
"my.com.tngdigital.common.security.ui.SecurityErrorActivity";
private static final String[] BOOLEAN_HOOK_CLASSES = {
"my.com.tngdigital.common.jailbrkendetect.JailBrokenApiImpl",
"my.com.tngdigital.common.jailbrkendetect.JailBrokenManager",
};
private static final String[] BLOCKED_SUPPORT_MARKERS = {
"36616543382169-rooting",
"36616508159769-emulator",
@@ -59,8 +60,6 @@ public final class TngRootBypassHook {
};
private static volatile long lastBlockedSuicideAt = 0L;
private static final long SOFT_CRASH_GUARD_MS = 10000L;
/** 用户已进入注册/登录后续页时,禁止 Splash 强拉回 Login。 */
private static volatile boolean registrationFlowActive = false;
private static final String[] REGISTRATION_FLOW_MARKERS = {
"GuideActivity",
@@ -82,6 +81,7 @@ public final class TngRootBypassHook {
"phonecheck", "com.abl.wallet.phone", "com.abl.wallet.otp",
"customer.registration", "customer.verify", "customer.login",
"login.options", "callingcode", "pin.token", "module.whitelist",
"secauth",
};
private TngRootBypassHook() {
@@ -91,7 +91,54 @@ public final class TngRootBypassHook {
return PACKAGE.equals(packageName);
}
private static final ThreadLocal<String> CURRENT_REQUEST_URL = new ThreadLocal<>();
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!isTargetPackage(lpparam.packageName)) {
return;
}
XposedBridge.log(TAG + " install for " + lpparam.packageName
+ " pid=" + Process.myPid()
+ " proc=" + getProcessName());
hookEarlyAttachLog(lpparam);
hookConscryptStatsLogGuard();
// 登录优先:藏 root + 拦自杀 Java 链 + bl#a/b不碰 native-bridge 探测短路。
hookSplashForceLogin(lpparam);
hookLoginDismissSplash(lpparam);
// 点区号会走 onCountryClick → i7.l loading真 show → HWUI setName/gralloc abort
hookLoadingDialogSkip();
// setName 一律 noopA16+Zygisk 下 native setName→dlopen libandroid 易 ART abort
// 区号 Compose 必须 HW软件绘制会 IllegalArgumentException(hardware bitmaps)。
hookHardwareRendererSetNameNoop();
hookCallingCodeAllowHwSurface(lpparam);
RootBypassHelper.hookFileExists(lpparam);
RootBypassHelper.hookRuntimeExec(lpparam);
RootBypassHelper.hookSystemGetProperty(lpparam);
ProcMapsFilterHook.install(lpparam);
hookAntiSuicide();
hookPromonSuicideUpstream(lpparam);
hookUncaughtPromonException(lpparam);
hookKillApplicationHandler(lpparam);
hookBlockSecurityErrorLaunch(lpparam);
hookPromonNativeGuard(lpparam);
hookPromonLifecycle(lpparam);
hookActivityThreadExit(lpparam);
hookFinishAllActivityAndKillApp(lpparam);
hookSecurityUrlOpeners(lpparam);
hookJailBroken(lpparam);
hookJailBrokenRpc(lpparam);
hookAppSecurityManager(lpparam);
hookAppSecurityCallbacks(lpparam);
hookSecurityErrorActivity(lpparam);
// 首页强制 eKYC「验证您的帐户」— 测试期直接跳过
hookHomeEkycVerifySkip(lpparam);
// seccomp 开着时必须 stub TigerTally init否则 fork getprop 永不退出 → App.onCreate ANR
hookTigerTally(lpparam);
hookTigerTallyAppWrappers(lpparam);
XposedBridge.log(TAG + " login-first hooks armed");
}
/** 进程启动最早打点,便于确认 LSPosed 是否注入(注册闪退常因 hook 未生效)。 */
private static void hookEarlyAttachLog(XC_LoadPackage.LoadPackageParam lpparam) {
@@ -112,58 +159,54 @@ public final class TngRootBypassHook {
}
}
/** Splash 优先生效;新 schedule 会取消旧 RunnableApplication 兜底 vs Splash 2000ms。 */
/** Splash 卡住救援;新 schedule 会取消旧 Runnable。 */
private static final Handler FORCE_LOGIN_HANDLER = new Handler(Looper.getMainLooper());
private static Runnable pendingForceLoginRunnable;
/** Promon native-bridge short-circuit 重入保护,避免 __cxa_guard_acquire 递归 abort。 */
private static final ThreadLocal<Integer> PROMON_BRIDGE_DEPTH = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return 0;
}
};
/** Splash 已离开(进 PIN/首页等)则取消救援。 */
private static volatile boolean splashNavigationDone = false;
private static void scheduleForceLoginToUserLogin(
/**
* 仅当 Splash 超时仍停在自身时才救援,避免已登录冷启动被强拉回 UserLogin。
* 有本地会话痕迹 → 优先 UserPin否则 → UserLogin。
*/
private static void scheduleSplashStuckRescue(
final Context appCtx, final Activity splashAct, final String reason, final long delayMs) {
if (appCtx == null) {
return;
}
splashNavigationDone = false;
if (pendingForceLoginRunnable != null) {
FORCE_LOGIN_HANDLER.removeCallbacks(pendingForceLoginRunnable);
}
final String login = "my.com.tngdigital.user.view.UserLoginActivity";
pendingForceLoginRunnable = new Runnable() {
@Override
public void run() {
pendingForceLoginRunnable = null;
try {
if (registrationFlowActive || isTopActivityRegistrationFlow(appCtx)) {
XposedBridge.log(TAG + " skip force login (" + reason + ", registration flow)");
if (splashNavigationDone) {
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", already left)");
return;
}
ActivityManager am =
(ActivityManager) appCtx.getSystemService(Context.ACTIVITY_SERVICE);
if (am != null) {
for (ActivityManager.AppTask task : am.getAppTasks()) {
ActivityManager.RecentTaskInfo info = task.getTaskInfo();
if (info == null || info.topActivity == null) {
continue;
}
String top = info.topActivity.getClassName();
if (top.endsWith(".UserLoginActivity")
|| top.endsWith(".UserPinActivity")
|| isRegistrationFlowActivity(top)) {
XposedBridge.log(TAG + " skip force login (" + reason + ", on " + top + ")");
return;
}
}
if (isTopActivityRegistrationFlow(appCtx)) {
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", registration flow)");
return;
}
String top = getTopActivityClassName(appCtx);
if (top != null && !isSplashActivityName(top)) {
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", on " + top + ")");
splashNavigationDone = true;
return;
}
if (!isUiVisibleForForceLogin(appCtx)) {
XposedBridge.log(TAG + " skip force login (" + reason + ", no visible UI / BAL)");
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", no visible UI / BAL)");
return;
}
boolean hasSession = hasLocalLoginSession(appCtx);
String target = hasSession
? "my.com.tngdigital.user.view.UserPinActivity"
: "my.com.tngdigital.user.view.UserLoginActivity";
Intent intent = new Intent();
intent.setClassName(PACKAGE, login);
intent.setClassName(PACKAGE, target);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
if (splashAct != null && !splashAct.isFinishing()) {
@@ -172,20 +215,141 @@ public final class TngRootBypassHook {
splashAct.finish();
} catch (Throwable ignored) {
}
XposedBridge.log(TAG + " forced → UserLogin (" + reason + ", from Splash)");
XposedBridge.log(TAG + " splash stuck → " + shortActivityName(target)
+ " (" + reason + ", session=" + hasSession + ", from Splash)");
} else {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
appCtx.startActivity(intent);
XposedBridge.log(TAG + " forced → UserLogin (" + reason + ", from AppCtx)");
XposedBridge.log(TAG + " splash stuck → " + shortActivityName(target)
+ " (" + reason + ", session=" + hasSession + ", from AppCtx)");
}
splashNavigationDone = true;
} catch (Throwable t) {
XposedBridge.log(TAG + " force login failed (" + reason + "): " + t.getMessage());
XposedBridge.log(TAG + " splash rescue failed (" + reason + "): " + t.getMessage());
}
}
};
FORCE_LOGIN_HANDLER.postDelayed(pendingForceLoginRunnable, delayMs);
}
private static String shortActivityName(String className) {
if (className == null) {
return "?";
}
int dot = className.lastIndexOf('.');
return dot >= 0 ? className.substring(dot + 1) : className;
}
private static boolean isSplashActivityName(String className) {
return className != null
&& (className.endsWith(".SplashActivity") || className.contains(".SplashActivity"));
}
private static String getTopActivityClassName(Context ctx) {
try {
ActivityManager am = (ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) {
return null;
}
for (ActivityManager.AppTask task : am.getAppTasks()) {
ActivityManager.RecentTaskInfo info = task.getTaskInfo();
if (info == null || info.topActivity == null) {
continue;
}
if (PACKAGE.equals(info.topActivity.getPackageName())) {
return info.topActivity.getClassName();
}
}
} catch (Throwable ignored) {
}
return null;
}
/**
* 粗判本地是否已有登录痕迹(有则冷启动应走 PIN而不是登录页
* SharedPreferences 文件名/键含 session、token、user、pin、login 等即视为已登录。
*/
private static boolean hasLocalLoginSession(Context ctx) {
try {
File prefsDir = new File(ctx.getApplicationInfo().dataDir, "shared_prefs");
if (!prefsDir.isDirectory()) {
return false;
}
File[] files = prefsDir.listFiles();
if (files == null) {
return false;
}
for (File f : files) {
String name = f.getName().toLowerCase(Locale.US);
if (!name.endsWith(".xml")) {
continue;
}
if (name.contains("session") || name.contains("token") || name.contains("user")
|| name.contains("login") || name.contains("account")
|| name.contains("auth") || name.contains("pin")
|| name.contains("credential") || name.contains("wallet")) {
if (f.length() > 64) {
return true;
}
}
// 扫键名:任意 prefs 里出现登录相关 key
if (prefsXmlLooksLikeLoggedIn(f)) {
return true;
}
}
} catch (Throwable t) {
XposedBridge.log(TAG + " hasLocalLoginSession failed: " + t.getMessage());
}
return false;
}
private static boolean prefsXmlLooksLikeLoggedIn(File xmlFile) {
java.io.BufferedReader reader = null;
try {
reader = new java.io.BufferedReader(new java.io.FileReader(xmlFile));
String line;
int lines = 0;
while ((line = reader.readLine()) != null && lines < 200) {
lines++;
String lower = line.toLowerCase(Locale.US);
if ((lower.contains("name=\"") || lower.contains("name='"))
&& (lower.contains("token") || lower.contains("session")
|| lower.contains("userid") || lower.contains("user_id")
|| lower.contains("loginid") || lower.contains("mobile")
|| lower.contains("phonenumber") || lower.contains("islogin")
|| lower.contains("logged") || lower.contains("access_token"))) {
// 排除空值
if (lower.contains(">true<") || lower.contains("value=\"true\"")
|| (lower.contains("value=\"") && !lower.contains("value=\"\"")
&& !lower.contains("value=\"0\"") && !lower.contains("value=\"false\""))
|| (lower.contains(">") && lower.contains("</string>")
&& !lower.contains("><"))) {
return true;
}
if (lower.contains("<string") && lower.contains("</string>")) {
int a = lower.indexOf('>');
int b = lower.lastIndexOf("</string>");
if (a >= 0 && b > a + 1 && (b - a) > 8) {
return true;
}
}
if (lower.contains("<boolean") && lower.contains("value=\"true\"")) {
return true;
}
}
}
} catch (Throwable ignored) {
} finally {
if (reader != null) {
try {
reader.close();
} catch (Throwable ignored) {
}
}
}
return false;
}
/** 后台 Service 重启(如 Firebase SessionLifecycle无可见 Activity强拉会被 BAL 拦截。 */
private static boolean isUiVisibleForForceLogin(Context ctx) {
ActivityManager.RunningAppProcessInfo state = new ActivityManager.RunningAppProcessInfo();
@@ -209,350 +373,177 @@ public final class TngRootBypassHook {
return state.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND;
}
private static final String[] HTTP_LOG_MARKERS = {
"otp", "verify", "pin", "register", "auth", "login", "sms", "mobile",
"risk", "token", "error", "code", "unexpected", "reference",
};
public static void install(XC_LoadPackage.LoadPackageParam lpparam) {
if (!isTargetPackage(lpparam.packageName)) {
return;
}
XposedBridge.log(TAG + " install for " + lpparam.packageName
+ " pid=" + Process.myPid()
+ " proc=" + getProcessName());
// Splash 常卡死Instrumentation 强拉即可。双通道会抢跑导致 Login 重载/卡死。
hookEarlyAttachLog(lpparam);
hookRegistrationFlowGuard(lpparam);
hookSplashForceLogin(lpparam);
hookLoginDismissSplash(lpparam);
hookHardwareRendererSetName();
hookBottomSelectDialogDiag(lpparam);
hookActivityLifecycleDiag(lpparam);
hookPromonApService(lpparam);
hookPromonBroadcastReceiver(lpparam);
hookAppAttachForceLogin(lpparam);
RootBypassHelper.hookFileExists(lpparam);
RootBypassHelper.hookRuntimeExec(lpparam);
RootBypassHelper.hookSystemGetProperty(lpparam);
ProcMapsFilterHook.install(lpparam);
hookAntiSuicide();
hookUncaughtPromonException(lpparam);
hookKillApplicationHandler(lpparam);
hookBlockSecurityErrorLaunch(lpparam);
hookPromonNativeGuard(lpparam);
hookPromonLifecycle(lpparam);
hookJnicLibrary(lpparam);
hookTigerTally(lpparam);
hookActivityThreadExit(lpparam);
hookForceExitFlow(lpparam);
hookFinishAllActivityAndKillApp(lpparam);
hookShowSecurityScreenForState(lpparam);
hookSecurityUrlOpeners(lpparam);
hookJailBroken(lpparam);
hookJailBrokenRpc(lpparam);
hookAppSecurityManager(lpparam);
hookAppSecurityCallbacks(lpparam);
hookPromonNativeBridge(lpparam);
hookSecurityBooleanChecks(lpparam);
hookSecurityErrorActivity(lpparam);
hookNetworkDiag(lpparam);
hookWebViewErrorDiag(lpparam);
}
private static void logActivityDiag(String phase, Activity activity) {
if (activity == null) {
return;
}
String name = activity.getClass().getName();
String lower = name.toLowerCase(Locale.US);
if (lower.contains("userpin")
|| lower.contains("userlogin")
|| lower.contains("registration")
|| lower.contains("otp")
|| lower.contains("verify")
|| lower.contains("sms")
|| lower.contains("webview")
|| lower.contains("issue")
|| lower.contains("guide")
|| lower.contains("error")
|| lower.contains("dialog")) {
XposedBridge.log(TAG + " ACT " + phase + " " + name);
}
}
/** 记录 OTP/登录相关 HTTP 请求与响应体,定位验证码提交失败原因。 */
private static void hookNetworkDiag(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"okhttp3.Request$Builder",
lpparam.classLoader,
"build",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
try {
Object url = XposedHelpers.callMethod(param.getResult(), "url");
if (url != null) {
CURRENT_REQUEST_URL.set(String.valueOf(url));
}
} catch (Throwable ignored) {
}
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " diag Request.Builder.build failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
"okhttp3.ResponseBody",
lpparam.classLoader,
"string",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String body = (String) param.getResult();
String url = CURRENT_REQUEST_URL.get();
CURRENT_REQUEST_URL.remove();
if (body == null) {
return;
}
if (!shouldLogHttp(url, body)) {
return;
}
String snippet = body.length() > 800
? body.substring(0, 800) + "..." : body;
XposedBridge.log(TAG + " HTTP rsp"
+ (url != null ? " " + url : "")
+ " body=" + snippet);
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " diag ResponseBody.string failed: " + t.getMessage());
}
XC_MethodHook callRequestHook = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
try {
Object req = XposedHelpers.callMethod(param.thisObject, "request");
logHttpRequest(lpparam.classLoader, req);
} catch (Throwable ignored) {
}
}
};
XC_MethodHook enqueueHook = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
try {
Object req = XposedHelpers.callMethod(param.thisObject, "request");
logHttpRequest(lpparam.classLoader, req);
} catch (Throwable ignored) {
}
}
};
/**
* Zygisk/命名空间下 android.util.StatsLog native 常 UnsatisfiedLinkError
* Conscrypt TLS 指标线程一写就炸 → ART fatal。直接 noop 指标写入。
*/
private static void hookConscryptStatsLogGuard() {
int hooked = 0;
for (String className : new String[]{
"okhttp3.RealCall", "okhttp3.internal.connection.RealCall"}) {
"com.android.org.conscrypt.metrics.StatsLogImpl",
"com.android.org.conscrypt.metrics.ConscryptStatsLog",
"com.google.android.gms.org.conscrypt.metrics.StatsLogImpl",
}) {
try {
XposedHelpers.findAndHookMethod(
className, lpparam.classLoader, "execute", callRequestHook);
XposedHelpers.findAndHookMethod(
className, lpparam.classLoader, "enqueue",
"okhttp3.Callback", enqueueHook);
Class<?> clazz = Class.forName(className);
for (Method method : clazz.getDeclaredMethods()) {
String n = method.getName();
if (!n.startsWith("write")
&& !n.startsWith("report")
&& !n.startsWith("count")
&& !"startWriterThread".equals(n)) {
continue;
}
hookMethodNoopSilent(method);
hooked++;
}
} catch (Throwable ignored) {
}
}
XposedBridge.log(TAG + " hooked network diag (okhttp)");
try {
Class<?> statsLog = Class.forName("android.util.StatsLog");
for (Method method : statsLog.getDeclaredMethods()) {
String n = method.getName();
if ("loadNativeLibrary".equals(n) || n.startsWith("write")) {
hookMethodNoopSilent(method);
hooked++;
}
}
} catch (Throwable ignored) {
}
XposedBridge.log(TAG + " conscrypt/StatsLog guard hooked n=" + hooked);
}
private static void logHttpRequest(ClassLoader loader, Object req) {
if (req == null) {
return;
}
Object url = XposedHelpers.callMethod(req, "url");
if (url == null) {
return;
}
String urlStr = String.valueOf(url);
if (!shouldLogHttpUrl(urlStr)) {
return;
}
XposedBridge.log(TAG + " HTTP req " + urlStr);
Object body = XposedHelpers.callMethod(req, "body");
if (body != null) {
logRequestBodySnippet(loader, body);
}
/**
* 区号 Compose Activity 生命周期标记。
* 必须在 execStart/onCreate 前设为 true供 enableHardwareAcceleration 放行。
*/
private static volatile boolean callingCodeSurfaceActive = false;
private static boolean isCaptchaDialog(String owner) {
return owner != null
&& (owner.contains("CaptchaWebViewDialog") || owner.contains("com.aliyun.captcha"));
}
private static boolean shouldLogHttpUrl(String url) {
if (url == null) {
private static boolean isLoadingDialog(String owner) {
return owner != null
&& (owner.contains("i7.") || owner.contains("Loading") || owner.contains("Progress"));
}
private static boolean isCallingCodeActivity(String className) {
return className != null && className.contains("UserSearchCallingCodeActivity");
}
private static boolean isCallingCodeIntent(Intent intent) {
if (intent == null) {
return false;
}
String lower = url.toLowerCase(Locale.US);
return lower.contains("tngdigital")
|| lower.contains("alipay")
|| lower.contains("aliyun")
|| lower.contains("otp")
|| lower.contains("verify")
|| lower.contains("register")
|| lower.contains("auth")
|| lower.contains("login")
|| lower.contains("pin")
|| lower.contains("sms");
}
private static boolean shouldLogHttp(String url, String body) {
if (shouldLogHttpUrl(url)) {
return true;
}
String lower = body.toLowerCase(Locale.US);
for (String marker : HTTP_LOG_MARKERS) {
if (lower.contains(marker)) {
if (intent.getComponent() != null) {
String cn = intent.getComponent().getClassName();
if (isCallingCodeActivity(cn)) {
return true;
}
}
return lower.contains("\"code\"") || lower.contains("reference");
String action = intent.getAction();
return action != null && action.contains("CallingCode");
}
private static void logRequestBodySnippet(ClassLoader loader, Object body) {
try {
Class<?> bufferClass = XposedHelpers.findClass("okio.Buffer", loader);
Object buffer = XposedHelpers.newInstance(bufferClass);
XposedHelpers.callMethod(body, "writeTo", buffer);
String text = (String) XposedHelpers.callMethod(buffer, "readUtf8");
if (text == null || text.isEmpty()) {
return;
private static String shortStack(int maxFrames) {
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
StringBuilder sb = new StringBuilder();
int n = 0;
for (StackTraceElement el : stack) {
String cn = el.getClassName();
if (cn.startsWith("java.") || cn.startsWith("dalvik.")
|| cn.startsWith("android.os.") || cn.startsWith("de.robv.android.xposed")
|| cn.contains("TngRootBypassHook") || cn.contains("LSPosed")
|| cn.contains("XposedBridge")) {
continue;
}
if (n > 0) {
sb.append(" <- ");
}
sb.append(el.getClassName()).append("#").append(el.getMethodName())
.append(":").append(el.getLineNumber());
if (++n >= maxFrames) {
break;
}
String snippet = text.length() > 500 ? text.substring(0, 500) + "..." : text;
XposedBridge.log(TAG + " HTTP req body=" + snippet);
} catch (Throwable ignored) {
}
return sb.length() == 0 ? "(empty)" : sb.toString();
}
private static void hookWebViewErrorDiag(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
"android.webkit.WebViewClient",
lpparam.classLoader,
"onReceivedError",
"android.webkit.WebView",
"android.webkit.WebResourceRequest",
"android.webkit.WebResourceError",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
try {
Object error = param.args[2];
Object code = XposedHelpers.callMethod(error, "getDescription");
Object url = XposedHelpers.callMethod(param.args[1], "getUrl");
XposedBridge.log(TAG + " WebView error url=" + url + " desc=" + code);
} catch (Throwable t) {
XposedBridge.log(TAG + " WebView error: " + t.getMessage());
}
}
});
XposedBridge.log(TAG + " hooked WebViewClient.onReceivedError");
} catch (Throwable t) {
XposedBridge.log(TAG + " WebView error hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
Dialog.class,
"show",
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String owner = param.thisObject.getClass().getName();
if (owner.contains("TNG") || owner.contains("Dialog")
|| owner.contains("Error") || owner.contains("i7.")) {
XposedBridge.log(TAG + " Dialog.show " + owner);
}
}
});
} catch (Throwable ignored) {
}
}
/** 仅记录注册/登录链 Activity 生命周期,便于 logcat 定位卡点。 */
private static void hookActivityLifecycleDiag(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(Application.class, "onCreate", new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Context ctx = (Context) param.thisObject;
if (ctx == null || !PACKAGE.equals(ctx.getPackageName())) {
return;
}
Application app = (Application) param.thisObject;
app.registerActivityLifecycleCallbacks(
new Application.ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
logActivityDiag("onCreate", activity);
}
@Override
public void onActivityStarted(Activity activity) {
}
@Override
public void onActivityResumed(Activity activity) {
logActivityDiag("onResume", activity);
}
@Override
public void onActivityPaused(Activity activity) {
}
@Override
public void onActivityStopped(Activity activity) {
}
@Override
public void onActivitySaveInstanceState(
Activity activity, Bundle bundle) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
});
XposedBridge.log(TAG + " registered activity lifecycle diag");
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " activity lifecycle diag failed: " + t.getMessage());
}
}
/** 已 skip show 的 loading Dialog让 isShowing=true 避免业务层卡死。 */
private static final Set<Dialog> skippedLoadingDialogs =
Collections.newSetFromMap(new WeakHashMap<>());
/**
* 区号选择弹窗诊断。勿关 HW 加速——Android 16 上会导致列表黑屏
* ANR 由 HardwareRenderer.setName 拦截兜底
* i7.l 等 loading一律 skip show + 伪装 isShowing
* Captcha 弹窗清 HW flag避免 Android 16 gralloc abort
*/
private static void hookBottomSelectDialogDiag(XC_LoadPackage.LoadPackageParam lpparam) {
private static void hookLoadingDialogSkip() {
try {
Class<?> clazz = XposedHelpers.findClass(
"my.com.tngdigital.common.widget.BottomSelectDialogFragment",
lpparam.classLoader);
XposedHelpers.findAndHookMethod(clazz, "onStart", new XC_MethodHook() {
XposedHelpers.findAndHookMethod(Dialog.class, "show", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " BottomSelectDialogFragment.onStart");
Dialog dialog = (Dialog) param.thisObject;
String owner = dialog.getClass().getName();
if (isLoadingDialog(owner)) {
skippedLoadingDialogs.add(dialog);
XposedBridge.log(TAG + " Dialog.show skip loading " + owner
+ " callingCode=" + callingCodeSurfaceActive);
param.setResult(null);
return;
}
if (isCaptchaDialog(owner)) {
try {
Window cw = dialog.getWindow();
if (cw != null) {
cw.clearFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);
}
} catch (Throwable ignored) {
}
XposedBridge.log(TAG + " Dialog.show captcha software " + owner);
}
}
});
XposedBridge.log(TAG + " hooked BottomSelectDialogFragment.onStart (diag only)");
XposedBridge.log(TAG + " hooked Dialog.show (skip loading only)");
} catch (Throwable t) {
XposedBridge.log(TAG + " BottomSelectDialog hook failed: " + t.getMessage());
XposedBridge.log(TAG + " Dialog.show hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(Dialog.class, "isShowing", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (skippedLoadingDialogs.contains(param.thisObject)) {
param.setResult(true);
}
}
});
XposedBridge.log(TAG + " hooked Dialog.isShowing (skipped loading)");
} catch (Throwable t) {
XposedBridge.log(TAG + " Dialog.isShowing hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(Dialog.class, "dismiss", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String owner = param.thisObject.getClass().getName();
if (isLoadingDialog(owner)) {
skippedLoadingDialogs.remove(param.thisObject);
}
}
});
XposedBridge.log(TAG + " hooked Dialog.dismiss (loading cleanup)");
} catch (Throwable t) {
XposedBridge.log(TAG + " Dialog.dismiss hook failed: " + t.getMessage());
}
}
/**
* ANR 栈Dialog.show → enableHardwareAcceleration → HardwareRenderer.setName → future.get 卡死
* 仅拦截 setName勿全局关 Dialog HW区号选择会黑屏
* 一律跳过 HardwareRenderer.setName。
* Pixel/A16 + Zygisk 命名空间下 native setName → dlopen("libandroid.so") 失败会 ART abort
*/
private static void hookHardwareRendererSetName() {
private static void hookHardwareRendererSetNameNoop() {
try {
XposedHelpers.findAndHookMethod(
"android.graphics.HardwareRenderer", null, "setName", String.class,
@@ -562,114 +553,107 @@ public final class TngRootBypassHook {
param.setResult(null);
}
});
XposedBridge.log(TAG + " hooked HardwareRenderer.setName (blocked)");
XposedBridge.log(TAG + " hooked HardwareRenderer.setName (always noop)");
} catch (Throwable t) {
XposedBridge.log(TAG + " HardwareRenderer.setName hook failed: " + t.getMessage());
XposedBridge.log(TAG + " HardwareRenderer.setName noop failed: " + t.getMessage());
}
}
/** Promon 隔离 Service打点确认 :goacqowmmt 进程 hook 已注入。 */
private static void hookPromonApService(XC_LoadPackage.LoadPackageParam lpparam) {
Class<?> svc = findPromonClass(lpparam.classLoader, "ap");
if (svc == null) {
XposedBridge.log(TAG + " Promon ap Service not found");
return;
}
try {
final String svcName = svc.getName();
XposedHelpers.findAndHookMethod(svc, "onCreate", new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " " + svcName + " onCreate pid=" + Process.myPid()
+ " proc=" + getProcessName());
/**
* 区号 Compose标记 surface + 保证 HW flag勿清、勿拦 enableHW
* 软件绘制会崩Software rendering doesn't support hardware bitmaps。
*/
private static void hookCallingCodeAllowHwSurface(XC_LoadPackage.LoadPackageParam lpparam) {
XC_MethodHook markOn = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Intent intent = extractIntent(param.args);
if (isCallingCodeIntent(intent)) {
callingCodeSurfaceActive = true;
XposedBridge.log(TAG + " callingCode surface ON (HW path)");
}
});
for (Method method : svc.getDeclaredMethods()) {
if (!"onStartCommand".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " " + svcName + " onStartCommand pid="
+ Process.myPid());
}
});
}
XposedBridge.log(TAG + " hooked " + svcName + " Service");
} catch (Throwable t) {
XposedBridge.log(TAG + " Promon ap Service hook failed: " + t.getMessage());
}
}
/** 标记注册链 Activity 活跃,防止 Splash 强拉 Login 清栈。 */
private static void hookRegistrationFlowGuard(XC_LoadPackage.LoadPackageParam lpparam) {
};
try {
XC_MethodHook flowGuardHook = new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.args[0];
if (activity == null) {
return;
}
String name = activity.getClass().getName();
if (isRegistrationFlowActivity(name)) {
registrationFlowActive = true;
if (pendingForceLoginRunnable != null) {
FORCE_LOGIN_HANDLER.removeCallbacks(pendingForceLoginRunnable);
pendingForceLoginRunnable = null;
XposedHelpers.findAndHookMethod(
Activity.class, "startActivity", Intent.class, markOn);
XposedHelpers.findAndHookMethod(
Activity.class, "startActivity", Intent.class, Bundle.class, markOn);
} catch (Throwable t) {
XposedBridge.log(TAG + " startActivity callingCode mark failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
Instrumentation.class,
"execStartActivity",
Context.class,
android.os.IBinder.class,
android.os.IBinder.class,
Activity.class,
Intent.class,
int.class,
Bundle.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Intent intent = (Intent) param.args[4];
if (isCallingCodeIntent(intent)) {
callingCodeSurfaceActive = true;
XposedBridge.log(TAG
+ " callingCode surface ON (execStart HW)");
}
}
XposedBridge.log(TAG + " registration flow active: " + name);
} else if (name.endsWith(".SplashActivity")) {
registrationFlowActive = false;
}
}
};
});
} catch (Throwable t) {
XposedBridge.log(TAG + " execStart callingCode mark failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
Instrumentation.class,
"callActivityOnCreate",
Activity.class,
Bundle.class,
flowGuardHook);
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.args[0];
if (activity == null) {
return;
}
if (isCallingCodeActivity(activity.getClass().getName())) {
callingCodeSurfaceActive = true;
try {
Window window = activity.getWindow();
if (window != null) {
window.addFlags(
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);
}
} catch (Throwable ignored) {
}
XposedBridge.log(TAG
+ " callingCode onCreate — keep HW for Compose");
}
}
});
XposedHelpers.findAndHookMethod(
Instrumentation.class,
"callActivityOnResume",
"callActivityOnDestroy",
Activity.class,
flowGuardHook);
hookLoginOptionsDiag(lpparam);
XposedBridge.log(TAG + " hooked registration flow guard");
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.args[0];
if (activity != null
&& isCallingCodeActivity(activity.getClass().getName())) {
callingCodeSurfaceActive = false;
XposedBridge.log(TAG + " callingCode surface OFF");
}
}
});
} catch (Throwable t) {
XposedBridge.log(TAG + " registration flow guard failed: " + t.getMessage());
}
}
/** Login 页 startLoginOptions 是进入注册/登录选项的网关 RPC 入口。 */
private static void hookLoginOptionsDiag(XC_LoadPackage.LoadPackageParam lpparam) {
final String login = "my.com.tngdigital.user.view.UserLoginActivity";
try {
Class<?> clazz = XposedHelpers.findClass(login, lpparam.classLoader);
for (Method method : clazz.getDeclaredMethods()) {
String name = method.getName();
if (!name.contains("LoginOptions") && !name.contains("loginOptions")
&& !name.contains("startLogin")) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " UserLoginActivity#" + name + " enter");
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " UserLoginActivity#" + name + " done");
}
});
}
XposedBridge.log(TAG + " hooked UserLoginActivity login-options diag");
} catch (Throwable t) {
XposedBridge.log(TAG + " login-options diag failed: " + t.getMessage());
XposedBridge.log(TAG + " callingCode lifecycle mark failed: " + t.getMessage());
}
XposedBridge.log(TAG + " callingCode allow-HW surface armed");
}
private static boolean isRegistrationFlowActivity(String className) {
@@ -717,35 +701,6 @@ public final class TngRootBypassHook {
return false;
}
/** Promon USB 广播 N 跑在主线程,复进时拖死 Looper。 */
private static void hookPromonBroadcastReceiver(XC_LoadPackage.LoadPackageParam lpparam) {
Class<?> clazz = findPromonClass(lpparam.classLoader, "N");
if (clazz == null) {
XposedBridge.log(TAG + " Promon N receiver not found");
return;
}
try {
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!"onReceive".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(null);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + clazz.getName() + " onReceive x" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " Promon N hook failed: " + t.getMessage());
}
}
/**
* 强拉 Login 后系统 Splash 遮罩常挂在 UserLogin 上windows=Splash Screen
* 导致复进「未响应」。onCreate/onResume 强制 dismiss。
@@ -829,7 +784,9 @@ public final class TngRootBypassHook {
}
/**
* Splash.onCreate 常被 Promon 堵死永远不返回;必须在 onCreate 入口before就调度强拉
* Splash 卡住救援:不立刻强拉 Login
* onCreate 只调度延迟检查;若已自行跳到 PIN/首页则取消。
* 超时仍停在 SplashPromon 堵死)才救援。
*/
private static void hookSplashForceLogin(XC_LoadPackage.LoadPackageParam lpparam) {
final String splash = "my.com.tngdigital.ewallet.ui.SplashActivity";
@@ -847,15 +804,20 @@ public final class TngRootBypassHook {
return;
}
String name = activity.getClass().getName();
if (!splash.equals(name) && !name.endsWith(".SplashActivity")) {
if (isSplashActivityName(name)) {
XposedBridge.log(TAG
+ " Splash.onCreate enter — schedule stuck rescue @4s");
scheduleSplashStuckRescue(
activity.getApplicationContext(),
activity,
"Splash/beforeOnCreate",
4000L);
return;
}
XposedBridge.log(TAG + " Splash.onCreate enter — schedule force login");
scheduleForceLoginToUserLogin(
activity.getApplicationContext(),
activity,
"Splash/beforeOnCreate",
2000L);
// 任何非 Splash Activity 创建 → 取消 Splash 救援
if (PACKAGE.equals(activity.getPackageName())) {
markSplashNavigationDone(name);
}
}
@Override
@@ -865,7 +827,7 @@ public final class TngRootBypassHook {
return;
}
String name = activity.getClass().getName();
if (!splash.equals(name) && !name.endsWith(".SplashActivity")) {
if (!isSplashActivityName(name)) {
return;
}
try {
@@ -875,126 +837,112 @@ public final class TngRootBypassHook {
dismissSplashScreen(activity);
}
});
XposedBridge.log(TAG + " hooked Instrumentation Splash force→UserLogin (before+after)");
XposedHelpers.findAndHookMethod(
Instrumentation.class,
"callActivityOnResume",
Activity.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.args[0];
if (activity == null) {
return;
}
String name = activity.getClass().getName();
if (PACKAGE.equals(activity.getPackageName())
&& !isSplashActivityName(name)) {
markSplashNavigationDone(name);
}
}
});
XposedBridge.log(TAG + " hooked Splash stuck-rescue (not always→Login)");
} catch (Throwable t) {
XposedBridge.log(TAG + " Splash force hook failed: " + t.getMessage());
}
}
/** Application.onCreate 兜底Splash beforeHook 未触发时仍强拉 Login跳过纯 Service 进程)。 */
private static void hookAppAttachForceLogin(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
android.app.Application.class,
"onCreate",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
final Context appCtx = (Context) param.thisObject;
if (appCtx == null || !PACKAGE.equals(appCtx.getPackageName())) {
return;
}
String proc = getProcessName();
if (proc != null && proc.contains(":")) {
return;
}
XposedBridge.log(TAG + " Application.onCreate — schedule force login fallback");
scheduleForceLoginToUserLogin(appCtx, null, "Application/onCreate", 3500L);
}
});
XposedBridge.log(TAG + " hooked Application.onCreate force→UserLogin fallback");
} catch (Throwable t) {
XposedBridge.log(TAG + " Application force hook failed: " + t.getMessage());
private static void markSplashNavigationDone(String activityName) {
if (splashNavigationDone) {
return;
}
splashNavigationDone = true;
if (pendingForceLoginRunnable != null) {
FORCE_LOGIN_HANDLER.removeCallbacks(pendingForceLoginRunnable);
pendingForceLoginRunnable = null;
XposedBridge.log(TAG + " cancel splash rescue — now on " + activityName);
}
}
private static volatile long lastSuicideLogAt = 0L;
private static volatile int suicideBlockCount = 0;
private static void hookAntiSuicide() {
XC_MethodHook blockSelfKill = new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (!shouldBlockThisSuicideCall(param)) {
return;
}
lastBlockedSuicideAt = System.currentTimeMillis();
suicideBlockCount++;
long now = lastBlockedSuicideAt;
if (now - lastSuicideLogAt > 3000L) {
lastSuicideLogAt = now;
XposedBridge.log(TAG + " blocked suicide #" + suicideBlockCount
+ " " + param.method.getDeclaringClass().getSimpleName()
+ "#" + param.method.getName()
+ argsSummary(param.args)
+ " thread=" + Thread.currentThread().getName()
+ " stack=" + shortStack(6));
}
param.setResult(null);
// 非主线程:打断自杀循环线程,避免只拦 kill 却卡死 UI
freezeSuicideCallerThread();
}
};
try {
XposedHelpers.findAndHookMethod(
Process.class,
"killProcess",
int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
if (((Integer) param.args[0]) == Process.myPid()) {
lastBlockedSuicideAt = System.currentTimeMillis();
XposedBridge.log(TAG + " blocked killProcess(self)");
param.setResult(null);
}
}
});
XposedHelpers.findAndHookMethod(Process.class, "killProcess", int.class, blockSelfKill);
} catch (Throwable t) {
XposedBridge.log(TAG + " killProcess hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
System.class,
"exit",
int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
lastBlockedSuicideAt = System.currentTimeMillis();
XposedBridge.log(TAG + " blocked System.exit(" + param.args[0] + ")");
param.setResult(null);
}
});
XposedHelpers.findAndHookMethod(System.class, "exit", int.class, blockSelfKill);
} catch (Throwable t) {
XposedBridge.log(TAG + " System.exit hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
Runtime.class,
"exit",
int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
lastBlockedSuicideAt = System.currentTimeMillis();
XposedBridge.log(TAG + " blocked Runtime.exit(" + param.args[0] + ")");
param.setResult(null);
}
});
XposedHelpers.findAndHookMethod(Runtime.class, "exit", int.class, blockSelfKill);
} catch (Throwable t) {
XposedBridge.log(TAG + " Runtime.exit hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
Runtime.class,
"halt",
int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
lastBlockedSuicideAt = System.currentTimeMillis();
XposedBridge.log(TAG + " blocked Runtime.halt(" + param.args[0] + ")");
param.setResult(null);
}
});
XposedHelpers.findAndHookMethod(Runtime.class, "halt", int.class, blockSelfKill);
} catch (Throwable t) {
XposedBridge.log(TAG + " Runtime.halt hook failed: " + t.getMessage());
}
try {
XposedHelpers.findAndHookMethod(
Process.class,
"sendSignal",
int.class,
int.class,
Process.class, "sendSignal", int.class, int.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
int pid = (Integer) param.args[0];
int signal = (Integer) param.args[1];
if (pid == Process.myPid() && (signal == 9 || signal == 15)) {
lastBlockedSuicideAt = System.currentTimeMillis();
XposedBridge.log(TAG + " blocked sendSignal(self, " + signal + ")");
param.setResult(null);
if (pid != Process.myPid()) {
return;
}
if (signal != 9 && signal != 15 && signal != 6) {
return;
}
lastBlockedSuicideAt = System.currentTimeMillis();
suicideBlockCount++;
if (lastBlockedSuicideAt - lastSuicideLogAt > 3000L) {
lastSuicideLogAt = lastBlockedSuicideAt;
XposedBridge.log(TAG + " blocked sendSignal(self," + signal
+ ") #" + suicideBlockCount);
}
param.setResult(null);
freezeSuicideCallerThread();
}
});
} catch (Throwable t) {
@@ -1023,6 +971,109 @@ public final class TngRootBypassHook {
} catch (Throwable t) {
XposedBridge.log(TAG + " finish hooks failed: " + t.getMessage());
}
XposedBridge.log(TAG + " anti-suicide armed (rate-limited + freeze caller)");
}
private static boolean shouldBlockThisSuicideCall(XC_MethodHook.MethodHookParam param) {
// 真崩溃KillApplicationHandler放行避免僵尸进程卡 Splash/黑屏
if (stackHasClass("com.android.internal.os.RuntimeInit$KillApplicationHandler")) {
return false;
}
String name = param.method.getName();
if ("killProcess".equals(name)) {
return ((Integer) param.args[0]) == Process.myPid();
}
// System/Runtime.exit/halt一律拦TNG 正常退出极少走这里)
return true;
}
private static boolean stackHasClass(String className) {
try {
for (StackTraceElement e : Thread.currentThread().getStackTrace()) {
if (className.equals(e.getClassName())) {
return true;
}
}
} catch (Throwable ignored) {
}
return false;
}
private static String argsSummary(Object[] args) {
if (args == null || args.length == 0) {
return "()";
}
StringBuilder sb = new StringBuilder("(");
for (int i = 0; i < args.length; i++) {
if (i > 0) {
sb.append(",");
}
sb.append(args[i]);
}
return sb.append(")").toString();
}
private static void freezeSuicideCallerThread() {
Thread t = Thread.currentThread();
if (t.getId() == Looper.getMainLooper().getThread().getId()) {
return;
}
// 栈里有 Promon 包才打断,避免误伤业务线程
if (!stackHasPromon()) {
return;
}
try {
t.interrupt();
} catch (Throwable ignored) {
}
}
private static boolean stackHasPromon() {
try {
for (StackTraceElement e : Thread.currentThread().getStackTrace()) {
if (isPromonPackageClass(e.getClassName())) {
return true;
}
}
} catch (Throwable ignored) {
}
return false;
}
/**
* 自杀链上游:只补 AppSecurityManager 杀进程路径 noop。
* 勿拦 Handler.post / 勿 short-circuit G/H 等:会弄死 ContentProvider 初始化Splash 僵尸卡住。
* bl/R/a 仍由 hookPromonNativeGuard 处理。
*/
private static void hookPromonSuicideUpstream(XC_LoadPackage.LoadPackageParam lpparam) {
hookAppSecurityKillPaths(lpparam);
XposedBridge.log(TAG + " promon suicide upstream armed (kill-path only)");
}
/** AppSecurityManager 里明确杀进程/退出的路径直接 noop勿匹配 destroy/shutdown。 */
private static void hookAppSecurityKillPaths(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> clazz = XposedHelpers.findClass(
"my.com.tngdigital.common.security.shielding.AppSecurityManager",
lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
String lower = method.getName().toLowerCase(Locale.US);
if (!lower.contains("kill")
&& !lower.contains("exit")
&& !lower.contains("force")
&& !lower.contains("die")
&& !lower.contains("suicide")
&& !lower.contains("terminate")) {
continue;
}
hookMethodNoopSilent(method);
hooked++;
}
XposedBridge.log(TAG + " AppSecurityManager kill-path noop n=" + hooked);
} catch (Throwable t) {
XposedBridge.log(TAG + " AppSecurityManager kill-path failed: " + t.getMessage());
}
}
private static boolean isSecurityRelatedActivity(Activity activity) {
@@ -1123,7 +1174,7 @@ public final class TngRootBypassHook {
if (intent == null) {
return false;
}
if (isSecurityErrorIntent(intent)) {
if (isSecurityErrorIntent(intent) || isHomeEkycVerifyIntent(intent)) {
return true;
}
Uri data = intent.getData();
@@ -1137,6 +1188,19 @@ public final class TngRootBypassHook {
return false;
}
private static boolean isHomeEkycVerifyIntent(Intent intent) {
if (intent == null) {
return false;
}
if (intent.getComponent() != null) {
String cls = intent.getComponent().getClassName();
if (cls != null && cls.contains("HomeEkycVerify")) {
return true;
}
}
return false;
}
private static boolean isBlockedSupportUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
@@ -1164,23 +1228,19 @@ public final class TngRootBypassHook {
}
/**
* Promon lifecycle1.9.10 用 vhvlnqgy.u旧版用 w。只吞异常,不全拦。
* Promon lifecycle1.9.10 用 vhvlnqgy.u旧版用 w。
* 实测 afterHook 仍跑 native → DeleteLocalRef 损坏 → ART Runtime abort (signal 6)。
* 登录优先onActivity* / onApplication* 全部 noop不调原 native。
*/
private static void hookPromonLifecycle(XC_LoadPackage.LoadPackageParam lpparam) {
Class<?> promonExc = findPromonClass(lpparam.classLoader, "W");
if (promonExc == null) {
promonExc = findPromonClass(lpparam.classLoader, "bd");
}
final Class<?> promonExcFinal = promonExc;
for (String simple : new String[]{"w", "u"}) {
hookPromonLifecycleClass(lpparam, simple, promonExcFinal);
hookPromonLifecycleClass(lpparam, simple);
}
}
private static void hookPromonLifecycleClass(
XC_LoadPackage.LoadPackageParam lpparam,
String simpleName,
Class<?> promonExc) {
String simpleName) {
Class<?> lifecycleClass = findPromonClass(lpparam.classLoader, simpleName);
if (lifecycleClass == null) {
return;
@@ -1193,29 +1253,19 @@ public final class TngRootBypassHook {
if (!name.startsWith("onActivity") && !name.startsWith("onApplication")) {
continue;
}
// 不调原 native避免 JNI DeleteLocalRef → ART abort
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
if (!param.hasThrowable()) {
return;
}
Throwable t = param.getThrowable();
if (isPromonThrowable(t, promonExc)) {
XposedBridge.log(TAG + " swallowed " + t.getClass().getSimpleName()
+ " in " + lifecycleName + "#" + method.getName());
param.setThrowable(null);
}
protected void beforeHookedMethod(MethodHookParam param) {
setSafeHookResult(param, method);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked
+ " " + lifecycleName + " lifecycle method(s) (afterHook only)");
}
XposedBridge.log(TAG + " hooked " + hooked + " " + lifecycleName
+ " lifecycle (noop)");
} catch (Throwable t) {
XposedBridge.log(TAG + " " + lifecycleName + " lifecycle hook failed: "
+ t.getMessage());
XposedBridge.log(TAG + " skip lifecycle " + lifecycleName + ": " + t.getMessage());
}
}
@@ -1230,12 +1280,16 @@ public final class TngRootBypassHook {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Throwable t = (Throwable) param.args[0];
if (t == null || !isPromonThrowableName(t.getClass().getName())) {
if (t == null) {
return;
}
XposedBridge.log(TAG + " swallowed uncaught " + t.getClass().getSimpleName()
+ " in " + lpparam.processName);
param.setResult(null);
if (isPromonThrowableName(t.getClass().getName())
|| isStatsLogNoise(t)) {
XposedBridge.log(TAG + " swallowed uncaught " + t.getClass().getSimpleName()
+ " in " + lpparam.processName
+ " msg=" + t.getMessage());
param.setResult(null);
}
}
});
XposedBridge.log(TAG + " hooked Thread.dispatchUncaughtException");
@@ -1245,8 +1299,8 @@ public final class TngRootBypassHook {
}
/**
* bl/R 全方法 short-circuita/b 之外的方法仍会跑 native~40s 后 stack_chk/SEGV。
* a.run 是 bl#b 后台 Runnable必须 beforeHook 直接 return
* 登录优先:只 short-circuit bl#a / bl#b文档有效路径。全拦 bl/R 六个方法会破坏
* u.onActivityCreated JNI → ART aborta.run 仍单独 noop
*/
private static void hookPromonBlSwallowExceptions(XC_LoadPackage.LoadPackageParam lpparam) {
for (String simple : new String[]{"bl", "R"}) {
@@ -1263,6 +1317,10 @@ public final class TngRootBypassHook {
try {
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
String methodName = method.getName();
if (!"a".equals(methodName) && !"b".equals(methodName)) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
@@ -1273,7 +1331,7 @@ public final class TngRootBypassHook {
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " " + clazz.getName()
+ " method(s), all short-circuit");
+ " method(s), a/b short-circuit only");
}
} catch (Throwable t) {
XposedBridge.log(TAG + " " + clazz.getName() + " short-circuit failed: "
@@ -1281,17 +1339,6 @@ public final class TngRootBypassHook {
}
}
private static void fixNullPromonResult(XC_MethodHook.MethodHookParam param, Method method) {
if (param.getResult() != null) {
return;
}
Class<?> returnType = method.getReturnType();
if (returnType == Integer.class || returnType == int.class) {
XposedBridge.log(TAG + " fixed null bl#" + method.getName() + " -> 0");
setSafeHookResult(param, method);
}
}
/** Promon 后台 Runnablebl#b 检测线程beforeHook 直接 noop禁止跑 native。 */
private static void hookPromonRunnable(XC_LoadPackage.LoadPackageParam lpparam) {
Class<?> runnableClass = findPromonClass(lpparam.classLoader, "a");
@@ -1346,10 +1393,6 @@ public final class TngRootBypassHook {
}
}
private static boolean isPromonException(Throwable t, Class<?> promonExc) {
return isPromonThrowable(t, promonExc);
}
private static boolean isPromonThrowable(Throwable t, Class<?> promonExc) {
if (t == null) {
return false;
@@ -1379,6 +1422,25 @@ public final class TngRootBypassHook {
return simple.length() == 1;
}
private static boolean isStatsLogNoise(Throwable t) {
for (Throwable cur = t; cur != null; cur = cur.getCause()) {
String name = cur.getClass().getName();
String msg = cur.getMessage();
if (name != null && name.contains("StatsLog")) {
return true;
}
if (msg != null && msg.contains("StatsLog")) {
return true;
}
if (cur instanceof UnsatisfiedLinkError
&& msg != null
&& (msg.contains("stats") || msg.contains("Stats"))) {
return true;
}
}
return false;
}
private static boolean isPromonPackageClass(String className) {
for (String prefix : PROMON_PKG_PREFIXES) {
if (className.startsWith(prefix + ".")) {
@@ -1398,122 +1460,6 @@ public final class TngRootBypassHook {
return null;
}
/** SecurityGuard探测命令 stub10101 init + 104xx/105xx sign/verify 走真实 native。 */
private static void hookJnicLibrary(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> clazz = XposedHelpers.findClass(
"com.hzchengdun.securityguard.adapter.JNICLibrary",
lpparam.classLoader);
XposedHelpers.findAndHookMethod(
clazz,
"doCommand",
int.class,
Object[].class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
int cmd = (Integer) param.args[0];
if (shouldStubJnicCmd(cmd)) {
Object[] payload = (Object[]) param.args[1];
Object stub = safeJnicReturn(cmd, payload);
XposedBridge.log(TAG + " stub JNICLibrary.doCommand cmd=" + cmd
+ " -> " + describeJnicResult(stub));
param.setResult(stub);
return;
}
if (shouldLogJnicCmd(cmd)) {
XposedBridge.log(TAG + " JNIC passthrough call cmd=" + cmd
+ " args=" + describeJnicArgs((Object[]) param.args[1]));
}
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
int cmd = (Integer) param.args[0];
if (shouldStubJnicCmd(cmd)) {
return;
}
if (!shouldLogJnicCmd(cmd)) {
return;
}
if (param.hasThrowable()) {
logJnicThrowable(cmd, param.getThrowable());
return;
}
XposedBridge.log(TAG + " JNIC passthrough cmd=" + cmd
+ " -> " + describeJnicResult(param.getResult()));
}
});
XposedBridge.log(TAG + " hooked JNICLibrary.doCommand (probe stub + init/verify passthrough)");
} catch (Throwable t) {
XposedBridge.log(TAG + " JNICLibrary hook failed: " + t.getMessage());
}
}
/** 仅 stub 低号 env/root 探测10101 init 与 104xx/105xx 必须 passthrough。 */
private static boolean shouldStubJnicCmd(int cmd) {
if (cmd == 10101 || cmd == 10102 || cmd == 10103 || cmd == 10104) {
return false;
}
if (cmd >= 10000) {
return false;
}
return true;
}
private static boolean shouldLogJnicCmd(int cmd) {
return cmd == 10101 || cmd == 10102 || cmd == 10103 || cmd == 10104
|| cmd == 10401 || cmd == 10501 || cmd == 10603
|| (cmd >= 10400 && cmd < 10700);
}
private static void logJnicThrowable(int cmd, Throwable t) {
StringBuilder sb = new StringBuilder();
sb.append(TAG).append(" JNIC passthrough cmd=").append(cmd)
.append(" err ").append(t.getClass().getName());
String msg = t.getMessage();
if (msg != null && !msg.isEmpty()) {
sb.append(" msg=").append(msg);
}
try {
Object code = XposedHelpers.callMethod(t, "getErrorCode");
if (code != null) {
sb.append(" errorCode=").append(code);
}
} catch (Throwable ignored) {
}
StackTraceElement[] stack = t.getStackTrace();
if (stack != null && stack.length > 0) {
sb.append(" at ").append(stack[0]);
}
XposedBridge.log(sb.toString());
}
private static String describeJnicArgs(Object[] args) {
if (args == null) {
return "null";
}
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < args.length; i++) {
if (i > 0) {
sb.append(", ");
}
Object arg = args[i];
if (arg == null) {
sb.append("null");
} else if (arg instanceof byte[]) {
sb.append("byte[").append(((byte[]) arg).length).append("]");
} else if (arg instanceof String) {
String s = (String) arg;
sb.append("String(").append(s.length() > 40 ? s.substring(0, 40) + "..." : s).append(")");
} else {
sb.append(arg.getClass().getSimpleName()).append("=").append(arg);
}
}
sb.append("]");
return sb.toString();
}
/**
* Aliyun TigerTally与 Promon 并行的设备指纹/风控 SDKlibtiger_tally.so
*
@@ -1587,35 +1533,55 @@ public final class TngRootBypassHook {
}
}
/** 短路 TigerTally 启动初始化native 会 fork getprop 等待 → seccomp 卡死 ANR。 */
/**
* seccomp 开着时:必须 stub init/initCommon/genericNt1否则 fork getprop 卡死。
* seccomp 关且非登录优先:只 stub genericNt1 探测。
*/
private static boolean isTigerShortCircuit(String className, String methodName) {
if ("com.aliyun.TigerTally.TigerTallyAPI".equals(className)
&& ("init".equals(methodName) || "initCommon".equals(methodName))) {
return true;
|| "com.aliyun.TigerTally.t.B".equals(className)
|| "com.aliyun.TigerTally.t.C".equals(className)) {
if ("init".equals(methodName) || "initCommon".equals(methodName)
|| "genericNt1".equals(methodName) || "initialize".equals(methodName)) {
return true;
}
}
return "com.aliyun.TigerTally.t.B".equals(className)
&& "genericNt1".equals(methodName);
}
private static Object safeJnicReturn(int cmd, Object[] args) {
if (args != null) {
for (Object arg : args) {
if (arg instanceof byte[]) {
return new byte[0];
/** TNG 封装层CaptchaInitializer / TigerTallyApiWrapper 直接 noop避免 Startup latch 卡住。 */
private static void hookTigerTallyAppWrappers(XC_LoadPackage.LoadPackageParam lpparam) {
String[] classes = {
"my.com.tngdigital.captcha.TigerTallyApiWrapper",
"my.com.tngdigital.app.launcher.initializer.CaptchaInitializer",
};
for (String className : classes) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int n = 0;
for (Method method : clazz.getDeclaredMethods()) {
String name = method.getName();
if (!"initialize".equals(name) && !"init".equals(name)
&& !"create".equals(name)) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " TigerTally wrapper SC "
+ className + "#" + method.getName());
setSafeHookResult(param, method);
}
});
n++;
}
XposedBridge.log(TAG + " hooked TigerTally wrapper " + className + " n=" + n);
} catch (Throwable t) {
XposedBridge.log(TAG + " TigerTally wrapper " + className
+ " skipped: " + t.getMessage());
}
}
return Integer.valueOf(0);
}
private static String describeJnicResult(Object result) {
if (result == null) {
return "null";
}
if (result instanceof byte[]) {
return "byte[" + ((byte[]) result).length + "]";
}
return result.getClass().getSimpleName() + "=" + result;
}
/** 拦截 ActivityThread / Handler 触发的应用退出Promon 常走 native→H.exit。 */
@@ -1785,93 +1751,6 @@ public final class TngRootBypassHook {
}
}
/** 拦截把 UnhandledEvent 导航成杀进程的入口。 */
private static void hookShowSecurityScreenForState(XC_LoadPackage.LoadPackageParam lpparam) {
try {
Class<?> clazz = XposedHelpers.findClass(
"my.com.tngdigital.common.security.shielding.AppSecurityManager",
lpparam.classLoader);
Class<?> unhandled = null;
try {
unhandled = XposedHelpers.findClass(
"my.com.tngdigital.common.security.model.UnhandledEvent",
lpparam.classLoader);
} catch (Throwable ignored) {
// optional
}
final Class<?> unhandledFinal = unhandled;
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (!"showSecurityScreenForState".equals(method.getName())) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Object state = param.args != null && param.args.length > 1
? param.args[1] : null;
if (state != null && unhandledFinal != null) {
try {
Object eventInfo = XposedHelpers.callMethod(state, "getEventInfo");
if (unhandledFinal.isInstance(eventInfo)) {
XposedBridge.log(TAG + " blocked showSecurityScreenForState UnhandledEvent");
param.setResult(null);
return;
}
} catch (Throwable ignored) {
// fall through to blanket block
}
}
XposedBridge.log(TAG + " blocked showSecurityScreenForState");
param.setResult(null);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " showSecurityScreenForState");
}
} catch (Throwable t) {
XposedBridge.log(TAG + " showSecurityScreenForState hook failed: " + t.getMessage());
}
}
private static void hookForceExitFlow(XC_LoadPackage.LoadPackageParam lpparam) {
String[] classes = {
"my.com.tngdigital.common.security.ui.SecurityErrorBaseActivity",
"my.com.tngdigital.common.security.ui.SecurityErrorActivity",
"my.com.tngdigital.common.security.SecurityForceExitCountdownPolicyKt",
"my.com.tngdigital.common.security.shielding.AppSecurityManager",
};
for (String className : classes) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
String name = method.getName();
String lower = name.toLowerCase(Locale.US);
if (!lower.contains("forceexit")
&& !lower.contains("exitcountdown")) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " blocked " + className + "#" + name);
setSafeHookResult(param, method);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " hooked " + hooked + " force-exit/queue method(s) in " + className);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " skip " + className + ": " + t.getMessage());
}
}
}
/** 消费 Promon Java 层安全回调,避免检测后走 SecurityError / native fallback 退出链。 */
private static void hookAppSecurityCallbacks(XC_LoadPackage.LoadPackageParam lpparam) {
try {
@@ -1895,62 +1774,6 @@ public final class TngRootBypassHook {
}
}
/** Promon native 桥接类:强制 int/boolean 检测返回安全值。 */
private static void hookPromonNativeBridge(XC_LoadPackage.LoadPackageParam lpparam) {
String[] simpleNames = {
"F", "bg", "b", "c", "d", "h", "k", "l", "m", "o", "s", "t", "z",
};
int total = 0;
for (String simpleName : simpleNames) {
Class<?> clazz = findPromonClass(lpparam.classLoader, simpleName);
if (clazz != null) {
total += hookPromonIntBooleanMethods(clazz);
}
}
if (total > 0) {
XposedBridge.log(TAG + " Promon native-bridge total hooks=" + total);
}
}
private static int hookPromonIntBooleanMethods(Class<?> clazz) {
int count = 0;
try {
for (Method method : clazz.getDeclaredMethods()) {
Class<?> returnType = method.getReturnType();
if (returnType != boolean.class && returnType != Boolean.class
&& returnType != int.class && returnType != Integer.class) {
continue;
}
if (method.getParameterTypes().length > 6) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
int depth = PROMON_BRIDGE_DEPTH.get();
if (depth > 0) {
return;
}
PROMON_BRIDGE_DEPTH.set(depth + 1);
try {
if (returnType == boolean.class || returnType == Boolean.class) {
param.setResult(false);
} else {
param.setResult(0);
}
} finally {
PROMON_BRIDGE_DEPTH.set(depth);
}
}
});
count++;
}
} catch (Throwable ignored) {
// class may be absent in this process
}
return count;
}
private static void hookSecurityUrlOpeners(XC_LoadPackage.LoadPackageParam lpparam) {
String[] classes = {
"my.com.tngdigital.common.security.shielding.AppSecurityManager",
@@ -2010,17 +1833,6 @@ public final class TngRootBypassHook {
}
}
private static void hookMethodNoop(Method method, String label) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
XposedBridge.log(TAG + " blocked " + label);
setSafeHookResult(param, method);
}
});
XposedBridge.log(TAG + " hooked " + label);
}
/** 高频回调(如 handleTapjackingCallback禁止逐次打 log避免复进主线程 ANR。 */
private static void hookMethodNoopSilent(Method method) {
XposedBridge.hookMethod(method, new XC_MethodHook() {
@@ -2154,8 +1966,8 @@ public final class TngRootBypassHook {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
String op = extractRpcOperation(param.args);
if (op != null && isRegistrationRpc(op)) {
XposedBridge.log(TAG + " RPC reg/login op=" + op + " "
if (op != null && (isRegistrationRpc(op) || op.contains("phoneCheck"))) {
XposedBridge.log(TAG + " RPC req op=" + op + " "
+ describeRpcInvocation(param.args));
}
if (!isJailBrokenRpcInvocation(param.args)) {
@@ -2170,6 +1982,26 @@ public final class TngRootBypassHook {
}
param.setResult(null);
}
@Override
protected void afterHookedMethod(MethodHookParam param) {
String op = extractRpcOperation(param.args);
if (op == null || (!isRegistrationRpc(op) && !op.contains("phoneCheck"))) {
return;
}
if (param.hasThrowable()) {
XposedBridge.log(TAG + " RPC rsp op=" + op + " err "
+ param.getThrowable().getClass().getSimpleName()
+ ": " + param.getThrowable().getMessage());
return;
}
Object result = param.getResult();
String snippet = result != null ? String.valueOf(result) : "null";
if (snippet.length() > 800) {
snippet = snippet.substring(0, 800) + "...";
}
XposedBridge.log(TAG + " RPC rsp op=" + op + " body=" + snippet);
}
};
for (String className : rpcClasses) {
try {
@@ -2430,12 +2262,6 @@ public final class TngRootBypassHook {
"isShowErrorScreen");
}
private static void hookSecurityBooleanChecks(XC_LoadPackage.LoadPackageParam lpparam) {
for (String className : BOOLEAN_HOOK_CLASSES) {
RootBypassHelper.hookSecurityClass(lpparam, className);
}
}
private static void hookSecurityErrorActivity(XC_LoadPackage.LoadPackageParam lpparam) {
hookSecurityErrorLaunch(lpparam);
hookGenericSecurityErrorFinish();
@@ -2511,6 +2337,128 @@ public final class TngRootBypassHook {
XposedBridge.log(TAG + " skip launchProcessNext noop (avoid native exit fallback)");
}
/**
* 首页强制「验证您的帐户」(HomeEkycVerifyActivity) — 测试期跳过。
* 拦启动 + onCreate finishcanBypassEkyc=trueenforceEkyc=false。
*/
private static void hookHomeEkycVerifySkip(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
Activity.class,
"onCreate",
Bundle.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.thisObject;
String name = activity.getClass().getName();
if (name != null && name.contains("HomeEkycVerify")) {
XposedBridge.log(TAG + " skip eKYC — finish " + name);
activity.finish();
}
}
});
XposedBridge.log(TAG + " hooked HomeEkycVerify Activity finish");
} catch (Throwable t) {
XposedBridge.log(TAG + " HomeEkycVerify finish hook failed: " + t.getMessage());
}
// 白名单绕过:返回 true
hookBooleanMethodsByName(lpparam,
"my.com.tngdigital.home.viewmodel.BypassEkycWhiteListChecker",
true, "canBypassEkyc", "bypassEkyc", "isWhitelist", "inWhitelist");
// 强制 eKYC 开关:返回 false
for (String className : new String[]{
"my.com.tngdigital.home.help.HomeEkycCheckHelper",
"my.com.tngdigital.home.ekyc.KycHomepagePopUpManager",
"my.com.tngdigital.home.ekyc.HomeEkycVerifyViewModel",
"my.com.tngdigital.home.viewmodel.HomeListActivityViewModel",
}) {
hookBooleanMethodsByName(lpparam, className, false,
"getEnforceEkyc", "enforceEkyc", "needShowEkyc", "needForceEkyc",
"isForceEkyc", "checkEkyc", "firstCheckEkyc", "needShowEkycCddAudit");
hookVoidMethodsByName(lpparam, className,
"checkEkyc", "checkEkycStatus", "checkEkycRequest",
"requestEkycStatus", "showEkyc", "launchEkyc", "openEkyc");
}
}
private static void hookBooleanMethodsByName(
XC_LoadPackage.LoadPackageParam lpparam,
String className,
boolean result,
String... nameHints) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (method.getReturnType() != boolean.class && method.getReturnType() != Boolean.class) {
continue;
}
String n = method.getName();
boolean match = false;
for (String hint : nameHints) {
if (n.equals(hint) || n.toLowerCase(Locale.US).contains(hint.toLowerCase(Locale.US))) {
match = true;
break;
}
}
if (!match) {
continue;
}
final boolean ret = result;
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(ret);
}
});
hooked++;
}
XposedBridge.log(TAG + " eKYC bool stub " + className + " n=" + hooked + " -> " + result);
} catch (Throwable t) {
XposedBridge.log(TAG + " eKYC bool stub skip " + className + ": " + t.getMessage());
}
}
private static void hookVoidMethodsByName(
XC_LoadPackage.LoadPackageParam lpparam,
String className,
String... nameHints) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (method.getReturnType() != void.class) {
continue;
}
String n = method.getName();
boolean match = false;
for (String hint : nameHints) {
if (n.equals(hint) || n.startsWith(hint)) {
match = true;
break;
}
}
if (!match) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(null);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " eKYC void noop " + className + " n=" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " eKYC void noop skip " + className + ": " + t.getMessage());
}
}
private static void hookReturnFalse(
XC_LoadPackage.LoadPackageParam lpparam, String className, String methodName) {
try {