Android来电提醒小助手:基于电话状态监听与局域网推送
手机在包里调成静音电脑在旁边开着通知栏电话进来时什么都没有发生等拿起手机看到未接来电已经错过了重要信息。为了解决这种场景可以做一个“快点接电话啦”来电提醒小助手手机检测到响铃事件后立即通过 HTTP 请求把事件推送到同一局域网里的电脑电脑端播放提示音提醒你接起电话。这个项目规模不大却把 Android 电话状态监听、运行时权限、异步网络调用、服务端接口和局域网联调串成了一条完整链路适合在 Android 模拟器或一台真机上跑通也能加深对事件推送场景的理解。下面会从需求拆解开始先说明这条链路为什么成立再给出两端的环境准备、项目结构、核心代码、联调步骤和排错清单。整体以最小可运行版本为主最后补充生产化和扩展建议。1. 先拆解“快点接电话啦”的完整提醒链路1.1 适用场景和普通提醒方案的区别这个项目解决的典型问题是手机不在身边、手机静音、会议中手机调成免打扰但电脑或者平板就在眼前。通用方案是戴智能手环但手环也有漏振或没电的情况。另一种方案是让手机在来电时通过本地网络通知电脑这种方式不依赖第三方云服务数据只在内网传输延迟低也方便自己控制逻辑。和普通闹钟提醒相比区别很明显普通闹钟是定时触发来电提醒是状态触发。普通闹钟不需要系统状态权限来电提醒需要获得电话状态监听能力。普通闹钟只在本机播放声音这个项目可以跨设备投递提醒事件。所以它不只是一个“响铃程序”而是一个典型的端到端事件系统端侧负责采集状态服务端负责消费状态并执行提醒动作。1.2 端到端数据流一条完整的来电提醒链路可以拆成这样Android 手机系统检测到电话状态变化。应用通过TelephonyManager注册监听器收到CALL_STATE_RINGING状态。应用把“当前有来电”这个事件组织成 JSON通过 HTTP POST 发送给局域网服务端。电脑上的服务端接收事件后打印日志并调用系统播放能力播放提示音。用户听到“快点接电话啦”的提示音回身拿起手机。关键点在于Android 端只负责“上报事实”服务端负责“执行动作”。这种解耦方式让后续扩展变得容易。以后可以不播放声音而是改成发短信提醒、推送到钉钉群、控制智能家居灯闪烁服务端接口保持不变只需要修改消费逻辑。1.3 为什么选用局域网 HTTP 推送而不是云推送选择局域网 HTTP 推送是刻意为之有几个原因不依赖公网服务器数据不经过第三方适合在家庭或办公内网使用。实现成本低Android 端一个HttpURLConnection就能完成发送。排错直观服务端打印日志、curl 模拟请求、浏览器访问端口都能快速定位链路。代价是只能在同一局域网下工作手机和电脑必须能互相访问。如果手机使用 4G/5G 网络而电脑在家链路就断了。但作为教学项目和家庭内网工具这个限制可以接受。要扩大到公网环境后续可以把 HTTP 推送替换为 WebSocket、MQTT 或者云厂商的消息通道。2. 环境准备和项目结构要对齐否则后面联调会出错2.1 两端环境要求端工具/依赖建议AndroidAndroid Studio使用较新的稳定版本Android编译语言Kotlin 1.8 以上AndroidminSdk21 以上AndroidtargetSdk33 或 34注意新版本权限差异服务端Python3.9 以上服务端FastAPI0.100 以上服务端Uvicorn0.20 以上服务端系统播放能力Windows 用 winsound其他系统替换播放命令如果原始环境没有明确版本落地前要先确认本机已安装对应工具避免把时间浪费在版本冲突上。Android 工程创建时选择空 Activity 模板即可Kotlin 版本跟随 Android Studio 默认设置。2.2 项目目录结构整个项目不复杂建议按两端分目录管理call-reminder/ ├── android/ # Android Studio 工程 │ └── app/src/main/ │ ├── AndroidManifest.xml │ └── java/com/example/callreminder/MainActivity.kt └── server/ ├── server.py # FastAPI 服务端 ├── requirements.txt └── call_reminder.wav # 提醒音频文件Android 端的包名可以根据自己的工程调整下面代码中的com.example.callreminder只作为示例。服务端的音频文件不是必须用固定名称只要server.py里引用路径保持一致即可。2.3 联调前的网络条件手机和电脑连接同一个路由器或者手机热点开给电脑。电脑能通过局域网 IP 被手机访问不只是本机的127.0.0.1。如果用 Android 模拟器模拟器访问宿主机必须使用特殊地址10.0.2.2这是模拟器内置的宿主机回环地址不能写127.0.0.1。这一步很多人会忽略实际是最常见的失败原因之一。后面联调章节会专门说明。3. Android 端权限、监听、推送三个部分要一起实现3.1 权限申请与 Android 13 的差异Android 端要监听电话状态至少需要READ_PHONE_STATE权限。这个权限属于危险权限Android 6API 23之后必须在运行时动态申请不能只写在 Manifest 里。Manifest 配置如下uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.READ_PHONE_STATE /INTERNET权限用于发送 HTTP 请求。READ_PHONE_STATE用于读取电话状态。在 Android 13 和 Android 14 环境中READ_PHONE_STATE仍然可以正常申请但需要注意电话状态和通话记录是两类权限。本示例只判断“是不是响铃”不需要读取完整通话记录因此不需要申请READ_CALL_LOG。如果后续要获取完整来电号码则要单独申请READ_CALL_LOG并且在隐私政策里明确说明用途。本教程出于安全考虑只上报事件类型、脱敏号码或未知占位符不依赖完整号码完成提醒。权限申请代码private val PERMISSION_REQUEST_CODE 1001 private fun checkAndRequestPermission() { if (ContextCompat.checkSelfPermission( this, Manifest.permission.READ_PHONE_STATE ) ! PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.READ_PHONE_STATE), PERMISSION_REQUEST_CODE ) } }在权限回调中再注册监听器override fun onRequestPermissionsResult( requestCode: Int, permissions: Arrayout String, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode PERMISSION_REQUEST_CODE grantResults.isNotEmpty() grantResults[0] PackageManager.PERMISSION_GRANTED ) { registerPhoneListener() } else { Toast.makeText(this, 没有电话权限无法监听来电, Toast.LENGTH_SHORT).show() } }不要在权限未授予时调用TelephonyManager.listen()否则回调可能不触发甚至抛出安全异常。3.2 用 PhoneStateListener 监听来电状态Android 中监听电话状态的核心类是TelephonyManager配合PhoneStateListener注册回调。当电话状态变成CALL_STATE_RINGING时说明有电话正在响铃。这里有一个版本背景Android 12API 31之后官方更推荐使用TelephonyCallback但PhoneStateListener作为旧接口在兼容场景下仍可运行。新项目可以关注TelephonyCallback的迁移方案本示例为了保持代码简单使用PhoneStateListener进行演示。private val phoneListener object : PhoneStateListener() { override fun onCallStateChanged(state: Int, phoneNumber: String?) { if (state ! TelephonyManager.CALL_STATE_RINGING) return // phoneNumber 在没有 READ_CALL_LOG 权限时可能为 null val safePhone phoneNumber?.takeLast(4)?.let { ****$it } ?: unknown sendEvent(RINGING, safePhone) } } private fun registerPhoneListener() { telephonyManager getSystemService(TelephonyManager::class.java) telephonyManager.listen(phoneListener, PhoneStateListener.LISTEN_CALL_STATE) }这段代码做了两件关键事情只对RINGING状态感兴趣避免在空闲和通话中状态重复触发。对电话号码做脱敏处理只保留后四位并且用****前缀占位服务端日志不会记录完整号码。一个容易踩的坑是在 Android 9 之后如果应用没有READ_CALL_LOG权限phoneNumber参数可能直接是null。很多新手以为是监听器没生效其实不是没生效是系统出于隐私保护不给你号码。因此不要把整个提醒逻辑建立在获取号码上。3.3 用 HttpURLConnection 发送事件不能在主线程执行收到响铃状态后需要向服务端发送 HTTP POST 请求。Android 不允许在主线程执行网络请求否则会抛出NetworkOnMainThreadException。所以发送逻辑要放到子线程中。private fun sendEvent(event: String, phone: String) { if (serverUrl.isBlank()) return Thread { try { val body JSONObject() .put(event, event) .put(phone, phone) .put(time, System.currentTimeMillis() / 1000) .toString() val conn URL(serverUrl).openConnection() as HttpURLConnection conn.requestMethod POST conn.connectTimeout 3000 conn.readTimeout 3000 conn.setRequestProperty(Content-Type, application/json) conn.doOutput true conn.outputStream.write(body.toByteArray()) Log.i(CallReminder, send result code${conn.responseCode}) conn.disconnect() } catch (e: Exception) { Log.e(CallReminder, send failed: ${e.message}) } }.start() }把连接超时和读取超时都设置为 3 秒是为了防止服务端不可达时线程长时间阻塞。日志里记录responseCode联调时可以直接用 Logcat 确认请求是否到达服务端。3.4 UI 入口和服务地址配置为了能灵活切换电脑的 IP不要硬编码服务端地址。布局文件中放一个EditText和Button用户输入类似http://192.168.1.100:8000的地址后点击保存应用会拼上/incoming-call接口路径并保存到SharedPreferences。class MainActivity : AppCompatActivity() { private lateinit var telephonyManager: TelephonyManager private var serverUrl override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val etServer findViewByIdEditText(R.id.etServer) val btnSave findViewByIdButton(R.id.btnSave) val saved getSharedPreferences(reminder, MODE_PRIVATE) etServer.setText(saved.getString(serverUrl, )) btnSave.setOnClickListener { val raw etServer.text.toString().trim() if (raw.isBlank()) { Toast.makeText(this, 请先填写服务端地址, Toast.LENGTH_SHORT).show() returnsetOnClickListener } serverUrl raw.trimEnd(/) /incoming-call saved.edit().putString(serverUrl, serverUrl).apply() registerPhoneListener() Toast.makeText(this, 已保存并开始监听, Toast.LENGTH_SHORT).show() } } // 权限申请、PhoneStateListener、sendEvent 代码见上文 }布局文件的关键是EditText的id为etServerButton的id为btnSave和 Kotlin 代码保持一致。这里的监听器注册时机比较重要。建议在用户点击保存地址并且权限已授予后再注册避免地址还没配置好就开始上报无用事件。4. 服务端接收事件后播报“快点接电话啦”4.1 为什么选 FastAPI服务端只需要暴露一个 POST 接口FastAPI 用 Pydantic 定义请求体后JSON 解析、参数校验、错误响应这些工作都不需要手动写。对于这种小型工具代码量比 Flask 更精简。如果你的环境不方便安装 FastAPI改用 Flask 也可以但下面的代码以 FastAPI 为例。4.2 接口定义和请求数据格式服务端接收的事件结构如下{ event: RINGING, phone: ****1234, time: 1700000000 }字段说明字段类型含义eventstring事件类型目前固定为 RINGINGphonestring脱敏后的号码或 unknowntimenumber事件发生的 Unix 时间戳服务端代码# server.py from fastapi import FastAPI from pydantic import BaseModel import time try: import winsound except ImportError: winsound None app FastAPI() class CallEvent(BaseModel): event: str phone: str time: int 0 def play_reminder(): if winsound is None: print(非 Windows 环境请替换为系统播放命令) return try: winsound.PlaySound( call_reminder.wav, winsound.SND_FILENAME | winsound.SND_ASYNC, ) except Exception as e: print(播放失败:, e) app.post(/incoming-call) def incoming_call(evt: CallEvent): current time.strftime(%H:%M:%S) print(f[{current}] event{evt.event} phone{evt.phone or unknown}) if evt.event RINGING: play_reminder() return {code: 0, message: ok}winsound.PlaySound只在 Windows 上可用所以用ImportError捕获导入失败的情况。在 Linux 或 macOS 上可以先打印日志再根据系统替换为aplay、afplay或pyttsx3文本转语音工具。4.3 安装依赖并启动服务先创建requirements.txtfastapi uvicorn然后安装并启动pip install -r requirements.txt uvicorn server:app --host 0.0.0.0 --port 8000这里必须写--host 0.0.0.0。如果只写127.0.0.1或直接执行uvicorn server:app默认将只监听本机回环地址真机通过局域网 IP 访问会失败。4.4 用 curl 先验证服务端Android 端还没联调之前先用 curl 模拟一个事件确认服务端能正常接收和播放声音curl -X POST http://127.0.0.1:8000/incoming-call \ -H Content-Type: application/json \ -d {event:RINGING,phone:****1234,time:1700000000}预期返回{code:0,message:ok}同时服务端控制台会打印一行日志并且如果音频文件路径正确电脑会播放提醒音。5. 模拟器与真机联调从 gsm 命令到防火墙检查5.1 用 Android 模拟器模拟来电Android 模拟器提供了模拟电话的调试命令。打开终端执行adb emu gsm call 13800000000这会让模拟器进入响铃状态相当于真的有一个电话进来。取消来电使用adb emu gsm cancel 13800000000在 Android Studio 的 Extended Controls 中也可以找到类似入口但命令行方式更适合写进自动化脚本。模拟器联调时服务端地址要填http://10.0.2.2:8000不能填http://127.0.0.1:8000。10.0.2.2是 Android 模拟器专门用于访问宿主机回环地址的固定地址这是模拟器网络模型决定的。只改地址这一处其他逻辑不变。5.2 真机联调时的网络和防火墙检查真机场景下手机和电脑要连在同一个局域网。先查出电脑的局域网 IPWindows 执行ipconfig找到“IPv4 地址”。macOS 执行ifconfig找到en0下的inet。Linux 执行ip addr找到对应网卡的 IP。然后在 App 的输入框中填写http://电脑的局域网IP:8000例如http://192.168.1.100:8000。如果真机请求失败极可能是 Windows 防火墙拦截了 8000 端口。可以先用管理员权限的命令行放行端口netsh advfirewall firewall add rule nameCallReminder dirin actionallow protocolTCP localport8000这只是一个测试用的规则。生产环境不要随意开放端口应该按最小权限原则配置防火墙。5.3 预期日志和闭环验证一次完整联调成功后应该看到三个结果服务端控制台打印类似日志[10:23:45] eventRINGING phone****1234Android 的 Logcat 中能看到CallReminder: send result code200电脑端播放提醒音或者至少打印了播放相关日志。如果其中一环缺失就进入下一章的排查步骤。6. 来电不提醒时按这条链路逐层排查6.1 排查清单速查表现象可能原因检查方式处理建议App 保存后没有任何日志没有授予电话权限或监听未注册检查系统设置中的权限状态查看 Logcat在权限回调中注册监听不要提前 register模拟器请求超时错误使用 127.0.0.1查看 serverUrl 配置模拟器使用 10.0.2.2真机请求失败uvicorn 只监听 127.0.0.1 或防火墙拦截执行 netstat 查看端口监听地址手机浏览器访问服务端地址uvicorn 加 0.0.0.0放行防火墙端口phone 始终是 unknown未申请 READ_CALL_LOG系统不返回完整号码查看服务端打印的 phone 字段不要依赖号码确需号码再单独处理 READ_CALL_LOG锁屏后不提醒应用进程被系统回收或 Doze 限制查看进程是否存活改为前台服务并常驻通知6.2 关于权限和号码的常见误解很多人在联调时发现phone字段一直是unknown第一反应是代码写错了。实际上这是 Android 隐私保护策略的一部分。没有READ_CALL_LOG权限时系统不会把完整来电号码传给普通应用。这个项目的核心目标是在电脑端提醒“快点接电话”不依赖完整号码也能工作。把“是否响铃”这个事实成功推送出去链路就算通了。如果想在日志里看到准确号码需要单独申请通话记录权限并且要确保应用在 Play Store 或应用分发场景下符合隐私政策这一步不能绕过系统权限机制。6.3 网络和服务端相关排查服务端能接收 curl 请求不代表手机一定能访问。要按顺序检查手机和电脑是否在同一网段可以先互相 ping 一下。服务端是否监听在0.0.0.0:8000用netstat -ano | findstr 8000或ss -ltnp | grep 8000查看。电脑系统防火墙是否放行 8000 端口。如果服务端跑在虚拟机和 Docker 中还要确认端口映射是否配置正确。还有一个非常隐蔽的问题如果电脑有多个网卡例如同时开着 Wi-Fi 和 VMware 虚拟网卡uvicorn --host 0.0.0.0会监听所有网卡这没问题但手机必须连接和电脑同一 Wi-Fi 的那个网段不能走虚拟网卡网段。6.4 Android 生命周期和主线程问题PhoneStateListener的回调默认运行在主线程。如果直接在回调里写HttpURLConnectionAndroid 会直接抛NetworkOnMainThreadException。发送事件必须放到子线程、协程或其他异步机制中。进程被杀问题更隐蔽。当前示例在 Activity 中注册监听器一旦用户划掉任务卡片进程可能被系统回收电话监听自然失效。要让这个功能持续可用生产版本必须使用前台服务和常驻通知。教学版本中只要保持 App 在前台或在最近任务中不划掉就能正常演示。7. 本地跑通之后生产化还差哪些工作7.1 学习版和生产版的差距当前版本是一个最小演示离真正每天使用还有差距通过 Activity 注册监听进程存活不稳定。使用明文 HTTP局域网内其他设备可以伪造事件。没有重试机制一次网络抖动就丢事件。播放声音时如果多个事件同时到达会出现声音叠加。日志没有落盘也没有保留策略。如果只是学习 Android 电话状态监听和 HTTP 推送这些都不是问题。但如果想用在家里或小办公室建议至少补上常驻服务和基础鉴权。7.2 安全和隐私建议不要把完整手机号推送到服务端建议只传事件类型和时间戳。如果确实需要号码必须脱敏后再传日志中禁止打印完整号码。服务端接口增加简单 token 校验例如 Header 中带X-Token防止局域网内其他设备恶意触发播报。局域网内不推荐长期开放无鉴权端口尤其不要绑定到公网网卡。日志设置保留时间避免无限增长。7.3 三个可扩展方向方向一接入群机器人提醒把服务端逻辑从“播放声音”改成调用钉钉、企业微信或飞书机器人 Webhook手机静音时也能在电脑或工作群里收到提醒。服务端接口不变只改提醒动作的发送目标。方向二改为前台服务把PhoneStateListener移入前台服务配合常驻通知能明显提高后台存活率。这是所有 Android 后台监听类项目迟早要做的改造。方向三改用 WebSocket 或 MQTT局域网内规模变大后HTTP 短连接可能不够灵活。可以升级为 WebSocket 长连接或 MQTT 消息订阅让电脑端实时收到事件也能支持多个提醒端同时在线。7.4 联调前的检查清单每次换环境、换电脑、换模拟器后建议按下面清单快速检查手机和电脑是否在同一网段能否互相 ping 通。服务端是否以0.0.0.0绑定端口。服务端是否用 curl 自测通过返回code等于0。电脑防火墙是否放行目标端口。模拟器场景是否使用10.0.2.2作为服务端地址。App 是否已授予READ_PHONE_STATE权限。Logcat 中是否出现send result code200。音频文件路径是否正确电脑系统是否开启声音。回到最开始的问题判断一个提醒方案是否好用不是看它用了多新的框架而是看在真正漏接电话的那一刻端到端链路是否稳定、是否能在几秒内触达。把“快点接电话啦”这个最小项目跑通之后你会对电话权限、后台进程、网络投递和服务端接口之间的配合有非常具体的认识。下一步可以从本地局域网推送扩展到群机器人或手机推送通道也可以把客户端改成前台服务增强稳定性。对新手来说最有价值的练习是先完整记录一次从模拟来电到电脑播报的过程再尝试在手机锁屏、断网、权限被拒等异常情况下观察日志这样才算真正吃透这条链路。