1. 短信读取这件事,为什么总在真机上翻车
Android 获取最新短信,说白了就是拿ContentResolver去查content://sms/这个系统收件箱,按date desc倒序排一下,取第一条就完事。听起来三行代码能搞定,但真正在真机上跑,你会发现坑一个接一个:模拟器上查得到、真机返回空 Cursor;权限在 Android 13 上申请了还是 SecurityException;查出来的date字段是毫秒时间戳,直接塞进SimpleDateFormat格式化出来是 1970 年;更别提有些厂商 ROM 对短信 Provider 做了额外限制。
这篇就聚焦一件事:在 Android 端通过 ContentResolver 读取收件箱里最新的一条短信,把运行时权限申请、按日期倒序查询、结果解析这条链路完整跑通。同时给出一份 TaoToken 统一 Key/API 通道的settings.json配置骨架,方便你在调试短信解析逻辑时,把模型调用也一并接进来做内容识别或格式化。适合正在做短信验证码自动填充、通知类短信归类、或者单纯想跑通 ContentResolver 查询的开发者。
我试过在几台不同 Android 版本的机器上反复验证,下面这套流程是能稳定拿到最新一条短信的。核心检索词就三个:Android、短信、ContentResolver,围绕它们展开。
2. 前置准备:权限、Provider 与 TaoToken 通道
2.1 短信权限的声明与运行时申请
读取短信属于危险权限,必须在AndroidManifest.xml里声明,然后在运行时动态申请。注意READ_SMS和RECEIVE_SMS是两回事:前者是读已有短信,后者是监听新到短信。本篇只读收件箱,所以核心是READ_SMS。
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.smsreader"> <uses-permission android:name="android.permission.READ_SMS" /> <!-- 如果后续要监听新短信,再加下面这条 --> <uses-permission android:name="android.permission.RECEIVE_SMS" /> <application android:allowBackup="true" android:label="SmsReader" android:theme="@style/Theme.AppCompat"> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>运行时申请用ActivityResultLauncher,别再用老的onRequestPermissionsResult回调,容易漏掉分支:
private final ActivityResultLauncher<String> smsPermissionLauncher = registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> { if (granted) { readLatestSms(); } else { Toast.makeText(this, "未授予短信读取权限", Toast.LENGTH_SHORT).show(); } }); private void ensurePermissionAndRead() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_SMS) == PackageManager.PERMISSION_GRANTED) { readLatestSms(); } else { smsPermissionLauncher.launch(Manifest.permission.READ_SMS); } }注意:Android 6.0 以下权限在安装时授予,但现在的项目基本不用管那么老的版本。Android 13 及以上对短信权限没有额外收紧,但部分厂商 ROM 会在应用商店审核层面限制,真机调试时如果一直返回空,先确认权限状态是不是
PERMISSION_GRANTED。
2.2 TaoToken 统一 Key/API 通道的 settings.json 骨架
短信读出来之后,很多时候你要做的是把短信内容丢给模型做解析,比如识别验证码、判断是不是营销短信。这时候如果每个模型都单独配 Key,管理起来很乱。TaoToken 提供统一 Key 和 API 通道,官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 入口是 https://taotoken.net/api 。
下面是一份settings.json配置骨架,放在项目根目录或者你的配置目录下,用于统一管理模型通道。字段名按你实际接入的 SDK 调整,这里给的是通用结构:
{ "taotoken": { "baseUrl": "https://taotoken.net/api", "apiKey": "sk-你的统一Key", "defaultModel": "claude-sonnet-4-20250514", "timeoutMs": 30000, "retry": { "maxAttempts": 3, "backoffMs": 800 } }, "smsParser": { "model": "claude-sonnet-4-20250514", "systemPrompt": "你是一个短信解析助手,从短信正文中提取验证码、发送方、业务类型,输出 JSON。", "maxTokens": 512 } }Key 的获取在控制台的 API Keys 页面:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。如果你只是想先验证模型能不能正常返回,可以直接用模型对话页面试一条:https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。长期做编码或 Agent 类任务,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
提示:
baseUrl末尾不要带斜杠,很多 SDK 拼接路径时会出双斜杠导致 404。API 地址统一用 https://taotoken.net/api ,不要加 UTM 参数到代码里。
3. 可复制配置:ContentResolver 查询最新短信
3.1 按 date desc 查询并取第一条
原始做法是query之后while (cursor.moveToNext())遍历全部,再在循环里判断i == cursor.getCount() - 1。这样写有两个问题:一是把整个收件箱都读了一遍,短信多的时候很慢;二是边界判断容易出错。既然只要最新一条,直接在查询参数里用sortOrder = "date desc",然后moveToFirst()就够了。
private void readLatestSms() { Cursor cursor = null; try { cursor = getContentResolver().query( Uri.parse("content://sms/inbox"), new String[]{"_id", "address", "body", "date", "type"}, null, null, "date desc" ); if (cursor == null) { Log.e("SmsReader", "cursor 为 null,Provider 可能不可用"); return; } if (!cursor.moveToFirst()) { Log.d("SmsReader", "收件箱为空"); return; } int idxAddress = cursor.getColumnIndex("address"); int idxBody = cursor.getColumnIndex("body"); int idxDate = cursor.getColumnIndex("date"); String address = cursor.getString(idxAddress); String body = cursor.getString(idxBody); long dateMillis = cursor.getLong(idxDate); String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) .format(new Date(dateMillis)); Log.d("SmsReader", "最新短信来自: " + address); Log.d("SmsReader", "时间: " + time); Log.d("SmsReader", "内容: " + body); } catch (SecurityException e) { Log.e("SmsReader", "缺少 READ_SMS 权限", e); } finally { if (cursor != null) { cursor.close(); } } }几个关键点:content://sms/inbox只查收件箱,比content://sms/更精确,避免把已发送、草稿也带出来;sortOrder传"date desc"让 Provider 在数据库层排序,比在 Java 层排序高效;moveToFirst()直接定位第一条,不用遍历。
3.2 结果解析与字段说明
短信 Provider 的字段里,常用的就这几个:
| 字段名 | 类型 | 说明 |
|---|---|---|
_id | int | 短信在数据库中的主键 |
address | String | 发送方号码,可能是手机号或服务号 |
body | String | 短信正文 |
date | long | 收到时间,毫秒时间戳 |
type | int | 1 为收件,2 为发件,3 为草稿 |
date字段是毫秒时间戳,直接new Date(long)再格式化就行。如果你拿到的是字符串,先Long.parseLong转一下。address有时为 null,比如某些系统短信,做展示时给个「未知」兜底。
如果你要把短信内容交给模型解析,可以在拿到body之后调用 TaoToken 通道。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有各语言 SDK 的调用示例。Claude Code 相关的接入说明在 https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode&utm_campaign=rewrite 。
4. 真机验证:从安装到看到日志
4.1 验证步骤
第一步,把上面的权限声明和readLatestSms()代码放进项目,在MainActivity.onCreate里调用ensurePermissionAndRead()。
第二步,用 USB 连接真机,打开开发者选项和 USB 调试,adb devices确认设备在线。
第三步,安装并启动应用,系统会弹出短信权限对话框,点允许。
第四步,用另一台手机给测试机发一条短信,内容随便写,比如「测试验证码 123456」。
第五步,看 Logcat,过滤 tagSmsReader,应该能看到类似输出:
D/SmsReader: 最新短信来自: 106xxxxxxxx D/SmsReader: 时间: 2025-01-15 14:32:08 D/SmsReader: 内容: 测试验证码 123456如果日志里address和body都正常,说明整条链路通了。这时候你可以把body丢给 TaoToken 的模型通道做进一步解析,比如提取验证码。
4.2 用 adb 直接验证 Provider 是否可读
有时候代码没问题,但真机就是返回空。可以先绕过应用,用 adb 直接查 Provider,确认系统层面是否允许:
adb shell content query --uri content://sms/inbox --projection address:body:date --sort "date DESC"如果这条命令能返回数据,说明 Provider 正常,问题在应用权限或代码;如果返回No result found或者权限错误,那就是系统层面限制了。部分厂商 ROM 对content query也有保护,这条命令失败不代表应用一定读不到,但能作为参考。
5. 本篇常见错排查
5.1 返回空 Cursor 或 SecurityException
最常见的原因是权限没真正授予。检查checkSelfPermission的返回值,别只看对话框弹没弹。Android 11 及以上如果用户选了「仅本次允许」,应用退到后台再回来权限会失效,需要重新申请。另外,targetSdkVersion太高时,部分 ROM 会要求你在 Manifest 里同时声明READ_SMS和RECEIVE_SMS才放行读取,可以先都加上试试。
还有一种情况是查询的 URI 写错了。content://sms/和content://sms/inbox行为不同,前者包含所有类型,后者只有收件箱。如果你要的是最新收到的短信,用inbox更准。
5.2 时间格式化出来是 1970 年
date字段是毫秒时间戳,但如果你用cursor.getString(idxDate)拿到的是字符串,再Long.parseLong一般没问题。出 1970 通常是因为getColumnIndex返回了 -1,导致取到了错误的列,或者date字段本身为 0。先打印idxDate确认列索引有效,再打印原始dateMillis看是不是 0。
5.3 查询很慢或 ANR
如果你用了content://sms/并且没加sortOrder,Provider 可能返回全部短信,几千条的时候主线程直接卡死。两个改法:一是查询放到子线程,用ExecutorService或Coroutine;二是 URI 用inbox并加"date desc",让数据库层排序并配合moveToFirst()只取一条。如果确实要读多条,用CursorLoader或者分页查询。
5.4 TaoToken 调用返回 401 或超时
先确认apiKey是不是从控制台复制的完整 Key,有没有多余空格。baseUrl用 https://taotoken.net/api ,不要自己拼/v1之类的路径,具体路径以接入文档为准。超时的话把timeoutMs调到 60000 试试,短信解析这种短文本一般不会超时,除非网络本身有问题。如果一直 401,去 API Keys 页面重新生成一个 Key 再试:https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。
6. 把短信读取接进你的调试链路
短信读取本身不复杂,难的是权限状态、URI 选择、时间戳解析这几个细节。把content://sms/inbox+date desc+moveToFirst()这套组合用熟,基本能覆盖「获取最新一条短信」的所有场景。读出来之后,如果你要做内容识别,把body通过 TaoToken 的统一通道发给模型,settings.json里配好baseUrl和apiKey就能跑。验证模型连通性用模型对话页面最快,长期编码任务再考虑 Coding Plan。接入过程中遇到报错,先查接入文档里的错误码说明,大部分问题都能定位到。