手机在包里调成静音,电脑在旁边开着通知栏,电话进来时什么都没有发生;等拿起手机看到未接来电,已经错过了重要信息。为了解决这种场景,可以做一个“快点接电话啦”来电提醒小助手:手机检测到响铃事件后,立即通过 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 两端环境要求
| 端 | 工具/依赖 | 建议 |
|---|---|---|
| Android | Android Studio | 使用较新的稳定版本 |
| Android | 编译语言 | Kotlin 1.8 以上 |
| Android | minSdk | 21 以上 |
| Android | targetSdk | 33 或 34,注意新版本权限差异 |
| 服务端 | Python | 3.9 以上 |
| 服务端 | FastAPI | 0.100 以上 |
| 服务端 | Uvicorn | 0.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 6(API 23)之后必须在运行时动态申请,不能只写在 Manifest 里。
Manifest 配置如下:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.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: Array<out 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 12(API 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 = findViewById<EditText>(R.id.etServer) val btnSave = findViewById<Button>(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() return@setOnClickListener } serverUrl = raw.trimEnd('/') + "/incoming-call" saved.edit().putString("serverUrl", serverUrl).apply() registerPhoneListener() Toast.makeText(this, "已保存并开始监听", Toast.LENGTH_SHORT).show() } } // 权限申请、PhoneStateListener、sendEvent 代码见上文 }布局文件的关键是EditText的id为etServer,Button的id为btnSave,和 Kotlin 代码保持一致。
这里的监听器注册时机比较重要。建议在用户点击保存地址并且权限已授予后再注册,避免地址还没配置好就开始上报无用事件。
4. 服务端:接收事件后播报“快点接电话啦”
4.1 为什么选 FastAPI
服务端只需要暴露一个 POST 接口,FastAPI 用 Pydantic 定义请求体后,JSON 解析、参数校验、错误响应这些工作都不需要手动写。对于这种小型工具,代码量比 Flask 更精简。
如果你的环境不方便安装 FastAPI,改用 Flask 也可以,但下面的代码以 FastAPI 为例。
4.2 接口定义和请求数据格式
服务端接收的事件结构如下:
{ "event": "RINGING", "phone": "****1234", "time": 1700000000 }字段说明:
| 字段 | 类型 | 含义 |
|---|---|---|
| event | string | 事件类型,目前固定为 RINGING |
| phone | string | 脱敏后的号码或 unknown |
| time | number | 事件发生的 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.txt:
fastapi 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 真机联调时的网络和防火墙检查
真机场景下,手机和电脑要连在同一个局域网。
先查出电脑的局域网 IP:
- Windows 执行
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 name="CallReminder" dir=in action=allow protocol=TCP localport=8000这只是一个测试用的规则。生产环境不要随意开放端口,应该按最小权限原则配置防火墙。
5.3 预期日志和闭环验证
一次完整联调成功后,应该看到三个结果:
- 服务端控制台打印类似日志:
[10:23:45] event=RINGING phone=****1234- Android 的 Logcat 中能看到:
CallReminder: send result code=200- 电脑端播放提醒音,或者至少打印了播放相关日志。
如果其中一环缺失,就进入下一章的排查步骤。
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的回调默认运行在主线程。如果直接在回调里写HttpURLConnection,Android 会直接抛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 code=200。 - 音频文件路径是否正确,电脑系统是否开启声音。
回到最开始的问题,判断一个提醒方案是否好用,不是看它用了多新的框架,而是看在真正漏接电话的那一刻,端到端链路是否稳定、是否能在几秒内触达。把“快点接电话啦”这个最小项目跑通之后,你会对电话权限、后台进程、网络投递和服务端接口之间的配合有非常具体的认识。
下一步可以从本地局域网推送扩展到群机器人或手机推送通道,也可以把客户端改成前台服务增强稳定性。对新手来说,最有价值的练习是先完整记录一次从模拟来电到电脑播报的过程,再尝试在手机锁屏、断网、权限被拒等异常情况下观察日志,这样才算真正吃透这条链路。