最近收到团队内部的一个需求,使用一台测试机来做短信转发,即写一个 App 监听短信,当收到的短信内容符合我们设定的规则时,通过接口传送给我们后端。

这个需求实际上并不难,有两个思路。

拦截短信广播

当手机收到短信时,会向系统发送一个广播,所以我们可以通过监听广播来获取短信内容。

public class SmsReceiver extends BroadcastReceiver {
    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (!action.equals("android.provider.Telephony.SMS_RECEIVED")) {
            return;
        }
        Bundle bundle = intent.getExtras();
        if (bundle == null) {
            return;
        }
        Object[] pdus = (Object[]) bundle.get("pdus");
        if (pdus == null) {
            return;
        }
        SmsMessage[] messages = new SmsMessage[pdus.length];
        StringBuilder sender = new StringBuilder();
        StringBuffer content = new StringBuffer();
        StringBuffer time = new StringBuffer();
        for (int i = 0; i < pdus.length; i++) {
            sender.append(messages[i].getOriginatingAddress());
            content.append(messages[i].getMessageBody());
            time.append(messages[i].getTimestampMillis());
        }
        ...
    }
}

接收短信时发送的是一个有序广播,所以我们可以在注册时提高其优先级,避免被其他软件拦截:

<manifest ...>

    <uses-permission android:name="android.permission.RECEIVE_SMS" />

    <application ...>
        <receiver
            android:name=".SmsReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter android:priority="1000">
                <action android:name="android.provider.Telephony.SMS_RECEIVED" />
            </intent-filter>
        </receiver>
    </application>
</manifest>

同时不要忘了动态申请 RECEIVE_SMS 权限。

这样就可以实时监听到短信的接收了,上面的代码我获取了发件人、短信内容以及时间戳,其他参数你也可以自行获取。

监听短信媒体库变化

除了监听系统广播,我们还可以监听短信媒体库的变化,短信一般都是存储在本地数据库中,我们能够像操作 SQLite 一样读取它。

public class SmsObserver extends ContentObserver {

    public SmsObserver(Handler handler, Context context) {
        super(handler);
        interceptSms(context);
    }

    private void interceptSms(Context context) {
        Cursor cursor = context.getContentResolver().query(Uri.parse("content://sms/inbox"), null, null, null, "date DESC");
        if (cursor.moveToFirst()) {
            String sender = cursor.getString(cursor.getColumnIndex("address"));
            String content = cursor.getString(cursor.getColumnIndex("body"));
            long date = cursor.getLong(cursor.getColumnIndex("date"));
            int type = cursor.getInt(cursor.getColumnIndex("type"));    // 1:接收,2:发送
            if (type == 1) {
                ...
            }
        }
        cursor.close();
    }
}

读取短信媒体库需要 READ_SMS 权限:

<manifest ...>
    <uses-permission android:name="android.permission.READ_SMS" />
    ...
</manifest>

动态申请成功后,在代码中注册:

SmsObserver smsObserver = new SmsObserver(new Handler(), this);
getContentResolver().registerContentObserver(Uri.parse("content://sms"), true, smsObserver);

这里给定的 Uri 可以查询不同的短信协议:

  • 收件箱:content://sms/inbox
  • 发件箱:content://sms/outbox
  • 已发送:content://sms/sent
  • 草稿:content://sms/draft
  • 发送失败:content://sms/failed
  • 待发送:content://sms/queued

通过 Cursor 查询并按照时间戳排序即可拿到最新的短信。

测试

测试也比较简单,两台手机就可以完成测试。

假如你没有两台手机,又或者心疼话费,那么也可以通过『Android Studio』自带的模拟器进行测试。

模拟器发送短信

在实际生产环境应当在真机上进行测试,因为国产 ROM 魔改出于隐私考虑有可能会对短信做处理。

比较

通过广播拦截的方式,假如有其他应用的 BroadcastReceiver 高于你设定的优先级,那么你可能无法拦截到此短信。

而监听短信媒体库的方式,由于状态繁多,会导致 onChange() 方法多次触发,造成不必要的计算,即使是同一条短信,在接收时触发回调,在阅读时也会触发,应该是短信状态变更导致。

使用国产 ROM 测试发现,两种方法监听验证码类短信都无法 100% 成功,通过广播拦截方式,会接收不到该短信的广播,而使用监听短信媒体库的方式,会触发媒体库变动的接口,但你依然查不到验证码的短信。

由于模拟器没有实体 SIM 卡无法接收真实的短信,且国外对于短信验证码的普及率不高,所以无法测试也没有测试的必要。

如有条件可以使用国外手机如 Google Pixel 系列进行测试。

因此,暂时找不到能够拦截短信验证码的方法,但这也的确符合常理,因为在国内互联网环境下,获取到验证码就可以对其对应的账号进行各种操作,比如重置或盗刷等等,系统对于此类短信的屏蔽是完全合理的。

综上,对于非验证码类短信可以正常拦截,根据自己的需求选择不同的方法即可,对于验证码类短信,就别动坏心思了。