
目标 分析App 站内跳离行为,采用 Instrumentation 在 execStartActivity 里阻塞 & 重分发
一、核心思路
- 拦截跳转:在 execStartActivity 里先不放行。
- 记录跳转时间:把 Intent 和时间戳缓存起来。
- 监听广告 SDK 回调:所有 SDK 点击回调都要统一入口,记录时间戳。
- 5 秒窗口匹配:判断跳转时间前后 5 秒内是否有广告点击回调。
- 放行或丢弃:如果匹配到回调 → 执行原跳转;没有 → 丢弃。
二、设计数据结构
arduino
// 记录跳转
class JumpRecord {
Intent intent;
long timestamp;
Runnable proceedRunnable; // 真正执行 startActivity 的回调
}
// 记录点击回调
class ClickRecord {
String adId; // 可选
long timestamp;
}
使用 队列 或 双端队列 保证顺序。
ini
Deque jumpQueue = new ArrayDeque<>();
Deque clickQueue = new ArrayDeque<>();
三、Instrumentation 拦截示例
csharp
@Override
public ActivityResult execStartActivity(
Context who, IBinder contextThread, IBinder token,
Activity target, Intent intent, int requestCode, Bundle options) {
if (isJumpOut(who, intent)) {
long now = System.currentTimeMillis();
JumpRecord record = new JumpRecord();
record.intent = intent;
record.timestamp = now;
record.proceedRunnable = () -> {
try {
base.execStartActivity(
who, contextThread, token,
target, intent, requestCode, options);
} catch (Exception e) {
e.printStackTrace();
}
};
synchronized (jumpQueue) {
jumpQueue.add(record);
}
Log.e("JumpGuard", "Jump blocked and cached: " + intent);
// 不立即跳转
return new ActivityResult(Activity.RESULT_CANCELED, null);
}
return base.execStartActivity(who, contextThread, token,
target, intent, requestCode, options);
}
四、SDK 点击回调统一入口示例
假设你能 hook / 包装 SDK:
scss
public void onAdClicked(String adId) {
long now = System.currentTimeMillis();
synchronized (clickQueue) {
clickQueue.add(new ClickRecord(adId, now));
}
checkJumpForClick(now);
}
五、匹配跳转与点击回调(5 秒窗口)
java
private void checkJumpForClick(long clickTime) {
synchronized (jumpQueue) {
Iterator it = jumpQueue.iterator();
while (it.hasNext()) {
JumpRecord jr = it.next();
if (Math.abs(jr.timestamp - clickTime) <= 5000) {
Log.e("JumpGuard", "Matched jump with click, proceed: " + jr.intent);
// 执行真实跳转
jr.proceedRunnable.run();
it.remove();
}
}
}
}
六、延迟清理机制
防止缓存无限增长:
scss
private void cleanupOldRecords() {
long now = System.currentTimeMillis();
synchronized (jumpQueue) {
jumpQueue.removeIf(jr -> now - jr.timestamp > 5000);
}
synchronized (clickQueue) {
clickQueue.removeIf(cr -> now - cr.timestamp > 5000);
}
}
七、逻辑总结
- 拦截所有标准跳离 Intent
- 缓存跳转信息,不立刻执行
- 监控 SDK 点击回调
- 匹配跳转与回调时间(前后 5 秒)
- 匹配到 → 执行跳转,未匹配 → 自动丢弃 / 清理
八、工程注意点
- Instrumentation 拦截风险
-
- 只在 Debug / 测试环境使用
- Android 9+ ROM 可能绕过
- 时间窗口
-
- 可根据广告实际延迟调整(一般 3~5 秒)
- 线程安全
-
- 队列操作必须加锁或使用 ConcurrentLinkedDeque
- 回滚机制
-
- 防止误拦应用自己内部跳转,可使用白名单包名
九、核心代码逻辑
1.hook hookInstrumentation:
ini
private void hookInstrumentation() {
try {
Class atClass = Class.forName("android.app.ActivityThread");
Method currentAT = atClass.getDeclaredMethod("currentActivityThread");
currentAT.setAccessible(true);
Object at = currentAT.invoke(null);
Field mInstrumentationField = atClass.getDeclaredField("mInstrumentation");
mInstrumentationField.setAccessible(true);
Instrumentation base = (Instrumentation) mInstrumentationField.get(at);
ProxyInstrumentation proxy = new ProxyInstrumentation(base);
mInstrumentationField.set(at, proxy);
Log.e("JumpGuard", "Instrumentation hooked successfully");
} catch (Throwable t) {
Log.e("JumpGuard", "hookInstrumentation failed", t);
}
}
2.ProxyInstrumentation.java(拦截标准 startActivity):
csharp
public class ProxyInstrumentation extends Instrumentation {
private final Instrumentation base;
public ProxyInstrumentation(Instrumentation base) {
this.base = base;
}
@Override
public ActivityResult execStartActivity(
Context who,
IBinder contextThread,
IBinder token,
Activity target,
Intent intent,
int requestCode,
Bundle options) {
if (JumpGuard.getInstance().isJumpOut(who, intent)) {
// 不立刻执行,先缓存
JumpGuard.getInstance().recordJump(intent, () -> {
try {
base.execStartActivity(
who, contextThread, token,
target, intent, requestCode, options);
} catch (Exception e) {
e.printStackTrace();
}
});
// 阻止立即跳转
return new ActivityResult(Activity.RESULT_CANCELED, null);
}
return base.execStartActivity(
who, contextThread, token,
target, intent, requestCode, options
);
}
}
3.JumpGuard.java(核心逻辑)
java
public class JumpGuard {
private static final long WINDOW_MS = 5000; // 前后5秒
private final Deque jumpQueue = new ArrayDeque<>();
private final Deque clickQueue = new ArrayDeque<>();
private static JumpGuard instance;
public static JumpGuard getInstance() {
if (instance == null) {
instance = new JumpGuard();
}
return instance;
}
private JumpGuard() { }
// ----------------- 跳转记录 -----------------
public void recordJump(Intent intent, Runnable proceedRunnable) {
long now = System.currentTimeMillis();
JumpRecord jr = new JumpRecord(intent, now, proceedRunnable);
synchronized (jumpQueue) {
jumpQueue.add(jr);
}
Log.e("JumpGuard", "Jump cached: " + intent);
cleanupOldRecords();
}
// ----------------- 点击回调 -----------------
public void recordClick(String adId) {
long now = System.currentTimeMillis();
ClickRecord cr = new ClickRecord(adId, now);
synchronized (clickQueue) {
clickQueue.add(cr);
}
Log.e("JumpGuard", "Click recorded: " + adId);
matchJumpWithClick(now);
cleanupOldRecords();
}
private void matchJumpWithClick(long clickTime) {
synchronized (jumpQueue) {
Iterator it = jumpQueue.iterator();
while (it.hasNext()) {
JumpRecord jr = it.next();
if (Math.abs(jr.timestamp - clickTime) <= WINDOW_MS) {
Log.e("JumpGuard", "Matched jump with click: " + jr.intent);
jr.proceedRunnable.run();
it.remove();
}
}
}
}
private void cleanupOldRecords() {
long now = System.currentTimeMillis();
synchronized (jumpQueue) {
jumpQueue.removeIf(jr -> now - jr.timestamp > WINDOW_MS);
}
synchronized (clickQueue) {
clickQueue.removeIf(cr -> now - cr.timestamp > WINDOW_MS);
}
}
// ----------------- 判定是否跳出 App -----------------
public boolean isJumpOut(Context context, Intent intent) {
ComponentName cmp = intent.getComponent();
if (cmp == null) return false;
String targetPkg = cmp.getPackageName();
return !context.getPackageName().equals(targetPkg);
}
// ----------------- 内部数据类 -----------------
private static class JumpRecord {
final Intent intent;
final long timestamp;
final Runnable proceedRunnable;
JumpRecord(Intent intent, long timestamp, Runnable proceedRunnable) {
this.intent = intent;
this.timestamp = timestamp;
this.proceedRunnable = proceedRunnable;
}
}
private static class ClickRecord {
final String adId;
final long timestamp;
ClickRecord(String adId, long timestamp) {
this.adId = adId;
this.timestamp = timestamp;
}
}
}