摘要:本文从零开始,手把手带你实现一套完整的 Android 短信收发模块。内容涵盖开发环境搭建与权限配置、短信列表界面设计、读取手机短信数据、新建发送界面交互、调用系统 API 发送短信、监听并处理收到短信的回复机制,以及完整流程联调与真机测试验证。同时,针对常见权限报错、运行时异常排查、不同 Android 版本的兼容性适配等痛点给出可落地的解决方案,并分享项目优化方向与安全隐私注意事项,帮助新手开发者避开过时教程的陷阱,快速跑通短信功能并适配当前主流开发环境。
关键词:Android;短信收发;权限配置;BroadcastReceiver;SmsManager;ContentProvider;运行时权限;真机测试;兼容性适配
目录
- [① 开发环境搭建与权限配置要点](#① 开发环境搭建与权限配置要点 "#%E2%91%A0-%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA%E4%B8%8E%E6%9D%83%E9%99%90%E9%85%8D%E7%BD%AE%E8%A6%81%E7%82%B9")
- [② 短信列表界面布局设计与实现](#② 短信列表界面布局设计与实现 "#%E2%91%A1-%E7%9F%AD%E4%BF%A1%E5%88%97%E8%A1%A8%E7%95%8C%E9%9D%A2%E5%B8%83%E5%B1%80%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0")
- [③ 读取手机短信数据的代码逻辑](#③ 读取手机短信数据的代码逻辑 "#%E2%91%A2-%E8%AF%BB%E5%8F%96%E6%89%8B%E6%9C%BA%E7%9F%AD%E4%BF%A1%E6%95%B0%E6%8D%AE%E7%9A%84%E4%BB%A3%E7%A0%81%E9%80%BB%E8%BE%91")
- [④ 新建短信发送界面的交互设计](#④ 新建短信发送界面的交互设计 "#%E2%91%A3-%E6%96%B0%E5%BB%BA%E7%9F%AD%E4%BF%A1%E5%8F%91%E9%80%81%E7%95%8C%E9%9D%A2%E7%9A%84%E4%BA%A4%E4%BA%92%E8%AE%BE%E8%AE%A1")
- [⑤ 调用系统 API 实现短信发送功能](#⑤ 调用系统 API 实现短信发送功能 "#%E2%91%A4-%E8%B0%83%E7%94%A8%E7%B3%BB%E7%BB%9F-api-%E5%AE%9E%E7%8E%B0%E7%9F%AD%E4%BF%A1%E5%8F%91%E9%80%81%E5%8A%9F%E8%83%BD")
- [⑥ 监听并处理收到短信的回复机制](#⑥ 监听并处理收到短信的回复机制 "#%E2%91%A5-%E7%9B%91%E5%90%AC%E5%B9%B6%E5%A4%84%E7%90%86%E6%94%B6%E5%88%B0%E7%9F%AD%E4%BF%A1%E7%9A%84%E5%9B%9E%E5%A4%8D%E6%9C%BA%E5%88%B6")
- [⑦ 完整流程联调与真机测试验证](#⑦ 完整流程联调与真机测试验证 "#%E2%91%A6-%E5%AE%8C%E6%95%B4%E6%B5%81%E7%A8%8B%E8%81%94%E8%B0%83%E4%B8%8E%E7%9C%9F%E6%9C%BA%E6%B5%8B%E8%AF%95%E9%AA%8C%E8%AF%81")
- [⑧ 常见权限报错与运行时异常排查](#⑧ 常见权限报错与运行时异常排查 "#%E2%91%A7-%E5%B8%B8%E8%A7%81%E6%9D%83%E9%99%90%E6%8A%A5%E9%94%99%E4%B8%8E%E8%BF%90%E8%A1%8C%E6%97%B6%E5%BC%82%E5%B8%B8%E6%8E%92%E6%9F%A5")
- [⑨ 不同 Android 版本的兼容性适配技巧](#⑨ 不同 Android 版本的兼容性适配技巧 "#%E2%91%A8-%E4%B8%8D%E5%90%8C-android-%E7%89%88%E6%9C%AC%E7%9A%84%E5%85%BC%E5%AE%B9%E6%80%A7%E9%80%82%E9%85%8D%E6%8A%80%E5%B7%A7")
- [⑩ 项目优化方向与安全隐私注意事项](#⑩ 项目优化方向与安全隐私注意事项 "#%E2%91%A9-%E9%A1%B9%E7%9B%AE%E4%BC%98%E5%8C%96%E6%96%B9%E5%90%91%E4%B8%8E%E5%AE%89%E5%85%A8%E9%9A%90%E7%A7%81%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9")
- [⑪ 实战代码:完整短信收发模块](#⑪ 实战代码:完整短信收发模块 "#%E2%91%AA-%E5%AE%9E%E6%88%98%E4%BB%A3%E7%A0%81%E5%AE%8C%E6%95%B4%E7%9F%AD%E4%BF%A1%E6%94%B6%E5%8F%91%E6%A8%A1%E5%9D%97")
在移动应用开发中,短信功能往往是被低估却又不可或缺的一环。无论是用于用户身份验证、订单通知,还是构建简易的内部通讯工具,直接调用系统原生短信能力都能带来最稳定的体验。然而,许多开发者在着手实现时,往往会卡在权限申请的繁琐流程上,或者在处理不同 Android 版本的行为差异时感到头疼。特别是随着系统安全策略的收紧,过去简单的配置方式已不再适用,稍有不慎就会导致应用在真机上崩溃或功能失效。
这篇文章将基于实际的开发场景,从零开始梳理一套完整的短信模块实现方案。我们不会只停留在理论层面,而是会深入代码细节,探讨如何优雅地处理权限请求、如何设计符合用户习惯的交互界面,以及如何应对接收短信时的后台监听逻辑。如果你正在为一个项目添加短信收发功能,或者想要重构现有的老旧代码以适配新系统,那么接下来的内容将提供可直接落地的参考路径。我们将避开那些过时的教程陷阱,专注于当前主流开发环境下的最佳实践,确保你的应用既能跑通流程,又能经得起真机测试的考验。
① 开发环境搭建与权限配置要点
启动任何涉及系统底层能力的功能前,环境的正确配置是基石。在现代 Android 开发中,首先需要在 AndroidManifest.xml 文件中声明必要的权限。对于短信模块,核心权限主要包括读取短信 (READ_SMS)、发送短信 (SEND_SMS) 以及接收短信 (RECEIVE_SMS)。值得注意的是,从 Android 6.0 (API Level 23) 开始,这些被归类为"危险权限",仅仅在清单中声明是不够的,必须在运行时动态向用户申请。
在 AndroidManifest.xml 中,我们需要在 <manifest> 根节点下添加如下权限声明:
xml
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
其中,READ_SMS 用于读取短信数据库,SEND_SMS 用于应用内静默发送短信,RECEIVE_SMS 用于监听新短信广播。如果你的应用只需要唤起系统短信应用来发送,那么 SEND_SMS 权限可以省略,这能减少权限弹窗对用户的打扰。
除了权限声明,依赖库的选择也至关重要。为了简化兼容性处理,建议引入 androidx.core 库中的 ActivityCompat 和 ContextCompat 类,它们能帮助我们以更统一的方式检查和处理权限状态。在 build.gradle 文件中添加依赖:
gradle
dependencies {
implementation "androidx.core:core-ktx:1.12.0"
implementation "androidx.appcompat:appcompat:1.6.1"
}
运行时权限的申请逻辑通常放在 Activity 的 onCreate 方法中,先检查权限是否已授予,未授予则发起请求:
java
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_SMS)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{
Manifest.permission.READ_SMS,
Manifest.permission.SEND_SMS,
Manifest.permission.RECEIVE_SMS
},
REQUEST_SMS_PERMISSIONS);
} else {
// 权限已授予,直接加载短信列表
loadSmsList();
}
同时,若需要监听短信广播,还需注册相应的 BroadcastReceiver,并在清单中配置 intent-filter,确保系统能将收到的短信事件投递给我们的应用。在开发初期,务必确认测试设备的系统版本,因为不同版本的默认短信应用行为可能存在差异,这直接影响后续的调试策略。建议在 build.gradle 中设置 minSdkVersion 为 23(Android 6.0),以覆盖运行时权限模型,同时将 targetSdkVersion 提升到较新版本,确保应用遵循最新的系统安全策略。
② 短信列表界面布局设计与实现
一个友好的短信列表界面是用户体验的第一道门槛。在设计布局时,我们通常采用 RecyclerView 来展示数据,因为它能高效处理大量列表项的复用与滚动。每个列表项(Item Layout)应包含发件人号码、短信内容摘要、接收时间以及阅读状态标识。为了提升可读性,发件人号码若匹配本地联系人,应尽量显示联系人姓名;若无匹配,则显示号码本身。
首先,在 activity_main.xml 中放置一个 RecyclerView 作为列表容器:
xml
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSmsList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="8dp"
tools:listitem="@layout/item_sms" />
接下来,创建列表项布局 item_sms.xml。使用 ConstraintLayout 来灵活安排各个控件的位置:将发件人信息置于左上角,时间戳置于右上角,内容摘要占据下方主要区域并限制最大行数以避免界面杂乱:
xml
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp">
<TextView
android:id="@+id/tvSender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/tvTime" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="#999999"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tvContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="2"
android:ellipsize="end"
android:textColor="#666666"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvSender"
app:layout_constraintEnd_toEndOf="parent" />
<View
android:id="@+id/viewUnreadDot"
android:layout_width="8dp"
android:layout_height="8dp"
android:background="@drawable/bg_unread_dot"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
针对未读短信,可以通过改变背景色或添加醒目的标记点来提示用户。上面的布局中预留了一个 viewUnreadDot 小圆点,配合一个简单的 drawable 即可实现:
xml
<!-- res/drawable/bg_unread_dot.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#FF5252" />
<size android:width="8dp" android:height="8dp" />
</shape>
在 Adapter 的实现中,需注意数据绑定的逻辑,确保当数据源更新时,界面能实时刷新。对于长短信的处理,建议在 UI 层做截断显示,并提供点击展开详情的交互,保持列表的整洁性。核心 Adapter 代码如下:
java
public class SmsAdapter extends RecyclerView.Adapter<SmsAdapter.SmsViewHolder> {
private final List<SmsMessage> data = new ArrayList<>();
public void submitList(List<SmsMessage> newList) {
data.clear();
data.addAll(newList);
notifyDataSetChanged();
}
@NonNull
@Override
public SmsViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_sms, parent, false);
return new SmsViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull SmsViewHolder holder, int position) {
SmsMessage sms = data.get(position);
holder.tvSender.setText(sms.address);
holder.tvTime.setText(formatTime(sms.date));
holder.tvContent.setText(sms.body);
// 未读标记:这里以 type 字段简单示意,实际可按业务状态判断
boolean unread = sms.type == 1;
holder.viewUnreadDot.setVisibility(unread ? View.VISIBLE : View.GONE);
}
@Override
public int getItemCount() {
return data.size();
}
private String formatTime(long timestamp) {
SimpleDateFormat sdf = new SimpleDateFormat("MM-dd HH:mm", Locale.getDefault());
return sdf.format(new Date(timestamp));
}
static class SmsViewHolder extends RecyclerView.ViewHolder {
TextView tvSender, tvTime, tvContent;
View viewUnreadDot;
SmsViewHolder(@NonNull View itemView) {
super(itemView);
tvSender = itemView.findViewById(R.id.tvSender);
tvTime = itemView.findViewById(R.id.tvTime);
tvContent = itemView.findViewById(R.id.tvContent);
viewUnreadDot = itemView.findViewById(R.id.viewUnreadDot);
}
}
}
最后,在 Activity 中完成 RecyclerView 的初始化与数据绑定:
java
RecyclerView rvSmsList = findViewById(R.id.rvSmsList);
rvSmsList.setLayoutManager(new LinearLayoutManager(this));
SmsAdapter adapter = new SmsAdapter();
rvSmsList.setAdapter(adapter);
// 数据加载完成后调用
adapter.submitList(smsList);
这样,一个结构清晰、可复用的短信列表界面就搭建完成了。后续如需增加点击展开详情、长按删除等交互,只需在 SmsViewHolder 中为 itemView 绑定相应监听即可。
③ 读取手机短信数据的代码逻辑
获取短信数据的核心在于查询系统提供的 ContentProvider。Android 系统将所有短信存储在 content://sms/ _uri_下,我们可以通过 ContentResolver 进行查询。代码逻辑上,首先构建一个查询投影(Projection),指定需要获取的字段,如地址 (address)、内容 (body)、日期 (date) 和类型 (type)。
java
public List<SmsMessage> loadSmsMessages(Context context) {
List<SmsMessage> smsList = new ArrayList<>();
Uri uri = Uri.parse("content://sms/inbox");
String[] projection = new String[]{"address", "body", "date", "type"};
// 按日期降序排列,确保最新短信在前
String sortOrder = "date DESC";
Cursor cursor = context.getContentResolver().query(uri, projection, null, null, sortOrder);
if (cursor != null) {
while (cursor.moveToNext()) {
String address = cursor.getString(cursor.getColumnIndexOrThrow("address"));
String body = cursor.getString(cursor.getColumnIndexOrThrow("body"));
long date = cursor.getLong(cursor.getColumnIndexOrThrow("date"));
int type = cursor.getInt(cursor.getColumnIndexOrThrow("type"));
smsList.add(new SmsMessage(address, body, date, type));
}
cursor.close();
}
return smsList;
}
这段代码展示了基本的查询流程。需要注意的是,Cursor 对象使用完毕后必须关闭,以防内存泄漏。另外,查询操作属于 I/O 密集型任务,严禁在主线程中执行,否则会导致 ANR(应用无响应)。应当将其放入协程或异步线程中运行,待数据加载完成后,再通过回调或 LiveData 通知 UI 层进行更新。
下面给出一个基于 ExecutorService 的异步加载示例,避免阻塞主线程:
java
public void loadSmsAsync(Context context, SmsLoadCallback callback) {
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.execute(() -> {
List<SmsMessage> result = loadSmsMessages(context);
new Handler(Looper.getMainLooper()).post(() -> callback.onLoaded(result));
});
executor.shutdown();
}
public interface SmsLoadCallback {
void onLoaded(List<SmsMessage> smsList);
}
对于不同类型的短信(如收件箱、已发送、草稿箱),只需修改 URI 路径即可实现分类读取。常用的 URI 对应关系如下:
| 短信类型 | URI 路径 | 说明 |
|---|---|---|
| 收件箱 | content://sms/inbox |
收到的短信 |
| 已发送 | content://sms/sent |
已发送的短信 |
| 草稿箱 | content://sms/draft |
未发送的草稿 |
| 全部 | content://sms/all |
所有短信(含收件、发送、草稿等) |
此外,type 字段也对应着固定的枚举值:1 表示收件箱、2 表示已发送、3 表示草稿箱、4 表示已发出、5 表示失败、6 表示队列中。在业务层可以根据 type 值做更精细的过滤与展示,例如只展示收件箱中未读的短信,或统计某段时间内的发送失败记录。
④ 新建短信发送界面的交互设计
发送短信的界面设计应遵循"极简"原则,减少用户的操作步骤。标准界面通常包含三个核心元素:收件人输入框、短信内容编辑区以及发送按钮。收件人输入框最好支持自动补全功能,能够读取本地联系人列表,让用户通过输入姓名或号码片段快速定位目标。
在交互细节上,内容编辑区应具备字数统计功能,实时显示当前输入字符数及剩余可用字数(通常单条短信限制为 160 个英文字符或 70 个中文字符),当超出限制时提示用户短信将被拆分计费。发送按钮在内容为空时应处于禁用状态,防止误触。此外,考虑到用户可能需要进行群发,界面还应支持添加多个收件人,并以标签(Chip)的形式展示在输入框上方,允许用户随时移除某个特定收件人。键盘弹出时,界面应自动调整布局,确保发送按钮始终可见且易于点击。
下面给出一个简洁的发送界面布局示例,使用 LinearLayout 纵向排列,配合 TextInputLayout 提升输入体验:
xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<!-- 收件人输入框,支持自动补全 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilRecipient"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="收件人">
<AutoCompleteTextView
android:id="@+id/etRecipient"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="phone"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 短信内容编辑区 -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="12dp"
android:hint="短信内容">
<EditText
android:id="@+id/etContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="top|start"
android:inputType="textMultiLine"
android:maxLength="500" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 字数统计与发送按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvCount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="0/70"
android:textColor="#999999"
android:textSize="12sp" />
<Button
android:id="@+id/btnSend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="发送" />
</LinearLayout>
</LinearLayout>
在代码层面,字数统计与发送按钮的联动逻辑如下。这里以中文字符 70 字为单条上限,超出后提示用户短信将被拆分计费:
java
EditText etContent = findViewById(R.id.etContent);
TextView tvCount = findViewById(R.id.tvCount);
Button btnSend = findViewById(R.id.btnSend);
etContent.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable s) {
int length = s.length();
tvCount.setText(length + "/70");
// 内容为空时禁用发送按钮,防止误触
btnSend.setEnabled(length > 0);
// 超过单条上限时提示拆分计费
if (length > 70) {
tvCount.setTextColor(getColor(R.color.warning));
tvCount.setText(length + "/70(将拆分为多条)");
} else {
tvCount.setTextColor(getColor(R.color.text_secondary));
}
}
});
对于群发场景,可以在收件人输入框上方放置一个 ChipGroup,用于展示已添加的多个收件人。当用户从联系人列表选中某人时,将其号码以 Chip 形式加入,并允许点击删除按钮移除:
java
ChipGroup chipGroup = findViewById(R.id.chipGroup);
private void addRecipientChip(String phoneNumber) {
Chip chip = new Chip(this);
chip.setText(phoneNumber);
chip.setCloseIconVisible(true);
chip.setOnCloseIconClickListener(v -> chipGroup.removeView(chip));
chipGroup.addView(chip);
}
最后,记得在 AndroidManifest.xml 中为发送界面所在的 Activity 配置 android:windowSoftInputMode="adjustResize",这样当键盘弹出时,系统会自动压缩布局高度,确保发送按钮始终可见,避免被软键盘遮挡。
⑤ 调用系统 API 实现短信发送功能
实现短信发送主要有两种方式:一种是直接调用系统短信应用,另一种是在应用内后台静默发送。出于安全性和用户体验的考虑,目前更推荐第一种方式,即通过 Intent 唤起系统默认短信应用。这种方式无需申请 SEND_SMS 权限,避免了复杂的权限弹窗,同时也让用户有机会在发送前最后确认内容。
方式一:唤起系统短信应用(推荐)
通过 Intent 唤起系统短信应用,核心是构造一个 ACTION_VIEW 的 Intent,并设置 sms: 协议的 URI。如果需要预填内容,可以通过 sms_body 附加字段传入。代码如下:
java
private void openSystemSmsApp(String phoneNumber, String message) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("sms:" + phoneNumber));
intent.putExtra("sms_body", message);
// 检查是否有能处理该 Intent 的应用
if (intent.resolveActivity(getPackageManager()) != null) {
startActivity(intent);
} else {
// 兜底逻辑:提示用户未安装短信应用
Toast.makeText(this, "未找到可用的短信应用", Toast.LENGTH_SHORT).show();
}
}
这里有几个细节值得注意:
sms:URI 中的号码不需要做 URL 编码,直接拼接即可;但如果号码来自用户输入,建议先做合法性校验,过滤掉空格、括号等非法字符,避免构造出无法解析的 URI。- 如果希望同时支持多个收件人,可以使用
sms:后跟多个以分号分隔的号码,例如sms:10086;10010,部分系统短信应用会将其识别为群发。 - 唤起系统应用后,用户可以在系统界面中最后确认收件人和内容,这既降低了误发风险,也符合用户对隐私的预期。
方式二:应用内静默发送(需 SEND_SMS 权限)
如果业务场景确实要求应用内静默发送(例如验证码自动回填后的回执),则必须申请 SEND_SMS 权限,并使用 SmsManager 类。代码如下:
java
SmsManager smsManager = SmsManager.getDefault();
ArrayList<String> parts = smsManager.divideMessage(message);
// 发送 multipart 短信以处理长文本
smsManager.sendMultipartTextMessage(phoneNumber, null, parts, null, null);
对于较短的短信,也可以直接使用 sendTextMessage 方法,代码更简洁:
java
SmsManager smsManager = SmsManager.getDefault();
smsManager.sendTextMessage(phoneNumber, null, message, null, null);
处理发送结果回调
使用 SmsManager 时需格外小心,务必处理好发送成功和失败的回调广播,以便给用户明确的反馈。sendTextMessage 和 sendMultipartTextMessage 都支持传入 PendingIntent 作为发送状态回调,分别对应"已发送"和"已送达"两个阶段。下面给出一个完整的封装示例:
java
private static final String ACTION_SMS_SENT = "com.example.sms.SMS_SENT";
private static final String ACTION_SMS_DELIVERED = "com.example.sms.SMS_DELIVERED";
private void sendSmsWithCallback(String phoneNumber, String message) {
SmsManager smsManager = SmsManager.getDefault();
// 发送状态回调:短信已提交给系统(不代表对方已收到)
PendingIntent sentIntent = PendingIntent.getBroadcast(this, 0,
new Intent(ACTION_SMS_SENT), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// 送达状态回调:对方手机已收到(部分运营商/机型可能不支持)
PendingIntent deliveredIntent = PendingIntent.getBroadcast(this, 1,
new Intent(ACTION_SMS_DELIVERED), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// 长短信自动拆分发送
ArrayList<String> parts = smsManager.divideMessage(message);
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
for (int i = 0; i < parts.size(); i++) {
sentIntents.add(sentIntent);
deliveredIntents.add(deliveredIntent);
}
smsManager.sendMultipartTextMessage(phoneNumber, null, parts, sentIntents, deliveredIntents);
}
对应的广播接收器需要在 onReceive 中根据 resultCode 判断发送结果:
java
public class SmsSendReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action == null) {
return;
}
switch (getResultCode()) {
case Activity.RESULT_OK:
if (ACTION_SMS_SENT.equals(action)) {
Toast.makeText(context, "短信已发送", Toast.LENGTH_SHORT).show();
} else if (ACTION_SMS_DELIVERED.equals(action)) {
Toast.makeText(context, "短信已送达", Toast.LENGTH_SHORT).show();
}
break;
case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
Toast.makeText(context, "发送失败:未知错误", Toast.LENGTH_SHORT).show();
break;
case SmsManager.RESULT_ERROR_NO_SERVICE:
Toast.makeText(context, "发送失败:无信号服务", Toast.LENGTH_SHORT).show();
break;
case SmsManager.RESULT_ERROR_NULL_PDU:
Toast.makeText(context, "发送失败:PDU 为空", Toast.LENGTH_SHORT).show();
break;
case SmsManager.RESULT_ERROR_RADIO_OFF:
Toast.makeText(context, "发送失败:飞行模式或信号关闭", Toast.LENGTH_SHORT).show();
break;
default:
Toast.makeText(context, "发送失败:错误码 " + getResultCode(), Toast.LENGTH_SHORT).show();
break;
}
}
}
同时,需要在 AndroidManifest.xml 中注册该接收器,并声明 SEND_SMS 权限:
xml
<uses-permission android:name="android.permission.SEND_SMS" />
<receiver android:name=".SmsSendReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.example.sms.SMS_SENT" />
<action android:name="com.example.sms.SMS_DELIVERED" />
</intent-filter>
</receiver>
注意事项
- 静默发送属于敏感操作,Google Play 等应用商店对
SEND_SMS权限的审核非常严格,非核心业务场景建议避免使用,优先走唤起系统应用的方案。 - 某些定制 ROM 可能会拦截非默认短信应用的发送请求,因此静默发送的成功率并非 100%,务必做好失败兜底和用户提示。
- 发送状态回调中的"已发送"仅代表短信已提交给系统短信中心,不代表对方已收到;"已送达"回调在部分运营商或机型上可能不会触发,不能作为唯一判断依据。
- 从 Android 10 开始,非默认短信应用访问短信数据库受限,但通过
SmsManager发送短信本身不受影响,只要持有SEND_SMS权限即可。
⑥ 监听并处理收到短信的回复机制
要实现收到短信后的自动处理或提醒,我们需要注册一个广播接收器 (BroadcastReceiver)。在清单文件中声明接收 android.provider.Telephony.SMS_RECEIVED 动作的过滤器。当系统收到新短信时,会发送有序广播,我们的接收器可以拦截并解析其中的数据。
1. 清单文件注册接收器
首先,在 AndroidManifest.xml 中声明 RECEIVE_SMS 权限,并注册广播接收器。为了让接收器能优先拿到短信广播,可以设置较高的 android:priority:
xml
<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>
2. 解析 PDU 并提取短信内容
解析逻辑主要涉及从 Intent 的 Bundle 中提取 PDUs (Protocol Data Units)。对于 Android 4.4 及以上版本,还需要注意短信格式的兼容性问题(GSM 与 CDMA)。下面给出一个完整的 SmsReceiver 实现,它负责解析短信、识别验证码并回调给 UI 层:
java
public class SmsReceiver extends BroadcastReceiver {
private static final String SMS_RECEIVED_ACTION = "android.provider.Telephony.SMS_RECEIVED";
private static final String ACTION_VERIFICATION_RECEIVED = "com.example.sms.VERIFICATION_RECEIVED";
@Override
public void onReceive(Context context, Intent intent) {
if (!SMS_RECEIVED_ACTION.equals(intent.getAction())) {
return;
}
Bundle bundle = intent.getExtras();
if (bundle == null) {
return;
}
Object[] pdus = (Object[]) bundle.get("pdus");
if (pdus == null || pdus.length == 0) {
return;
}
StringBuilder fullMessage = new StringBuilder();
String sender = null;
// 长短信会被拆分成多条 PDU,需要拼接完整内容
for (Object pdu : pdus) {
SmsMessage sms = SmsMessage.createFromPdu((byte[]) pdu);
if (sender == null) {
sender = sms.getOriginatingAddress();
}
fullMessage.append(sms.getMessageBody());
}
String content = fullMessage.toString();
Log.d("SmsReceiver", "收到短信,发件人: " + sender + ",内容: " + content);
// 在这里根据业务规则处理收到的短信,例如识别验证码
if (content.contains("验证码") || content.matches(".*\\d{4,6}.*")) {
// 匹配到验证码,可回调给 UI 层自动填充
Intent callback = new Intent(ACTION_VERIFICATION_RECEIVED);
callback.setPackage(context.getPackageName());
callback.putExtra("sender", sender);
callback.putExtra("code", extractCode(content));
context.sendBroadcast(callback);
}
}
private String extractCode(String content) {
java.util.regex.Matcher matcher =
java.util.regex.Pattern.compile("\\d{4,6}").matcher(content);
return matcher.find() ? matcher.group() : "";
}
}
3. 在 Activity 中接收验证码回调
在 Activity 中动态注册一个内部接收器,用于接收 SmsReceiver 发出的验证码广播,并自动填充到输入框:
java
private final BroadcastReceiver verificationReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String code = intent.getStringExtra("code");
if (code != null && etCode != null) {
etCode.setText(code);
Toast.makeText(MainActivity.this, "验证码已自动填充", Toast.LENGTH_SHORT).show();
}
}
};
@Override
protected void onStart() {
super.onStart();
IntentFilter filter = new IntentFilter("com.example.sms.VERIFICATION_RECEIVED");
registerReceiver(verificationReceiver, filter);
}
@Override
protected void onStop() {
super.onStop();
unregisterReceiver(verificationReceiver);
}
4. 后台监听与高版本适配
需要注意的是,高版本 Android 对后台广播的限制较多。从 Android 8.0 (Oreo) 开始,系统限制了隐式广播的注册,但 SMS_RECEIVED 属于系统有序广播,静态注册仍然可以触发。不过,当应用处于后台或被杀死时,广播的投递可能被延迟或拦截。为了确保监听的稳定性,可以采取以下策略:
- 前台服务保活:如果业务对短信监听有强实时性要求(如验证码自动填充),可以启动一个前台服务,在服务中动态注册接收器,获得更高的优先级。
- WorkManager 兜底 :对于非实时性任务(如统计短信、生成报告),可以结合
WorkManager做延迟处理,避免长时间占用前台服务。 - 动态注册优先:在应用处于前台时,优先使用动态注册的接收器;进入后台后再依赖静态注册的接收器,两者互补。
5. 注意事项
SmsMessage.createFromPdu()在 Android 4.4 及以上版本中,建议使用createFromPdu(byte[] pdu, String format)重载,并传入bundle.getString("format")以正确处理 GSM/CDMA 格式差异。- 有序广播中,如果接收器调用了
abortBroadcast(),后续接收器将无法收到该广播,请谨慎使用。 - 验证码识别正则建议根据实际短信模板调整,避免误匹配普通数字短信。
- 从 Android 10 开始,非默认短信应用读取短信数据库受限,但通过广播接收器监听新短信不受影响,只要持有
RECEIVE_SMS权限即可。
⑦ 完整流程联调与真机测试验证
代码编写完成后,联调与真机测试是验证功能稳定性的关键步骤。模拟器在短信功能的模拟上往往存在局限,无法真实反映权限弹窗、系统拦截策略以及多卡设备的行为,因此必须使用真机进行测试。
1. 测试环境准备
在开始联调之前,建议先做好以下准备工作,避免测试过程中反复返工:
- 准备多台真机:至少准备 2~3 台不同品牌、不同 Android 版本的设备(如一台 Android 6.0 老机型、一台 Android 8.0 主流机型、一台 Android 10+ 新机型),覆盖主流系统版本。
- 准备测试 SIM 卡:至少准备一张可正常收发短信的 SIM 卡,最好再准备一张用于互发测试的副卡,方便验证收发双向流程。
- 开启开发者选项:在每台设备上开启「开发者选项」和「USB 调试」,并安装好对应的 USB 驱动。
- 关闭省电模式:测试后台监听时,务必关闭设备的省电模式或对应用的白名单限制,否则广播可能被系统挂起,导致误判。
- 准备测试号码:准备一个可接收短信的测试号码(如另一台手机),用于验证发送与接收的完整闭环。
2. 核心测试用例清单
建议按照下面的用例清单逐项验证,覆盖正常、异常与边界三类场景:
| 测试类别 | 测试项 | 预期结果 | 备注 |
|---|---|---|---|
| 正常流程 | 首次启动申请权限 | 弹出权限弹窗,授权后进入主界面 | 验证 READ_SMS、SEND_SMS、RECEIVE_SMS 三项 |
| 正常流程 | 读取短信列表 | 列表正确展示收件箱短信,按时间倒序 | 检查发件人、内容、时间、未读标记 |
| 正常流程 | 唤起系统应用发送 | 跳转到系统短信应用,内容预填正确 | 验证 sms: URI 与 sms_body 参数 |
| 正常流程 | 应用内静默发送 | 对方能收到短信,回调提示「已发送」 | 需持有 SEND_SMS 权限 |
| 正常流程 | 接收新短信 | 应用能收到广播并解析出内容 | 验证 SMS_RECEIVED 广播触发 |
| 异常流程 | 拒绝权限 | 应用不崩溃,给出引导提示 | 验证 onRequestPermissionsResult 分支 |
| 异常流程 | 无 SIM 卡 | 发送失败并提示「无信号服务」 | 验证 RESULT_ERROR_NO_SERVICE 分支 |
| 异常流程 | 飞行模式 | 发送失败并提示「飞行模式或信号关闭」 | 验证 RESULT_ERROR_RADIO_OFF 分支 |
| 边界情况 | 超长短信(>70 中文字符) | 自动拆分为多条发送,对方能收到完整内容 | 验证 divideMessage 与 sendMultipartTextMessage |
| 边界情况 | 特殊字符(表情、换行) | 内容完整传输,无乱码 | 验证编码处理 |
| 边界情况 | 群发多个收件人 | 每个收件人都能收到短信 | 验证 sms:10086;10010 格式 |
| 边界情况 | 后台/被杀状态接收 | 应用被杀死后仍能收到广播并触发逻辑 | 验证静态注册接收器 |
3. 联调步骤与日志排查
在联调过程中,重点观察 Logcat 日志,捕捉任何潜在的崩溃堆栈或权限异常。建议在关键节点添加日志输出,便于快速定位问题:
java
// 在权限申请回调中打印授权结果
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
for (int i = 0; i < permissions.length; i++) {
Log.d("SmsDebug", "权限 " + permissions[i] + " 结果: "
+ (grantResults[i] == PackageManager.PERMISSION_GRANTED ? "已授予" : "被拒绝"));
}
}
// 在短信接收广播中打印解析结果
Log.d("SmsDebug", "收到短信,发件人: " + sender + ",内容: " + content);
// 在发送回调中打印结果码
Log.d("SmsDebug", "发送结果码: " + getResultCode());
使用 Android Studio 的 Logcat 面板,按以下步骤排查:
- 过滤日志 :在 Logcat 中按包名或
SmsDebug标签过滤,只查看本应用的日志,避免被系统日志淹没。 - 观察崩溃堆栈 :如果应用崩溃,Logcat 会输出
FATAL EXCEPTION堆栈,定位到具体行号即可快速修复。 - 验证广播触发 :在真机上向测试号码发送短信,观察 Logcat 是否出现
SmsReceiver的日志。若未出现,说明广播未触发,需检查清单注册或系统拦截策略。 - 检查权限状态 :通过
adb shell pm list permissions -d或直接在应用内打印权限状态,确认运行时权限是否已正确授予。
4. 后台与多设备专项测试
特别要测试在应用处于后台或被杀死状态下,接收短信广播是否能正常触发。具体操作如下:
- 后台测试:将应用切到后台(按 Home 键),然后向测试号码发送短信,观察应用是否仍能收到广播并触发逻辑(如验证码自动填充)。
- 杀死进程测试 :在「最近任务」中划掉应用,或通过
adb shell am force-stop 包名强制停止应用,再发送短信,验证静态注册的接收器是否仍能触发。 - 多卡设备测试:在双卡手机上分别用两张卡收发短信,确认应用能正确处理不同 SIM 卡来源的短信。
- 定制 ROM 测试:在 MIUI、ColorOS、EMUI 等定制系统上重点测试,因为厂商对后台广播和权限的管理策略可能存在差异,必要时引导用户将应用加入电池优化白名单。
5. 常见问题与处理建议
在真机测试中,如果遇到以下问题,可以参考对应的处理思路:
- 权限弹窗不出现 :检查
targetSdkVersion是否 ≥ 23,以及是否在onCreate中正确调用了requestPermissions。 - 广播收不到 :确认清单中
RECEIVE_SMS权限已声明、接收器已注册且android:exported="true";检查设备是否开启了省电模式或对应用的后台限制。 - 发送失败 :查看 Logcat 中的
resultCode,根据错误码(NO_SERVICE、RADIO_OFF等)判断是信号问题还是权限问题。 - 读取列表为空 :确认已授予
READ_SMS权限,且当前应用是默认短信应用(Android 10+ 非默认应用读取受限)。 - 返回应用状态丢失 :唤起系统短信应用后返回,若界面状态丢失,可在
onResume中重新加载数据或恢复输入框内容。
通过以上系统化的联调与真机测试,可以最大程度地发现并解决隐藏的兼容性问题,确保短信模块在真实用户设备上稳定运行。
⑧ 常见权限报错与运行时异常排查
在开发过程中,权限问题是导致功能失败的最常见原因。下面按异常类型逐一拆解,并给出对应的排查思路与解决方案,帮助你在真机调试时快速定位问题。
1. SecurityException:权限未授予或未声明
这是短信模块中最典型的异常,通常发生在未获得运行时授权就尝试读取短信、调用 SmsManager 或访问短信数据库时。触发场景包括:
- 清单文件中未声明
READ_SMS、SEND_SMS、RECEIVE_SMS权限; - 已声明权限但未在运行时动态申请(Android 6.0+ 危险权限必须运行时申请);
- 用户拒绝了权限弹窗,但代码仍继续执行敏感操作。
排查步骤:
- 检查
AndroidManifest.xml中是否声明了对应权限; - 确认
targetSdkVersion≥ 23,否则系统不会触发运行时权限弹窗; - 在访问短信资源前,先调用
ContextCompat.checkSelfPermission()检查权限状态; - 在
onRequestPermissionsResult()中处理用户拒绝的情况,给出引导提示而非直接崩溃。
解决方案:封装一个统一的权限检查与申请工具类,在每次访问短信资源前强制校验:
java
public static boolean ensureSmsPermission(Activity activity) {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_SMS)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activity,
new String[]{Manifest.permission.READ_SMS,
Manifest.permission.SEND_SMS,
Manifest.permission.RECEIVE_SMS},
REQUEST_SMS_PERMISSIONS);
return false;
}
return true;
}
2. IllegalArgumentException:URI 格式错误或参数非法
这类异常多见于构造 sms: URI 或查询短信数据库时参数传递不当。典型场景包括:
- 号码中包含空格、括号、
#等非法字符,导致Uri.parse("sms:" + phoneNumber)解析失败; - 查询短信数据库时传入的投影(Projection)字段名拼写错误;
ContentResolver.query()的 selection 参数包含非法 SQL 语法。
排查步骤:
- 打印构造出的 URI 字符串,检查是否包含非法字符;
- 核对投影字段名是否与系统短信数据库一致(
address、body、date、type等); - 检查 selection 参数是否使用了正确的占位符
?而非直接拼接字符串。
解决方案:在拼接号码前做合法性校验,过滤掉非法字符:
java
private String sanitizePhoneNumber(String phoneNumber) {
// 过滤掉空格、括号、短横线等非法字符
return phoneNumber.replaceAll("[\\s()\\-]", "");
}
3. CalledFromWrongThreadException:子线程更新 UI
短信查询属于 I/O 密集型操作,若在主线程执行会导致 ANR;反之,若在子线程中直接更新 UI 组件,则会抛出 CalledFromWrongThreadException。这是新手最容易踩的坑之一。
排查步骤:
- 检查是否在
Thread、ExecutorService或协程中直接调用了TextView.setText()等 UI 操作; - 确认数据加载完成后是否通过
runOnUiThread()、Handler或LiveData切回主线程。
解决方案 :使用 runOnUiThread() 或 Handler 将 UI 更新切回主线程:
java
new Thread(() -> {
List<SmsMessage> list = repository.loadInbox(context);
runOnUiThread(() -> {
adapter.submitList(list);
tvStatus.setText("共加载 " + list.size() + " 条短信");
});
}).start();
4. 广播接收器未触发
如果 SmsReceiver 收不到短信广播,通常不是崩溃异常,而是功能静默失效。常见原因包括:
- 清单文件中未声明
RECEIVE_SMS权限; - 接收器未注册,或
android:exported属性设置错误; - 设备开启了省电模式,系统挂起了后台广播;
- 应用被强制停止后,静态注册的接收器无法触发(Android 3.1+ 强制停止后需手动启动一次应用才能恢复)。
排查步骤:
- 确认清单中
<receiver>已正确注册且android:exported="true"; - 在
onReceive()中打印日志,确认广播是否到达; - 检查设备是否对应用开启了电池优化限制;
- 通过
adb shell dumpsys package 包名查看接收器注册状态。
5. 常见异常速查表
为便于快速对照,下表汇总了短信模块开发中常见的异常类型、触发原因与处理建议:
| 异常类型 | 典型触发场景 | 排查要点 | 处理建议 |
|---|---|---|---|
SecurityException |
未授权读取短信、调用 SmsManager |
检查权限声明与运行时申请 | 封装权限校验工具类,拒绝时引导用户 |
IllegalArgumentException |
sms: URI 含非法字符、投影字段错误 |
打印 URI 与字段名 | 号码合法性校验,核对字段拼写 |
CalledFromWrongThreadException |
子线程更新 UI | 检查线程切换逻辑 | 用 runOnUiThread() 或 Handler 切回主线程 |
NullPointerException |
Cursor 为空、bundle 为 null |
判空处理 | 查询前判空,cursor != null 再遍历 |
| 广播未触发 | 权限缺失、省电模式、强制停止 | 检查清单注册与系统限制 | 确认 exported="true",引导加入电池白名单 |
ActivityNotFoundException |
设备无短信应用 | 检查 resolveActivity() |
调用前判空,给出兜底提示 |
6. 通用排查流程
当遇到难以定位的异常时,建议按以下流程系统排查:
- 查看 Logcat:按包名或自定义标签过滤日志,定位崩溃堆栈的具体行号;
- 复现问题:在真机上稳定复现,记录触发步骤与系统版本;
- 检查权限 :通过
adb shell pm list permissions -d或应用内打印权限状态; - 验证清单 :确认权限声明、接收器注册、
exported属性均正确; - 排除系统限制:关闭省电模式、将应用加入电池优化白名单后重测;
- 多设备验证:在不同品牌、不同 Android 版本上交叉测试,排除定制 ROM 差异。
利用 Android Studio 的 Profiler 工具和详细的日志输出,可以快速定位这些运行时异常的根源。掌握上述排查思路后,绝大多数短信模块的异常都能在几分钟内定位并解决。
⑨ 不同 Android 版本的兼容性适配技巧
Android 生态的碎片化要求我们在开发时必须考虑版本差异。Android 6.0 引入了运行时权限模型,这是第一个分水岭;Android 8.0 (Oreo) 对后台执行限制进行了严格管控,影响了后台监听短信的持久性;而 Android 10 (Q) 及更高版本则进一步限制了应用访问设备标识符和非重置 ID 的能力,并对短信默认应用的权限做了更细致的划分。
针对不同版本,应采用"检测 - 适配"的策略。例如,在 Android 10+ 上,若非默认短信应用,访问短信数据库的权限可能会被完全禁止,此时应降级为仅使用 Intent 唤起系统应用的方式。对于后台广播的限制,可以考虑使用 WorkManager 来处理延迟任务,或者在应用处于前台时动态注册接收器以获得更高的优先级。利用 Build.VERSION.SDK_INT 进行判断,编写分支逻辑,是确保应用在各种系统版本上都能平稳运行的必要手段。
1. 版本差异核心要点
在动手写适配代码之前,先梳理清楚各版本在短信相关能力上的关键差异:
- Android 6.0 (API 23) :引入运行时权限模型,
READ_SMS、SEND_SMS、RECEIVE_SMS从安装时授权改为运行时动态申请。这是所有短信功能开发必须跨过的第一道门槛。 - Android 8.0 (API 26) :限制隐式广播注册,但
SMS_RECEIVED属于系统有序广播,静态注册仍可触发;同时后台执行限制收紧,后台任务需改用JobScheduler或前台服务。 - Android 10 (API 29):非默认短信应用访问短信数据库受限,读取短信需引导用户将应用设为默认短信应用;后台启动 Activity 受限。
- Android 11+ (API 30+) :包可见性(Package Visibility)机制收紧,查询其他应用需在清单中声明
<queries>;标识符访问进一步受限;仅默认短信应用可完整访问短信数据库。
2. 版本差异对比表
为便于快速对照,下表汇总了 Android 6.0、8.0、10.0 及更高版本在短信相关能力上的关键差异与适配建议:
| Android 版本 | 短信权限模型 | 后台广播限制 | 默认短信应用访问 | 适配策略建议 |
|---|---|---|---|---|
| Android 6.0 (API 23) | 引入运行时权限,READ_SMS、SEND_SMS、RECEIVE_SMS 需动态申请 |
后台广播限制较少,BroadcastReceiver 可较稳定触发 |
未强制要求默认短信应用,普通应用可读取短信数据库 | 使用 ActivityCompat.requestPermissions() 动态申请权限;在 onRequestPermissionsResult() 中处理授权结果 |
| Android 8.0 (API 26) | 沿用运行时权限模型,权限申请逻辑不变 | 限制隐式广播注册,静态注册的 SMS_RECEIVED 广播仍可接收,但后台执行受限 |
仍可访问短信数据库,但系统开始收紧后台行为 | 静态注册 SMS_RECEIVED 接收器;后台任务改用 JobScheduler 或前台服务,避免被系统挂起 |
| Android 10 (API 29) | 权限模型不变,但非默认短信应用访问短信数据库受限 | 后台启动 Activity 受限,后台广播进一步收紧 | 非默认短信应用访问短信数据库可能被完全禁止 | 降级为使用 Intent 唤起系统短信应用发送;读取短信需引导用户设为默认短信应用 |
| Android 11+ (API 30+) | 权限模型不变,包可见性、标识符访问进一步收紧 | 后台广播限制持续加强,需前台服务保障监听 | 仅默认短信应用可完整访问短信数据库 | 优先使用 Intent 方案;确需后台监听时结合前台服务 + 动态注册接收器,并处理好包可见性声明 |
3. 基于 SDK_INT 的分支适配代码
在实际开发中,最常用的手段就是通过 Build.VERSION.SDK_INT 判断当前系统版本,从而执行不同的逻辑分支。下面给出一个完整的适配示例,覆盖读取短信、发送短信与后台监听三个核心场景:
java
public class SmsCompat {
/**
* 判断当前应用是否为默认短信应用(Android 4.4+ 支持)
*/
public static boolean isDefaultSmsApp(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
return Telephony.Sms.getDefaultSmsPackage(context)
.equals(context.getPackageName());
}
return true; // Android 4.4 以下无默认短信应用概念
}
/**
* 读取短信列表:Android 10+ 非默认短信应用读取受限
*/
public static List<SmsMessage> loadInboxCompat(Context context) {
// Android 10+ 且非默认短信应用时,读取可能被拒绝
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
&& !isDefaultSmsApp(context)) {
// 降级处理:提示用户设为默认短信应用,或返回空列表
return new ArrayList<>();
}
// 正常读取逻辑
return new SmsRepository().loadInbox(context);
}
/**
* 发送短信:优先唤起系统应用,Android 10+ 推荐此方案
*/
public static void sendSmsCompat(Activity activity, String phone, String message) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+ 推荐唤起系统短信应用,避免权限与默认应用限制
SmsSender.openSystemSmsApp(activity, phone, message);
} else {
// 低版本可尝试静默发送,但需持有 SEND_SMS 权限
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.SEND_SMS)
== PackageManager.PERMISSION_GRANTED) {
SmsSender.sendSilently(activity, phone, message);
} else {
SmsSender.openSystemSmsApp(activity, phone, message);
}
}
}
/**
* 注册短信接收器:Android 8.0+ 需注意隐式广播限制
*/
public static void registerSmsReceiver(Context context, BroadcastReceiver receiver) {
IntentFilter filter = new IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Android 8.0+ 动态注册需指定 RECEIVER_EXPORTED 或 NOT_EXPORTED
context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
} else {
context.registerReceiver(receiver, filter);
}
}
}
4. 包可见性声明(Android 11+)
从 Android 11 (API 30) 开始,系统引入了包可见性机制。如果应用需要查询或与其他应用交互(例如检测系统短信应用是否存在),必须在 AndroidManifest.xml 中声明 <queries> 元素,否则 resolveActivity() 可能返回 null,导致唤起系统短信应用失败:
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<!-- 声明需要查询的短信应用,确保 resolveActivity() 能正确返回 -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="sms" />
</intent>
</queries>
</manifest>
5. 适配策略总结
综合来看,短信模块的版本适配可以归纳为以下几条核心原则:
- 权限申请统一走运行时模型 :无论目标版本如何,都使用
ActivityCompat.requestPermissions()动态申请,并在回调中处理拒绝与"不再询问"的情况。 - 读取短信优先引导设为默认应用:在 Android 10+ 上,若应用不是默认短信应用,读取短信数据库会被拒绝,应主动检测并引导用户前往系统设置。
- 发送短信优先唤起系统应用 :
Intent方案无需SEND_SMS权限,也规避了默认应用限制,是跨版本最稳妥的发送方式。 - 后台监听结合前台服务 :Android 8.0+ 后台执行受限,若业务对短信监听有强实时性要求,应使用前台服务 + 动态注册接收器,并处理好
RECEIVER_EXPORTED标志。 - 善用
Build.VERSION.SDK_INT分支 :把版本差异封装成独立的兼容工具类(如上面的SmsCompat),让业务代码保持简洁,避免到处散落版本判断。
掌握以上适配技巧,你的短信模块就能在从 Android 6.0 到最新版本的各类设备上平稳运行,既不会因权限问题崩溃,也不会因系统限制而静默失效。
⑩ 项目优化方向与安全隐私注意事项
随着功能的完善,优化与安全隐私成为不可忽视的议题。一个能跑通的短信模块只是起点,如何让它更流畅、更健壮、更经得起安全审计,才是决定项目能否长期维护的关键。下面从性能、架构、安全与隐私四个维度展开。
1. 性能优化方向
短信列表的加载与渲染是性能优化的重点。短信数据库可能包含成千上万条记录,若每次进入页面都全量查询,必然造成卡顿。建议从以下几个方面入手:
- 分页加载 :不要一次性查询全部短信,改用分页(如每页 50 条),配合
RecyclerView的滚动加载更多,显著降低首屏耗时与内存占用。 - 缓存机制 :对查询结果做内存缓存(如
LruCache)或磁盘缓存,避免频繁 I/O。短信数据变化不频繁,缓存命中率通常较高。 - 索引优化 :查询时尽量使用
date、thread_id等字段作为排序与过滤条件,减少全表扫描。 - 列表项复用 :确保
RecyclerView的 ViewHolder 正确复用,避免在onBindViewHolder中做耗时操作(如重复创建SimpleDateFormat),可将其提升为成员变量或使用ThreadLocal。 - 图片懒加载 :若列表项包含联系人头像,务必使用
Glide或Coil等库实现懒加载与内存缓存,避免在主线程解码图片。
下面给出一个基于 Paging 3 的分页加载示例,帮助你快速落地:
kotlin
// 使用 Paging 3 实现短信列表分页
class SmsPagingSource(private val repository: SmsRepository) :
PagingSource<Int, SmsMessage>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, SmsMessage> {
return try {
val page = params.key ?: 0
val pageSize = params.loadSize
val data = repository.loadInboxPage(page, pageSize)
LoadResult.Page(
data = data,
prevKey = if (page > 0) page - 1 else null,
nextKey = if (data.isNotEmpty()) page + 1 else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
2. 架构优化方向
短信功能往往只是应用的一个子模块,若所有逻辑都堆在 Activity 中,代码会迅速腐化。建议采用分层架构,让职责清晰、便于测试:
- Repository 层:封装所有短信数据源(ContentProvider 查询、发送、接收),对外暴露统一接口,屏蔽底层实现细节。
- ViewModel 层 :持有
LiveData或StateFlow,负责业务状态管理,与 UI 解耦,天然支持配置变更。 - 工具类拆分 :将权限申请、版本适配、号码校验等通用逻辑抽成独立工具类(如
SmsPermissionHelper、SmsCompat),避免重复代码。 - 依赖注入 :项目规模较大时,可引入
Hilt或Koin管理依赖,提升可测试性与可维护性。
一个典型的分层结构如下:
text
ui/ -> Activity、Fragment、Adapter
viewmodel/ -> SmsViewModel(状态与业务编排)
repository/ -> SmsRepository(数据访问)
util/ -> SmsCompat、SmsPermissionHelper、PhoneNumberUtils
3. 安全注意事项
短信涉及用户高度敏感的通信数据,安全红线不容触碰。以下是必须遵守的底线:
- 最小权限原则 :只申请业务真正需要的权限。例如仅需发送短信时,优先用
Intent唤起系统应用,不申请SEND_SMS;仅需接收验证码时,只申请RECEIVE_SMS,不要顺带申请READ_SMS。 - 敏感数据加密 :若必须本地存储短信内容(如草稿、验证码),禁止明文写入
SharedPreferences或普通文件,应使用EncryptedSharedPreferences或Keystore加密。 - 日志脱敏 :打印日志时不要输出完整短信内容或完整手机号,可只打印前几位与后几位(如
138****1234),防止敏感信息泄露到 Logcat。 - 传输加密:若短信内容需要上传到服务器(如云端备份),必须走 HTTPS,并对请求体做签名校验,防止中间人攻击。
- 拒绝外部注入:解析短信内容时,注意对文本做转义处理,避免将短信内容直接拼进 SQL、HTML 或 WebView,防止注入攻击。
下面给出一个使用 EncryptedSharedPreferences 加密存储验证码的示例:
java
// 使用 EncryptedSharedPreferences 加密存储敏感短信内容
MasterKey masterKey = new MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build();
SharedPreferences prefs = EncryptedSharedPreferences.create(
context,
"sms_secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);
prefs.edit().putString("last_verification_code", code).apply();
4. 隐私注意事项
隐私合规不仅是道德要求,更是应用上架(尤其是 Google Play)的硬性门槛。需要重点落实以下几点:
- 隐私政策披露:在隐私政策中明确说明收集了哪些短信数据、用途是什么、存储多久、是否会共享给第三方。用户首次启动时,应提供隐私政策入口。
- 权限用途说明:申请权限时,向用户清晰解释"为什么需要读取短信""数据如何使用",避免模糊表述引发信任危机。
- 提供关闭入口:在应用设置页提供权限管理入口,允许用户随时撤销短信相关权限,并同步清理已缓存的短信数据。
- 数据最小化:只收集业务必需的数据,不批量拉取用户全部短信用于分析;验证码识别后,及时丢弃原始内容。
- 合规上架 :Google Play 对
READ_SMS、SEND_SMS等敏感权限审核极严,非核心功能(如默认短信应用、短信备份)基本无法通过。若你的应用不属于这些场景,务必优先使用Intent唤起系统应用,避免权限审核被拒。
5. 优化与安全自检清单
为方便对照落地,下表汇总了优化与安全的关键检查项:
| 维度 | 检查项 | 建议做法 |
|---|---|---|
| 性能 | 短信列表是否分页加载 | 使用 Paging 3 或手动分页,避免全量查询 |
| 性能 | 查询是否在主线程执行 | 放入协程或 ExecutorService,避免 ANR |
| 架构 | 短信逻辑是否集中在 Activity | 拆分为 Repository + ViewModel 分层 |
| 安全 | 短信内容是否明文存储 | 使用 EncryptedSharedPreferences 加密 |
| 安全 | 日志是否输出完整短信/号码 | 脱敏处理,只保留部分字符 |
| 隐私 | 是否申请了多余权限 | 遵循最小权限原则,按需申请 |
| 隐私 | 隐私政策是否披露数据用途 | 明确说明收集范围、用途与存储期限 |
| 合规 | 是否依赖 SEND_SMS 静默发送 |
优先 Intent 方案,规避上架审核风险 |
掌握以上优化方向与安全隐私要点,你的短信模块不仅能跑得流畅,更能经得起真机测试、安全审计与上架审核的多重考验。记住:功能完善只是第一步,让用户放心使用才是长久之计。
⑪ 实战代码:完整短信收发模块
前面各节分别讲解了权限配置、界面设计、数据读取、发送与接收监听。下面把这些逻辑整合成一个可直接运行的实战模块,包含权限申请、短信读取、发送以及广播接收四个核心部分,方便你对照落地。
1. 权限申请封装
权限申请是短信模块最容易踩坑的地方。一个健壮的封装不仅要能发起请求,还要妥善处理「用户拒绝」「勾选不再询问」「拒绝后引导去设置页」等常见场景。下面给出一个完整可复用的 SmsPermissionHelper,它支持回调接口,方便在 Activity 中统一处理授权结果:
java
public class SmsPermissionHelper {
private static final int REQUEST_SMS_PERMISSIONS = 100;
// 短信模块需要的全部权限
private static final String[] SMS_PERMISSIONS = {
Manifest.permission.READ_SMS,
Manifest.permission.SEND_SMS,
Manifest.permission.RECEIVE_SMS
};
/**
* 检查是否已授予全部短信权限
*/
public static boolean hasSmsPermissions(Context context) {
for (String permission : SMS_PERMISSIONS) {
if (ContextCompat.checkSelfPermission(context, permission)
!= PackageManager.PERMISSION_GRANTED) {
return false;
}
}
return true;
}
/**
* 发起权限申请
*/
public static void requestSmsPermissions(Activity activity) {
ActivityCompat.requestPermissions(activity, SMS_PERMISSIONS, REQUEST_SMS_PERMISSIONS);
}
/**
* 判断用户是否勾选了「不再询问」
* 若为 true,应引导用户前往系统设置页手动开启
*/
public static boolean shouldShowRationale(Activity activity) {
for (String permission : SMS_PERMISSIONS) {
if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) {
return true;
}
}
return false;
}
/**
* 处理授权结果回调,返回是否全部授权成功
*/
public static boolean handlePermissionResult(int requestCode, int[] grantResults) {
if (requestCode != REQUEST_SMS_PERMISSIONS) {
return false;
}
if (grantResults == null || grantResults.length == 0) {
return false;
}
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
return false;
}
}
return true;
}
/**
* 跳转到系统应用详情页,引导用户手动开启权限
*/
public static void openAppSettings(Activity activity) {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.parse("package:" + activity.getPackageName()));
activity.startActivity(intent);
}
}
在 Activity 中配合回调使用,可以覆盖完整的授权流程:
java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if (SmsPermissionHelper.hasSmsPermissions(this)) {
loadSmsList();
} else {
SmsPermissionHelper.requestSmsPermissions(this);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (SmsPermissionHelper.handlePermissionResult(requestCode, grantResults)) {
// 全部授权成功,继续加载短信
loadSmsList();
return;
}
// 授权失败,区分「可再次申请」与「不再询问」两种场景
if (SmsPermissionHelper.shouldShowRationale(this)) {
// 用户拒绝但未勾选「不再询问」,可再次弹窗申请
Toast.makeText(this, "短信权限是读取/发送短信所必需的", Toast.LENGTH_SHORT).show();
SmsPermissionHelper.requestSmsPermissions(this);
} else {
// 用户勾选了「不再询问」,只能引导去系统设置页
new AlertDialog.Builder(this)
.setTitle("需要短信权限")
.setMessage("请在设置中手动开启短信权限,否则短信功能将无法使用")
.setPositiveButton("去设置", (dialog, which) ->
SmsPermissionHelper.openAppSettings(this))
.setNegativeButton("取消", null)
.show();
}
}
private void loadSmsList() {
// 查询属于 I/O 操作,放到子线程执行
new Thread(() -> {
List<SmsMessage> list = new SmsRepository().loadInbox(this);
runOnUiThread(() -> {
// 将 list 交给 RecyclerView Adapter 渲染
Toast.makeText(this, "共加载 " + list.size() + " 条短信", Toast.LENGTH_SHORT).show();
});
}).start();
}
}
这里有几个设计要点值得说明:
- 权限数组统一管理 :把
READ_SMS、SEND_SMS、RECEIVE_SMS集中放在一个数组里,后续增删权限只需改一处,避免散落各处导致遗漏。 - 区分「拒绝」与「不再询问」 :
shouldShowRequestPermissionRationale()返回false且权限未授予时,说明用户已勾选「不再询问」,此时再弹权限框也不会出现,必须引导去系统设置页。 - 回调统一收敛 :
handlePermissionResult()把授权结果收敛为布尔值,Activity只需关心「成功」或「失败」,逻辑更清晰。 - 跳转设置页兜底 :
openAppSettings()使用ACTION_APPLICATION_DETAILS_SETTINGS直达应用详情页,用户可手动开启权限后返回,体验更顺畅。
提示:如果你的应用只需要发送短信(走
Intent唤起系统应用),可以只申请SEND_SMS甚至不申请任何短信权限;只需要接收验证码时,仅申请RECEIVE_SMS即可。遵循最小权限原则,既能减少弹窗打扰,也能降低应用商店的审核风险。
2. 短信数据模型与读取
短信数据模型是整个模块的数据基石。除了 address、body、date、type 这四个核心字段外,系统短信表还提供了 thread_id(会话线程 ID)、read(是否已读)、person(联系人 ID)等实用字段。把它们一并纳入模型,能让列表展示、未读标记、会话分组等业务逻辑更顺手:
java
public class SmsMessage {
public final long id; // 短信唯一 ID
public final String address; // 发件人/收件人号码
public final String body; // 短信内容
public final long date; // 时间戳(毫秒)
public final int type; // 类型:1收件箱 2已发送 3草稿 4已发出 5失败 6队列
public final long threadId; // 会话线程 ID,用于按会话分组
public final int read; // 是否已读:0未读 1已读
public SmsMessage(long id, String address, String body, long date,
int type, long threadId, int read) {
this.id = id;
this.address = address;
this.body = body;
this.date = date;
this.type = type;
this.threadId = threadId;
this.read = read;
}
}
SmsRepository 负责统一封装对系统 ContentProvider 的访问。除了收件箱,还应支持按类型分类读取,并对外暴露统一的查询入口。这里把投影字段、排序规则都抽成常量,避免魔法字符串散落各处:
java
public class SmsRepository {
// 查询投影:只取需要的字段,避免全字段扫描
private static final String[] PROJECTION = {
"_id", "address", "body", "date", "type", "thread_id", "read"
};
// 按时间倒序,最新短信在前
private static final String SORT_ORDER = "date DESC";
/**
* 读取收件箱短信
*/
public List<SmsMessage> loadInbox(Context context) {
return query(context, Uri.parse("content://sms/inbox"), null);
}
/**
* 按类型读取短信(1收件箱 2已发送 3草稿 4已发出 5失败 6队列)
*/
public List<SmsMessage> loadByType(Context context, int type) {
return query(context, Uri.parse("content://sms/all"),
"type = ?", new String[]{String.valueOf(type)});
}
/**
* 读取某个会话线程下的全部短信
*/
public List<SmsMessage> loadByThread(Context context, long threadId) {
return query(context, Uri.parse("content://sms/all"),
"thread_id = ?", new String[]{String.valueOf(threadId)});
}
/**
* 核心查询方法:统一处理 Cursor 遍历与资源释放
*/
private List<SmsMessage> query(Context context, Uri uri,
String selection, String[] selectionArgs) {
List<SmsMessage> smsList = new ArrayList<>();
Cursor cursor = null;
try {
cursor = context.getContentResolver()
.query(uri, PROJECTION, selection, selectionArgs, SORT_ORDER);
if (cursor != null) {
while (cursor.moveToNext()) {
smsList.add(new SmsMessage(
cursor.getLong(cursor.getColumnIndexOrThrow("_id")),
cursor.getString(cursor.getColumnIndexOrThrow("address")),
cursor.getString(cursor.getColumnIndexOrThrow("body")),
cursor.getLong(cursor.getColumnIndexOrThrow("date")),
cursor.getInt(cursor.getColumnIndexOrThrow("type")),
cursor.getLong(cursor.getColumnIndexOrThrow("thread_id")),
cursor.getInt(cursor.getColumnIndexOrThrow("read"))
));
}
}
} catch (Exception e) {
// 权限未授予或数据库异常时,返回空列表并记录日志
Log.e("SmsRepository", "query sms failed", e);
} finally {
if (cursor != null) {
cursor.close(); // 必须关闭,防止内存泄漏
}
}
return smsList;
}
}
查询属于 I/O 密集型操作,严禁在主线程执行。这里再提供一个基于 ExecutorService 的异步封装,配合回调接口,让 Activity 侧调用更简洁:
java
public class SmsRepository {
// ... 上面的同步查询方法 ...
/**
* 异步读取收件箱,结果通过回调返回(主线程)
*/
public void loadInboxAsync(Context context, SmsLoadCallback callback) {
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.execute(() -> {
List<SmsMessage> result = loadInbox(context);
new Handler(Looper.getMainLooper()).post(() -> callback.onLoaded(result));
});
executor.shutdown();
}
public interface SmsLoadCallback {
void onLoaded(List<SmsMessage> smsList);
}
}
使用示例:
java
new SmsRepository().loadInboxAsync(this, smsList -> {
// 回调已在主线程,可直接更新 RecyclerView
adapter.submitList(smsList);
});
这里有几个设计要点值得说明:
- 字段尽量齐全 :把
thread_id、read一并读出来,后续做会话分组、未读角标时无需再回查数据库,一次查询即可满足列表展示。 - 查询入口统一收敛 :
loadInbox、loadByType、loadByThread都走同一个私有query()方法,Cursor 的遍历、异常处理、资源释放只写一遍,避免重复代码。 - try-finally 保证释放 :把
cursor.close()放在finally中,即使查询中途抛异常也能确保 Cursor 被关闭,杜绝内存泄漏。 - 异步回调切回主线程 :
loadInboxAsync内部用Handler把结果切回主线程,调用方无需关心线程切换,直接更新 UI 即可。
3. 短信发送封装
发送短信是短信模块的核心动作之一。一个健壮的 SmsSender 封装,不仅要覆盖「唤起系统应用」和「应用内静默发送」两种方式,还应处理好长短信拆分、发送结果回调、权限检查等细节,避免在真机上出现「短信没发出去却毫无提示」的尴尬。下面给出一个完整可复用的封装:
java
public class SmsSender {
// 发送结果回调的 Action,用于区分「已发送」与「送达」两种广播
private static final String ACTION_SMS_SENT = "com.example.sms.SMS_SENT";
private static final String ACTION_SMS_DELIVERED = "com.example.sms.SMS_DELIVERED";
/**
* 方式一:唤起系统短信应用(推荐,无需 SEND_SMS 权限)
* 用户可在系统界面中最后确认收件人与内容,降低误发风险。
*/
public static void openSystemSmsApp(Activity activity, String phoneNumber, String message) {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("sms:" + phoneNumber));
intent.putExtra("sms_body", message);
if (intent.resolveActivity(activity.getPackageManager()) != null) {
activity.startActivity(intent);
} else {
Toast.makeText(activity, "未找到可用的短信应用", Toast.LENGTH_SHORT).show();
}
}
/**
* 方式二:应用内静默发送(需 SEND_SMS 权限)
* 自动拆分长短信,并通过 PendingIntent 回调发送结果。
*
* @param context 上下文(建议传 Activity,便于处理权限与回调)
* @param phoneNumber 收件人号码
* @param message 短信内容
* @param callback 发送结果回调,可为 null(不关心结果时)
*/
public static void sendSilently(Context context, String phoneNumber,
String message, SmsSendCallback callback) {
// 1. 权限检查:未授予 SEND_SMS 时直接回调失败,避免静默失败
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED) {
if (callback != null) {
callback.onResult(false, "未授予 SEND_SMS 权限");
}
return;
}
// 2. 构造发送与送达的 PendingIntent,用于接收系统回调
PendingIntent sentIntent = PendingIntent.getBroadcast(
context, 0,
new Intent(ACTION_SMS_SENT).setPackage(context.getPackageName()),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
PendingIntent deliveredIntent = PendingIntent.getBroadcast(
context, 1,
new Intent(ACTION_SMS_DELIVERED).setPackage(context.getPackageName()),
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
// 3. 动态注册结果接收器(在 Activity 中注册更便于更新 UI)
registerResultReceiver(context, callback);
// 4. 发送:长短信自动拆分,短短信走 sendTextMessage
SmsManager smsManager = SmsManager.getDefault();
if (message.length() > 70) {
ArrayList<String> parts = smsManager.divideMessage(message);
smsManager.sendMultipartTextMessage(
phoneNumber, null, parts, sentIntent, deliveredIntent);
} else {
smsManager.sendTextMessage(
phoneNumber, null, message, sentIntent, deliveredIntent);
}
}
/**
* 动态注册发送结果广播接收器,根据 resultCode 判断发送是否成功。
*/
private static void registerResultReceiver(Context context, SmsSendCallback callback) {
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context ctx, Intent intent) {
String action = intent.getAction();
if (ACTION_SMS_SENT.equals(action)) {
boolean success = (getResultCode() == Activity.RESULT_OK);
if (callback != null) {
callback.onResult(success,
success ? "发送成功" : "发送失败,错误码 " + getResultCode());
}
// 回调完成后注销,避免内存泄漏
ctx.unregisterReceiver(this);
}
}
};
IntentFilter filter = new IntentFilter(ACTION_SMS_SENT);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Android 13+ 动态注册广播需指定 RECEIVER_EXPORTED / NOT_EXPORTED
context.registerReceiver(receiver, filter,
Context.RECEIVER_NOT_EXPORTED);
} else {
context.registerReceiver(receiver, filter);
}
}
/**
* 发送结果回调接口
*/
public interface SmsSendCallback {
void onResult(boolean success, String message);
}
}
在 Activity 中配合回调使用,可以给用户明确的发送反馈:
java
public void onSendClick(View view) {
String phone = etRecipient.getText().toString().trim();
String content = etContent.getText().toString().trim();
if (phone.isEmpty() || content.isEmpty()) {
Toast.makeText(this, "请填写收件人和短信内容", Toast.LENGTH_SHORT).show();
return;
}
// 方式一:唤起系统应用(推荐)
SmsSender.openSystemSmsApp(this, phone, content);
// 方式二:应用内静默发送(需 SEND_SMS 权限)
// SmsSender.sendSilently(this, phone, content, (success, msg) ->
// Toast.makeText(this, msg, Toast.LENGTH_SHORT).show());
}
这里有几个设计要点值得说明:
- 权限前置检查 :静默发送前先检查
SEND_SMS权限,未授予时直接回调失败,避免SecurityException崩溃或静默失败。 - 长短信自动拆分 :内容超过 70 个中文字符时,用
divideMessage()拆分后走sendMultipartTextMessage,短短信走sendTextMessage,兼顾正确性与性能。 - 发送结果回调 :通过
PendingIntent接收系统广播,resultCode == RESULT_OK表示发送成功,否则给出错误码便于排查。 - 动态注册与注销 :结果接收器在回调完成后立即
unregisterReceiver,避免重复注册导致的内存泄漏。 - Android 13+ 兼容 :动态注册广播时按
Build.VERSION.SDK_INT区分RECEIVER_NOT_EXPORTED,避免高版本系统抛异常。 - 两种方式按需选择:默认推荐唤起系统应用(无需权限、用户可确认);只有验证码回执等强业务场景才用静默发送,且要提前评估应用商店的审核风险。
4. 短信接收广播接收器
短信接收是整个模块的「入口」之一。一个健壮的 SmsReceiver 不仅要能正确解析 PDU 并提取短信内容,还应处理好长短信拼接、验证码识别、动态注册兼容(Android 13+)、以及避免在 onReceive 中做耗时操作等细节。下面给出一个完整可复用的实现:
java
public class SmsReceiver extends BroadcastReceiver {
private static final String SMS_RECEIVED_ACTION = "android.provider.Telephony.SMS_RECEIVED";
private static final String ACTION_VERIFICATION = "com.example.sms.VERIFICATION_RECEIVED";
@Override
public void onReceive(Context context, Intent intent) {
// 1. 只处理短信到达广播,其它广播直接忽略
if (!SMS_RECEIVED_ACTION.equals(intent.getAction())) {
return;
}
// 2. 提取 PDU 数组,解析失败时安全返回
Bundle bundle = intent.getExtras();
if (bundle == null) {
return;
}
Object[] pdus = (Object[]) bundle.get("pdus");
if (pdus == null || pdus.length == 0) {
return;
}
// 3. 逐条解析 PDU,拼接长短信内容,并记录发件人
StringBuilder fullMessage = new StringBuilder();
String sender = null;
for (Object pdu : pdus) {
SmsMessage sms = SmsMessage.createFromPdu((byte[]) pdu);
if (sms == null) {
continue; // 个别 PDU 解析失败时跳过,不中断整条短信
}
if (sender == null) {
sender = sms.getOriginatingAddress();
}
fullMessage.append(sms.getMessageBody());
}
String content = fullMessage.toString();
if (content.isEmpty()) {
return;
}
// 4. 识别验证码:优先匹配「验证码」关键词,其次匹配 4-6 位数字
String code = extractCode(content);
if (code != null) {
// 5. 通过应用内广播回调给 UI 层,自动填充验证码输入框
Intent callback = new Intent(ACTION_VERIFICATION);
callback.setPackage(context.getPackageName()); // 只发给本应用,避免被外部拦截
callback.putExtra("sender", sender);
callback.putExtra("code", code);
callback.putExtra("content", content);
context.sendBroadcast(callback);
}
}
/**
* 提取短信中的验证码。
* 优先匹配「验证码」关键词附近的数字,其次匹配任意 4-6 位数字。
* 未匹配到时返回 null,避免误判普通短信。
*/
private String extractCode(String content) {
// 策略一:匹配「验证码」关键词后的 4-6 位数字
java.util.regex.Matcher keywordMatcher =
java.util.regex.Pattern.compile("验证码[^\\d]{0,6}(\\d{4,6})")
.matcher(content);
if (keywordMatcher.find()) {
return keywordMatcher.group(1);
}
// 策略二:匹配任意 4-6 位数字(作为兜底)
java.util.regex.Matcher digitMatcher =
java.util.regex.Pattern.compile("\\d{4,6}").matcher(content);
if (digitMatcher.find()) {
return digitMatcher.group();
}
return null;
}
}
在 Activity 中动态注册一个内部接收器,用于接收 SmsReceiver 发出的验证码广播,并自动填充到输入框。这里需要特别注意 Android 13+ 的动态注册兼容处理:
java
public class MainActivity extends AppCompatActivity {
private static final String ACTION_VERIFICATION = "com.example.sms.VERIFICATION_RECEIVED";
private final BroadcastReceiver verificationReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String code = intent.getStringExtra("code");
String sender = intent.getStringExtra("sender");
if (code != null) {
// 自动填充验证码到输入框
etCode.setText(code);
Toast.makeText(MainActivity.this,
"收到来自 " + sender + " 的验证码", Toast.LENGTH_SHORT).show();
}
}
};
@Override
protected void onStart() {
super.onStart();
// 动态注册验证码回调接收器
IntentFilter filter = new IntentFilter(ACTION_VERIFICATION);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Android 13+ 必须显式指定导出标志,否则抛异常
registerReceiver(verificationReceiver, filter,
Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(verificationReceiver, filter);
}
}
@Override
protected void onStop() {
super.onStop();
// 页面不可见时注销,避免内存泄漏
unregisterReceiver(verificationReceiver);
}
}
这里有几个设计要点值得说明:
- PDU 解析容错 :
createFromPdu()可能返回null,逐条解析时做空判断并continue,避免单条 PDU 异常导致整条短信丢失。 - 长短信自动拼接 :多段 PDU 依次
append到StringBuilder,保证长短信内容完整;发件人只取第一段的getOriginatingAddress()。 - 验证码识别策略:优先匹配「验证码」关键词附近的数字,避免把普通短信里的电话号码误判为验证码;匹配不到关键词时再兜底匹配任意 4-6 位数字。
- 应用内广播限定包名 :回调广播通过
setPackage(context.getPackageName())限定只发给本应用,防止被其它应用拦截或伪造。 - 动态注册与注销配对 :在
onStart注册、onStop注销,页面不可见时及时释放,避免内存泄漏。 - Android 13+ 兼容 :动态注册广播时按
Build.VERSION.SDK_INT区分RECEIVER_NOT_EXPORTED,高版本系统强制要求显式声明导出标志,否则运行时会抛SecurityException。 onReceive不做耗时操作 :广播接收器的onReceive运行在主线程,且系统对执行时间有严格限制(约 10 秒),这里只做解析与转发,真正的 UI 更新交给Activity侧完成。
5. 在 Activity 中串联完整流程
前面几节分别封装了权限申请、数据读取、发送与接收,这一节把它们在 MainActivity 中串联成一个可运行的完整流程。核心思路是:进入页面先检查权限 → 有权限则加载短信列表 → 点击发送走 SmsSender → 收到验证码自动填充 。下面给出一个完整可复用的 MainActivity:
java
public class MainActivity extends AppCompatActivity {
private static final String ACTION_VERIFICATION = "com.example.sms.VERIFICATION_RECEIVED";
private SmsRepository repository = new SmsRepository();
private SmsAdapter adapter = new SmsAdapter();
// 验证码回调接收器:接收 SmsReceiver 转发的应用内广播
private final BroadcastReceiver verificationReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String code = intent.getStringExtra("code");
String sender = intent.getStringExtra("sender");
if (code != null) {
etCode.setText(code);
Toast.makeText(MainActivity.this,
"收到来自 " + sender + " 的验证码", Toast.LENGTH_SHORT).show();
}
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 1. 初始化列表
RecyclerView rvSmsList = findViewById(R.id.rvSmsList);
rvSmsList.setLayoutManager(new LinearLayoutManager(this));
rvSmsList.setAdapter(adapter);
// 2. 权限检查:未授予则申请,已授予则直接加载
if (!SmsPermissionHelper.hasSmsPermissions(this)) {
SmsPermissionHelper.requestSmsPermissions(this);
} else {
loadSmsList();
}
}
@Override
protected void onStart() {
super.onStart();
// 3. 动态注册验证码回调接收器(Android 13+ 需指定导出标志)
IntentFilter filter = new IntentFilter(ACTION_VERIFICATION);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(verificationReceiver, filter,
Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(verificationReceiver, filter);
}
}
@Override
protected void onStop() {
super.onStop();
// 4. 页面不可见时注销,避免内存泄漏
unregisterReceiver(verificationReceiver);
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (SmsPermissionHelper.handlePermissionResult(requestCode, grantResults)) {
loadSmsList();
} else {
Toast.makeText(this, "未授予短信权限,功能不可用", Toast.LENGTH_SHORT).show();
}
}
/**
* 异步加载短信列表,结果回到主线程后刷新 RecyclerView
*/
private void loadSmsList() {
repository.loadInboxAsync(this, smsList -> {
adapter.submitList(smsList);
Toast.makeText(this, "共加载 " + smsList.size() + " 条短信",
Toast.LENGTH_SHORT).show();
});
}
/**
* 发送按钮点击:唤起系统短信应用(推荐,无需 SEND_SMS 权限)
*/
public void onSendClick(View view) {
String phone = etRecipient.getText().toString().trim();
String content = etContent.getText().toString().trim();
if (phone.isEmpty() || content.isEmpty()) {
Toast.makeText(this, "请填写收件人和短信内容", Toast.LENGTH_SHORT).show();
return;
}
SmsSender.openSystemSmsApp(this, phone, content);
}
/**
* 静默发送示例(需 SEND_SMS 权限,按需启用)
*/
private void sendSilentlyDemo() {
SmsSender.sendSilently(this, "10086", "你好,这是一条测试短信",
(success, msg) -> Toast.makeText(this, msg, Toast.LENGTH_SHORT).show());
}
}
这里有几个设计要点值得说明:
- 权限申请与回调闭环 :
onCreate中先检查权限,未授予则申请;onRequestPermissionsResult中根据结果决定是否加载列表,形成「申请 → 回调 → 加载」的完整闭环。 - 列表加载异步化 :
loadSmsList走SmsRepository.loadInboxAsync,内部已切回主线程,Activity侧无需关心线程切换,直接adapter.submitList刷新即可。 - 验证码自动填充 :
verificationReceiver在onStart注册、onStop注销,与SmsReceiver发出的应用内广播配对,收到验证码后自动填入etCode输入框。 - 发送方式按需选择 :默认走
openSystemSmsApp唤起系统应用(无需权限、用户可确认);静默发送封装在sendSilentlyDemo中,按业务需要启用。 - Android 13+ 兼容 :动态注册广播时按
Build.VERSION.SDK_INT区分RECEIVER_NOT_EXPORTED,避免高版本系统抛SecurityException。 - 生命周期配对:注册/注销、申请/回调都严格配对,避免内存泄漏与重复注册。
6. 清单文件配置
最后,把前面所有模块在 AndroidManifest.xml 中统一配置起来。一个完整的清单文件需要包含:权限声明、SmsReceiver 广播接收器注册、SmsSendReceiver 发送结果接收器注册,以及各 Activity 的声明。下面给出一个完整可复用的配置:
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 1. 权限声明 -->
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat">
<!-- 2. 主界面 Activity -->
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 3. 短信接收广播接收器(监听新短信) -->
<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>
<!-- 4. 发送结果广播接收器(静默发送时接收系统回调) -->
<receiver
android:name=".SmsSendReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.example.sms.SMS_SENT" />
<action android:name="com.example.sms.SMS_DELIVERED" />
</intent-filter>
</receiver>
</application>
</manifest>
这里有几个配置要点值得说明:
- 权限按需声明 :
READ_SMS用于读取短信列表,SEND_SMS用于应用内静默发送,RECEIVE_SMS用于监听新短信广播。如果只走「唤起系统应用」方案,SEND_SMS可以省略,减少权限弹窗对用户的打扰。 SmsReceiver的android:exported="true":短信广播来自系统,必须允许外部(系统)触发,因此导出标志必须为true;同时通过android:permission="android.permission.BROADCAST_SMS"限制只有系统能投递,防止第三方应用伪造短信广播。android:priority="999":设置较高优先级,让我们的接收器优先拿到短信广播,便于第一时间处理验证码;但要注意,Android 8.0+ 对隐式广播的静态注册有限制,SMS_RECEIVED属于豁免广播,可以正常静态注册。SmsSendReceiver的android:exported="false":发送结果回调只发给本应用,无需对外导出,设为false更安全,也符合 Android 12+ 对显式导出标志的强制要求。windowSoftInputMode="adjustResize":为发送界面配置该属性,键盘弹出时自动压缩布局,确保发送按钮不被遮挡。android:allowBackup建议设为false:短信数据涉及用户隐私,关闭备份可避免敏感数据被云备份,提升安全性。
以上代码把权限申请、短信读取、发送与接收监听整合成了一个完整的可运行模块。你可以直接复制到项目中,替换掉示例中的业务逻辑(如验证码识别规则、列表渲染方式),即可快速跑通一套基础的短信收发功能。记得在真机上测试,并针对不同 Android 版本按第⑨节的适配策略做分支处理。