【android bluetooth 协议分析 14】【HFP详解 1】【案例一: 手机侧显示来电,但车机侧没有显示来电: 讲解AT+CLCC命令】

1. 背景

今天上报了一例, 手机是连接了 蓝牙的。 但此时来电时,车机侧不显示来电。可以在手机侧看到来电。

这里简单分享一下这个问题。 借着这个问题, 我们讲解一下 :

  • Sent AT+CLCC
  • Rcvd +CLCC: 1,1,4,0,0,"173xxxxxxx7",129," 173 xxxxxx7 "

2. 案例分析

1. 问题情况日志:

  • 从 btsnoop 文件中很清楚的看到
  • 我们车机下发了 Sent AT+CLCC
c 复制代码
145585	2025-06-03 15:07:17.284728	22:22:29:ba:87:e9 (xxx_87e9)	e4:aa:e4:6b:c9:22 (xxx)	HFP	22	Sent AT+CLCC 
  • 手机只回复了 ok
c 复制代码
145590	2025-06-03 15:07:17.293452	e4:aa:e4:6b:c9:22 (xxx)	22:22:29:ba:87:e9 (xxx_87e9)	HFP	20	Rcvd   OK  

并没有回复 对应的 来电信息。

shell 复制代码
06-03 15:07:17.294828 14402 14468 D HeadsetClientStateMachine: Connected: command result: 0 queuedAction: 50
06-03 15:07:17.294838 14402 14468 D HeadsetClientStateMachine: queryCallsDone
06-03 15:07:17.294857 14402 14468 D HeadsetClientStateMachine: currCallIdSet [] newCallIdSet [] callAddedIds [] callRemovedIds [] callRetainedIds []
06-03 15:07:17.294867 14402 14468 D HeadsetClientStateMachine: ADJUST: currCallIdSet [] newCallIdSet [] callAddedIds [] callRemovedIds [] callRetainedIds []
  • 在协议栈中, 查询当前电话的状态, 可以看到, 啥也没有 check 到。所以没有给 telecom 上报电话。此时车机也就看不到 来电信息。

2.正常的日志

同样是正常的来电,车机下发 Sent AT+CLCC 手机是有对应的回应的。

c 复制代码
174913	2025-06-03 15:09:58.108299	22:22:29:ba:87:e9 (xxx_87e9)	Apple_aa:5d:47 (年年🈶钱🧲🧲🧲🧲🧲🧲🧲🧲🧲💰💰💰¥66wWwwwwww)	HFP	22	Sent AT+CLCC 

174915	2025-06-03 15:09:58.259760	Apple_aa:5d:47 (年年🈶钱🧲🧲🧲🧲🧲🧲🧲🧲🧲💰💰💰¥66wWwwwwww)	22:22:29:ba:87:e9 (xxx_87e9)	HFP	69	Rcvd   +CLCC: 1,1,4,0,0,"173xxxxxxx7",129," 173 xxxxxx7 " 


174916	2025-06-03 15:09:58.305326	Apple_aa:5d:47 (年年🈶钱🧲🧲🧲🧲🧲🧲🧲🧲🧲💰💰💰¥66wWwwwwww)	22:22:29:ba:87:e9 (xxx_87e9)	HFP	19	Rcvd   OK  

3. AT+CLCC+CLCC 讲解

在蓝牙电话(Hands-Free Profile, HFP)中,AT+CLCC+CLCC 是用于查询当前通话列表状态的重要命令。下面将结合 HFP 协议规范(Bluetooth HFP 1.8 或更高版本)对这两个命令进行详细分析,并结合车机(Hands-Free unit, HF)与手机(Audio Gateway, AG)之间的交互流程。


1. 命令交互背景说明

动作主体 命令方向 命令内容 含义
车机(HF) 发送 AT+CLCC 查询当前所有通话(Call List)
手机(AG) 响应 +CLCC: ... 返回一个或多个通话状态详情

这个命令用于实现类似于"显示当前所有正在进行的通话(包括通话状态、是否是拨出、是否是会议等)"的功能。


2.命令详解

1. 车机发送的命令:AT+CLCC

  • 定义:AT 命令,表示"List Current Calls"

  • 格式
    AT+CLCC

  • 含义

    请求手机(AG)返回当前处于活跃状态的通话(包括呼出、呼入、保持、挂起等),每一条通话信息会以 +CLCC: 开头返回。

  • 常见触发场景

    • 用户按下车机上的"通话列表"按钮;
    • 通话状态发生变化后自动触发(如接听、拨出等);
    • HF 想同步 AG 的通话状态时主动发起。

2. 手机响应的命令:+CLCC:

  • 格式
    +CLCC: <idx>,<dir>,<status>,<mode>,<mpty>[,<number>,<type>]

    • idx:通话索引(1~7)

    • dir:方向(0=手机发起,1=手机接收)

    • status:状态(详见下表)

    • mode:音频模式(0=语音)

    • mpty:是否为会议通话(0=否,1=是)

    • number:可选字段,电话号码

    • type:电话号码类型(见 GSM 07.07)

1. 通话状态码说明(status):
status 值 含义
0 活跃(active)
1 保持(held)
2 拨号中(dialing)
3 振铃中(alerting)
4 呼入(incoming)
5 呼叫等待(waiting)
2. 示例响应:
shell 复制代码
+CLCC: 1,0,0,0,0,"1234567890",129
+CLCC: 2,1,4,0,0,"9876543210",129

表示:

  • 通话1是手机拨出的,已经处于活跃状态;
  • 通话2是呼入电话,尚未接听。

3. 实际交互流程图(车机为 HF,手机为 AG)

shell 复制代码
HF (车机)                          AG (手机)
   |                                 |
   |---> AT+CLCC ------------------> |   // 请求通话状态
   |                                 |
   |<--- +CLCC: 1,... -------------- |   // 返回通话1信息
   |<--- +CLCC: 2,... -------------- |   // 返回通话2信息(若有)
   |<--- OK ------------------------ |   // 结束响应

4. 典型使用场景分析

场景 1:车机显示当前通话列表

  • 用户在车机上查看通话状态
  • 车机发送 AT+CLCC
  • 手机返回当前通话信息(如当前通话是拨号中、振铃中等)

场景 2:通话状态同步

  • 手机接到电话,但车机未收到 RING
  • 车机可通过轮询 AT+CLCC 获得当前呼入状态并在屏幕上提示用户

场景 3:多通话处理

  • 手机有多个通话(一个保持、一个活跃)
  • +CLCC: 会返回多个条目,车机可选择切换

5. HFP 协议相关规范出处

  • 参考规范
    • Bluetooth HFP 1.8+ Specification
    • GSM AT command set (3GPP TS 27.007)

6. aosp 中源码分享

在 aosp 中我们是如何在来电的时候触发 查询当前的 电话列表的呢?

java 复制代码
// android/app/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java
        public synchronized boolean processMessage(Message message) {
            logD("Connected process message: " + message.what);
            switch (message.what) {
...
                case QUERY_CURRENT_CALLS: // 3. 处理 QUERY_CURRENT_CALLS 事件
                    removeMessages(QUERY_CURRENT_CALLS);
                    if (DBG) {
                        Log.d(TAG, "mClccPollDuringCall=" + mClccPollDuringCall);
                    }
                    // If there are ongoing calls periodically check their status.
                    if (mCalls.size() > 1
                            && mClccPollDuringCall) {
                        sendMessageDelayed(QUERY_CURRENT_CALLS,
                                mService.getResources().getInteger(
                                R.integer.hfp_clcc_poll_interval_during_call));
                    } else if (mCalls.size() > 0) {
                        sendMessageDelayed(QUERY_CURRENT_CALLS,
                                QUERY_CURRENT_CALLS_WAIT_MILLIS);
                    }
                    queryCallsStart(); // 4. 这里会触发向 手机查询 当前 的通话列表
                    break;
                    ...
                case StackEvent.STACK_EVENT:
                    Intent intent = null;
                    StackEvent event = (StackEvent) message.obj;
                    logD("Connected: event type: " + event.type);

                    switch (event.type) {                        
                        case StackEvent.EVENT_TYPE_CALL:
                        case StackEvent.EVENT_TYPE_CALLSETUP: // 1. 每次来电都会触发 setup 回调
                        case StackEvent.EVENT_TYPE_CALLHELD:
                        case StackEvent.EVENT_TYPE_RESP_AND_HOLD:
                        case StackEvent.EVENT_TYPE_CLIP:
                        case StackEvent.EVENT_TYPE_CALL_WAITING:
                            sendMessage(QUERY_CURRENT_CALLS); // 2. 发送 QUERY_CURRENT_CALLS 事件
                            break;

上面的已经说明了触发流程:

  1. 每次来电都会触发 setup 回调
  2. 发送 QUERY_CURRENT_CALLS 事件
  3. 处理 QUERY_CURRENT_CALLS 事件
  4. 这里会通过调用 queryCallsStart 触发向 手机查询 当前 的通话列表

1. queryCallsStart 讲解

c 复制代码
// android/app/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java
    private boolean queryCallsStart() {
        logD("queryCallsStart");
        clearPendingAction();
        mNativeInterface.queryCurrentCalls(mCurrentDevice);
        addQueuedAction(QUERY_CURRENT_CALLS, 0);
        return true;
    }

// android/app/src/com/android/bluetooth/hfpclient/NativeInterface.java
    public boolean queryCurrentCalls(BluetoothDevice device) {
        return queryCurrentCallsNative(getByteAddress(device));
    }
    

// android/app/jni/com_android_bluetooth_hfpclient.cpp
static jboolean queryCurrentCallsNative(JNIEnv* env, jobject object,
                                        jbyteArray address) {


  bt_status_t status = sBluetoothHfpClientInterface->query_current_calls(
      (const RawAddress*)addr);

}


static const bthf_client_interface_t bthfClientInterface = {

    .query_current_calls = query_current_calls,

};

#define BTA_HF_CLIENT_AT_CMD_CLCC 12

// system/btif/src/btif_hf_client.cc
/*******************************************************************************
 *
 * Function         query_current_calls
 *
 * Description      query list of current calls
 *
 * Returns          bt_status_t
 *
 ******************************************************************************/
static bt_status_t query_current_calls(UNUSED_ATTR const RawAddress* bd_addr) {
  
  if (cb->peer_feat & BTA_HF_CLIENT_PEER_ECS) {
    BTA_HfClientSendAT(cb->handle, BTA_HF_CLIENT_AT_CMD_CLCC/*这里下发了 这个命令 12*/, 0, 0, NULL);
    return BT_STATUS_SUCCESS;
  }

  return BT_STATUS_UNSUPPORTED;
}

// 最终会在  bta_hf_client_send_at_cmd 中处理 BTA_HF_CLIENT_AT_CMD_CLCC 该命令
// system/bta/hf_client/bta_hf_client_at.cc
void bta_hf_client_send_at_cmd(tBTA_HF_CLIENT_DATA* p_data) {
  ...
  tBTA_HF_CLIENT_DATA_VAL* p_val = (tBTA_HF_CLIENT_DATA_VAL*)p_data;
  char buf[BTA_HF_CLIENT_AT_MAX_LEN];

  APPL_TRACE_DEBUG("%s: at cmd: %d", __func__, p_val->uint8_val);
  switch (p_val->uint8_val) {
    ...
    case BTA_HF_CLIENT_AT_CMD_CLCC:
      bta_hf_client_send_at_clcc(client_cb);
      break;
    ...
    }

}

// system/bta/hf_client/bta_hf_client_at.cc
void bta_hf_client_send_at_clcc(tBTA_HF_CLIENT_CB* client_cb) {
  const char* buf;

  APPL_TRACE_DEBUG("%s", __func__);

  buf = "AT+CLCC\r";

  // 最终调用 bta_hf_client_send_at 下发 AT+CLCC 命令
  bta_hf_client_send_at(client_cb, BTA_HF_CLIENT_AT_CLCC, buf, strlen(buf));
}

2. +CLCC

当我们 收到 AG 侧的 电话列表如何解析?

java 复制代码
// android/app/src/com/android/bluetooth/hfpclient/HeadsetClientStateMachine.java
        public synchronized boolean processMessage(Message message) {
            logD("Connected process message: " + message.what);

            switch (message.what) {

                case StackEvent.STACK_EVENT:
                    Intent intent = null;
                    StackEvent event = (StackEvent) message.obj;
                    logD("Connected: event type: " + event.type);

                    switch (event.type) {

                            switch (queuedAction.first) {
                                case QUERY_CURRENT_CALLS:
                                    queryCallsDone();
                                    break;
  • 当我们收到 手机侧(AG) 上报的电话列表事件时,就会触发 queryCallsDone 函数调用。
1. queryCallsDone 详解

函数 queryCallsDone() 是 AOSP 中 HFP (Hands-Free Profile) 客户端的一个核心函数,位于 HfpClientService 的呼叫状态同步流程中,用于处理 车机(HF)查询手机(AG)呼叫状态的响应(即 CLCC 命令的返回)

  • AT+CLCC:车机(HF)发送此 AT 命令到手机(AG),请求当前通话列表。
  • +CLCC :手机返回当前呼叫信息,每一个通话对应一个 +CLCC:
  • 本函数在收到所有 +CLCC 响应后调用,用于更新车机端的通话状态映射表 mCalls

先解释一下 如下几个变量在 HeadsetClientStateMachine 中的含义:

变量名 含义
mCalls 当前车机端缓存的呼叫状态
mCallsUpdate 本轮 CLCC 响应中得到的新状态
HF_ORIGINATED_CALL_ID 车机主动发起的通话 ID(临时设定为 -1)
c 复制代码
    private void queryCallsDone() {
        logD("queryCallsDone");

		/*
			1. 复制当前 ID 集合(去除 -1):
				生成旧通话 ID 集合,排除掉车机发起但未匹配成功的呼叫(ID = -1)。
		*/
        Set<Integer> currCallIdSet = new HashSet<Integer>();
        currCallIdSet.addAll(mCalls.keySet());
        // Remove the entry for unassigned call.
        currCallIdSet.remove(HF_ORIGINATED_CALL_ID);

		/*
			2. 获取新通话 ID 集合:
				从 CLCC 响应中收集新一轮的通话 ID。
		*/
        Set<Integer> newCallIdSet = new HashSet<Integer>();
        newCallIdSet.addAll(mCallsUpdate.keySet());


		/*
			3. 计算三类通话 ID
				Added:本轮新出现的通话。
				Removed:旧有但不再出现的通话(说明已结束)。
				Retained:两轮都有的,可能有字段更新。
		*/
        // Added.
        Set<Integer> callAddedIds = new HashSet<Integer>();
        callAddedIds.addAll(newCallIdSet);
        callAddedIds.removeAll(currCallIdSet);

        // Removed.
        Set<Integer> callRemovedIds = new HashSet<Integer>();
        callRemovedIds.addAll(currCallIdSet);
        callRemovedIds.removeAll(newCallIdSet);

        // Retained.
        Set<Integer> callRetainedIds = new HashSet<Integer>();
        callRetainedIds.addAll(currCallIdSet);
        callRetainedIds.retainAll(newCallIdSet);

		// 打印当前对比状态, 打印三个集合,便于开发者追踪通话状态变化。
        logD("currCallIdSet " + mCalls.keySet() + " newCallIdSet " + newCallIdSet
                + " callAddedIds " + callAddedIds + " callRemovedIds " + callRemovedIds
                + " callRetainedIds " + callRetainedIds);

		/*
			4. 尝试将 HF_ORIGINATED_CALL_ID 匹配到手机返回的一个真实通话
		*/
        // First thing is to try to associate the outgoing HF with a valid call.
        Integer hfOriginatedAssoc = -1;
        if (mCalls.containsKey(HF_ORIGINATED_CALL_ID)) {
            HfpClientCall c = mCalls.get(HF_ORIGINATED_CALL_ID);
            long cCreationElapsed = c.getCreationElapsedMilli();
            if (callAddedIds.size() > 0) {
               //  匹配第一通新增通话
                logD("Associating the first call with HF originated call");
                hfOriginatedAssoc = (Integer) callAddedIds.toArray()[0];
                mCalls.put(hfOriginatedAssoc, mCalls.get(HF_ORIGINATED_CALL_ID));
                mCalls.remove(HF_ORIGINATED_CALL_ID);

                // Adjust this call in above sets.
                // 调整集合状态
                callAddedIds.remove(hfOriginatedAssoc);
                callRetainedIds.add(hfOriginatedAssoc);
                /*
	                说明:HF 发出呼叫后手机可能返回一条 +CLCC(呼叫状态)作为回应,这里尝试将其匹配起来,避免重复。
                */
            } else if (SystemClock.elapsedRealtime() - cCreationElapsed > OUTGOING_TIMEOUT_MILLI) {
	            /*
		            如果没有匹配上任何新通话,且超时了:
				        异常处理:超时未收到回应,说明手机没有处理成功,发出 AT+CHUP 结束呼叫。
	            */
                Log.w(TAG, "Outgoing call did not see a response, clear the calls and send CHUP");
                // We send a terminate because we are in a bad state and trying to
                // recover.
                terminateCall();

                // Clean out the state for outgoing call.
                for (Integer idx : mCalls.keySet()) {
                    HfpClientCall c1 = mCalls.get(idx);
                    c1.setState(HfpClientCall.CALL_STATE_TERMINATED);
                    sendCallChangedIntent(c1);
                }
                mCalls.clear();

                // We return here, if there's any update to the phone we should get a
                // follow up by getting some call indicators and hence update the calls.
                return;
            }
        }

        logD("ADJUST: currCallIdSet " + mCalls.keySet() + " newCallIdSet " + newCallIdSet
                + " callAddedIds " + callAddedIds + " callRemovedIds " + callRemovedIds
                + " callRetainedIds " + callRetainedIds);

		/*
			5. 终止并移除已结束通话
		*/
        // Terminate & remove the calls that are done.
        for (Integer idx : callRemovedIds) {
            HfpClientCall c = mCalls.remove(idx);
            c.setState(HfpClientCall.CALL_STATE_TERMINATED);
            sendCallChangedIntent(c); // 发送广播,通知 telecom。 电话状态发生改变
        }

		/*
			6. 添加新增通话
		*/
        // Add the new calls.
        for (Integer idx : callAddedIds) {
            HfpClientCall c = mCallsUpdate.get(idx);
            mCalls.put(idx, c);
            sendCallChangedIntent(c); // 发送广播,通知 telecom。 电话状态发生改变
        }

		/*
			7. 更新保留的通话(如状态、号码变化)
		*/
        // Update the existing calls.
        for (Integer idx : callRetainedIds) {
            HfpClientCall cOrig = mCalls.get(idx);
            HfpClientCall cUpdate = mCallsUpdate.get(idx);

            // If any of the fields differs, update and send intent
            if (!cOrig.getNumber().equals(cUpdate.getNumber())
                    || cOrig.getState() != cUpdate.getState()
                    || cOrig.isMultiParty() != cUpdate.isMultiParty()) {

                // Update the necessary fields.
                cOrig.setNumber(cUpdate.getNumber());
                cOrig.setState(cUpdate.getState());
                cOrig.setMultiParty(cUpdate.isMultiParty());

                // Send update with original object (UUID, idx).
                sendCallChangedIntent(cOrig); // 发送广播,通知 telecom。 电话状态发生改变
            }
        }

		/*
			8. 是否继续轮询 CLCC
		*/
        if (mCalls.size() > 0) {
	        // 如通话还未完成,继续轮询 AT+CLCC。防止漏掉状态变更。


            // Continue polling even if not enabled until the new outgoing call is associated with
            // a valid call on the phone. The polling would at most continue until
            // OUTGOING_TIMEOUT_MILLI. This handles the potential scenario where the phone creates
            // and terminates a call before the first QUERY_CURRENT_CALLS completes.
            if (mClccPollDuringCall
                    || (mCalls.containsKey(HF_ORIGINATED_CALL_ID))) {
                sendMessageDelayed(QUERY_CURRENT_CALLS,
                        mService.getResources().getInteger(
                        R.integer.hfp_clcc_poll_interval_during_call));
            } else {
                if (getCall(HfpClientCall.CALL_STATE_INCOMING) != null) {
                    logD("Still have incoming call; polling");
                    sendMessageDelayed(QUERY_CURRENT_CALLS, QUERY_CURRENT_CALLS_WAIT_MILLIS);
                } else {
                    removeMessages(QUERY_CURRENT_CALLS);
                }
            }
        }

		/*
			9. 清空本轮临时状态
		*/
        mCallsUpdate.clear();
    }

总结:queryCallsDone 的作用:

这段逻辑就是为了实现以下 HFP 关键功能:

  1. 从手机解析 +CLCC 返回
  2. 对比新旧通话状态
  3. 发送呼叫变化事件到上层应用(如车机的通话 UI);
  4. 处理异常情况,如手机未回应新呼叫
  5. 继续轮询,确保状态同步
2. sendCallChangedIntent

发送广播,通知 telecom。 电话状态发生改变

c 复制代码
	// HfpClientCall c: 当前通话对象,它封装了该通话的 ID、状态(如拨出、接听、挂断)、号码、多方标志等信息。
	
    private void sendCallChangedIntent(HfpClientCall c) {
        logD("sendCallChangedIntent " + c);
        /*
	        构建一个 Intent,action 是 BluetoothHeadsetClient.ACTION_CALL_CHANGED,即 "蓝牙 HFP 客户端通话状态变更" 的广播标识。

			这是系统定义的标准广播,其他模块可以监听这个广播了解蓝牙通话状态。
        */
        Intent intent = new Intent(BluetoothHeadsetClient.ACTION_CALL_CHANGED);

		/*
			设置该广播为 前台广播,即优先级较高,会被及时处理。
			避免因系统延迟或资源限制而延后处理该重要事件.
		*/
        intent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND);

		// 添加通话对象到广播, 这样接收方就可以获取通话对象,分析其 ID、状态、号码等具体内容。
        intent.putExtra(BluetoothHeadsetClient.EXTRA_CALL, c);
        Utils.sendBroadcast(mService, intent, BLUETOOTH_CONNECT/*发送广播需要的权限,只有持有此权限的广播接收器才能接收*/,
                Utils.getTempAllowlistBroadcastOptions()/*该方法设置了一个临时 allowlist 权限策略,允许在 Doze 模式或省电模式下依旧发送此广播; 保证通话相关状态不会被系统省电策略忽略。*/);


		// 通知连接服务更新, 这里会触发通知到 telecom.
        HfpClientConnectionService.onCallChanged(c.getDevice(), c);
    }
    

7.小结

命令 主体 含义 功能
AT+CLCC HF 请求当前通话列表 发起查询
+CLCC: ... AG 当前所有通话的状态信息列表 返回每一通话状态

这个命令对 HFP 功能的实现非常关键,尤其在实现通话管理(多通话、呼叫等待、会议通话)时。

相关推荐
还鮟2 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡3 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi003 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体
zhangphil5 小时前
Android理解onTrimMemory中ComponentCallbacks2的内存警戒水位线值
android
你过来啊你5 小时前
Android View的绘制原理详解
android
移动开发者1号8 小时前
使用 Android App Bundle 极致压缩应用体积
android·kotlin
移动开发者1号8 小时前
构建高可用线上性能监控体系:从原理到实战
android·kotlin
ii_best13 小时前
按键精灵支持安卓14、15系统,兼容64位环境开发辅助工具
android
美狐美颜sdk13 小时前
跨平台直播美颜SDK集成实录:Android/iOS如何适配贴纸功能
android·人工智能·ios·架构·音视频·美颜sdk·第三方美颜sdk
恋猫de小郭18 小时前
Meta 宣布加入 Kotlin 基金会,将为 Kotlin 和 Android 生态提供全新支持
android·开发语言·ios·kotlin