Android 短信收发模块从零到实战:权限配置、界面设计、发送接收与兼容性适配全攻略

摘要:本文从零开始,手把手带你实现一套完整的 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 面板,按以下步骤排查:

  1. 过滤日志 :在 Logcat 中按包名或 SmsDebug 标签过滤,只查看本应用的日志,避免被系统日志淹没。
  2. 观察崩溃堆栈 :如果应用崩溃,Logcat 会输出 FATAL EXCEPTION 堆栈,定位到具体行号即可快速修复。
  3. 验证广播触发 :在真机上向测试号码发送短信,观察 Logcat 是否出现 SmsReceiver 的日志。若未出现,说明广播未触发,需检查清单注册或系统拦截策略。
  4. 检查权限状态 :通过 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+ 危险权限必须运行时申请);
  • 用户拒绝了权限弹窗,但代码仍继续执行敏感操作。

排查步骤:

  1. 检查 AndroidManifest.xml 中是否声明了对应权限;
  2. 确认 targetSdkVersion ≥ 23,否则系统不会触发运行时权限弹窗;
  3. 在访问短信资源前,先调用 ContextCompat.checkSelfPermission() 检查权限状态;
  4. 在 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 语法。

排查步骤:

  1. 打印构造出的 URI 字符串,检查是否包含非法字符;
  2. 核对投影字段名是否与系统短信数据库一致(address、body、date、type 等);
  3. 检查 selection 参数是否使用了正确的占位符 ? 而非直接拼接字符串。

解决方案:在拼接号码前做合法性校验,过滤掉非法字符:

java 复制代码
private String sanitizePhoneNumber(String phoneNumber) {
    // 过滤掉空格、括号、短横线等非法字符
    return phoneNumber.replaceAll("[\\s()\\-]", "");
}

3. CalledFromWrongThreadException:子线程更新 UI

短信查询属于 I/O 密集型操作,若在主线程执行会导致 ANR;反之,若在子线程中直接更新 UI 组件,则会抛出 CalledFromWrongThreadException。这是新手最容易踩的坑之一。

排查步骤:

  1. 检查是否在 Thread、ExecutorService 或协程中直接调用了 TextView.setText() 等 UI 操作;
  2. 确认数据加载完成后是否通过 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+ 强制停止后需手动启动一次应用才能恢复)。

排查步骤:

  1. 确认清单中 <receiver> 已正确注册且 android:exported="true";
  2. 在 onReceive() 中打印日志,确认广播是否到达;
  3. 检查设备是否对应用开启了电池优化限制;
  4. 通过 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. 通用排查流程

当遇到难以定位的异常时,建议按以下流程系统排查:

  1. 查看 Logcat:按包名或自定义标签过滤日志,定位崩溃堆栈的具体行号;
  2. 复现问题:在真机上稳定复现,记录触发步骤与系统版本;
  3. 检查权限 :通过 adb shell pm list permissions -d 或应用内打印权限状态;
  4. 验证清单 :确认权限声明、接收器注册、exported 属性均正确;
  5. 排除系统限制:关闭省电模式、将应用加入电池优化白名单后重测;
  6. 多设备验证:在不同品牌、不同 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 版本按第⑨节的适配策略做分支处理。

相关推荐
2601_962885722 小时前
如何用 Python 判断一只股票“追涨”还是“抄底”更有效?
android·开发语言·python
素师良码2 小时前
第10篇:屏ESD Check内核逻辑驱动代码完整拆解
android
随遇丿而安2 小时前
第18周:ViewModel 基础功能 + 架构优化
android
问心无愧05135 小时前
[GXYCTF2019]Ping Ping Ping
android·笔记
事圆则缓5 小时前
Kotlin 异步与并发:从一次搜索请求走到状态流
android·开发语言·kotlin
帅得不敢出门6 小时前
Android Framework定制卸载确认窗口
android
Android-Flutter7 小时前
Compose 什么叫重组?怎么样触发重组?
android·compose
硫酸锌018 小时前
安卓手机连接Windows电脑后通过DHCP服务为手机分配IP地址
android·windows·计算机网络
骑着蜗牛撵大象3278 小时前
设备监控数据存与查:Spring Boot 接入 TimescaleDB 超表的完整落地
android·spring boot·后端·springboot·时序数据库·es·设备监控