☰
Android蓝牙耳机拨打电话全解析:HFP检测、SCO切换与权限避坑
2026/10/15 12:47:11 网站建设 项目流程

简介:蓝牙耳机拨打电话完整工程代码,面向嵌入式开发、无线通信及蓝牙协议栈学习者,集中展示A2DP音频传输、HSP/HFP免提通话、GAP/GATT设备发现与配对连接等核心机制的代码级实现。资源基于开源工具链构建,包含构建脚本与控制程序,可通过autogen、configure、make等标准流程完成配置、编译与安装,并配套hfconsole交互控制端,实现拨号、音量调节、来电处理等操作验证。压缩包整体约864KB,以C语言源码工程为主,结构紧凑,适合直接定位协议处理、音频流与设备控制模块进行针对性研读。已有442人学习使用,通过分析源码可深入理解蓝牙通话链路的建立、音频数据收发、控制命令解析以及多线程调度等关键细节,对希望从原理走向实践、或在此基础上进行二次开发的开发者具有较高的参考价值。

1. 蓝牙耳机拨打电话的全部代码:能直接落地,还是只能当参考?

蓝牙耳机拨打电话听起来只是系统电话应用的重定向,真正做起来才知道,把“拨号”这件事做对,需要同时搞定权限、蓝牙连接、声音通道、通话状态监听四层。某公司的模拟项目X 里,团队在安卓设备上做耳机侧通话路由,光 SCO 建立时机就踩了两周,最后发现拨号本身没问题,坑全在声音通道上。这份资源把整条链路拆开重排了:蓝牙连接检测、权限申请、CALL 拨号、SCO 切声、通话状态监听,以及耳机按键触发,代码可以直接抄进自己的工程。适合两类人:一类是正在写蓝牙耳机配套 App 的安卓开发者,另一类是拿耳机做原型验证的硬件工程师。下面按我实际拆这份代码的顺序,把选型理由、参数和踩坑一起讲完。

2. 蓝牙拨号的三条路径:为什么先选 Intent 直达而不是 HFP AT 指令?

2.1 三条路径的本质差异

蓝牙耳机拨打电话,在实现层面有三条完全不同的路。第一条是 Intent.ACTION_CALL,也就是普通 Android App 里最常见的做法:构造一个tel:的 Uri,调起系统电话界面并直接拨出。这条路代码量最小,不依赖蓝牙协议栈的底层接口,只要把CALL_PHONE权限申请下来,前台场景下就能稳定工作。第二条是 HFP AT 指令,HFP(Hands-Free Profile)是蓝牙耳机与手机之间通话专用的协议,拨号操作本质是向免提模块发送一条ATD+号码\r的指令。但这条指令的收发通道由蓝牙协议栈管理,普通 App 拿不到,一般只有系统级应用或者耳机嵌入式侧才能直接操作,独立 App 想走这条路的代价很高。第三条是耳机侧按键触发,也就是用户按耳机上的接听键来发起动作,这条路的本质是媒体按键事件,它只是一个入口,最终还是要落到 Intent 拨号或者 HFP AT 指令上。

这三条路径的差异,我整理成了下面这张表,选型时基本看这张表就够了。

方案入口前置条件主要限制
Intent.ACTION_CALLApp 按钮 / 耳机按键触发CALL_PHONE 权限、前台 ActivityAndroid 10 后后台启动 Activity 受限
HFP AT 指令嵌入式侧 / 系统级应用系统签名权限、HFP 已连接普通 App 拿不到协议栈接口
耳机按键事件用户按耳机接听键注册 MediaSession 并激活只是入口,仍需拨号与 SCO 配合

我实际用下来,这份代码仓库的主路径选的是 Intent.ACTION_CALL,HFP AT 指令只在备注里给了方向,核心原因还是那句话:独立 App 拿不到普通权限之外的协议栈接口。后面逐步展开代码,你看到实现细节时会更能理解这个选择。

2.2 权限模型:哪些能申请,哪些是黑匣子

拨打电话最直接的权限是CALL_PHONE,这是危险权限,可以在运行时动态申请,也是整套方案里最基础的权限。如果你只想打开拨号盘而不直接拨出,可以用ACTION_DIAL,那个不需要CALL_PHONE,但只做半套,意义不大。真正麻烦的是 Android 版本分裂带来的权限变化:Android 6 到 11 用的是BLUETOOTH和BLUETOOTH_ADMIN,Android 12 开始改成BLUETOOTH_CONNECT和BLUETOOTH_SCAN,而且是运行时权限,不动态申请直接拿BluetoothAdapter.getProfileConnectionState()就会崩溃。Android 10 开始,系统限制后台应用启动 Activity,这意味着 App 退到后台以后,按耳机按键想调起拨号界面会失败,除非用前台服务保持可见,或者申请拨号器角色。Android 8 开始,TelephonyManager.endCall()被标记为隐藏 API,普通应用不能保证反射成功,这也是自动挂断功能最不确定的一环。

这些权限里,MODIFY_PHONE_STATE和BLUETOOTH_PRIVILEGED属于签名权限,普通 App 拿不到。很多人以为把MODIFY_PHONE_STATE写进 Manifest 就能挂断电话,实际编译能过,运行必报 SecurityException,这一点后面避坑章节会再展开。理解权限的边界,比抄代码更重要,因为 Android 12 以后的闪退大多不是逻辑错误,而是权限模型变了。

2.3 选型结论:主路径用 Intent.ACTION_CALL 的三个理由

第一条理由非常直接:代码量最少,稳定。HFP AT 指令方案需要系统签名或嵌入式配合,普通 App 工程里连调试入口都难找。第二条理由是兼容性好,Intent.ACTION_CALL 是系统公开能力,只要申请了权限,不管哪家 ROM 的前台场景都能调起拨号。第三条理由是后续扩展方便,这份资源里的耳机按键触发,最终也是回调到同一个拨号函数,路径换入口不换逻辑,改造成本很低。

当然,Intent 方案也有绕不开的弊端:它调起的是系统电话界面,不是你自己画的通话界面;声音通道切换还是要靠 SCO,这部分躲不掉。如果你的项目目标是做整套通话 UI,而不是蓝牙耳机拨号功能验证,那这条路就不够用了,需要去研究 TelecomManager 和 InCallService,那就是另一个量级的工程。这份代码的场景很明确:蓝牙耳机侧发起拨号、接通、挂断、切声音,在这个范围内它足够完整。

3. 核心代码落地:连接检测、ACTION_CALL 拨号、SCO 切声与通话状态监听

3.1 第一步:确认耳机已连接 HFP,而不是 A2DP

拨号前必须先确认蓝牙耳机已经通过 HFP(Headset Profile)连接。很多人在这里会犯错,用A2DP的连接状态来判断耳机是否可用。A2DP 负责的是音乐播放,HFP 才负责通话,一个耳机可能 A2DP 已连接但 HFP 还在协商中,这时候拨号打过去声音不会进耳机。正确做法是查HEADSETProfile 的连接状态。

private val TAG = "BluetoothDialer" /** * 确认当前是否有蓝牙耳机通过 HFP 连接。 * 这里只关心 Profile 连接状态,不关心具体是哪个设备。 */ fun isHeadsetConnected(context: Context): Boolean { val adapter = BluetoothAdapter.getDefaultAdapter() ?: return false // Android 12+ 必须先动态申请 BLUETOOTH_CONNECT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED ) { Log.e(TAG, "Android 12+ 需要先授予 BLUETOOTH_CONNECT") return false } } return adapter.getProfileConnectionState(BluetoothProfile.HEADSET) == BluetoothProfile.STATE_CONNECTED }

这里的核心逻辑是调用getProfileConnectionState()时传入BluetoothProfile.HEADSET,返回值等于STATE_CONNECTED才说明耳机已经在通话协议上就绪。getDefaultAdapter()返回空时直接返回 false,避免后续空指针。如果你在 UI 层还需要监听耳机的插拔状态,可以动态注册ACTION_ACL_CONNECTED和ACTION_ACL_DISCONNECTED两个广播,在广播里重新调用这个函数刷新连接状态。注意ACTION_ACL_*系列广播在 Android 12 上同样需要BLUETOOTH_CONNECT权限,漏掉就是闪退。

3.2 第二步:拨号,注意号码里的 # 和 * 别被转义吃掉

拨号代码本身不复杂,复杂的是号码处理。网上大量教程里的写法是Uri.parse("tel:" + phoneNumber),这个写法拨普通手机号没问题,但一旦号码里包含#或*,比如运营商服务码*100#,#会被默认编码成%23,导致拨号盘识别失败。这份资源里的写法做了特殊处理:

fun dialNumber(context: Context, rawNumber: String) { if (rawNumber.isBlank()) return // 缺少 CALL_PHONE 权限时直接退出,避免 startActivity 抛异常 if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED ) { Log.e(TAG, "缺少 CALL_PHONE 权限,无法直接拨号") return } // 第二个参数 "#*" 是关键:允许 # 和 * 原样保留 val safeNumber = Uri.encode(rawNumber, "#*") val intent = Intent(Intent.ACTION_CALL).apply { data = Uri.parse("tel:$safeNumber") flags = Intent.FLAG_ACTIVITY_NEW_TASK } context.startActivity(intent) }

这里最值得注意的就是Uri.encode(rawNumber, "#*")这个重载,第二个参数是要保留不编码的字符集合。ACTION_CALL和ACTION_DIAL的区别也顺带说一句:ACTION_CALL是直接拨出,ACTION_DIAL只弹出拨号界面,本场景要的是前者。flags加上FLAG_ACTIVITY_NEW_TASK是因为这里的 context 可能是非 Activity 上下文,不加在某些机型上会报错。另外,rawNumber建议在使用前做一次 trim,去掉空格和换行,这个细节能避免不少诡异问题。

3.3 第三步:SCO 音频通道,把通话声音真正切到耳机

SCO 是蓝牙耳机通话时专用的音频链路,全称是 Synchronous Connection-Oriented。很多人在这里翻车:ACTION_CALL已经拨号成功了,通话也接通了,但声音从手机听筒出来,耳机里一片安静,问题就是 SCO 没有建立。SCO 的建立不能靠调用isBluetoothScoOn = true硬切,这个过程是异步的,必须配合系统广播确认链路真正连通。

class ScoController(private val context: Context) { private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager /** 建立 SCO 链路,注意这是异步过程,不是调用后立刻生效 */ fun startSco() { if (audioManager.isBluetoothScoAvailableOffCall) { audioManager.startBluetoothSco() } } fun stopSco() { audioManager.stopBluetoothSco() audioManager.isBluetoothScoOn = false } /** 监听 SCO 状态广播,在 UI 层等真正 CONNECTED 后再显示通话中状态 */ fun registerScoReceiver(receiver: BroadcastReceiver) { context.registerReceiver( receiver, IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_CHANGED) ) } }

这段代码里的startSco()只是发起请求,系统随后会广播ACTION_SCO_AUDIO_STATE_CHANGED,携带的EXTRA_SCO_AUDIO_STATE依次是SCO_AUDIO_STATE_CONNECTING、SCO_AUDIO_STATE_CONNECTED、SCO_AUDIO_STATE_DISCONNECTED。只有在收到CONNECTED后才能认为声音通道切换成功。isBluetoothScoAvailableOffCall这个判断是为了避免在设备本身不支持通话 SCO 时白忙一场。Android 12 以后系统推荐用AudioManager.setCommunicationDevice()新 API,但实测中很多国产 ROM 对老接口的兼容更好,保守方案是两者都保留,优先用新 API,失败后回退到老接口。

3.4 第四步:监听通话状态,联动 UI 与声音路由

拨号之后要实时感知通话状态,才能决定什么时候建立 SCO、什么时候显示挂断按钮。这里用TelephonyManager.listen()注册监听:

@Suppress("DEPRECATION") fun watchCallState(context: Context, callback: (Int, String?) -> Unit) { val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager val listener = object : PhoneStateListener() { override fun onCallStateChanged(state: Int, phoneNumber: String?) { callback(state, phoneNumber) } } tm.listen(listener, PhoneStateListener.LISTEN_CALL_STATE) }

回调里拿到的state有三种关键取值:CALL_STATE_IDLE表示空闲,CALL_STATE_RINGING表示来电响铃,CALL_STATE_OFFHOOK表示已进入通话。在实际项目里,我一般是在OFFHOOK时调用startSco(),在IDLE时调用stopSco(),这样 SCO 的建立和释放刚好贴着通话生命周期。注意phoneNumber参数在部分 Android 版本和机型上会因为权限限制拿不到,不要依赖它做核心逻辑。PhoneStateListener在 Android 12 上已标记弃用,推荐用TelephonyCallback,但老接口依然可用,加@Suppress("DEPRECATION")是为了让编译安静,不是偷懒。

3.5 挂断:能反射就反射,反射不了就引到通知栏

自动挂断是这个资源里最敏感的部分,因为系统并没有给普通应用提供公开的挂断接口。Android 8 之前,TelephonyManager.endCall()还是公开 API,但之后被标记为隐藏。网上很多工程用反射来调,这个代码仓库里也保留了这个方案,但我必须把边界说清楚:

fun endCallCompat(tm: TelephonyManager): Boolean { return try { val endCall = tm.javaClass.getMethod("endCall") endCall.isAccessible = true endCall.invoke(tm) as Boolean } catch (t: Throwable) { Log.w(TAG, "endCall 反射失败: ${t.message}") false } }

这段代码在 Android 8 以下基本可用,在 Android 8 到 10 的多数 ROM 上也能跑通,但 Android 11 之后部分厂商加强了隐藏 API 限制,反射可能直接被拒。如果你的 App 面向最新系统,更稳的做法是在通话状态下弹出一条系统通知,引导用户通过通知栏挂断,或者直接把挂断动作绑定到耳机侧按键事件上。TelecomManager.endCall()是公开 API,但需要MODIFY_PHONE_STATE签名权限,普通应用写了等于没写。搞清楚这个边界,比找到一个能用的反射代码更重要。

3.6 参数速查表

函数关键参数作用
isHeadsetConnected()BluetoothProfile.HEADSET检查 HFP 连接状态
dialNumber()Uri.encode(rawNumber, "#*")构造 tel Uri 并保留特殊字符
startSco()isBluetoothScoAvailableOffCall发起 SCO 异步建立
watchCallState()LISTEN_CALL_STATE监听通话状态变化
endCallCompat()@hide反射尝试自动挂断,失败返回 false

4. 避坑记录:权限崩溃、后台启动限制与 SCO 无声的五个真实场景

4.1 Android 12 上打开页面直接崩溃,Android 11 上一切正常

现象:同一份代码,Android 11 手机上跑得好好的,换到 Android 12 手机,打开蓝牙页面就闪退,Logcat 里报SecurityException: Need BLUETOOTH_CONNECT permission。

原因:Android 12 开始,蓝牙相关权限从普通权限升级为运行时权限。Manifest 里声明了BLUETOOTH_CONNECT还不够,必须在代码里动态申请,否则任何蓝牙 API 调用都会抛异常。

解决:动态申请BLUETOOTH_CONNECT,并处理onRequestPermissionsResult回调。建议在申请前先判断Build.VERSION.SDK_INT >= 31,老版本走老权限逻辑,新版本走新权限逻辑。不要想当然地只加 Manifest 声明。

4.2 拨号成功,通话声音却从手机外放出来

现象:ACTION_CALL已经调起拨号,电话也打通了,但声音从手机底部扬声器出来,蓝牙耳机里完全没声音。

原因:SCO 链路没有建立。最典型的原因是调用了startBluetoothSco()后马上设置isBluetoothScoOn = true,但 SCO 建立是异步的,这两个操作之间没有等待系统广播确认链路已连通。

解决:监听ACTION_SCO_AUDIO_STATE_CHANGED,收到SCO_AUDIO_STATE_CONNECTED后再更新音频路由。另外,部分 ROM 在OFFHOOK状态刚切进来时立即调用startBluetoothSco()会失效,实测需要延迟 200 到 500 毫秒再调用。这一点是这个资源里最值得记住的经验,属于那种不踩一遍根本不会想到的细节。

4.3 App 退到后台后,按耳机按键拨号没有反应

现象:App 在前台时一切正常,按 Home 键退到后台,再按耳机上的接听键,界面弹不起来,Logcat 里提示后台启动 Activity 被限制。

原因:Android 10 开始限制后台应用启动 Activity。耳机按键触发的拨号本质上是一个 App 进程内的回调,回调里调startActivity()时如果 App 不在前台,系统直接拦截。

解决:按耳机按键时先判断应用是否在前台,不在前台就通过前台服务把进程状态提升,或者弹一条高优先级通知引导用户点击进入。真正要完全绕开这个限制,只能引导用户把 App 设为默认拨号器角色,这个需要系统设置界面配合,普通应用无法自己完成。

4.4 拨打运营商服务码 *100# 时号码被截断

现象:拨普通手机号正常,拨打*100#这类服务码时,拨号盘显示%23100%23,号码无法识别,拨号失败。

原因:Uri.parse("tel:" + phoneNumber)这个写法里,#被默认编码成%23。HTTP 场景下这个编码是对的,但电话 Uri 场景下#是号码的一部分,不应该编码。

解决:使用Uri.encode(rawNumber, "#*"),第二个参数明确放行#和*。这个坑在绝大多数教程里都不会提,只有真正打运营商服务码才会遇到。

4.5 自动挂断反射调用失败,返回 false

现象:调用endCallCompat()后电话没有挂断,返回 false,日志里出现NoSuchMethodException或SecurityException。

原因:TelephonyManager.endCall()在 Android 8 后被标记为隐藏 API,不同厂商对反射的限制力度不同。部分严格限制的 ROM 会直接拒绝访问,这不是你代码写错了,是系统策略问题。

解决:不要把反射当主力方案。实际项目里,我一般把反射挂断作为 Android 10 以下机型的优先方案,Android 11 以上引导用户使用通知栏挂断,或者利用耳机侧按键事件来挂断。这样既不违背系统限制,也不影响用户体验。

5. 进阶:把耳机按键接进来,实现“单击拨号 + 双击挂断”的完整链路

前面几章讲的都是 App 内部主动拨号的场景,蓝牙耳机真正的价值在于耳机侧按键操作。这一章把 KEYCODE_HEADSETHOOK 接进来,实现单击拨号、双击挂断的完整链路。核心思路是注册一个 MediaSession,拦截耳机接听键的媒体按键事件,再配合通话状态判断执行不同动作。

private val hookClick = Handler(Looper.getMainLooper()) private var lastHookClickAt = 0L private var singleClickRunnable: Runnable? = null fun handleHeadsetHook(callState: Int) { val now = SystemClock.uptimeMillis() // 400ms 内收到第二次点击,判定为双击 if (now - lastHookClickAt < 400L) { singleClickRunnable?.let { hookClick.removeCallbacks(it) } singleClickRunnable = null lastHookClickAt = 0L endCallCompat(telephonyManager) return } lastHookClickAt = now val task = Runnable { when (callState) { TelephonyManager.CALL_STATE_IDLE -> dialLastOrPreset() TelephonyManager.CALL_STATE_RINGING, TelephonyManager.CALL_STATE_OFFHOOK -> startSco() } } singleClickRunnable = task hookClick.postDelayed(task, 500L) }

这段逻辑的关键在于单击和双击的互斥处理:第一次按键不立即执行动作,而是延迟 500 毫秒等第二次按键;如果第二次来了,就把尚未执行的单击任务取消,改为执行挂断。lastHookClickAt用来做双击判定窗口,400 毫秒这个值可以根据实际耳机微调,不同耳机按键消抖时间不一样,太短会漏判双击,太长会让单击响应变肉。dialLastOrPreset()是我封装的一个函数,优先重拨最近号码,没有最近号码就拨打预设号码,避免重复读取通话记录权限。

实际测试时还有一个容易忽略的坑:部分耳机的长按拒接会在底层产生两次 ACTION_DOWN 事件,导致单击被误判成双击。我的处理方式是在onMediaButtonEvent的ACTION_UP回调里判断按键持续时长,如果超过 600 毫秒就丢弃这次事件,不进入上面的双击判定流程。这个细节是在某款国产蓝牙耳机上碰到的,折腾了大半天才定位到是按键事件重复上报,不是代码逻辑问题。

从那以后,我每次把这类拨号 Demo 递出去之前,都会强制自己走一遍四步检查:权限清单是否匹配目标系统版本、拨号时是否在前台、SCO 建立时机是否正确、挂断逻辑有没有回退方案。看起来都是小细节,但每个都能让功能直接失效。这套代码仓库把链路都打通了,剩下的是按你的目标机型做一轮针对性适配。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询