简介:这是一套面向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+ 注意点 |
|---|---|---|---|
| 短信 | 动态注册 BroadcastReceiver | RECEIVE_SMS | 第三方应用仍可用动态广播 |
| 通知 | NotificationListenerService | 用户手动开启通知使用权 | 弹窗仅一次,拒绝后需去设置页开启 |
| 来电 | TelephonyCallback / PhoneStateListener | READ_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.bat,Linux/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_token=YOUR_TOKEN' \ -H 'Content-Type: application/json' \ -d '{"msgtype":"text","text":{"content":"短信验证码:123456"}}' # 企业微信群机器人 curl -X POST 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_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/短信验证码/123456'Server酱使用的是表单 POST,内容放在desp字段里,支持 Markdown 渲染:
curl -X POST 'https://sctapi.ftqq.com/SCT_KEY.send' \ -d 'title=短信验证码' \ -d 'desp=你的验证码是 123456'| 渠道 | 协议 | 鉴权位置 | 成功标识 |
|---|---|---|---|
| 钉钉机器人 | POST JSON | URL 参数 access_token | errcode=0 |
| 企业微信机器人 | POST JSON | URL 参数 key | errcode=0 |
| 飞书机器人 | POST JSON | hook URL | code=0 |
| Bark | GET | URL 路径 key | 返回 200 |
| Server酱 | POST Form | URL 路径 KEY | errno=0 |
| PushPlus | POST Form | 请求体 token | code=200 |
这部分最容易踩的坑是 HTTP 状态码 200 但业务失败,比如钉钉返回errcode=310000表示关键词不匹配。所以转发器里不能只看 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_sms | to, text | 发送短信 |
| query_sms | limit, offset | 读取短信列表 |
| query_call | limit, offset | 读取通话记录 |
| query_contacts | keyword | 模糊查联系人 |
| 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 设置。
本文还有配套的精品资源,点击获取