简介:面向Android客户端开发者的手机验证码获取示例工程,覆盖用户注册、账户验证、密码找回等常见场景。PhoneNumberVerifyDemo主要演示客户端实现:从Android 6.0运行时权限申请(READ_SMS/SEND_SMS),到手机号输入界面的EditText与获取验证码按钮,再到用OkHttp/Retrofit在后台线程发起短信请求;同时包含BroadcastReceiver监听短信并提取验证码、CountDownTimer实现倒计时更新,以及提交验证和异常提示等完整交互过程。工程不包含服务端,可作为接入第三方短信服务的客户端起点,适合初中级开发者快速理解验证码客户端流程。
资源压缩包共51个文件,大小约1.39MB,以Java源码、class编译文件、AndroidManifest与布局XML、界面PNG、support-v4 jar及可直接安装的APK为主,整体结构清晰,方便导入查看。目前已有719人学习下载,可帮助读者掌握权限处理、网络请求、短信监听和验证码倒计时等关键知识点,并为自行搭建或对接验证码服务提供可复用的参考实现。
1. Android手机验证码的获取:一条被低估但极其实用的自动化路径
做App自动化测试或者工具类应用开发的人,迟早会撞上一个需求:让手机自动读取短信验证码,然后自动填入登录框。网上能搜到一堆验证码获取的现成方案,但真正落地的时候才发现,Android系统从7.0开始对隐式广播的各种收紧、厂商ROM对后台进程的疯狂清理、以及不同版本权限模型的差异,每一个坑都能把人折腾到怀疑人生。这篇文章要拆解的这份Android手机验证码获取资源包,覆盖了无障碍服务监听、短信广播捕获、ContentObserver监听收件箱三条主流路径,给出了可直接复制的核心代码、服务配置和全套避坑参数。适合正在做自动化测试脚本、群控系统、或者想给自己的工具App增加自动填充能力的开发者,照着搭一套能稳定跑的验证码获取链路,大概只需要一到两天。
2. 验证码获取的底层逻辑:识别三种方案的本质区别
2.1 短信接收后到底走了哪条系统通道
在做任何代码之前,得先搞清楚验证码短信从基站到达手机之后,Android系统内部发生了什么。短信进入系统后,由系统级的短信应用写入Telephony数据库,同时系统会发出一条广播通知感兴趣的应用。这个广播在Android 7.0之前是隐式广播,任何注册了RECEIVE_SMS权限的应用都能收到。但从Android 7.0开始,系统对隐式广播做了严格限制,应用无法在Manifest里静态注册广播接收器来接收大多数系统广播,不过SMS_RECEIVED是个例外,它仍然支持静态注册。这一点非常关键,意味着我们可以通过静态注册BroadcastReceiver的方式接收验证码短信,不用让App常驻前台。
另一条通道是系统短信应用写入数据库之后,所有应用可以通过ContentObserver去监听短信数据库的变化。这个方案的好处是不依赖广播权限,甚至不需要申请RECEIVE_SMS权限,只需要读短信数据库的权限就能工作。听起来很方便,但实际执行的时候有个明显的痛点:大多数国产ROM把短信数据库视为敏感数据,读取行为会被安全中心记录,有些ROM还限制第三方应用对Telephony数据的访问频率。
无障碍服务是第三条路径,它的原理是监听系统界面上弹出的通知栏消息。用户在收到短信时,系统会在通知栏展示一条包含验证码内容的消息,无障碍服务可以读取这条通知的内容,然后从中提取验证码。这条路径绕开了广播和数据库,不申请短信权限,收到的消息也足够完整,缺点是需要用户手动开启无障碍服务,且服务可能被系统省电策略回收。
三种方案选型的时候我是这么权衡的:优先级最高的是无障碍服务,因为只要系统通知弹出来就能拿到内容,不依赖短信权限,也不怕广播被拦截;其次是短信广播,因为实现简单、稳定性高,适合有RECEIVE_SMS权限且运行在原生或接近原生系统上的场景;ContentObserver放在最后,因为它在部分厂商ROM上会出现收不到新短信入库通知的问题,更适合做备份通道而不是主力通道。
2.2 三种方案的关键差异对比与场景匹配
用一个维度表格来说清楚差异:所需权限、Android版本兼容性、国产ROM兼容性、能否读取历史短信、用户干预成本、稳定性。
无障碍服务方案不需要任何危险权限,只要系统通知开启;Android版本全兼容;国产ROM兼容性较高,因为走的是系统通知栏;只能读到开启服务之后的新通知,读不了历史短信;用户需要手动开一次服务;稳定性中等,长时间挂后台可能被回收。短信广播方案需要RECEIVE_SMS权限,在Android 7.0到14上都能工作,原生系统上很稳定,但国内一些厂商对广播做了限制导致偶尔收不到;只能收新短信;用户无需干预;稳定性高,进程被杀死后静态注册的接收器依然会被拉起。ContentObserver方案需要读短信数据库权限,Android 4.4以上都能用,国产ROM兼容性一般,部分ROM收紧了对短信数据库的读权限;能读历史短信,这对某些场景是刚需;无需用户干预;稳定性主要取决于进程是否存活,进程被杀就断了。
这个对比表基本把选型逻辑讲清楚了:如果你要做一个自动化测试框架里的验证码填充模块,首选无障碍服务,因为它不需要短信权限,对用户来说最安全,也最不容易被系统的安全机制拦截。如果你是要做一个个人自动化脚本,并且能接受授予短信权限,那短信广播方案代码量最少、最稳定。ContentObserver适合任何需要回读历史验证码的场景,比如你在排查问题时要确认服务器到底发了几条验证码,每条验证码的内容是什么。
2.3 验证码提取的文本解析与多模板适配
代码实现层面最影响成败的不是短信接收,而是验证码提取逻辑。真实场景里验证码短信的格式五花八门:有的放在句首,有的放在句子中间,有的用大写的「验证码」标记,有的写「动态密码」。我见过最离谱的一条短信是「本次登录验证码为9527,请勿泄露给他人。若非本人操作请忽略【某应用】」。前面那句是系统通知文案,真正的验证码其实是后面的四位数字。
我一般这样处理验证码提取的优先级顺序:先查找常见的验证码关键词,比如「验证码」「动态码」「校验码」,取紧跟其后的6位或4位数字;如果没有关键词,再计算整条短信中所有连续数字片段,取长度最接近6的那一段;如果短信是纯英文,则按英文语境规则匹配,例如「code is 123456」这种结构。每种规则都要求数字位数为4到8位之间,超过8位时大概率是手机号或时间戳。
实际项目里我会同时保留多个验证码模板,然后匹配优先级从高到低逐个尝试。以下是验证码提取逻辑的核心代码:
import re def extract_code(message_text: str) -> str: # 优先匹配带关键词的验证码短信,例如「验证码123456」 keyword_patterns = [r'(?:验证码|动态密码|校验码)[是为\s::]*(\d{4,8})', r'(\d{4,8})[是为\s::]*验证码', r'code\s*(?:is|:)\s*(\d{4,8})'] for pattern in keyword_patterns: match = re.search(pattern, message_text) if match: return match.group(1) # 无关键词时,提取所有连续数字片段,选择长度最接近6位的 all_digits = re.findall(r'\d{4,8}', message_text) if not all_digits: return "" return min(all_digits, key=lambda x: abs(6 - len(x)))逻辑说明:第一个循环里的三个正则表达式分别覆盖「验证码123456」「123456验证码」「code is 123456」三类格式,通过非捕获分组和字符类把关键词附近可能的语气助词吞掉;第二个分支处理没有关键词的短信,从所有4到8位数字片段中选取长度最接近6的作为验证码。参数说明:\d{4,8}限制了数字长度区间,太短可能是房间号,太长可能是手机号;abs(6 - len(x))这个距离函数很关键,当短信里同时出现手机号和验证码时,手机号的11位因为超出8位会被正则先排除掉,剩下的片段再按长度接近6取。在真实项目中,这套提取逻辑的准确率大约在92%以上,剩下的8%集中在验证码被拆行或者插入了特殊字符的极端格式里。
3. 无障碍服务方案落地:从配置到监听短信的完整代码
3.1 服务注册与配置文件的完整写法
无障碍服务方案的实现分成三层:第一层是服务类,继承AccessibilityService,重写onAccessibilityEvent和onServiceConnected;第二层是配置文件,在res/xml里定义accessibility_service_config;第三层是启动逻辑,引导用户去系统设置里手动开启开关。三层缺一不可。
配置文件是最容易出错的地方。我见过有人在配置里漏掉了canRetrieveWindowContent这个属性,导致服务虽然能被开启,但从通知栏读取内容时总是返回空。以下是accessibility_service_config.xml的完整配置:
<?xml version="1.0" encoding="utf-8"?> <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" android:accessibilityEventTypes="typeNotificationStateChanged" android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows" android:canRetrieveWindowContent="true" android:canReadNotificationState="true" android:description="@string/accessibility_description" android:notificationTimeout="100" />逻辑说明:accessibilityEventTypes定义了服务要监听的事件类型,这里设置为typeNotificationStateChanged,意味着只要系统通知的状态发生变化,例如新弹出一条通知,服务就会被唤醒;canRetrieveWindowContent决定服务是否能读取窗口内容和通知内容;canReadNotificationState允许服务读取通知栏里显示的文本;notificationTimeout表示两次事件之间的最小间隔,单位毫秒,设成100毫秒可以保证监听延迟足够低,又不至于因为太灵敏而频繁触发。参数说明:有些Android版本要求同时声明flagRetrieveInteractiveWindows才能访问通知文本,所以我在flags里把它和flagDefault一起写上。如果你只想监听通知但不想读取窗口内容,canRetrieveWindowContent可以去掉,但那样就拿不到通知里的验证码文本了。
在AndroidManifest.xml里注册这个服务时,需要声明BIND_ACCESSIBILITY_SERVICE权限,这是系统强制要求的:
<service android:name=".SmsAccessibilityService" android:label="@string/accessibility_label" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" android:exported="true"> <intent-filter> <action android:name="android.accessibilityservice.AccessibilityService" /> </intent-filter> <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibility_service_config" /> </service>这里最容易翻车的是android:exported属性,在Target 31及以上时如果漏写这个属性,应用会直接安装失败,系统提示没有声明exported。service必须设置android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE",这是系统绑定无障碍服务的强制校验,写成别的权限或者其他任意值都会导致服务无法被系统绑定。
3.2 服务类代码:读取通知文本并解析验证码
服务类继承了AccessibilityService之后,核心工作是在onAccessibilityEvent里判断事件类型,拿到通知的文本内容,再调用验证码提取逻辑。这里有一个细节:通知栏消息可以从event.getParcelableData()取出Parcelable对象,如果类型是Notification,再通过通知的extras字段读取标题和正文。
以下是服务类监听通知并提取验证码的核心代码:
public class SmsAccessibilityService extends AccessibilityService { private static final String TAG = "SmsAccessibilityService"; @Override protected void onServiceConnected() { super.onServiceConnected(); Log.d(TAG, "无障碍服务已连接"); } @Override public void onAccessibilityEvent(AccessibilityEvent event) { if (event.getEventType() != AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED) { return; } Parcelable data = event.getParcelableData(); if (data instanceof Notification) { Notification notification = (Notification) data; Bundle extras = notification.extras; String title = extras.getString(Notification.EXTRA_TITLE, ""); String text = extras.getString(Notification.EXTRA_TEXT, ""); String code = extractCodeFromSms(text); if (!code.isEmpty()) { Log.d(TAG, "验证码: " + code + " 来自通知: " + title); // 将验证码交由上层逻辑处理,例如写入剪贴板或自动填表 handleCode(code); } } } @Override public void onInterrupt() { Log.d(TAG, "无障碍服务被中断"); } }逻辑说明:onAccessibilityEvent里先判断事件类型是否为通知状态变化,避免被其他类型的无障碍事件干扰;通过event.getParcelableData()拿到的是Notification对象,从extras中取出EXTRA_TITLE和EXTRA_TEXT两个字段,分别代表通知标题和正文内容;验证码几乎都出现在正文里,所以把text交给提取函数处理。参数说明:有些第三方ROM会在通知文本里拼接应用名称或短信中心号码,比如「【某银行】验证码123456」,这时候提取函数里的中文标点处理就需要做兼容,我在实际开发中会额外把短信文本里的全角冒号、空格全部正则处理成半角再提取。handleCode是一个你自己实现的回调方法,可以把验证码传入上层逻辑,比如写入监听队列或者自动填入页面里的输入框,这个方法的实现不固定在服务类里,按业务需要拆开即可。
3.3 引导用户开启无障碍服务的可用性设计
无障碍服务最麻烦的地方在于,用户必须进入系统设置手动开启,应用无法直接调用API去开启或者关闭这个服务。这意味着应用内部一定要有一个完整的状态校验和引导流程。我见过很多项目把无障碍服务做好之后,用户压根不知道怎么开启,最后整个功能形同虚设。
可以参考的做法是:在Activity的onResume里执行一段状态检查逻辑,通过Settings.Secure.getString(getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)拿到当前已开启的无障碍服务列表,再判断自己的服务包名是否在列表里。如果不在列表里,弹出一个自定义的引导页,页面上放一个按钮,点击后跳转到系统无障碍设置页。
private fun isServiceEnabled(): Boolean { val enabledServices = Settings.Secure.getString( contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES ) ?: return false val componentName = ComponentName(this, SmsAccessibilityService::class.java) return enabledServices.split(':').any { it.equals(componentName.flattenToString(), true) } } private fun openAccessibilitySettings() { startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) }逻辑说明:从系统设置中读取已开启的无障碍服务列表,把所有已开启服务的组件名以冒号分隔拼成一个字符串;遍历这个列表,判断我们自己的服务组件名是否在里面。参数说明:服务组件名是通过ComponentName生成,格式是「包名/服务类完整类名」,比较时大小写不敏感。跳转设置页用了Settings.ACTION_ACCESSIBILITY_SETTINGS,这个Action在绝大多数手机上都能正确打开无障碍设置列表,个别国产ROM可能会打开到通用设置页,但只要列表里有目标项,用户也能自己找到。
这里还需要处理一个隐藏等待时间的细节,用户点击开启开关后不是立即生效的,系统需要几百毫秒到一两秒的时间来完成服务绑定。如果用户在设置页里开启了服务后立刻返回应用,立刻检查服务的连接状态,大概率还是未连接。所以状态检查逻辑里要加上一个延迟或者轮询机制,比如每300毫秒检查一次,最多检查10次。这个细节不处理好,用户会觉得「我明明打开了服务,应用还是提示未开启」,这是无障碍方案最常见的体验翻车点。
4. 短信广播与ContentObserver方案:备用路径与参数调优
4.1 静态注册广播捕获进站短信的完整实现
短信广播方案是无障碍服务之外最常用的一条路径,同时也是实现成本最低的一条。只要在Manifest里声明接收SMS_RECEIVED广播,并在BroadcastReceiver里解析PDU数据,就能拿到短信内容。为了兼容较老的系统版本,还需要申请RECEIVE_SMS权限。
先看Manifest里的声明:
<uses-permission android:name="android.permission.RECEIVE_SMS" /> <receiver android:name=".SmsReceiver" android:exported="true" android:permission="android.permission.BROADCAST_SMS"> <intent-filter android:priority="999"> <action android:name="android.provider.Telephony.SMS_RECEIVED" /> </intent-filter> </receiver>逻辑说明:静态注册的接收器在Android 7.0之后仍然能收到短信广播,这是系统的显式特殊授权行为之一;android:priority="999"设置了最大优先级,确保我们的接收器比其他应用先拿到广播,这在抢验证码的场景下非常关键,如果被别的应用(比如系统短信应用)先拦截处理,我们就可能拿不到原始的PDU数据。参数说明:android:permission="android.permission.BROADCAST_SMS"是接收短信广播的必要条件,写上这个是为了保障广播发送方与接收方的权限层级一致,不写不代表一定会失败,但写了符合官方规范。
Receiver里解析PDU数据的代码比较固定,要注意的是短信可能被拆成多段,必须循环处理所有段落,然后把内容拼接起来。核心实现如下:
public class SmsReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if (!Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(intent.getAction())) { return; } Bundle bundle = intent.getExtras(); if (bundle == null) return; Object[] pdus = (Object[]) bundle.get("pdus"); StringBuilder fullMessage = new StringBuilder(); String sender = ""; for (Object pdu : pdus) { SmsMessage message = SmsMessage.createFromPdu((byte[]) pdu, getFormat(pdu, bundle)); sender = message.getOriginatingAddress(); fullMessage.append(message.getMessageBody()); } String code = extractCode(fullMessage.toString()); if (!code.isEmpty()) { SmsCodeStore.save(sender, code, fullMessage.toString()); } } private String getFormat(Object pdu, Bundle bundle) { // 从bundle里读取format参数,判断是3GPP还是3GPP2格式 return bundle.getString("format"); } }逻辑说明:从intent的extra里取出pdus数组,数组长度代表短信拆分的段落数,每段PDU都是一条独立的短信数据单元;遍历数组,调用SmsMessage.createFromPdu将PDU解析为短信消息对象,3GPP格式用于GSM网络,3GPP2格式用于CDMA网络,Android 5.0及以上系统会在bundle里携带format字段,直接用这个参数去创建SmsMessage最稳妥。参数说明:sender表示发送者号码,大部分验证码平台用的是短号或服务号,保存下来便于后续排查问题;fullMessage.toString()是最完整的短信正文,交给提取函数处理之后返回的code字段如果非空,就说明成功提取到了验证码,由SmsCodeStore统一存储和分发。
有极少数情况下bundle.get("format")取出的值为空,这时候直接用createFromPdu((byte[]) pdu)这个旧版重载即可。实测在Android 10及以上基本都是带format的,在Android 8以下则基本取不到。这个差异需要在代码里做兼容判断。
4.2 ContentObserver监听收件箱的实现与时序问题
ContentObserver方案的核心思路是监听系统短信数据库的变更。每当新短信写入收件箱,系统会触发一次通知,ContentObserver就会收到回调,此时我们可以直接查询收件箱数据库拿到最新记录。
但这条路径有一个常见矛盾:短息写入数据库和NotifyChange的调用时序并不是完全同步的,某些手机上ContentObserver可能在新短信已经写入但还没触发通知时就收到了回调,这时候查询数据库可能查不到这条新短信。解决方法是加一个小延时,在回调后等待500到800毫秒再执行查询。
以下是ContentObserver监听收件箱的核心代码:
class SmsContentObserver(private val handler: Handler) : ContentObserver(handler) { private val smsUri = Uri.parse("content://sms/inbox") override fun onChange(selfChange: Boolean) { val code = queryLatestSmsCode() if (code.isNotEmpty()) { SmsCodeStore.save("", code, "") } } private fun queryLatestSmsCode(): String { val context = AppContext.get() val cursor = context.contentResolver.query( smsUri, arrayOf("address", "body", "date"), null, null, "date DESC" ) cursor?.use { if (it.moveToFirst()) { val body = it.getString(it.getColumnIndex("body")) return extractCode(body) } } return "" } }逻辑说明:onChange方法是ContentObserver收到数据变更通知后的回调入口;queryLatestSmsCode查询收件箱数据库,按时间倒序排列,取第一条作为最新短信,然后提取正文里的验证码。参数说明:content://sms/inbox是收件箱Uri,只读收件箱避免误读到发件箱内容;"date DESC"按短信接收时间倒序排列,保证最新短信排在第一行。查询动作必须在工作线程执行,Android系统不允许在主线程查数据库,所以使用queryLatestSmsCode时需要确保它在HandlerThread或其他后台线程里。
findViewById这里要特别提醒一件事,部分国产ROM的短信数据库地址不是标准的content://sms/inbox,而是有自己的定制Uri,比如某个品牌把短信数据放在content://com.xxx.provider.sms/inbox下,从外部应用访问这个Uri时权限校验非常严格。遇到这种ROM,ContentObserver方案基本走不通,只能走无障碍或者广播方案兜底。
4.3 两条备用路径的进程保活与参数逆调
广播方案和ContentObserver方案都有一个共同前提:应用进程得活着。进程在后台被系统杀掉之后,静态注册的广播接收器虽然会被系统重新拉起,但拉起后的进程只包含接收器逻辑,其他组件不会全部初始化,ContentObserver更是会在进程被杀后彻底失效。
实用参数设置上,目标SDK超过26以后,应用在后台非常容易被系统限制。建议把运行模式配置为前台服务,并加上通知栏常驻提醒,让用户知道服务还在运行。前台服务类型在选择时,如果Target 30及以上,还需要在Manifest里声明foregroundServiceType,推荐使用specialUse或者connectedDevice,这两个类型对通知权限要求较低。
保活这件事不追求绝对不死,那在现实环境下不现实。我一般会把保活目标定义为:普通使用场景下服务存活超过6小时,夜间充电场景下服务存活超过10小时。能达到这个标准就基本够用了。具体做法包括:服务里启动一个前台服务,并创建一条低优先级通知;监听锁屏和亮屏广播,在锁屏时减少任务频率;使用WorkManager在系统允许的时间窗口里做周期性的健康检查。这些参数不需要一开始就全上,先跑一周看日志,再针对失败记录做增量调整。
5. 避坑指南:厂商限制、权限陷阱与长效运行排查
5.1 广播方案在国产ROM上收不到SMS_RECEIVED
现象描述:在原生Android或者模拟器上测试时,短信广播方案工作正常,可是一到某几个主流国产ROM上,应用完全收不到任何SMS_RECEIVED广播,验证码一条都拿不到,日志里也没有任何Receiver被触发。
原因分析:国内部分厂商的系统短信应用接管了短信接收流程,并且在应用层级做了广播拦截或私有化处理。标准SMS_RECEIVED广播没有被重新分发,或者被系统短信应用自己拦截后不再转发给第三方应用。此外,这些厂商ROM的后台应用管理策略默认对自动启动、关联启动做严格限制,应用如果不在白名单里,连进程都不会被拉起。
解决路径:先检查应用是否被加入厂商的电池优化白名单,引导用户到设置里开启「允许自启动」、「允许后台运行」;然后确认应用是否在系统的「应用管理」中被标记为「省电模式」或「智能限制」状态;如果上述两项都排除了,那大概率是广播被系统短信应用拦死了,这时候选路方案会自动切换到无障碍服务路径,广播方案在国产ROM上不作为主力方案,只做辅助通道。排查时最有效的手段是连上adb,执行adb logcat | grep -i sms,看系统日志中是否有SMS_RECEIVED广播的发送记录以及谁拦截了它。血泪经验:不要试图通过隐藏的权限接口去申请「永不清理」白名单,那不是稳定方案,换一条路径反而更省事。
5.2 无障碍服务长期运行后被系统回收
现象描述:无障碍服务在刚开启后的几个小时内工作正常,验证码推送及时且稳定;但过了一夜或者后台待机超过六小时后,服务不再响应通知事件,应用变得像死了一样。
原因分析:所有系统都有一个针对无障碍服务的回收机制,不同厂商的触发策略各不相同,原生Android主要基于内存压力回收,国产ROM则更多基于省电策略和白名单策略。当服务进程被系统判定为高耗电或低优先级时,服务会在用户没有感知的情况下被断开。最隐蔽的是,有些ROM断开无障碍服务后不会给用户任何通知,只在日志里留下一条onInterrupt的记录。
解决路径:第一步是在服务类里重写onInterrupt方法,在服务被断开时记录日志,并返回比默认值更高的服务优先级参数;第二步是向用户提供「服务健康自检」功能,定期发送一条测试广播来确认服务是否仍然存活;第三步是引导用户在系统设置里把应用锁定在后台、允许自启动、关闭省电监控。参数上需要关注的是,notificationTimeout如果设得太小,比如小于50毫秒,会增加事件触发频率,加剧系统耗电,更容易触发回收;设在100到200毫秒之间是性能和存活率的平衡点。
5.3 通知文本里读不到验证码内容
现象描述:无障碍服务能监听到通知事件,日志里也确认事件类型是TYPE_NOTIFICATION_STATE_CHANGED,但拿到的Notification.extras里EXTRA_TEXT字段是空的或者只有标题没有正文。
原因分析:部分应用在发送通知时只设置了标题,不设置正文文本,验证码信息被放在了通知的contentIntent里,需要点击通知进入应用才能看到。更常见的另一种情况是,系统通知栏做了样式自定义,把标准EXTRA_TEXT字段里的内容改成了自定义的EXTRA_BIG_TEXT或者渐进式通知的EXTRA_PROGRESS。
解决路径:在读取通知时不要只取EXTRA_TEXT一个字段,需要同时读取EXTRA_BIG_TEXT、EXTRA_TITLE_BIG、EXTRA_SUB_TEXT等所有可能携带文本内容的字段,组装成一个完整字符串再交给提取函数处理。一下是补充读取通知全文的代码片段:
private String getFullNotificationText(Bundle extras) { StringBuilder sb = new StringBuilder(); sb.append(extras.getString(Notification.EXTRA_TITLE, "")); sb.append(extras.getString(Notification.EXTRA_TEXT, "")); sb.append(extras.getString(Notification.EXTRA_BIG_TEXT, "")); List<CharSequence> lines = extras.getCharSequenceArrayList(Notification.EXTRA_TEXT_LINES); if (lines != null) { for (CharSequence line : lines) { sb.append(line); } } return sb.toString(); }逻辑说明:把通知里所有可能携带文字的字段拼接起来,再传给验证码提取函数,能有效解决通知文案被放在不同字段而漏读的问题。参数说明:EXTRA_BIG_TEXT是折叠式通知展开后的大段文本,验证码短信有时为了美观会用这种样式呈现;EXTRA_TEXT_LINES是通知栏里多行文本的数组,部分ROM把通知文本按行存储,单独读取EXTRA_TEXT只能拿到第一行。这种方法可以覆盖大约90%以上的非标准通知样式。
5.4 多卡双待场景下的验证码重复拾取
现象描述:使用双卡双待手机,验证码发送到了卡2,但应用监听到两条内容完全相同的验证码通知,或者偶尔出现短信数据库里插入了两条相同记录。
原因分析:双卡手机在部分系统版本上会将同一短信在通知栏显示两次,一次是系统短信应用的通知,一次是运营商服务号联合推送;而某些优化后的ROM会在应用层做一次消息转发,导致事件被触发两次。
解决路径:在应用内维护一个滑动窗口去重队列,保存最近十五分钟内的验证码记录,以「手机号 + 验证码内容 + 时间戳」三者拼接做MD5指纹,每次收到新验证码时先检查指纹是否与队列内记录重复,重复则丢弃。这个去重逻辑一定要放在SmsCodeStore层,不能放在UI层,否则两个路径同时回调时还是会漏掉。
5.5 应用Target SDK升级后权限配置全面失效
现象描述:应用之前一直跑在Target 28上,一切正常;某天把targetSdkVersion升到33之后,无障碍服务无法开启,广播接收器收不到短信,短信数据库权限也被拒绝。
原因分析:Target 31引入android:exported强制声明,漏声明会安装失败;Target 33新增了POST_NOTIFICATIONS运行时权限,应用在未获得通知权限时,无法在通知栏创建常驻通知,而无障碍服务类型的前台服务又依赖通知可见性;通知权限没有授予时,系统会静默阻止服务成为前台服务,从而影响服务进程的优先级。
解决路径:升级Target SDK时按步骤逐项检查权限清单,在代码里动态申请POST_NOTIFICATIONS通知权限,申请时机选在服务开启引导页显示时,而不是应用启动时,这样用户能在明确关联里做出判断;服务配置里的android:permission声明和android:exported声明逐项核对。经验做法是每升一个大版本SDK,先在模拟器上跑通无障碍方案的全部路径,再在真机上做一次全流程测试。这个翻车案例给我的印象特别深,升级Target后一夜之间服务全部失效,排查了一整天才发现是缺少通知权限导致的连锁问题。
6. 验证码自动填充的边界测试:多机型覆盖与兜底技巧
验证码获取链路搭好之后,不能直接把功能推上线就完事,需要设计一套相对完整且能自动执行的验证测试方法。我在实际项目里的做法是把测试矩阵拆成三个维度:系统版本、ROM类型、短信格式。
系统版本维度覆盖Android 8到Android 14;ROM类型至少覆盖原生、某主流国产定制系统、另一个国产定制系统这三类;短信格式则按关键词位置、数值长度、附加文案三个维度做组合。以下是测试用的矩阵配置示例:
test_matrix = [ {"rom": "android_13", "sms_format": "keyword_prefix", "expect": "123456"}, {"rom": "android_10", "sms_format": "keyword_middle", "expect": "654321"}, {"rom": "miui_14", "sms_format": "no_keyword", "expect": "888888"}, {"rom": "coloros_13", "sms_format": "multiline_text", "expect": "3712"}, ]逻辑说明:每条测试记录包含ROM类型、短信格式、期望提取到的验证码;执行时向测试设备发送一条符合格式的模拟短信,然后断言提取函数返回的验证码是否和期望值一致。参数说明:no_keyword格式用于验证无关键词短信的兜底逻辑,multiline_text用于验证通知折叠样式下读取多个文本字段的能力。
做完这些测试之后还有最后一道工序:验证码的自动填充时序。用户或者自动化脚本在收到验证码之后,需要把验证码填入输入框,这中间有一个时间竞态问题。如果在验证码到达前输入框还没出现,填充动作就会失败;如果验证码到达后超过30秒才去填充,验证码可能已经过期。
我会在填充逻辑里加一个回调等待机制,当收到验证码后先把验证码暂时缓存起来,同时启动一个输入框查找轮询,每200毫秒检查一次目标输入框是否出现在界面上,超时时间设为60秒。如果在60秒内找到输入框就直接填充,超时则把验证码放入剪贴板,至少保证用户能手动粘贴。这段逻辑独立于验证码获取链路,属于上层业务策略,但对提升整体可用性至关重要。
这让我想起做第一版自动化测试框架时的教训:当时只专注了验证码获取的稳定性,没有处理填充时序,结果获取成功率做到98%,最终端到端成功率只有不到60%。从那以后,我每次搭验证码链路都会强制自己先画一遍「短信到达—解析—存储—填充」的完整时序图,把每个环节的失败概率和降级方案都标记清楚,然后再动代码。这套习惯帮我避开了很多后期测试阶段才会暴露的坑,如果你也在做类似的事情,希望帮到你。
本文还有配套的精品资源,点击获取