1. 短信监听为什么总在真机上翻车
Android 短信接收这件事,看起来只是注册一个ContentObserver就完事,但真机跑起来经常出现「能拦截、拿不到内容」「onChange 只回调一次」「Android 13 之后直接静默失败」这些情况。核心检索词先摆出来:ContentObserver 是 Android 里监听ContentProvider数据变化的观察者,短信场景下它监听的是content://sms/这个数据库 URI;它能做到的是在短信写入收件箱后收到通知,适合做验证码回填、支付二次确认、自动化测试这类需要感知新短信的场景;适合已经会写 Activity/Service、但对 ContentProvider 和运行时权限不熟的中级开发者。
我试过在 Android 10 到 Android 14 的机器上跑同一套监听代码,差异非常大。Android 9 及以前,READ_SMS一个静态权限就能读收件箱;Android 10 开始收紧了content://sms/的访问;Android 13 把POST_NOTIFICATIONS拆出来,Android 14 又对前台服务类型做了限制。所以本文不会只给你一段onChange,而是把权限声明、注册/注销时机、查询字段、以及如何用 TaoToken 统一 Key/API 通道做验证串成一条能跑通的链路。目标很明确:你照着配完,能在 Logcat 里看到新短信的address和body,并且确认 TaoToken 的配置骨架生效。
先明确一个边界:ContentObserver 监听的是「数据库变化」,不是「短信到达广播」。SMS_RECEIVED_ACTION广播在 Android 4.4 之后只有默认短信应用能收到,普通 App 拿不到。所以如果你的 App 不是默认短信应用,ContentObserver 是更现实的路子,但它依赖READ_SMS权限,且查询时机要处理好——onChange触发时数据可能还没完全落库,直接查可能拿到空 Cursor。
2. TaoToken 前置:把 Key 和 API 通道先理清
在写监听代码之前,先把验证通道搭好。TaoToken 在这里的角色是统一 Key 管理和 API 通道,你不需要在 App 里散落多个厂商的 Key,而是通过一个配置骨架统一走。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api (这个不加 UTM)。
你需要先拿到 API Key,入口在控制台的 API Keys 页面:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。拿到之后不要硬编码进 Java 代码,而是放进settings.json或local.properties,通过 BuildConfig 注入。这样做的原因是:短信监听这类代码经常要跑在测试机上,Key 泄露风险高,集中管理比散落安全。
配置骨架的核心字段就三个:baseUrl、apiKey、model。baseUrl固定指向https://taotoken.net/api,apiKey从控制台复制,model按你实际要验证的模型填。如果你只是想确认通道通不通,用模型对话页面手动发一条最快:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。如果你后面要做长期编码或 Agent 类任务,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面写了请求头和返回结构。Claude Code 相关的接入说明在 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claude_code&utm_campaign=rewrite 。这些链接先存着,第 4 节验证请求会用到。
3. 可复制配置:权限、Observer、settings.json
3.1 AndroidManifest 权限声明
先解决权限。注意WRITE_SMS在 Android 4.4 之后对非默认短信应用已经无效,声明了也不会授予,但保留声明不会报错。真正需要的是READ_SMS和RECEIVE_SMS。
<uses-permission android:name="android.permission.READ_SMS" /> <uses-permission android:name="android.permission.RECEIVE_SMS" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <application> <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> </application>POST_NOTIFICATIONS是 Android 13 起必须的,否则你的前台服务通知不显示,监听容易被系统杀掉。READ_PHONE_STATE在部分机型上查询短信时会用到,保留。
3.2 运行时权限申请
静态声明不够,Android 6.0 起READ_SMS是危险权限,必须运行时申请。
private static final int REQ_SMS = 1001; private void requestSmsPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (checkSelfPermission(Manifest.permission.READ_SMS) != PackageManager.PERMISSION_GRANTED) { requestPermissions( new String[]{Manifest.permission.READ_SMS, Manifest.permission.RECEIVE_SMS}, REQ_SMS); } else { registerSmsObserver(); } } else { registerSmsObserver(); } } @Override public void onRequestPermissionsResult(int code, String[] perms, int[] results) { super.onRequestPermissionsResult(code, perms, results); if (code == REQ_SMS && results.length > 0 && results[0] == PackageManager.PERMISSION_GRANTED) { registerSmsObserver(); } else { Log.e(TAG, "READ_SMS denied, observer not registered"); } }3.3 ContentObserver 完整实现
这是核心。相比原始片段,我做了三处修正:onChange里用getContentResolver().query而不是废弃的managedQuery;查询条件用date > lastTime避免重复处理;while (cursor.moveToNext())而不是错误的while (cursor.isLast())。
public class SmsObserver extends ContentObserver { private static final String TAG = "SmsObserver"; private static final Uri SMS_URI = Uri.parse("content://sms/inbox"); private final Context ctx; private final Handler handler; private long lastQueryTime = 0L; public SmsObserver(Handler handler, Context ctx) { super(handler); this.handler = handler; this.ctx = ctx; } @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); Log.i(TAG, "onChange uri=" + uri + " selfChange=" + selfChange); queryLatestSms(); } private void queryLatestSms() { Cursor cursor = null; try { cursor = ctx.getContentResolver().query( SMS_URI, new String[]{"_id", "address", "body", "date", "read"}, "date > ?", new String[]{String.valueOf(lastQueryTime)}, "date desc" ); if (cursor == null) { Log.w(TAG, "cursor is null, maybe no permission"); return; } while (cursor.moveToNext()) { String address = cursor.getString(cursor.getColumnIndexOrThrow("address")); String body = cursor.getString(cursor.getColumnIndexOrThrow("body")); long date = cursor.getLong(cursor.getColumnIndexOrThrow("date")); lastQueryTime = Math.max(lastQueryTime, date); Log.i(TAG, "sms from=" + address + " body=" + body + " date=" + date); Message msg = handler.obtainMessage(MSG_SMS_BACK_1); Bundle b = new Bundle(); b.putString("address", address); b.putString("body", body); msg.setData(b); handler.sendMessage(msg); } } catch (SecurityException e) { Log.e(TAG, "query sms SecurityException", e); } finally { if (cursor != null) cursor.close(); } } }3.4 注册与注销
注册用registerContentObserver,注销必须成对,否则 Activity 销毁后泄漏。
private SmsObserver smsObserver; private void registerSmsObserver() { if (smsObserver != null) return; smsObserver = new SmsObserver(new Handler(Looper.getMainLooper()), this); getContentResolver().registerContentObserver( Uri.parse("content://sms/"), true, smsObserver); Log.i(TAG, "SmsObserver registered"); } @Override protected void onDestroy() { super.onDestroy(); if (smsObserver != null) { getContentResolver().unregisterContentObserver(smsObserver); smsObserver = null; Log.i(TAG, "SmsObserver unregistered"); } }3.5 settings.json 配置骨架
把 TaoToken 的通道配置抽出来,放在app/src/main/assets/settings.json:
{ "taotoken": { "baseUrl": "https://taotoken.net/api", "apiKey": "${TAOTOKEN_API_KEY}", "model": "your-model-name", "timeoutMs": 30000, "headers": { "Content-Type": "application/json", "Authorization": "Bearer ${TAOTOKEN_API_KEY}" } }, "sms": { "observerUri": "content://sms/inbox", "queryLimit": 20, "filterAddress": "" } }${TAOTOKEN_API_KEY}在构建时通过build.gradle的buildConfigField替换,不要提交真实 Key 到仓库。
android { defaultConfig { buildConfigField "String", "TAOTOKEN_API_KEY", "\"${project.findProperty('TAOTOKEN_API_KEY') ?: ""}\"" } }本地gradle.properties里写TAOTOKEN_API_KEY=你的Key,.gitignore排除它。
4. 验证请求:确认监听和配置都生效
4.1 验证短信监听
装到真机后,用另一台手机给测试机发一条短信。Logcat 过滤SmsObserver,应该看到:
I/SmsObserver: onChange uri=content://sms/ selfChange=false I/SmsObserver: sms from=106xxxxxxxx body=【验证码】123456 date=1710000000000如果只看到onChange没有sms from,说明查询被拦了,检查READ_SMS是否真的授予。用adb shell dumpsys package your.package | grep READ_SMS确认。
4.2 验证 TaoToken 配置
配置生效的验证分两步。第一步,用 curl 直接打 API,确认 Key 和 baseUrl 对:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "your-model-name", "messages": [{"role": "user", "content": "ping"}] }'返回里如果有choices字段,说明通道通。第二步,在 App 里读settings.json并打印:
InputStream is = getAssets().open("settings.json"); BufferedReader br = new BufferedReader(new InputStreamReader(is)); StringBuilder sb = new StringBuilder(); String line; while ((line = br.readLine()) != null) sb.append(line); br.close(); JSONObject root = new JSONObject(sb.toString()); JSONObject tt = root.getJSONObject("taotoken"); Log.i(TAG, "baseUrl=" + tt.getString("baseUrl") + " model=" + tt.getString("model") + " keyLen=" + BuildConfig.TAOTOKEN_API_KEY.length());keyLen大于 0 且baseUrl是https://taotoken.net/api,就说明配置骨架生效。想手动确认模型可用性,去模型对话页面发一条:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。
4.3 把短信内容接到验证链路
监听拿到body后,可以把它作为 prompt 的一部分发给 TaoToken 做解析,比如提取验证码。这一步不是必须的,但能验证「监听 + 通道」整条链路:
private void verifySmsWithTaoToken(String smsBody) { new Thread(() -> { try { JSONObject payload = new JSONObject(); payload.put("model", BuildConfig.TAOTOKEN_MODEL); JSONArray messages = new JSONArray(); JSONObject userMsg = new JSONObject(); userMsg.put("role", "user"); userMsg.put("content", "从这条短信里提取6位数字验证码,只返回数字:" + smsBody); messages.put(userMsg); payload.put("messages", messages); OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .build(); Request req = new Request.Builder() .url(BuildConfig.TAOTOKEN_BASE_URL + "/v1/chat/completions") .addHeader("Authorization", "Bearer " + BuildConfig.TAOTOKEN_API_KEY) .addHeader("Content-Type", "application/json") .post(RequestBody.create(payload.toString(), MediaType.parse("application/json"))) .build(); Response resp = client.newCall(req).execute(); Log.i(TAG, "taotoken resp code=" + resp.code() + " body=" + resp.body().string()); } catch (Exception e) { Log.e(TAG, "verifySmsWithTaoToken failed", e); } }).start(); }返回 200 且 body 里有数字,整条链路就通了。
5. 本篇常见错排查
5.1 onChange 不回调
最常见原因是权限没给。Android 6.0 以上READ_SMS必须运行时申请,只在 Manifest 声明无效。另一个原因是注册的 URI 写错,必须是content://sms/或content://sms/inbox,写成content://sms/inbox/带尾斜杠部分机型不触发。
5.2 能回调但 Cursor 为空
onChange触发时数据可能还没落库,直接查会拿到空 Cursor。解决办法是在onChange里延迟 500ms 再查,或者用date > lastQueryTime条件配合重试。原始片段里while (cursor.isLast())是逻辑错误,isLast()返回布尔值,循环体根本不会按预期执行,应该用while (cursor.moveToNext())。
5.3 Android 13/14 上监听被系统杀
Android 13 起后台进程限制更严,ContentObserver 在 App 进入后台后可能不再收到回调。需要配合前台服务,并在 Manifest 里声明foregroundServiceType。Android 14 要求前台服务类型明确,短信相关可以用dataSync或specialUse,具体看你的场景。
5.4 TaoToken 请求 401
先确认Authorization头是Bearer加 Key,中间有空格。再确认 Key 没有多余换行,从控制台复制时容易带上。如果还是 401,去 API Keys 页面重新生成一个:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。接入细节看文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
5.5 查询报 SecurityException
content://sms/inbox在部分厂商 ROM 上需要额外权限,比如小米的READ_SMS之外还要开「短信权限」开关。捕获SecurityException并打日志,能快速定位是权限问题还是 URI 问题。
6. 接入与排障的下一步
如果你卡在权限或 Observer 注册上,优先去 API Keys 页面确认 Key 状态,再对照接入文档检查请求头:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 和 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果只是想快速验证模型通道,用模型对话页面手动发一条最省事:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。长期要做编码或 Agent 类任务,Coding Plan 的入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
最后留一个实操建议:把lastQueryTime持久化到 SharedPreferences,App 重启后不会重复处理旧短信。这个细节在验证码场景里很关键,否则用户每次打开 App 都会看到历史验证码被重新解析。