ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android短信转发与消息网关搭建:监听、规则匹配到Webhook推送全解析

Android短信转发与消息网关搭建:监听、规则匹配到Webhook推送全解析 简介这是一套面向Android开发者的短信转发工具完整项目源码适合有Kotlin基础、希望搭建手机消息中转或远程控制服务的开发者。其功能覆盖短信、来电、App通知的监控与转发支持钉钉、企业微信、飞书、邮箱、Bark、Webhook、Server酱、PushPlus等主流通道同时提供服务端与客户端可远程发短信、查短信、查通话、查电量等。资源包共474个文件压缩后38.23MB其中以252个Kotlin源码和136个XML资源文件为主搭配Gradle构建脚本、YAML配置、WebP/PNG图片素材及少量AAR依赖库目录结构清晰便于编译运行和二次扩展。已有438人学习下载适合正在研究多平台消息推送集成或Android系统级监听转发的开发者。通过源码可掌握短信广播监听、通知栏读取、多机器人API对接以及C/S远程控制等关键实现思路还能借助drawio原理图快速理清整体架构。1. 短信转发器 SmsForwarder在旧手机上搭一个消息网关出差在外最怕收不到银行验证码。我曾在抽屉里翻出一台闲置的 Android 手机插上旧卡连上 Wi-Fi然后装上 SmsForwarder之后所有短信、来电和 App 通知都会按规则转发到钉钉群验证码再也不会困在没信号的 SIM 卡里。它和普通“微信同步短信”工具不一样核心是一套事件监听加规则匹配的消息路由系统。面向开发者、运维、搞自动化的同事也可以把多台手机变成“消息网桥”根据来源、关键词和通道灵活分发。SmsForwarder 还带一个主动控制服务端能在远端发短信、查通话、查电量。下文从监听链路、构建、渠道对接和控制协议逐个拆开。2. 监听链路短信、来电与通知是如何被感知的SmsForwarder 的价值在于能同时感知三类系统事件短信、电话呼入、App 通知。这三类事件在 Android 上分别对应不同的系统 API权限要求也不同。把链路拆开才知道转发延迟和漏报通常卡在哪一层。2.1 短信广播动态注册而不是静态接收许多教程还在用清单文件里的receiver静态注册android.provider.Telephony.SMS_RECEIVED但从 Android 8 开始大部分隐式广播已经禁止静态注册短信广播虽然保留却仍建议动态注册以避免厂商定制系统拦截。动态注册的好处是只在转发器存活时监听配合前台服务可以做到一个进程处理完整链路。public class SmsReceiver extends BroadcastReceiver { private static final String ACTION_SMS_RECEIVED android.provider.Telephony.SMS_RECEIVED; Override public void onReceive(Context context, Intent intent) { if (!ACTION_SMS_RECEIVED.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 body new StringBuilder(); String sender ; long timestamp 0L; for (Object pdu : pdus) { SmsMessage message SmsMessage.createFromPdu((byte[]) pdu, 3gpp); body.append(message.getMessageBody()); sender message.getDisplayOriginatingAddress(); timestamp message.getTimestampMillis(); } ForwardEngine.dispatch(MessageEvent.ofSms(sender, body.toString(), timestamp)); } }长短信会被拆成多条 PDU所以循环里要不断拼接body最后再统一交给ForwardEngine。getDisplayOriginatingAddress()返回来源号码SmsMessage.createFromPdu(byte[], 3gpp)里的3gpp按 GSM 方式解析遇到 CDMA 网络也应能兼容主流厂商实现。2.2 NotificationListenerService读取通知内容的唯一可靠入口App 通知转发不能靠 AccessibilityService 硬取界面文字那样既卡又容易误判。SmsForwarder 走的是NotificationListenerService用户在设置里授权“通知使用权”后系统会把每个新通知的标题、内容、包名主动推给这个服务不需要轮询也不消耗额外电量。public class ForwardNotificationListener extends NotificationListenerService { Override public void onNotificationPosted(StatusBarNotification sbn) { if (sbn null || !isEnabled(sbn.getPackageName())) { return; } Bundle extras sbn.getNotification().extras; String title extras.getString(Notification.EXTRA_TITLE, ); String text extras.getString(Notification.EXTRA_TEXT, ); String packageName sbn.getPackageName(); long postTime sbn.getPostTime(); ForwardEngine.dispatch(MessageEvent.ofApp(packageName, title, text, postTime)); } private boolean isEnabled(String packageName) { return RuleStore.getInstance().getWatchPackages().contains(packageName); } }注意sbn.getPostTime()返回通知到达系统的时间生产者时间比消费端时间更可信。isEnabled这一层过滤很关键因为手机上每个 App 都会产生通知没有包名白名单的话钉钉群会被微信家族刷屏。2.3 来电监控新版 TelephonyCallback 替换 PhoneStateListener来电监听的难点不是 API 本身而是旧版PhoneStateListener.listen()在 Android 12 以后被标记废弃SmsForwarder 会同时兼容新旧两套写法。TelephonyManager tm (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { tm.registerTelephonyCallback(context.getMainExecutor(), new TelephonyCallback() { Override public void onCallStateChanged(int state, String phoneNumber) { if (state TelephonyManager.CALL_STATE_RINGING) { ForwardEngine.dispatch(MessageEvent.ofCall(phoneNumber, System.currentTimeMillis())); } } }); } else { tm.listen(new PhoneStateListener() { Override public void onCallStateChanged(int state, String phoneNumber) { if (state TelephonyManager.CALL_STATE_RINGING) { ForwardEngine.dispatch(MessageEvent.ofCall(phoneNumber, System.currentTimeMillis())); } } }, PhoneStateListener.LISTEN_CALL_STATE); }新版registerTelephonyCallback需要传入一个Executor这里用context.getMainExecutor()能避免 Handler 手动切换线程。需要注意phoneNumber参数只有在CALL_STATE_RINGING时才有值挂断事件里的号码是空的所以别在IDLE里尝试读取。2.4 统一事件抽象与规则匹配三种来源的事件结构并不一致短信有正文和号码来电只有号码通知有包名和标题。SmsForwarder 的转发规则不能针对每个来源写三套逻辑必须抽象成统一事件对象。public class MessageEvent { public enum Source { SMS, CALL, APP } public Source source; public String sender; public String body; public long timestamp; public String packageName; public static MessageEvent ofSms(String sender, String body, long timestamp) { MessageEvent e new MessageEvent(); e.source Source.SMS; e.sender sender; e.body body; e.timestamp timestamp; return e; } // ofCall / ofApp 省略字段赋值方式相同 }规则匹配时只要针对sender和body两个字符串字段做正则或关键词判断。这样一个规则可以同时命中“1069开头的银行短信”和“招商银行 App 通知”因为通知里的sender字段可以复用为包名或 App 名称。监听类型关键 API权限 / 授权Android 13 注意点短信动态注册 BroadcastReceiverRECEIVE_SMS第三方应用仍可用动态广播通知NotificationListenerService用户手动开启通知使用权弹窗仅一次拒绝后需去设置页开启来电TelephonyCallback / PhoneStateListenerREAD_PHONE_STATE需要声明并在运行时请求这段表格在规划权限清单时会反复用到。我一般建议先只开短信权限跑通 Demo再加通知监听避免一上来就陷入厂商机型对后台弹出的限制。3. 用 Gradle 把这些模块拧成一张网项目下载下来后第一眼看到的不是AndroidManifest.xml而是一堆 Gradle 脚本build.gradle、versions.gradle、x-library.gradle还有一个gradlew.bat。这代表它是标准 Gradle 工程模块化程度较高直接导入 Android Studio 即可。3.1 工程结构与版本统一versions.gradle的核心作用是集中管理 SDK 版本和依赖版本。如果项目要长期维护这种写法比在每个 module 里写死版本号更稳升级依赖时只需要改一个文件。// versions.gradle ext { minSdk 24 targetSdk 34 compileSdk 34 okhttpVersion 4.12.0 coroutinesVersion 1.7.3 gsonVersion 2.10.1 }x-library.gradle是一个公共配置脚本内部通常声明 AAR 目录、Java 兼容版本、打包开关。frpclib.aar这种二进制库就放在某个libs目录下工程根目录的build.gradle里会有对应flatDir仓库声明。3.2 命令行构建流程不打开 Android Studio 也可以直接产出 APK。Windows 上项目自带gradlew.batLinux/macOS 则用同名的gradlewshell 脚本。这里以 Debug 包为例# Linux / macOS chmod x gradlew ./gradlew :app:assembleDebug # Windows gradlew.bat :app:assembleDebugassembleDebug会执行编译、资源合并、签名Debug 签名由 Gradle 自动生成和打包全流程。产物在app/build/outputs/apk/debug/app-debug.apk。如果要发布给别人安装建议改用assembleRelease但需要先在app/build.gradle里配置签名信息否则产出的是未签名的 APK。3.3 安装后的权限清单安装不能只用默认权限需要手动开放通知使用权和“后台运行”权限。下面是这个项目通常涉及的权限对照表权限用途建议RECEIVE_SMS接收短信广播必选READ_SMS读取历史短信 / 主动查询主动控制查短信时需要READ_PHONE_STATE监听来电状态需要READ_CALL_LOG主动查通话记录主动控制时需要FOREGROUND_SERVICE维持后台监听必选通知使用权读取 App 通知设置里手动开启Android 12 以上还会有“精确闹钟”和“后台弹窗”之类限制但转发器目前只需要保证前台服务存活即可。我一般会先关闭系统省电策略把应用锁定在后台任务列表否则锁屏几分钟后广播接收器会被系统冻结。3.4 启动前检查启动后第一时间看logcat里有没有权限拒绝异常adb logcat -s AndroidRuntime:E SmsForward:D如果看到红字SecurityException说明某个权限没授予。部分厂商 ROM 的“通知使用权”入口很深最直接的方式是在代码里跳转到Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS页面让用户手动勾选。4. 规则引擎与钉钉、企业微信等渠道的 Webhook 对接转发器最容易被玩出花的就是规则和渠道。SmsForwarder 不把渠道写死而是让每个事件先过规则匹配器再交给对应的渠道发送器。这样新加一个渠道只需要写一个实现类不需要改监听代码。4.1 转发规则配置结构规则本质是一组谓词叠加指定来源类型、匹配发送方、匹配关键词最后指定要投递的渠道列表。[ { name: 银行验证码, source: [SMS, APP], senderRegex: ^(95|106)\\d{4,}, keyword: 验证码, channels: [dingtalk, bark], atMobiles: [13800000000] } ]senderRegex采用 Java 正则语法95 和 106 开头的号码是常见的银行和服务商短号。keyword在这里是“包含”关系不是正则。规则命中后事件会被推给dingtalk和bark两个通道atMobiles仅在支持 消息的渠道里生效。4.2 渠道适配层设计每个渠道在代码里对应一个Forwarder接口单个渠道的配置项放在自己的配置类里比如BarkConfig、DingTalkConfig。接口只暴露一个send方法内部自己拼 HTTP 请求。public interface Forwarder { String name(); boolean send(MessageEvent event, ChannelConfig config); }ChannelConfig是基类包含webhookUrl、token、enabled等通用字段。这样后端做重试时只需要依赖接口不需要关心里面是钉钉还是 Server酱。发送失败时返回false重试调度器会按 2 秒、5 秒、15 秒的间隔重试三次。4.3 各渠道的 Webhook 请求差异钉钉群机器人、企业微信群机器人、飞书机器人的请求结构几乎一样都是 POST 一个msgtype/text的 JSON 结构只是 URL 参数名不同。Bark 和 Server酱则简单粗暴一个 GET 一个表单 POST。实际操作中最好先把这些请求体保存成文件做对比。# 钉钉群自定义机器人 curl -X POST https://oapi.dingtalk.com/robot/send?access_tokenYOUR_TOKEN \ -H Content-Type: application/json \ -d {msgtype:text,text:{content:短信验证码123456}} # 企业微信群机器人 curl -X POST https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyYOUR_KEY \ -H Content-Type: application/json \ -d {msgtype:text,text:{content:短信验证码123456}} # 飞书群机器人 curl -X POST https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_HOOK \ -H Content-Type: application/json \ -d {msg_type:text,content:{text:短信验证码123456}}三个接口对中文内容都要求 UTF-8返回包都是{errcode:0}或{code:0}才算成功。企业微信的key在 Webhook 地址的key参数里飞书的则是一个完整 hook 路径。Bark 的推送直接把 title 和 body 拼到 URL 上curl https://api.day.app/YOUR_DEVICE_KEY/短信验证码/123456Server酱使用的是表单 POST内容放在desp字段里支持 Markdown 渲染curl -X POST https://sctapi.ftqq.com/SCT_KEY.send \ -d title短信验证码 \ -d desp你的验证码是 123456渠道协议鉴权位置成功标识钉钉机器人POST JSONURL 参数 access_tokenerrcode0企业微信机器人POST JSONURL 参数 keyerrcode0飞书机器人POST JSONhook URLcode0BarkGETURL 路径 key返回 200Server酱POST FormURL 路径 KEYerrno0PushPlusPOST Form请求体 tokencode200这部分最容易踩的坑是 HTTP 状态码 200 但业务失败比如钉钉返回errcode310000表示关键词不匹配。所以转发器里不能只看 HTTP Code必须解析业务响应体。4.4 验证渠道连通性任何时候怀疑渠道配错了先用上面的 curl 手工推一条固定内容能收到再排查规则是否命中。规则命中后如果没推送把日志级别调到 DEBUG开启 HTTP 响应体打印确认事件有没有走到发送器。5. 主动控制服务端远程发短信、查手机状态是怎么做到的转发是这个项目的基本盘主动控制才是它区别于普通工具的点。SmsForwarder 的服务端运行在 PC 或云主机上客户端是 Android 手机两端的控制指令走长连接。设备不需要公网 IP因为客户端会主动连上服务端。5.1 控制指令协议设计协议采用 JSON 文本每条指令带唯一 ID。这样客户端执行完可以携带id回包便于服务端关联请求和响应。{ id: cmd_1705312000001, type: send_sms, params: { to: 13800138000, text: 测试消息 } }type字段对应客户端不同操作params是参数对象。查询类指令不需要太多参数比如查询电量的params为空对象分页查询短信时则传{ limit: 20, offset: 0 }。指令类型参数说明send_smsto, text发送短信query_smslimit, offset读取短信列表query_calllimit, offset读取通话记录query_contactskeyword模糊查联系人query_battery无返回电量百分比和充电状态device_status无查询连接状态5.2 服务端 WebSocket 实现控制端服务端的最简实现可以用 Python FastAPI 的 WebSocket 接口把客户端连接对象保存在内存里收到 HTTP 请求时再通过该连接把指令下发下去。from fastapi import FastAPI, WebSocket app FastAPI() device_connection: WebSocket | None None app.websocket(/ws/device) async def ws_device(ws: WebSocket): global device_connection await ws.accept() device_connection ws try: while True: await ws.receive_text() # 保活心跳 except Exception: device_connection None app.post(/send_sms) async def send_sms(to: str, text: str): if not device_connection: return {status: device_offline} payload { id: cmd_123, type: send_sms, params: {to: to, text: text} } await device_connection.send_json(payload) return {status: delivered}device_connection保存的是 WebSocket 对象客户端掉线后会自动触发异常分支置空。这种方式适合单设备场景多设备就需要在连接对象上带上设备 ID用字典保存多个连接。5.3 Android 端指令分发与执行客户端收到 WebSocket 消息后根据type字段分发到不同 Handler。发短信使用系统SmsManager发送结果通过状态回调回传。case send_sms: String to params.optString(to); String text params.optString(text); SmsManager sm SmsManager.getDefault(); sm.sendTextMessage(to, null, text, null, null); sendResponse(originId, ok, SMS_SENT); break; case query_battery: BatteryManager bm context.getSystemService(BatteryManager.class); int level bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY); sendResponse(originId, ok, String.valueOf(level)); break;注意sendTextMessage是非阻塞的但它的可靠回调依赖PendingIntent上面的代码为简洁没有贴出完整实现。实际生产环境里需要监听ACTION_SMS_SENT否则发送失败时客户端仍然会返回成功服务端这边看到的状态就是假数据。5.4 frpclib.aar 在主动控制里的作用手机在运营商 NAT 后服务端无法直接访问客户端。项目里的frpclib.aar是 FRP 客户端库它让 Android 端主动向公网 FRP 服务端发起连接并把手机本地某个端口映射到公网机器的端口上。这样即使没有公网 IP控制端也能访问手机上的本地 API。[common] server_addr your-vps-ip server_port 7000 token your-token [sms-forwarder] type tcp local_ip 127.0.0.1 local_port 9000 remote_port 9001这段 TOML 是 FRP 客户端常见配置在 SmsForwarder 里则被封装成代码参数不再暴露给用户。remote_port 9001表示公网机器上的 9001 端口会转发到手机本地的 9000。实现时必须给该服务加 token 校验否则任何拿到端口的人都能向手机下发短信发送指令。6. 排错与验证一条验证码在设备上经历了什么线上跑转发器时最怕“短信到了钉钉没响”。排除网络原因后先看日志链路。开启 debug 模式后logcat 里会出现从事件进入到规则命中的完整轨迹。adb logcat -s SmsForwarder:D SmsForwarder.http:DSmsForwarder标签用于打印事件分发和规则匹配结果SmsForwarder.http打印 HTTP 请求与响应。如果看到event skipped by rule说明短信进了系统但没匹配到规则如果看到http 200但响应体里是errcode则是渠道配置问题。转发器还有一个常见隐患A 手机把短信转发到 B 手机B 手机又装了 SmsForwarder于是两条设备互相转发形成回环。解决方法是事件入队前计算哈希用“发送方 正文 时间戳”的前几位做去重键。String deduplicationKey event.sender | event.body | event.timestamp; if (deduplicationCache.containsKey(deduplicationKey)) { return; } deduplicationCache.put(deduplicationKey, System.currentTimeMillis());缓存里存的是事件时间戳超过十秒的旧键自动清理避免内存膨胀。这个策略对重复通知同样有效某些 App 在锁屏后会把同一条通知重新抛一次。验证整条转发链路是否正常最快的方法是在模拟器上直接插入一条模拟短信adb emu sms send 13800138000 测试验证码123456这条命令会触发真实的SMS_RECEIVED广播和实体手机收到短信完全相同。观察钉钉群是否在 2 到 3 秒内出现内容如果没有回看 logcat 中短信广播是否触发。真机上没有adb emu命令我一般准备一张副卡手动发一条“验证码”测试号码同时抓包对比事件时间戳与钉钉响应时间误差超过 5 秒就要检查网络代理和 DNS 设置。本文还有配套的精品资源点击获取
返回列表