fix(tng): Splash 卡住才救援,并跳过首页强制 eKYC

避免已登录冷启动被强拉回登录页;HomeEkycVerify 测试期 finish + canBypass/enforce stub。
This commit is contained in:
mars
2026-08-04 14:10:24 +08:00
parent f9426d6b68
commit e80f7f908c
2 changed files with 388 additions and 37 deletions

View File

@@ -18,6 +18,7 @@ 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;
@@ -131,6 +132,8 @@ public final class TngRootBypassHook {
hookAppSecurityManager(lpparam);
hookAppSecurityCallbacks(lpparam);
hookSecurityErrorActivity(lpparam);
// 首页强制 eKYC「验证您的帐户」— 测试期直接跳过
hookHomeEkycVerifySkip(lpparam);
// seccomp 开着时必须 stub TigerTally init否则 fork getprop 永不退出 → App.onCreate ANR
hookTigerTally(lpparam);
hookTigerTallyAppWrappers(lpparam);
@@ -156,51 +159,54 @@ public final class TngRootBypassHook {
}
}
/** Splash 优先生效;新 schedule 会取消旧 RunnableApplication 兜底 vs Splash 2000ms。 */
/** Splash 卡住救援;新 schedule 会取消旧 Runnable。 */
private static final Handler FORCE_LOGIN_HANDLER = new Handler(Looper.getMainLooper());
private static Runnable pendingForceLoginRunnable;
/** 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 (isTopActivityRegistrationFlow(appCtx)) {
XposedBridge.log(TAG + " skip force login (" + reason + ", registration flow)");
if (splashNavigationDone) {
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", already left)");
return;
}
ActivityManager am =
(ActivityManager) appCtx.getSystemService(Context.ACTIVITY_SERVICE);
if (am != null) {
for (ActivityManager.AppTask task : am.getAppTasks()) {
ActivityManager.RecentTaskInfo info = task.getTaskInfo();
if (info == null || info.topActivity == null) {
continue;
}
String top = info.topActivity.getClassName();
if (top.endsWith(".UserLoginActivity")
|| top.endsWith(".UserPinActivity")
|| isRegistrationFlowActivity(top)) {
XposedBridge.log(TAG + " skip force login (" + reason + ", on " + top + ")");
return;
}
}
if (isTopActivityRegistrationFlow(appCtx)) {
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", registration flow)");
return;
}
String top = getTopActivityClassName(appCtx);
if (top != null && !isSplashActivityName(top)) {
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", on " + top + ")");
splashNavigationDone = true;
return;
}
if (!isUiVisibleForForceLogin(appCtx)) {
XposedBridge.log(TAG + " skip force login (" + reason + ", no visible UI / BAL)");
XposedBridge.log(TAG + " skip splash rescue (" + reason + ", no visible UI / BAL)");
return;
}
boolean hasSession = hasLocalLoginSession(appCtx);
String target = hasSession
? "my.com.tngdigital.user.view.UserPinActivity"
: "my.com.tngdigital.user.view.UserLoginActivity";
Intent intent = new Intent();
intent.setClassName(PACKAGE, login);
intent.setClassName(PACKAGE, target);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP);
if (splashAct != null && !splashAct.isFinishing()) {
@@ -209,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();
@@ -657,7 +784,9 @@ public final class TngRootBypassHook {
}
/**
* Splash.onCreate 常被 Promon 堵死永远不返回;必须在 onCreate 入口before就调度强拉
* Splash 卡住救援:不立刻强拉 Login
* onCreate 只调度延迟检查;若已自行跳到 PIN/首页则取消。
* 超时仍停在 SplashPromon 堵死)才救援。
*/
private static void hookSplashForceLogin(XC_LoadPackage.LoadPackageParam lpparam) {
final String splash = "my.com.tngdigital.ewallet.ui.SplashActivity";
@@ -675,15 +804,20 @@ public final class TngRootBypassHook {
return;
}
String name = activity.getClass().getName();
if (!splash.equals(name) && !name.endsWith(".SplashActivity")) {
if (isSplashActivityName(name)) {
XposedBridge.log(TAG
+ " Splash.onCreate enter — schedule stuck rescue @4s");
scheduleSplashStuckRescue(
activity.getApplicationContext(),
activity,
"Splash/beforeOnCreate",
4000L);
return;
}
XposedBridge.log(TAG + " Splash.onCreate enter — schedule force login");
scheduleForceLoginToUserLogin(
activity.getApplicationContext(),
activity,
"Splash/beforeOnCreate",
2000L);
// 任何非 Splash Activity 创建 → 取消 Splash 救援
if (PACKAGE.equals(activity.getPackageName())) {
markSplashNavigationDone(name);
}
}
@Override
@@ -693,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 {
@@ -703,12 +837,42 @@ 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());
}
}
private static void markSplashNavigationDone(String activityName) {
if (splashNavigationDone) {
return;
}
splashNavigationDone = true;
if (pendingForceLoginRunnable != null) {
FORCE_LOGIN_HANDLER.removeCallbacks(pendingForceLoginRunnable);
pendingForceLoginRunnable = null;
XposedBridge.log(TAG + " cancel splash rescue — now on " + activityName);
}
}
private static volatile long lastSuicideLogAt = 0L;
private static volatile int suicideBlockCount = 0;
@@ -1010,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();
@@ -1024,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;
@@ -2160,6 +2337,128 @@ public final class TngRootBypassHook {
XposedBridge.log(TAG + " skip launchProcessNext noop (avoid native exit fallback)");
}
/**
* 首页强制「验证您的帐户」(HomeEkycVerifyActivity) — 测试期跳过。
* 拦启动 + onCreate finishcanBypassEkyc=trueenforceEkyc=false。
*/
private static void hookHomeEkycVerifySkip(XC_LoadPackage.LoadPackageParam lpparam) {
try {
XposedHelpers.findAndHookMethod(
Activity.class,
"onCreate",
Bundle.class,
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Activity activity = (Activity) param.thisObject;
String name = activity.getClass().getName();
if (name != null && name.contains("HomeEkycVerify")) {
XposedBridge.log(TAG + " skip eKYC — finish " + name);
activity.finish();
}
}
});
XposedBridge.log(TAG + " hooked HomeEkycVerify Activity finish");
} catch (Throwable t) {
XposedBridge.log(TAG + " HomeEkycVerify finish hook failed: " + t.getMessage());
}
// 白名单绕过:返回 true
hookBooleanMethodsByName(lpparam,
"my.com.tngdigital.home.viewmodel.BypassEkycWhiteListChecker",
true, "canBypassEkyc", "bypassEkyc", "isWhitelist", "inWhitelist");
// 强制 eKYC 开关:返回 false
for (String className : new String[]{
"my.com.tngdigital.home.help.HomeEkycCheckHelper",
"my.com.tngdigital.home.ekyc.KycHomepagePopUpManager",
"my.com.tngdigital.home.ekyc.HomeEkycVerifyViewModel",
"my.com.tngdigital.home.viewmodel.HomeListActivityViewModel",
}) {
hookBooleanMethodsByName(lpparam, className, false,
"getEnforceEkyc", "enforceEkyc", "needShowEkyc", "needForceEkyc",
"isForceEkyc", "checkEkyc", "firstCheckEkyc", "needShowEkycCddAudit");
hookVoidMethodsByName(lpparam, className,
"checkEkyc", "checkEkycStatus", "checkEkycRequest",
"requestEkycStatus", "showEkyc", "launchEkyc", "openEkyc");
}
}
private static void hookBooleanMethodsByName(
XC_LoadPackage.LoadPackageParam lpparam,
String className,
boolean result,
String... nameHints) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (method.getReturnType() != boolean.class && method.getReturnType() != Boolean.class) {
continue;
}
String n = method.getName();
boolean match = false;
for (String hint : nameHints) {
if (n.equals(hint) || n.toLowerCase(Locale.US).contains(hint.toLowerCase(Locale.US))) {
match = true;
break;
}
}
if (!match) {
continue;
}
final boolean ret = result;
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(ret);
}
});
hooked++;
}
XposedBridge.log(TAG + " eKYC bool stub " + className + " n=" + hooked + " -> " + result);
} catch (Throwable t) {
XposedBridge.log(TAG + " eKYC bool stub skip " + className + ": " + t.getMessage());
}
}
private static void hookVoidMethodsByName(
XC_LoadPackage.LoadPackageParam lpparam,
String className,
String... nameHints) {
try {
Class<?> clazz = XposedHelpers.findClass(className, lpparam.classLoader);
int hooked = 0;
for (Method method : clazz.getDeclaredMethods()) {
if (method.getReturnType() != void.class) {
continue;
}
String n = method.getName();
boolean match = false;
for (String hint : nameHints) {
if (n.equals(hint) || n.startsWith(hint)) {
match = true;
break;
}
}
if (!match) {
continue;
}
XposedBridge.hookMethod(method, new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
param.setResult(null);
}
});
hooked++;
}
if (hooked > 0) {
XposedBridge.log(TAG + " eKYC void noop " + className + " n=" + hooked);
}
} catch (Throwable t) {
XposedBridge.log(TAG + " eKYC void noop skip " + className + ": " + t.getMessage());
}
}
private static void hookReturnFalse(
XC_LoadPackage.LoadPackageParam lpparam, String className, String methodName) {
try {