Skip to content

跳过按钮点击升级为手势(完善 #90)+ 息屏/解锁后的热启动广告处理 - #110

Merged
zfdang merged 6 commits into
masterfrom
fix/click-escalation
Sep 22, 2026
Merged

zfdang merged 6 commits into
masterfrom
fix/click-escalation

Conversation

@zfdang

@zfdang zfdang commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

本 PR 包含两部分改动,均在真机(OPPO PHY110 / Android 16)上验证过。

一、点击方式:ACTION_CLICK 无效时升级为坐标手势(完善 #90)

#90 提出把所有 performAction(ACTION_CLICK) 换成坐标手势点击,动机成立:performAction 返回 true 只表示 View 处理了事件,部分广告 SDK 会忽略无障碍点击;真机上也观察到知乎首次命中时节点尚未布局(Position=[0,0,0,0]),performAction 返回 true 但无效。

但 #90 的做法有回归风险:坐标为 [0,0,0,0] 时会点到屏幕左上角;广告恰好已关闭时手势会落在下一屏,最坏点进广告落地页;同时删掉了控件规则里用户可配置的 onlyClick 语义。本 PR 在保留 performAction 优点的前提下解决同一问题,可替代 #90。

  • 命中按钮时第一次仍用 performAction(ACTION_CLICK)(节点不可点则试父容器)。
  • performAction 明确返回 false → 立刻退回坐标手势(原有行为)。
  • performAction 返回 true 但 ≥500ms 后同一按钮仍出现在扫描结果中 → 视为假成功,第二次升级为坐标手势;每个按钮每轮最多 2 次尝试。
  • 按钮身份 = class + resourceId + 坐标,不含文字,倒计时 "跳过 5 → 跳过 4" 算同一按钮。
  • 手势点击前校验坐标非空、在屏幕内且 isVisibleToUser(),不满足则不点击。
  • 控件规则 onlyClick=true 仍直接手势点击。

二、息屏 / 解锁后的热启动广告

原逻辑只响应 USER_PRESENT 广播,对最近记录的包名无条件重启流程(哪怕是白名单应用或桌面),然后被动等事件。真机排查发现 OPPO 系统对第三方应用直接跳过 USER_PRESENT / SCREEN_ON 的投递(广播历史里原因为 oplus skip sp),该路径在这类机型上完全收不到通知。

  • 基于无障碍事件识别热启动:出现其他包的系统窗口且此时 KeyguardManager.isKeyguardLocked() 或屏幕不可交互,则标记当前目标应用被锁屏覆盖;之后该应用窗口回到前台即视为热启动,重启跳过流程。不依赖任何广播;下拉通知栏、音量面板(未锁屏)和应用自身的对话框不会触发。
  • 广播接收器保留作补充触发,并同时监听 SCREEN_ON(无锁屏的设备不会发 USER_PRESENT)。
  • 两条路径都只在前台应用属于目标包时生效,并立即扫描其窗口、1 秒后再补扫一次,不再被动等事件。

验证

  • 单元测试共 55 个(Robolectric 38 + 纯规则 17),新增:点击升级/立即回退/父容器/无效坐标/onlyClick/新一轮重置;解锁广播/SCREEN_ON/非目标应用/锁屏仍是活动窗口;事件识别热启动及其反例(通知栏、自身对话框、非目标应用)。
  • 真机:中国电信 24ms、知乎 450ms 命中并一次点击成功;"打开知乎 → 息屏 → 亮屏解锁"日志显示锁屏覆盖被识别、解锁后热启动流程重启并扫描(本次知乎未展示热启动广告,无点击记录)。

🤖 Generated with Claude Code

…ives

performAction(ACTION_CLICK) returning true only means the view handled
the event; some ad SDKs ignore accessibility clicks, and a node that is
not laid out yet (bounds 0,0,0,0, seen with Zhihu on a real device)
reports success without any effect. PR #90 proposed replacing every
ACTION_CLICK with a coordinate gesture, which handles those apps but
taps the origin for unlaid-out nodes, can hit whatever replaced the ad
when it closed a moment earlier, and drops the onlyClick rule flag.

This keeps ACTION_CLICK (node, then its parent) as the first attempt,
falls back to a gesture immediately when ACTION_CLICK is rejected, and
otherwise escalates to a gesture only when the same widget is still on
screen at least 500 ms later. A widget is attempted at most twice per
skip-ad process. Widgets are identified by class, view id and bounds so
that a changing countdown label ("跳过 5" -> "跳过 4") does not reset the
attempt count. Gestures are only dispatched at a non-empty, on-screen,
visible spot. The onlyClick flag of widget rules still forces a gesture.

Adds Robolectric tests for the escalation, the immediate fallback, the
parent click, invalid coordinates, onlyClick and attempt reset; 47 unit
tests in total.
Apps often show a splash ad when they come back to the foreground after
the screen was locked. The existing hook only reacted to USER_PRESENT,
restarted the skip-ad process for whatever package was last recorded,
even a whitelisted app or the launcher, and then waited passively for
events.

- detect the warm start from the accessibility events: a system window
  shown while the keyguard is locked (or the screen is off) marks the
  current target app as covered, and the next window of that app
  restarts the skip-ad process. On OPPO devices the system skips
  USER_PRESENT/SCREEN_ON deliveries to third-party apps ("oplus skip sp"),
  so this path does not depend on broadcasts at all
- the broadcast receiver stays as an additional trigger and now also
  listens for SCREEN_ON (devices without a lock screen never send
  USER_PRESENT)
- both paths only act when the foreground app is one we handle, and
  scan its window right away and once more a second later instead of
  waiting for further events

Adds Robolectric tests for the unlock broadcast, SCREEN_ON, unhandled
apps, a foreign active window, the event-based detection and its
negatives (notification shade, the app's own dialogs); 55 unit tests.
@zfdang zfdang changed the title 跳过按钮点击:ACTION_CLICK 无效时升级为坐标手势点击(完善 #90) 跳过按钮点击升级为手势(完善 #90)+ 息屏/解锁后的热启动广告处理 Sep 22, 2026
- a gesture is only dispatched after AccessibilityNodeInfo.refresh()
  succeeds and the refreshed node still belongs to a target package,
  still matches its rule, and is a visible spot on screen. A queued
  snapshot of a button whose ad has already closed is therefore never
  tapped at its old coordinates
- widget rules stay active until the matched widget has had its final
  attempt, so a button that only matches by id can still be escalated
  to a gesture at the next sighting
- click attempts are counted only when ACTION_CLICK or a gesture was
  actually issued; a gesture refused by the checks (e.g. the widget is
  not visible yet) leaves the attempt budget untouched
- the pending warm start is kept while the device is still locked: a
  same-package window during the lock screen, or SCREEN_ON with the
  keyguard up, no longer starts a process that expires before the user
  unlocks

Adds regression tests for each case; 61 unit tests in total.
@zfdang

zfdang commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

已按审查意见修复,见提交 refresh nodes before gestures and fix retry bookkeeping found in review:

  1. P1 旧快照点击下一屏:手势前先 node.refresh(),失败(节点已不存在)不点;刷新后重新校验包名属于目标包、规则仍匹配(关键字 / 控件规则各自复核)、坐标可见有效,再发手势。回归测试:广告关闭后的旧快照 refresh() 返回 false → 0 次手势;刷新后包名变为桌面 → 0 次手势。
  2. P2 控件规则无法获得第二次机会:clickNode 返回 NONE / CLICKED / FINAL,setTargetedWidgets 只在 FINAL(该控件已完成最终尝试)时清空。回归测试:仅按 id 匹配的按钮首次 ACTION_CLICK 假成功 → 规则保留 → 500ms 后再遇升级为手势 → 规则清空。
  3. P2 锁屏期间提前消耗热启动标记:同包窗口事件到来时若仍锁屏/息屏,保留 wakeupPending;广播路径在锁屏状态下不启动流程,改为设置 wakeupPending 交给事件路径。回归测试:锁屏期间同包窗口事件不启动且标记保留、解锁后启动;锁屏时收到 SCREEN_ON 不启动,解锁后应用窗口回来即启动。
  4. P2 未发出的手势消耗次数:只有真正发出 ACTION_CLICK 或手势才计次。回归测试:onlyClick 按钮不可见时连扫三次不计次,变为可见后仍能点击。

单元测试共 61 个,全部通过。

startSkipAdProcess() clears the per-process click attempts. A click
whose performAction() was still blocking on the worker when the user
left and re-entered the app then recorded itself into the fresh table,
so the same button in the new ad was either held back by the retry
interval or escalated straight to a gesture.

Every startSkipAdProcess() now bumps a generation counter. A traversal
stops as soon as the generation changes, and a click only records its
attempt when it completes under the generation it started in.

Adds two controlled-concurrency tests; 63 unit tests in total.
@zfdang

zfdang commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

已修复,见提交 keep in-flight clicks and scans from touching a newer skip-ad process:

按建议引入流程代数 skipAdGeneration,每次 startSkipAdProcess() 递增。遍历开始时捕获代数,代数变化即停止遍历(候选关键字节点也不再点击);clickNode 在点击发出后只在代数未变时记录尝试,旧流程返回的调用不会写入新流程的计数表。

回归测试(受控并发,2 条):performAction 阻塞期间切出再进入应用,旧点击返回后不计入新表,同一按钮在新广告里仍是首次 ACTION_CLICK、不被 500ms 拦截也不升级手势;旧遍历在新流程开始后停止,不会点击新流程窗口里的节点。单元测试共 63 个,全部通过。

The generation check before recording a click attempt was still a
check-then-act: the worker could pass the check, get descheduled, and
write into the table that startSkipAdProcess() had cleared in between.

Click state now lives in a ClickSession object. startSkipAdProcess()
installs a fresh session; a traversal captures the session it started
under and passes it to every click, so an attempt is recorded in that
session no matter when the click completes. A click that finishes after
the user re-entered the app lands in the discarded old session and can
never hold back or escalate the first click of the new ad. The stale
traversal itself still stops as soon as the session changes.

Test asserts the record ends up in the old session and the new one stays
empty; 63 unit tests in total.
@zfdang

zfdang commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

已修复,见提交 give every skip-ad process its own click session:

按建议改为每轮流程持有独立状态对象:startSkipAdProcess() 新建 ClickSession(内含点击计数表),遍历开始时捕获该对象并传递给每一次点击,尝试记录只写入捕获到的 session。无论线程在校验与写入之间如何被调度,旧点击写的都是已被丢弃的旧 session,新流程的表不可能被污染,因此也不再需要代数校验与写入之间的同步。旧遍历仍在 session 变更时立即停止。

回归测试更新为直接断言:旧点击返回后记录落在旧 session(1 条),新 session 为空(0 条)。单元测试共 63 个,全部通过。

The final attempt of a widget rule cleared the shared setTargetedWidgets
field. When that attempt completed after the user had re-entered the
app, it switched off the widget rules of the new process, which reuses
the same rule set.

The flag now lives in the ClickSession the scan started under and only
that session's rules are considered done; scheduling reads the rules
through activeWidgetRules(), which hides them once the current session
is finished.

Test blocks the old process inside its final gesture, starts a new
process and checks the rules stay active; 64 unit tests in total.
@zfdang

zfdang commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

已修复,见提交 keep the widget-rules-done flag per skip-ad process:

"控件规则已完成"状态也放进 ClickSession(widgetRulesDone),FINAL 只标记扫描开始时捕获的那个 session;调度扫描统一通过 activeWidgetRules() 读取规则(当前 session 已完成则视为无规则),setTargetedWidgets 字段本身不再被点击路径修改。旧流程的最后一次点击无论何时返回,都只会关闭旧 session 的规则。

回归测试:旧流程的第二次(最终)手势在发出前阻塞,期间切出再进入应用启动新流程(复用同一规则 Set),旧手势返回后断言新流程的控件规则仍然有效,且新广告里的按钮仍被规则处理。单元测试共 64 个,全部通过。

@zfdang
zfdang merged commit f8784d3 into master Sep 22, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant