Compare commits
15 Commits
193c04a24b
...
ysc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fe84a8dd3 | ||
|
|
ae65351d8e | ||
|
|
abf41b9ab6 | ||
|
|
b3f2b2b3c2 | ||
|
|
293979122d | ||
|
|
a4aed01b29 | ||
|
|
c8cbfdff82 | ||
|
|
1517ff4671 | ||
|
|
1e2021d8fd | ||
|
|
88a2b319f8 | ||
|
|
e80f7f908c | ||
|
|
f9426d6b68 | ||
|
|
bc51fb35cc | ||
|
|
5378a34f58 | ||
|
|
609635aba1 |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ Android 应用,监听通知栏消息并通过 **双通道**(通知监听 + X
|
||||
> **Telegram 抓消息专文**:[docs/Telegram抓消息说明.md](docs/Telegram抓消息说明.md)
|
||||
> **手机部署与银行 bypass 操作**:详见 [docs/手机操作手册.md](docs/手机操作手册.md)
|
||||
> **MariBank 风控与 register 载荷**:[docs/MariBank风控与载荷说明.md](docs/MariBank风控与载荷说明.md)
|
||||
> **TNG Money Packet 领取台**:[docs/TNG_MoneyPacket领取台.md](docs/TNG_MoneyPacket领取台.md)
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
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 长时间无心跳:仅把 TNG 拉到前台(不强进历史页),
|
||||
* 仍无效再强停重开。历史页跳转只由 Xposed 短 bounce(会自动 finish),避免一直把人拽进历史页。
|
||||
*/
|
||||
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();
|
||||
|
||||
if (hookRecoverStep == 0) {
|
||||
if (now - lastHookRecoverAt < 90_000L) {
|
||||
return;
|
||||
}
|
||||
lastHookRecoverAt = now;
|
||||
hookRecoverStep = 1;
|
||||
// 不强进 MoneyPacketHistoryActivity:用户已在 App 内时会被反复打断
|
||||
boolean ok = TngProcessHelper.launch(this);
|
||||
if (ok) {
|
||||
Toast.makeText(this, "Hook 无心跳,已尝试唤醒 TNG 前台…", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (hookRecoverStep == 1) {
|
||||
if (now - lastHookRecoverAt < 2 * 60_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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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://<电脑局域网IP>: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;
|
||||
|
||||
@@ -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) + "…";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.miraclegarden.smsmessage.mmp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.text.TextUtils;
|
||||
|
||||
/**
|
||||
* 记录 TNG 进程内 Hook 心跳,用于判断是否挂上最新模块。
|
||||
*/
|
||||
public final class MmpHookStatus {
|
||||
|
||||
private static final String PREF = "mmp_hook_status";
|
||||
private static volatile int sLiveCode;
|
||||
private static volatile String sLiveName = "";
|
||||
private static volatile long sLiveAt;
|
||||
private static volatile String sHostPkg = "";
|
||||
|
||||
private MmpHookStatus() {
|
||||
}
|
||||
|
||||
public static void noteAlive(Context context, int versionCode, String versionName,
|
||||
String hostPackage) {
|
||||
sLiveCode = versionCode;
|
||||
sLiveName = versionName != null ? versionName : "";
|
||||
sLiveAt = System.currentTimeMillis();
|
||||
sHostPkg = hostPackage != null ? hostPackage : "";
|
||||
if (context == null) {
|
||||
return;
|
||||
}
|
||||
prefs(context).edit()
|
||||
.putInt("live_code", sLiveCode)
|
||||
.putString("live_name", sLiveName)
|
||||
.putLong("live_at", sLiveAt)
|
||||
.putString("host_pkg", sHostPkg)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public static void ensureLoaded(Context context) {
|
||||
if (sLiveAt > 0 || context == null) {
|
||||
return;
|
||||
}
|
||||
SharedPreferences sp = prefs(context);
|
||||
sLiveCode = sp.getInt("live_code", 0);
|
||||
sLiveName = sp.getString("live_name", "");
|
||||
sLiveAt = sp.getLong("live_at", 0L);
|
||||
sHostPkg = sp.getString("host_pkg", "");
|
||||
}
|
||||
|
||||
public static long lastAliveAt(Context context) {
|
||||
ensureLoaded(context);
|
||||
return sLiveAt;
|
||||
}
|
||||
|
||||
public static int liveVersionCode(Context context) {
|
||||
ensureLoaded(context);
|
||||
return sLiveCode;
|
||||
}
|
||||
|
||||
public static String liveVersionName(Context context) {
|
||||
ensureLoaded(context);
|
||||
return sLiveName != null ? sLiveName : "";
|
||||
}
|
||||
|
||||
public static int installedVersionCode(Context context) {
|
||||
if (context == null) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
PackageInfo pi = context.getPackageManager()
|
||||
.getPackageInfo(MmpModuleInfo.XPOSED_PACKAGE, 0);
|
||||
return pi != null ? pi.versionCode : 0;
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static String installedVersionName(Context context) {
|
||||
if (context == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
PackageInfo pi = context.getPackageManager()
|
||||
.getPackageInfo(MmpModuleInfo.XPOSED_PACKAGE, 0);
|
||||
return pi != null && pi.versionName != null ? pi.versionName : "";
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return 人类可读模块状态
|
||||
*/
|
||||
public static String describe(Context context) {
|
||||
ensureLoaded(context);
|
||||
int installed = installedVersionCode(context);
|
||||
String installedName = installedVersionName(context);
|
||||
if (installed <= 0) {
|
||||
return "模块APK:未安装";
|
||||
}
|
||||
String apkPart = "模块APK:" + (TextUtils.isEmpty(installedName)
|
||||
? String.valueOf(installed) : installedName);
|
||||
boolean apkLatest = installed >= MmpModuleInfo.EXPECTED_VERSION_CODE;
|
||||
if (!apkLatest) {
|
||||
apkPart += "(旧,期望" + MmpModuleInfo.EXPECTED_VERSION_NAME + ")";
|
||||
}
|
||||
|
||||
long age = sLiveAt > 0 ? System.currentTimeMillis() - sLiveAt : -1L;
|
||||
String livePart;
|
||||
if (age < 0) {
|
||||
livePart = "运行中:未检测到(请强停再开 TNG)";
|
||||
} else if (age > 3 * 60_000L) {
|
||||
livePart = "运行中:心跳偏旧(" + (age / 60_000L) + "分钟前 "
|
||||
+ (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + ")";
|
||||
} else if (sLiveCode >= MmpModuleInfo.EXPECTED_VERSION_CODE) {
|
||||
livePart = "运行中:最新(" + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName) + ")";
|
||||
} else if (sLiveCode > 0) {
|
||||
livePart = "运行中:旧版(" + (TextUtils.isEmpty(sLiveName) ? sLiveCode : sLiveName)
|
||||
+ ",期望" + MmpModuleInfo.EXPECTED_VERSION_NAME + ")";
|
||||
} else {
|
||||
livePart = "运行中:未知";
|
||||
}
|
||||
return apkPart + " · " + livePart;
|
||||
}
|
||||
|
||||
public static boolean isLiveLatest(Context context) {
|
||||
ensureLoaded(context);
|
||||
long age = sLiveAt > 0 ? System.currentTimeMillis() - sLiveAt : -1L;
|
||||
return age >= 0 && age <= 3 * 60_000L && sLiveCode >= MmpModuleInfo.EXPECTED_VERSION_CODE;
|
||||
}
|
||||
|
||||
private static SharedPreferences prefs(Context context) {
|
||||
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.miraclegarden.smsmessage.mmp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
* 领取台本地设置(不依赖电脑调试台也能读写)。
|
||||
*/
|
||||
public final class MmpLocalSettings {
|
||||
|
||||
private static final String PREF = "mmp_settings_local";
|
||||
|
||||
private MmpLocalSettings() {
|
||||
}
|
||||
|
||||
public static JSONObject defaults() {
|
||||
JSONObject o = new JSONObject();
|
||||
try {
|
||||
o.put("historyCooldownSec", 8);
|
||||
o.put("detailCooldownSec", 8);
|
||||
o.put("dedupSec", 3);
|
||||
o.put("detailGapMs", 80);
|
||||
o.put("pagePollMs", 1000);
|
||||
o.put("openHistoryIfNoTemplate", true);
|
||||
o.put("autoBounceHistoryOnDisconnect", true);
|
||||
o.put("autoLaunchTngIfKilled", true);
|
||||
o.put("autoWatchWhileOpen", true);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
public static JSONObject load(Context context) {
|
||||
JSONObject out = defaults();
|
||||
if (context == null) {
|
||||
return out;
|
||||
}
|
||||
SharedPreferences sp = prefs(context);
|
||||
try {
|
||||
String raw = sp.getString("json", null);
|
||||
if (!TextUtils.isEmpty(raw)) {
|
||||
JSONObject saved = new JSONObject(raw);
|
||||
mergeInto(out, saved);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static void save(Context context, JSONObject json) {
|
||||
if (context == null || json == null) {
|
||||
return;
|
||||
}
|
||||
JSONObject merged = defaults();
|
||||
mergeInto(merged, json);
|
||||
prefs(context).edit().putString("json", merged.toString()).apply();
|
||||
}
|
||||
|
||||
public static void mergeInto(JSONObject target, JSONObject src) {
|
||||
if (target == null || src == null) {
|
||||
return;
|
||||
}
|
||||
String[] keys = {
|
||||
"historyCooldownSec", "detailCooldownSec", "dedupSec", "detailGapMs", "pagePollMs",
|
||||
"openHistoryIfNoTemplate", "autoBounceHistoryOnDisconnect",
|
||||
"autoLaunchTngIfKilled", "autoWatchWhileOpen"
|
||||
};
|
||||
for (String k : keys) {
|
||||
if (!src.has(k)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
target.put(k, src.get(k));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SharedPreferences prefs(Context context) {
|
||||
return context.getApplicationContext().getSharedPreferences(PREF, Context.MODE_PRIVATE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.miraclegarden.smsmessage.mmp;
|
||||
|
||||
/**
|
||||
* Xposed 模块版本约定(与 xposed-module/build.gradle 同步)。
|
||||
*/
|
||||
public final class MmpModuleInfo {
|
||||
public static final String XPOSED_PACKAGE = "com.miraclegarden.smsmessage.xposed";
|
||||
public static final int EXPECTED_VERSION_CODE = 5;
|
||||
public static final String EXPECTED_VERSION_NAME = "1.2.2";
|
||||
|
||||
private MmpModuleInfo() {
|
||||
}
|
||||
}
|
||||
@@ -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 activityStatus:ACTIVE / 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);
|
||||
}
|
||||
}
|
||||
@@ -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/meta,claims 为空)
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,23 +70,32 @@ 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);
|
||||
|
||||
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(AppConfig.DEBUG_SERVER_URL + "/api/debug/push")
|
||||
.url(target + "/api/debug/push")
|
||||
.header("Content-Type", "application/json")
|
||||
.post(RequestBody.create(json.toString(), JSON))
|
||||
.post(RequestBody.create(body, JSON))
|
||||
.build();
|
||||
|
||||
Log.d(TAG, "forwarding [" + source + "] " + messageInfo.getPackageName()
|
||||
+ " -> " + AppConfig.DEBUG_SERVER_URL);
|
||||
+ " -> " + target);
|
||||
|
||||
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();
|
||||
}
|
||||
Log.w(TAG, "forward failed [" + source + "] -> " + target
|
||||
+ ": " + e.getMessage());
|
||||
finishOne(pending, onComplete);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -84,15 +103,16 @@ public final class DebugForwarder {
|
||||
int code = response.code();
|
||||
response.close();
|
||||
if (code >= 200 && code < 300) {
|
||||
Log.d(TAG, "forward ok [" + source + "] " + title);
|
||||
Log.d(TAG, "forward ok [" + source + "] -> " + target
|
||||
+ " " + title);
|
||||
} else {
|
||||
Log.w(TAG, "forward http " + code + " [" + source + "] " + title);
|
||||
}
|
||||
if (onComplete != null) {
|
||||
onComplete.run();
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
633
app/src/main/res/layout/activity_mmp_claim.xml
Normal file
633
app/src/main/res/layout/activity_mmp_claim.xml
Normal 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 断线时短进历史页再建连(约 2 秒后自动返回;已在历史页不会重复打开)"
|
||||
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>
|
||||
324
app/src/main/res/layout/activity_mmp_detail.xml
Normal file
324
app/src/main/res/layout/activity_mmp_detail.xml
Normal 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>
|
||||
209
app/src/main/res/layout/activity_mmp_help.xml
Normal file
209
app/src/main/res/layout/activity_mmp_help.xml
Normal 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>
|
||||
89
app/src/main/res/layout/item_mmp_claim.xml
Normal file
89
app/src/main/res/layout/item_mmp_claim.xml
Normal 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>
|
||||
134
app/src/main/res/layout/item_mmp_packet.xml
Normal file
134
app/src/main/res/layout/item_mmp_packet.xml
Normal 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
147
debug-server/mmp_help.html
Normal 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://<电脑IP>: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
542
debug-server/mmp_page.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
loadSettings().then(loadData);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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:
|
||||
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)
|
||||
print("[{0}] [{1}] [{2}] {3} | {4}".format(
|
||||
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:
|
||||
|
||||
137
debug-server/tng_mmp_mitm_addon.py
Normal file
137
debug-server/tng_mmp_mitm_addon.py
Normal 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()]
|
||||
590
docs/MariBank SG 3100012 根因分析与突破方案.md
Normal file
590
docs/MariBank SG 3100012 根因分析与突破方案.md
Normal 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 chars;351 比 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, ← OK(permissive=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
|
||||
→ 生成 dataKey(RSA 加密的会话密钥)
|
||||
```
|
||||
|
||||
在函数入口 `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-410(register 请求):
|
||||
```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)。
|
||||
|
||||
---
|
||||
|
||||
## 四、可执行突破方案
|
||||
|
||||
### ⚡ 方案 1:PlayIntegrityFix(今天,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 问题,需进一步分析
|
||||
|
||||
### ⚡ 方案 3:Frida 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 组装点
|
||||
```
|
||||
|
||||
### ⚡ 方案 4:Native `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=-1):retval.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.js(Frida 明文截获脚本)
|
||||
|
||||
```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 级别至 DEVICE(30 分钟)
|
||||
2. **干净机重放实验** → 验证根因(需干净机配合)
|
||||
3. **Frida spawn + RegisterNatives** → 定位 `vvuwuuvuu` 偏移 → Ghidra 分析明文结构
|
||||
|
||||
*2026-07-06 17:05 SGT*
|
||||
@@ -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 工作记录 |
|
||||
|
||||
---
|
||||
|
||||
139
docs/MariBank新加坡突破.md
Normal file
139
docs/MariBank新加坡突破.md
Normal 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*
|
||||
171
docs/TNG_MoneyPacket领取台.md
Normal file
171
docs/TNG_MoneyPacket领取台.md
Normal 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://<电脑IP>: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)/ **省流**(约 30–45s)。
|
||||
|
||||
日志里若出现 `skip dup activityId=...`,表示短时去重生效,不是没抓到。
|
||||
`settings applied hist=…` 表示 Hook 已拉到新配置。
|
||||
|
||||
| 已领完停刷 | `remainingCount<=0` / `claimed>=total` / status FINISH·COMPLETE·EXPIRE / 金额凑齐,且**已有领取名单**后不再自动拉该包详情 |
|
||||
| 断线重连 | session 丢失或 RPC 鉴权/网络失败 → 标记断开;session 恢复或 Activity resume 时**强制**重拉历史 |
|
||||
|
||||
日志:`packet DONE stop-refresh`、`session lost`、`session recovered → force re-fetch`。
|
||||
|
||||
---
|
||||
|
||||
## 排障
|
||||
|
||||
### 打开 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 监听列表勾选了 TNG(Hook 转发依赖主 App 接收广播)
|
||||
6. 电脑 USB 共享 IP 变化后更新 `AppConfig.DEBUG_SERVER_URLS` 并重装主 App
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `xposed-module/.../hook/TngMoneyPacketHook.java` | Quake RPC Hook、自动拉详情、轮询领取台设置 |
|
||||
| `app/.../mmp/MmpHookStatus.java` | 检测 TNG 内 Hook 是否最新模块 |
|
||||
| `app/.../mmp/MmpClaim.java` | 领取人含 `userId`(receiverId) |
|
||||
| `debug-server/server.py` | `/mmp` 领取台、`/api/mmp`、`/api/mmp/settings` |
|
||||
| `debug-server/mmp_settings.json` | 领取台刷新设置(自动生成) |
|
||||
| `app/.../AppConfig.java` | `DEBUG_SERVER_URLS` 双地址 |
|
||||
| `app/.../network/DebugForwarder.java` | 向多个调试地址推送 |
|
||||
| `scripts/start-debug-server.ps1` | 启动调试台 + adb reverse |
|
||||
64
docs/TNG_captcha逆向.md
Normal file
64
docs/TNG_captcha逆向.md
Normal 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` | 解析 result,code==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
|
||||
36
docs/TNG_开放问题与抓包能力.md
Normal file
36
docs/TNG_开放问题与抓包能力.md
Normal 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,首页跳过不等于全功能可用。
|
||||
@@ -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
|
||||
|
||||
|
||||
12
docs/更新说明.md
12
docs/更新说明.md
@@ -1,5 +1,17 @@
|
||||
# 更新说明
|
||||
|
||||
## TNG eWallet 登录/注册区号(2026-08-03)
|
||||
|
||||
- **包名** `my.com.tngdigital.ewallet` v1.9.10,Android 16(Pixel 6)Root + LSPosed
|
||||
- **问题**:登录/注册点区号时 `i7.l` loading 弹窗触发 HWUI gralloc ABRT(signal 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 风控 bypass(2026-07-06)
|
||||
|
||||
- **菲律宾 SeaBank**(`ph.seabank.seabank`):Root Pixel 6 上 **注册成功、可发 OTP**(`api.seabank.ph` → `code=0`)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* pc==lr 循环 SEGV 也 skip;libc++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 时跳回 lr(Looper 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=129):Promon 自杀是 kill(pid,SIGABRT) → si_code=SI_USER。
|
||||
* 不拦 tgkill/tkill:ART 的 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;
|
||||
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) {
|
||||
static const char *kFallbacks[] = {
|
||||
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) {
|
||||
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 调用方 PLT(libhwui/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();
|
||||
|
||||
Binary file not shown.
@@ -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
25
reverse/frida/mini_run.py
Normal 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
|
||||
9
reverse/frida/mini_timer.js
Normal file
9
reverse/frida/mini_timer.js
Normal 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);
|
||||
143
reverse/frida/run_frida_sg_native.py
Normal file
143
reverse/frida/run_frida_sg_native.py
Normal 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()
|
||||
113
reverse/frida/run_frida_tng_native.py
Normal file
113
reverse/frida/run_frida_tng_native.py
Normal 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())
|
||||
178
reverse/frida/run_tng_compare.py
Normal file
178
reverse/frida/run_tng_compare.py
Normal 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()
|
||||
48
reverse/frida/run_tng_diag.py
Normal file
48
reverse/frida/run_tng_diag.py
Normal 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()
|
||||
42
reverse/frida/run_tng_empty_spawn.py
Normal file
42
reverse/frida/run_tng_empty_spawn.py
Normal 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()
|
||||
214
reverse/frida/run_tng_patch_libc_svc.py
Normal file
214
reverse/frida/run_tng_patch_libc_svc.py
Normal 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()
|
||||
107
reverse/frida/run_tng_tiger_fread.py
Normal file
107
reverse/frida/run_tng_tiger_fread.py
Normal 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())
|
||||
60
reverse/frida/strace_tng_exit.py
Normal file
60
reverse/frida/strace_tng_exit.py
Normal 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())
|
||||
634
reverse/frida/trace_maribank_sg_native.js
Normal file
634
reverse/frida/trace_maribank_sg_native.js
Normal 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);
|
||||
});
|
||||
74
reverse/frida/trace_tng_diag_exit.js
Normal file
74
reverse/frida/trace_tng_diag_exit.js
Normal 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);
|
||||
282
reverse/frida/trace_tng_native_exit.js
Normal file
282
reverse/frida/trace_tng_native_exit.js
Normal 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");
|
||||
147
reverse/frida/trace_tng_tiger_fread.js
Normal file
147
reverse/frida/trace_tng_tiger_fread.js
Normal 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)");
|
||||
41
reverse/scripts/_dump_captcha_methods.py
Normal file
41
reverse/scripts/_dump_captcha_methods.py
Normal 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)
|
||||
81
reverse/scripts/_scan_calling_code.py
Normal file
81
reverse/scripts/_scan_calling_code.py
Normal 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()
|
||||
68
reverse/scripts/_scan_calling_code_deep.py
Normal file
68
reverse/scripts/_scan_calling_code_deep.py
Normal 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()
|
||||
16
reverse/scripts/_scan_captcha.py
Normal file
16
reverse/scripts/_scan_captcha.py
Normal 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))
|
||||
59
reverse/scripts/_scan_country_select.py
Normal file
59
reverse/scripts/_scan_country_select.py
Normal 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()
|
||||
41
reverse/scripts/_scan_tng_promon_pkg.py
Normal file
41
reverse/scripts/_scan_tng_promon_pkg.py
Normal 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}")
|
||||
41
reverse/scripts/_scan_tng_structure.py
Normal file
41
reverse/scripts/_scan_tng_structure.py
Normal 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)
|
||||
68
reverse/scripts/clash_switch_sg.py
Normal file
68
reverse/scripts/clash_switch_sg.py
Normal 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()
|
||||
114
reverse/scripts/download_install_tng.py
Normal file
114
reverse/scripts/download_install_tng.py
Normal 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())
|
||||
110
reverse/scripts/dump_tng_nativelib_methods.py
Normal file
110
reverse/scripts/dump_tng_nativelib_methods.py
Normal 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)
|
||||
167
reverse/scripts/dump_tng_runtime_kill.py
Normal file
167
reverse/scripts/dump_tng_runtime_kill.py
Normal 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())
|
||||
22
reverse/scripts/find_character_crypto.py
Normal file
22
reverse/scripts/find_character_crypto.py
Normal 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("/", "."))
|
||||
20
reverse/scripts/find_character_crypto2.py
Normal file
20
reverse/scripts/find_character_crypto2.py
Normal 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("/", "."))
|
||||
21
reverse/scripts/find_crypto_manager_class.py
Normal file
21
reverse/scripts/find_crypto_manager_class.py
Normal 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("/", "."))
|
||||
23
reverse/scripts/find_dfp_empty_class.py
Normal file
23
reverse/scripts/find_dfp_empty_class.py
Normal 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)
|
||||
27
reverse/scripts/find_getdfp.py
Normal file
27
reverse/scripts/find_getdfp.py
Normal 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())
|
||||
19
reverse/scripts/find_getdfp_by_mmkv_class.py
Normal file
19
reverse/scripts/find_getdfp_by_mmkv_class.py
Normal 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)
|
||||
31
reverse/scripts/find_getdfp_class.py
Normal file
31
reverse/scripts/find_getdfp_class.py
Normal 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)
|
||||
21
reverse/scripts/find_getdfp_class2.py
Normal file
21
reverse/scripts/find_getdfp_class2.py
Normal 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))
|
||||
17
reverse/scripts/find_unavailable_i18n.py
Normal file
17
reverse/scripts/find_unavailable_i18n.py
Normal 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])
|
||||
24
reverse/scripts/find_user_register_i18n.py
Normal file
24
reverse/scripts/find_user_register_i18n.py
Normal 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)
|
||||
13
reverse/scripts/inspect_clash_cache.py
Normal file
13
reverse/scripts/inspect_clash_cache.py
Normal 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 ""))
|
||||
18
reverse/scripts/list_sg_safemode.py
Normal file
18
reverse/scripts/list_sg_safemode.py
Normal 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("/", "."))
|
||||
28
reverse/scripts/list_sg_safemode_obf.py
Normal file
28
reverse/scripts/list_sg_safemode_obf.py
Normal 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)
|
||||
21
reverse/scripts/list_sg_shps_classes.py
Normal file
21
reverse/scripts/list_sg_shps_classes.py
Normal 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))
|
||||
20
reverse/scripts/list_shps_classes.py
Normal file
20
reverse/scripts/list_shps_classes.py
Normal 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)
|
||||
156
reverse/scripts/parse_vuwuuwvw_log.py
Normal file
156
reverse/scripts/parse_vuwuuwvw_log.py
Normal 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()
|
||||
28
reverse/scripts/scan_dfp_empty_ctx.py
Normal file
28
reverse/scripts/scan_dfp_empty_ctx.py
Normal 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)
|
||||
28
reverse/scripts/scan_sg_crypto_dfp.py
Normal file
28
reverse/scripts/scan_sg_crypto_dfp.py
Normal 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)
|
||||
20
reverse/scripts/scan_sg_dfp.py
Normal file
20
reverse/scripts/scan_sg_dfp.py
Normal 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)
|
||||
35
reverse/scripts/scan_sg_split_utils.py
Normal file
35
reverse/scripts/scan_sg_split_utils.py
Normal 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)
|
||||
33
reverse/scripts/scan_sg_splits.py
Normal file
33
reverse/scripts/scan_sg_splits.py
Normal 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)
|
||||
34
reverse/scripts/scan_sg_unavailable.py
Normal file
34
reverse/scripts/scan_sg_unavailable.py
Normal 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])
|
||||
94
reverse/scripts/scan_tng.py
Normal file
94
reverse/scripts/scan_tng.py
Normal 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)
|
||||
18
reverse/scripts/scan_tng_bl_methods.py
Normal file
18
reverse/scripts/scan_tng_bl_methods.py
Normal 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())
|
||||
25
reverse/scripts/scan_tng_callbacks.py
Normal file
25
reverse/scripts/scan_tng_callbacks.py
Normal 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())
|
||||
37
reverse/scripts/scan_tng_exit.py
Normal file
37
reverse/scripts/scan_tng_exit.py
Normal 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)
|
||||
72
reverse/scripts/scan_tng_exit10.py
Normal file
72
reverse/scripts/scan_tng_exit10.py
Normal 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
|
||||
103
reverse/scripts/scan_tng_exiting_deep.py
Normal file
103
reverse/scripts/scan_tng_exiting_deep.py
Normal 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()
|
||||
70
reverse/scripts/scan_tng_keys.py
Normal file
70
reverse/scripts/scan_tng_keys.py
Normal 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()
|
||||
53
reverse/scripts/scan_tng_nativelib.py
Normal file
53
reverse/scripts/scan_tng_nativelib.py
Normal 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)
|
||||
19
reverse/scripts/scan_tng_nativelib2.py
Normal file
19
reverse/scripts/scan_tng_nativelib2.py
Normal 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())
|
||||
16
reverse/scripts/scan_tng_promon.py
Normal file
16
reverse/scripts/scan_tng_promon.py
Normal 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)
|
||||
18
reverse/scripts/scan_tng_security_error.py
Normal file
18
reverse/scripts/scan_tng_security_error.py
Normal 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()
|
||||
61
reverse/scripts/scan_tng_so_exit.py
Normal file
61
reverse/scripts/scan_tng_so_exit.py
Normal 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))
|
||||
41
reverse/scripts/scan_tng_so_kill.py
Normal file
41
reverse/scripts/scan_tng_so_kill.py
Normal 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')}"
|
||||
)
|
||||
40
reverse/scripts/scan_tng_suicide_map.py
Normal file
40
reverse/scripts/scan_tng_suicide_map.py
Normal 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()
|
||||
66
reverse/scripts/scan_tng_tigertally.py
Normal file
66
reverse/scripts/scan_tng_tigertally.py
Normal 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()
|
||||
36
reverse/scripts/scan_tng_url.py
Normal file
36
reverse/scripts/scan_tng_url.py
Normal 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()
|
||||
24
reverse/scripts/scan_user_bundle.py
Normal file
24
reverse/scripts/scan_user_bundle.py
Normal 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)
|
||||
466
reverse/scripts/test_tng_full_flow.py
Normal file
466
reverse/scripts/test_tng_full_flow.py
Normal 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≈600–780
|
||||
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 控件出现在 dump(Splash 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
10
scripts/_tng_anr_dump.sh
Normal 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"
|
||||
6
scripts/_tng_anr_render.sh
Normal file
6
scripts/_tng_anr_render.sh
Normal 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"
|
||||
22
scripts/_tng_getprop_hunt.sh
Normal file
22
scripts/_tng_getprop_hunt.sh
Normal 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
24
scripts/_tng_pin_check.sh
Normal 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
|
||||
39
scripts/_tng_pipe_writer.sh
Normal file
39
scripts/_tng_pipe_writer.sh
Normal 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 ==="
|
||||
65
scripts/_tng_pipe_writer2.sh
Normal file
65
scripts/_tng_pipe_writer2.sh
Normal 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
46
scripts/_tng_proc_scan.sh
Normal 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) 所有管道/套接字 fd(TigerTally 握手候选)
|
||||
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 ==="
|
||||
@@ -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")
|
||||
|
||||
35
scripts/install-tng-xapk.ps1
Normal file
35
scripts/install-tng-xapk.ps1
Normal 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
|
||||
}
|
||||
87
scripts/launch-maribank-sg.ps1
Normal file
87
scripts/launch-maribank-sg.ps1
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
58
scripts/maribank-sg-all-in.ps1
Normal file
58
scripts/maribank-sg-all-in.ps1
Normal 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"
|
||||
}
|
||||
153
scripts/maribank-sg-register.ps1
Normal file
153
scripts/maribank-sg-register.ps1
Normal 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
32
scripts/pull-tng-apk.ps1
Normal 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"
|
||||
48
scripts/run-frida-sg-native.ps1
Normal file
48
scripts/run-frida-sg-native.ps1
Normal 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
|
||||
41
scripts/start-tng-mitm.ps1
Normal file
41
scripts/start-tng-mitm.ps1
Normal 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
|
||||
}
|
||||
20
scripts/test-tng-full-flow.ps1
Normal file
20
scripts/test-tng-full-flow.ps1
Normal 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
|
||||
51
scripts/tng-bypass-finish.ps1
Normal file
51
scripts/tng-bypass-finish.ps1
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 JSON(key 多为 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();
|
||||
}
|
||||
}
|
||||
@@ -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>";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* DFP(device 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.miraclegarden.smsmessage.xposed.hook;
|
||||
|
||||
/**
|
||||
* PH vs SG MariBank 差异配置(基于 reverse/apks 扫描,非「换包名即相同」)。
|
||||
* <p>
|
||||
* SG 3.2.2:dex 内无 {@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-core:SG 无 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",
|
||||
};
|
||||
}
|
||||
|
||||
/** 加密 JNI:PH 有 utils.d 包装;SG 仅 uvwuvwuv + uvwwwwuv(classes6.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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
hooked += hookShpsRisk(lpparam);
|
||||
if (!lateHooksCoreInstalled) {
|
||||
lateHooksCoreInstalled = true;
|
||||
hookShpsRisk(lpparam);
|
||||
hookShpsToken(lpparam);
|
||||
hookErrorFlowLogging(lpparam);
|
||||
MariBankSdkUtilsHook.installLate(lpparam);
|
||||
MariBankAttestationHook.installLate(lpparam);
|
||||
MariBankFullCaptureHook.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-dex:safemode / 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());
|
||||
}
|
||||
|
||||
@@ -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) + "...";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
lateNativeInstalled = true;
|
||||
hookShpsNativeBridge(lpparam);
|
||||
hookShpsNativeCore(lpparam);
|
||||
XposedBridge.log(TAG + " late native hooks installed for " + lpparam.packageName);
|
||||
}
|
||||
}
|
||||
});
|
||||
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());
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2868 @@
|
||||
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 = 120_000L;
|
||||
private static final long HISTORY_BOUNCE_FINISH_DELAY_MS = 2_200L;
|
||||
/** 无模板时最多主动打开历史页次数(进程内),避免一直把用户拽进历史页 */
|
||||
private static volatile int sNoTemplateOpenCount;
|
||||
private static final int MAX_NO_TEMPLATE_OPEN = 2;
|
||||
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);
|
||||
sNoTemplateOpenCount = 0;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情有 summary(ACTIVE / 有总额)但领取名单为空:刚发出去、还没人领 */
|
||||
private static boolean looksLikeValidEmptyPacket(Object result, String activityId) {
|
||||
if (result == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Object summary = null;
|
||||
try {
|
||||
summary = XposedHelpers.callMethod(result, "getSummaryInfo");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (summary == null) {
|
||||
try {
|
||||
summary = XposedHelpers.getObjectField(result, "summaryInfo");
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
if (summary == null) {
|
||||
return false;
|
||||
}
|
||||
String id = firstNonEmpty(
|
||||
stringField(summary, "activityId", "getActivityId"),
|
||||
activityId);
|
||||
if (!looksLikeActivityId(id)) {
|
||||
return false;
|
||||
}
|
||||
String status = stringField(summary, "activityStatus", "getActivityStatus");
|
||||
if (!TextUtils.isEmpty(status)) {
|
||||
String up = status.toUpperCase(Locale.US);
|
||||
if (up.contains("ACTIVE") || up.contains("OPEN") || up.contains("PROGRESS")
|
||||
|| up.contains("FINISH") || up.contains("COMPLETE")
|
||||
|| up.contains("EXPIRE")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
String total = null;
|
||||
try {
|
||||
Object money = XposedHelpers.callMethod(summary, "getTotalAmount");
|
||||
if (money != null) {
|
||||
total = normalizeMoneyText(String.valueOf(
|
||||
XposedHelpers.callMethod(money, "getAmount")));
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
return !TextUtils.isEmpty(total);
|
||||
} catch (Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean snapshotHasPacketMeta(MmpSnapshot snapshot) {
|
||||
return snapshot != null
|
||||
&& looksLikeActivityId(snapshot.packetId)
|
||||
&& (!TextUtils.isEmpty(snapshot.totalAmount)
|
||||
|| !TextUtils.isEmpty(snapshot.activityStatus)
|
||||
|| !TextUtils.isEmpty(snapshot.senderName));
|
||||
}
|
||||
|
||||
private static boolean fetchDetailByActivityId(String activityId, String senderUserId) {
|
||||
ClassLoader cl = sAppClassLoader;
|
||||
ensureRpcTaskAndLogin(cl);
|
||||
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;
|
||||
}
|
||||
if (sNoTemplateOpenCount >= MAX_NO_TEMPLATE_OPEN) {
|
||||
XposedBridge.log(TAG + " auto-history no template, open capped ("
|
||||
+ reason + " count=" + sNoTemplateOpenCount + ")");
|
||||
return false;
|
||||
}
|
||||
sNoTemplateOpenCount++;
|
||||
XposedBridge.log(TAG + " auto-history no template → open HistoryActivity ("
|
||||
+ reason + " #" + sNoTemplateOpenCount + ")");
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 已在历史页则不再 startActivity,避免反复跳转
|
||||
try {
|
||||
Context ctx0 = getContext();
|
||||
if (ctx0 instanceof android.app.Activity) {
|
||||
String cur = ((android.app.Activity) ctx0).getClass().getName();
|
||||
if (cur.contains("MoneyPacketHistoryActivity")) {
|
||||
XposedBridge.log(TAG + " already on HistoryActivity, skip open");
|
||||
if (bounceBack) {
|
||||
sLastHistoryBounceAt = now;
|
||||
sBounceHistoryPending = true;
|
||||
scheduleHistoryBounceFinish(ctx0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
if (bounceBack) {
|
||||
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.fromApplication(Hilt 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;
|
||||
}
|
||||
}
|
||||
@@ -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 一律 noop(A16+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 会取消旧 Runnable(Application 兜底 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 + ")");
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
return sb.length() == 0 ? "(empty)" : sb.toString();
|
||||
}
|
||||
|
||||
/** 已 skip show 的 loading Dialog,让 isShowing=true 避免业务层卡死。 */
|
||||
private static final Set<Dialog> skippedLoadingDialogs =
|
||||
Collections.newSetFromMap(new WeakHashMap<>());
|
||||
|
||||
/**
|
||||
* i7.l 等 loading:一律 skip show + 伪装 isShowing。
|
||||
* Captcha 弹窗清 HW flag,避免 Android 16 gralloc abort。
|
||||
*/
|
||||
private static void hookLoadingDialogSkip() {
|
||||
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()) {
|
||||
XposedHelpers.findAndHookMethod(Dialog.class, "show", new XC_MethodHook() {
|
||||
@Override
|
||||
protected void beforeHookedMethod(MethodHookParam param) {
|
||||
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;
|
||||
}
|
||||
String snippet = text.length() > 500 ? text.substring(0, 500) + "..." : text;
|
||||
XposedBridge.log(TAG + " HTTP req body=" + snippet);
|
||||
if (isCaptchaDialog(owner)) {
|
||||
try {
|
||||
Window cw = dialog.getWindow();
|
||||
if (cw != null) {
|
||||
cw.clearFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
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 + " Dialog.show captcha software " + owner);
|
||||
}
|
||||
}
|
||||
});
|
||||
XposedBridge.log(TAG + " hooked WebViewClient.onReceivedError");
|
||||
XposedBridge.log(TAG + " hooked Dialog.show (skip loading only)");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " WebView error hook failed: " + t.getMessage());
|
||||
XposedBridge.log(TAG + " Dialog.show hook failed: " + t.getMessage());
|
||||
}
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
Dialog.class,
|
||||
"show",
|
||||
new XC_MethodHook() {
|
||||
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 (owner.contains("TNG") || owner.contains("Dialog")
|
||||
|| owner.contains("Error") || owner.contains("i7.")) {
|
||||
XposedBridge.log(TAG + " Dialog.show " + owner);
|
||||
if (isLoadingDialog(owner)) {
|
||||
skippedLoadingDialogs.remove(param.thisObject);
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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");
|
||||
}
|
||||
});
|
||||
XposedBridge.log(TAG + " hooked Dialog.dismiss (loading cleanup)");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " activity lifecycle diag failed: " + t.getMessage());
|
||||
XposedBridge.log(TAG + " Dialog.dismiss hook failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 区号选择弹窗诊断。勿关 HW 加速——Android 16 上会导致列表黑屏。
|
||||
* ANR 由 HardwareRenderer.setName 拦截兜底。
|
||||
* 一律跳过 HardwareRenderer.setName。
|
||||
* Pixel/A16 + Zygisk 命名空间下 native setName → dlopen("libandroid.so") 失败会 ART abort。
|
||||
*/
|
||||
private static void hookBottomSelectDialogDiag(XC_LoadPackage.LoadPackageParam lpparam) {
|
||||
try {
|
||||
Class<?> clazz = XposedHelpers.findClass(
|
||||
"my.com.tngdigital.common.widget.BottomSelectDialogFragment",
|
||||
lpparam.classLoader);
|
||||
XposedHelpers.findAndHookMethod(clazz, "onStart", new XC_MethodHook() {
|
||||
@Override
|
||||
protected void beforeHookedMethod(MethodHookParam param) {
|
||||
XposedBridge.log(TAG + " BottomSelectDialogFragment.onStart");
|
||||
}
|
||||
});
|
||||
XposedBridge.log(TAG + " hooked BottomSelectDialogFragment.onStart (diag only)");
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " BottomSelectDialog hook failed: " + t.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ANR 栈:Dialog.show → enableHardwareAcceleration → HardwareRenderer.setName → future.get 卡死。
|
||||
* 仅拦截 setName;勿全局关 Dialog HW(区号选择会黑屏)。
|
||||
*/
|
||||
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());
|
||||
}
|
||||
});
|
||||
for (Method method : svc.getDeclaredMethods()) {
|
||||
if (!"onStartCommand".equals(method.getName())) {
|
||||
continue;
|
||||
}
|
||||
XposedBridge.hookMethod(method, new XC_MethodHook() {
|
||||
/**
|
||||
* 区号 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) {
|
||||
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;
|
||||
}
|
||||
XposedBridge.log(TAG + " registration flow active: " + name);
|
||||
} else if (name.endsWith(".SplashActivity")) {
|
||||
registrationFlowActive = false;
|
||||
Intent intent = extractIntent(param.args);
|
||||
if (isCallingCodeIntent(intent)) {
|
||||
callingCodeSurfaceActive = true;
|
||||
XposedBridge.log(TAG + " callingCode surface ON (HW path)");
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
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)");
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (Throwable t) {
|
||||
XposedBridge.log(TAG + " execStart callingCode mark failed: " + t.getMessage());
|
||||
}
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
Instrumentation.class,
|
||||
"callActivityOnCreate",
|
||||
Activity.class,
|
||||
Bundle.class,
|
||||
flowGuardHook);
|
||||
XposedHelpers.findAndHookMethod(
|
||||
Instrumentation.class,
|
||||
"callActivityOnResume",
|
||||
Activity.class,
|
||||
flowGuardHook);
|
||||
hookLoginOptionsDiag(lpparam);
|
||||
XposedBridge.log(TAG + " hooked registration flow guard");
|
||||
} 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() {
|
||||
new XC_MethodHook() {
|
||||
@Override
|
||||
protected void beforeHookedMethod(MethodHookParam param) {
|
||||
XposedBridge.log(TAG + " UserLoginActivity#" + name + " enter");
|
||||
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");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void afterHookedMethod(MethodHookParam param) {
|
||||
XposedBridge.log(TAG + " UserLoginActivity#" + name + " done");
|
||||
}
|
||||
});
|
||||
XposedHelpers.findAndHookMethod(
|
||||
Instrumentation.class,
|
||||
"callActivityOnDestroy",
|
||||
Activity.class,
|
||||
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");
|
||||
}
|
||||
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/首页则取消。
|
||||
* 超时仍停在 Splash(Promon 堵死)才救援。
|
||||
*/
|
||||
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")) {
|
||||
return;
|
||||
}
|
||||
XposedBridge.log(TAG + " Splash.onCreate enter — schedule force login");
|
||||
scheduleForceLoginToUserLogin(
|
||||
if (isSplashActivityName(name)) {
|
||||
XposedBridge.log(TAG
|
||||
+ " Splash.onCreate enter — schedule stuck rescue @4s");
|
||||
scheduleSplashStuckRescue(
|
||||
activity.getApplicationContext(),
|
||||
activity,
|
||||
"Splash/beforeOnCreate",
|
||||
2000L);
|
||||
4000L);
|
||||
return;
|
||||
}
|
||||
// 任何非 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())) {
|
||||
private static void markSplashNavigationDone(String activityName) {
|
||||
if (splashNavigationDone) {
|
||||
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());
|
||||
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() {
|
||||
try {
|
||||
XposedHelpers.findAndHookMethod(
|
||||
Process.class,
|
||||
"killProcess",
|
||||
int.class,
|
||||
new XC_MethodHook() {
|
||||
XC_MethodHook blockSelfKill = new XC_MethodHook() {
|
||||
@Override
|
||||
protected void beforeHookedMethod(MethodHookParam param) {
|
||||
if (((Integer) param.args[0]) == Process.myPid()) {
|
||||
if (!shouldBlockThisSuicideCall(param)) {
|
||||
return;
|
||||
}
|
||||
lastBlockedSuicideAt = System.currentTimeMillis();
|
||||
XposedBridge.log(TAG + " blocked killProcess(self)");
|
||||
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, 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 lifecycle:1.9.10 用 vhvlnqgy.u,旧版用 w。只吞异常,不全拦。
|
||||
* Promon lifecycle:1.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,13 +1280,17 @@ 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;
|
||||
}
|
||||
if (isPromonThrowableName(t.getClass().getName())
|
||||
|| isStatsLogNoise(t)) {
|
||||
XposedBridge.log(TAG + " swallowed uncaught " + t.getClass().getSimpleName()
|
||||
+ " in " + lpparam.processName);
|
||||
+ " in " + lpparam.processName
|
||||
+ " msg=" + t.getMessage());
|
||||
param.setResult(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
XposedBridge.log(TAG + " hooked Thread.dispatchUncaughtException");
|
||||
} catch (Throwable t) {
|
||||
@@ -1245,8 +1299,8 @@ public final class TngRootBypassHook {
|
||||
}
|
||||
|
||||
/**
|
||||
* bl/R 全方法 short-circuit:a/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 abort;a.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 后台 Runnable(bl#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:探测命令 stub;10101 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 并行的设备指纹/风控 SDK(libtiger_tally.so)。
|
||||
*
|
||||
@@ -1587,36 +1533,56 @@ 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))) {
|
||||
|| "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)。 */
|
||||
private static void hookActivityThreadExit(XC_LoadPackage.LoadPackageParam lpparam) {
|
||||
@@ -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 finish;canBypassEkyc=true;enforceEkyc=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 {
|
||||
|
||||
Reference in New Issue
Block a user