Android 上来电怎么判:7 个条件加 5 个中文词
Andee 是一个跑在 Android 上、靠无障碍服务读屏和点按的 agent,他和你共用同一台手机、同一个麦克风。在这种前提下做一个常开的语音唤醒入口,第一个要解决的问题不是识别率,而是什么时候必须闭嘴 。结论先给:判断"是否在通话"不要用 AudioManager.getMode(),改用通知;通话状态用 Set<String> 而不是 Boolean;唤醒词能不能拿麦克风,交给一个七项合取的门。
这几天行业里在谈同一件事的另一面。11 月 5 日 Baseten 发布 Sandboxes,给每个 agent 一台独立 microVM,断开就快照、25 毫秒恢复;10 月 22 日 Google 的 Agent Compute Engine、10 月 23 日 AWS 的 Infinity 都在解"agent 空闲时怎么把机器收回去"。云端 agent 的架构核心问题是怎么在没人用的时候省钱 。本机 agent 恰好相反:机器一直有人用,核心问题是怎么在别人要用的时候让开。这条路上公开的写法很少,这篇把我踩过的顺序、状态形状和四个覆盖不到的洞摊开。
一、getMode() 为什么不行
第一版就是轮询 AudioManager.getMode(),真机上三个坑:
MODE_IN_CALL在部分 OEM 上需要电话权限,不给就恒为MODE_NORMAL。MODE_IN_COMMUNICATION的置位时机是本 App 已经打开自己的录音之后 。等它变成真,唤醒词那条AudioRecord早就把麦克风占住了------它描述的是既成事实,不是预警。- 响铃窗口完全不在它的语义里。电话在响、还没接通,系统模式仍是普通模式,但麦克风已经被来电 App 拿走了。
所以判据必须换成事件源。通知是系统在事件发生当下发出的,比任何轮询都早。
二、两层判定,五个中文词兜底
kotlin
private fun isCallNotification(sbn: StatusBarNotification): Boolean {
if (sbn.notification.category == Notification.CATEGORY_CALL) return true
if (sbn.packageName in CN_VOICE_APPS) {
val title = sbn.notification.extras
?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
val text = sbn.notification.extras
?.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty()
for (pat in CN_CALL_KEYWORDS) if (pat in title || pat in text) return true
}
return false
}
第一层是平台契约,AOSP 电话、WhatsApp、Telegram 都老实标 CATEGORY_CALL,命中就不用看别的。
第二层是为微信和 QQ 准备的。这两个包不设 CATEGORY_CALL ------2026-09-22 在真机上抓到的那条语音通话通知,cat=null。它们改用文字区分:语音通话中 / 视频通话中 / 正在语音通话 / 正在视频通话 / 来电。这五个词在这两个 App 的普通聊天通知里不会出现(聊天通知是"张三: 在吗"或"13个联系人发来66条消息"这种),所以文案匹配在这里是安全的。
这五条串禁止走 AppLocale。 它们是对方 App 写在自己通知里的字,跟随对方的语言设置,不跟随我们的。把它们翻译过来,用户看到的字一个都没变,只会让检测在语言真的会切换的设备上静默失效------漏掉一通电话,没有任何日志。
三、状态形状:Set 而不是 Boolean
两个边缘会让布尔值出错:
| 边缘 | 现象 | 布尔值的错法 |
|---|---|---|
| 并发通话 | WhatsApp 未接来电横幅弹出时,电话仍在响 | 第一个 onNotificationRemoved 就清零,麦克风在一通进行中的电话里被打开 |
| 通知更新 | 同一通电话从"正在呼叫"变成"通话 00:12",onNotificationPosted 再触发一次 |
按次数计数永远清不干净 |
解法和边界都被同一个选择吃掉:用 StatusBarNotification.key(平台给的唯一标识)做元素,放进并发集合。post 两次看起来像一次,remove 只摘掉那一个。
kotlin
private val activeKeys = ConcurrentHashMap.newKeySet<String>()
val isActive: Boolean get() = activeKeys.isNotEmpty()
59 行,没有更多。但有一处必须写:onListenerDisconnected() 里要 reset()。原因是------监听器被解绑之后,再没有人会来报告"那通电话的通知没了",那个 key 会永久留在集合里,唤醒词从此拿不到麦克风。设置页开关看着是开的,人叫不醒它,也没有任何异常。
四、七项合取的门
真正限制唤醒词的是一个布尔表达式,不是一段流程:
kotlin
gate = {
!exitRequested && wakeListeningAllowed() && !asr.isActive() && !tts.isSpeaking() &&
!meeting.isActive() && !CallState.isActive && !Earcon.isSounding()
}
七项里两项值得单独说:
!tts.isSpeaking():否则他会听见自己念出接近唤醒词的音,被自己叫醒。!Earcon.isSounding():那两声"叮"是他自己合成的,但扬声器在出声、音频资源在被占,和"别人拿着麦克风"是同一类占用。
wakeListeningAllowed() 是 screenOn || wakeWhileLocked,后者来自设置里那行「锁屏时也听」。这条曾经只有"屏幕亮着",后果是口袋里那台手机永远叫不醒------注释里原话是"This term used to be the screen alone, and that is the whole reason a phone in a pocket answered nothing."
监管线程 POLL_MS = 100L,任何一项翻假,下一轮就 mic.stop()。麦克风被别的东西占住打不开时退 2_000L 再试,一次唤醒后 2_500L 内不响应第二次,防止一句拖长的话启动两个回合。没录过唤醒词时不持有麦克风------未配置的安装不该让状态栏绿点亮着。
消费这个状态的地方有三处,缺一处就会出现"电话里他突然开口":
startTurn():命中CallState.isActive就只弹一句字幕后return。speakCardText():meeting.isActive() || CallState.isActive直接不出声。followUpWindow():通话中不打开"等下一句"的窗口。
还有一条不对称,写在这里因为它是设计决定:收听回合失去音频焦点时,只有来电终止回合 ;其他抢占者(视频自动播放、消息音)最多要回两次焦点(MAX_REGRABS = 2),要不到就继续听。半句话比丢掉焦点贵。
五、代价
四件事按严重程度排:
- 只覆盖通知层。 App 内的 VoIP(游戏语音、某些会议软件的临时通话)既没有
CATEGORY_CALL,也不在那两个包名里,检测不到,那段麦克风照样会抢。 - 五个中文词是硬编码的。 微信切换成英文界面,这层兜底失效。代码注释说明这是有意的:它只为微信和 QQ 存在,其他 App 要么走平台契约,要么本来就无法和聊天消息区分。
- 100 毫秒的粒度。 刚响就挂的电话可能落在两次轮询之间,留下半声噪声,或者干脆没被记上。
- 没有内核隔离。 云端那台 microVM 有独立内核;他和宿主共用同一个内核、同一个电量、同一个用户身份。这是"更近",不是"更安全"。
如果只看一句话:云端的 agent 在优化"不被使用时的成本",本机的 agent 在优化"被使用时的礼貌"。两套代码几乎不可能互相复用。