71 · 查询侧 — fetchActiveDevices 与四维 active 位

0. 本篇讲清什么

“active 设备”是蓝牙音频的核心概念:连接着的设备可以有很多台,但同一时刻真正出声/通话的只有一台(或一组)。CachedBluetoothDevice(下称 CBD)用几个布尔字段把这个状态缓存在每个设备对象里,UI 的”正在使用""媒体/手机音频”角标全部依赖它。

本篇是 67-active设备与多设备.md §1.1 的深度展开,专注**查询侧(pull 拉取)**这一半闭环:

  1. fetchActiveDevices() 的完整实现,dcddif 与 xcddif 两份副本逐行 diff(注意:本仓实际只有两份副本,不是三份,见 §1 的前提修正);
  2. 四个 active 布尔字段的定义位置;
  3. isActiveDevice(int profile) 的分支逻辑与”永远返回 false 而不崩溃”的设计;
  4. 底层四个 Profile 类的 getActiveDevice() / getActiveDevices() 实现,重点讲 mService / mBluetoothAdapter 为 null 时的返回语义(null vs 空 List)以及为什么上层绝不 NPE
  5. 单 active(A2DP/HFP)vs 双 active(助听器/LeAudio)的协议层原因;
  6. fetchActiveDevices() 的全部调用点;
  7. 布尔位在 CBD 内部的两个消费点:getConnectionSummary / getCarConnectionSummary
  8. pull(fetchActiveDevices)与 push(onActiveDeviceChanged)双轨模型的设计动机。

推送侧(onActiveDeviceChanged 的事件链路)由本系列的 72 篇展开,本篇只在 §8 给出对照面。

前提修正(重要):调研任务假设存在 f3dif 三个 flavor 目录各有一份副本。实测本仓 base/settingsLibAndroid/src/只有 dcddif/xcddif/ 两个源集目录ls 验证;find -type d -name f3dif 全仓无结果)。build.gradle:46-67 的映射关系是:

Gradle 构建 flavor实际源集
dcdsrc/main/java + src/dcddif/java
xcdsrc/main/java + src/xcddif/java
globalsrc/main/java + src/xcddif/java(+ 预留的 src/global/java 覆盖层,当前不存在该目录)

所以”LeAudio 第四维”在 xcddif 副本里(xcd 与 global 两个构建 flavor 共享),不存在第三份独立副本。本文所有”三 flavor 对照”均按”dcddif vs xcddif(= xcd/global 共用)“两列给出。 另一个前提修正:任务给的 dcddif 调用点”refreshBluetoothClass:597”实测不成立——dcddif refreshBluetoothClass()(CachedBluetoothDevice.java:595-598)只调 initAdapterDevice() + refresh(),并不调用 fetchActiveDevices()。真实调用点见 §6。


1. 数据结构:四个 active 布尔字段

1.1 dcddif:三维

dcddif CachedBluetoothDevice.java:146-149

    // Active device state
    private boolean mIsActiveDeviceA2dp = false;
    private boolean mIsActiveDeviceHeadset = false;
    private boolean mIsActiveDeviceHearingAid = false;

三个字段,默认 false。字段上方的注释 // Active device state 把它们和下面的 // Media profile connect state(连接失败标志位)分组隔开——active 位和 connected 位是两套独立状态:连接是链路层的”在不在”,active 是音频策略层的”用不用”。

1.2 xcddif:四维

xcddif CachedBluetoothDevice.java:125-129

    // Active device state
    private boolean mIsActiveDeviceA2dp = false;
    private boolean mIsActiveDeviceHeadset = false;
    private boolean mIsActiveDeviceHearingAid = false;
    private boolean mIsActiveDeviceLeAudio = false;

多出的第四维 mIsActiveDeviceLeAudio 对应 LE Audio profile(BluetoothProfile.LE_AUDIO,int 值 22)。xcddif 副本基于更新的 AOSP(Android T 代 settingslib),同期多出来的还有 CSIP 组管理、HearingAidInfo、TWS Plus 电池等一整套字段(xcddif CachedBluetoothDevice.java:111-156)。

要点:这四个布尔位是 CBD 对”本设备是否是某 profile 的 active 设备”的本地缓存,不是实时查询。谁来写它?两条路:pull(fetchActiveDevices(),本篇主角)和 push(onActiveDeviceChanged(),下一篇主角)。


2. 核心方法:fetchActiveDevices() 逐段精讲

2.1 dcddif 版(CachedBluetoothDevice.java:767-783)

    private void fetchActiveDevices() {
        if (mProfileManager == null) {
            return;
        }
        A2dpProfile a2dpProfile = mProfileManager.getA2dpProfile();
        if (a2dpProfile != null) {
            mIsActiveDeviceA2dp = mDevice.equals(a2dpProfile.getActiveDevice());
        }
        HeadsetProfile headsetProfile = mProfileManager.getHeadsetProfile();
        if (headsetProfile != null) {
            mIsActiveDeviceHeadset = mDevice.equals(headsetProfile.getActiveDevice());
        }
        HearingAidProfile hearingAidProfile = mProfileManager.getHearingAidProfile();
        if (hearingAidProfile != null) {
            mIsActiveDeviceHearingAid = hearingAidProfile.getActiveDevices().contains(mDevice);
        }
    }

逐段拆解:

第一段(:768-770)——mProfileManager 判空守卫。 mProfileManager 是构造函数注入的 final 字段(dcddif CachedBluetoothDevice.java:117),正常流程不会为 null;这个守卫主要保护单元测试直接反射构造 CBD 的场景。注意:为 null 时直接 return,四个布尔位保持原值(可能是陈旧值)——这是一个”宁可陈旧、不可崩溃”的取舍,排障时要意识到(见 §7.1)。

第二段(:771-774)——A2DP 维。 mProfileManager.getA2dpProfile() 返回 A2dpProfile 包装对象;为 null 说明该 profile 的代理还没建立(蓝牙刚开、proxy 未连上),此时跳过赋值(不是置 false!),A2dp 维保持上一次的值。非 null 时:

mIsActiveDeviceA2dp = mDevice.equals(a2dpProfile.getActiveDevice());
  • mDevice 是 CBD 自己包裹的 BluetoothDevice永不为 null(构造时必传);
  • a2dpProfile.getActiveDevice() 可能返回 null(无 active 设备时,见 §4.1);
  • mDevice.equals(null) 返回 false不 NPE——BluetoothDevice.equals()(框架代码)对 null 参数按 Object.equals 契约返回 false。

所以 A2DP 维的语义是:“当前系统 A2DP 的 active 设备恰好是我” → true;“没有 active 设备”或”active 的是别人” → false。

第三段(:775-778)——Headset(HFP)维。 与 A2DP 完全同构:equals(getActiveDevice()),单 active 语义。

第四段(:779-782)——HearingAid 维。 换了判断方式:

mIsActiveDeviceHearingAid = hearingAidProfile.getActiveDevices().contains(mDevice);

返回类型从单个 BluetoothDevice 变成 List<BluetoothDevice>,判断从 equals 变成 contains。为什么?见 §5——助听器左右耳两只同时 active。而 null 安全靠两层保证:getActiveDevices() 在 mService 为 null 时返回空 ArrayList 而不是 nulldcddif HearingAidProfile.java:179),空 List 的 contains() 恒为 false。

2.2 xcddif 版(CachedBluetoothDevice.java:976-993)

    private void fetchActiveDevices() {
        A2dpProfile a2dpProfile = mProfileManager.getA2dpProfile();
        if (a2dpProfile != null) {
            mIsActiveDeviceA2dp = mDevice.equals(a2dpProfile.getActiveDevice());
        }
        HeadsetProfile headsetProfile = mProfileManager.getHeadsetProfile();
        if (headsetProfile != null) {
            mIsActiveDeviceHeadset = mDevice.equals(headsetProfile.getActiveDevice());
        }
        HearingAidProfile hearingAidProfile = mProfileManager.getHearingAidProfile();
        if (hearingAidProfile != null) {
            mIsActiveDeviceHearingAid = hearingAidProfile.getActiveDevices().contains(mDevice);
        }
        LeAudioProfile leAudio = mProfileManager.getLeAudioProfile();
        if (leAudio != null) {
            mIsActiveDeviceLeAudio = leAudio.getActiveDevices().contains(mDevice);
        }
    }

与 dcddif 的 diff 只有两处:

  1. 删掉了 mProfileManager 判空(xcddif 版直接使用, :977);
  2. 多出第四段(:989-992)LeAudio 维,写法与 HearingAid 同构:getActiveDevices().contains(mDevice)——因为 LE Audio 的 active 也是”一组”(CIS 组,见 §5)。

前两维(A2DP/Headset)的判断式与 dcddif 逐字相同,但底层 getActiveDevice() 的实现已经换了数据源(mService → mBluetoothAdapter,见 §4.2),上层代码无感知——这是包装层的价值。


3. 读出口:isActiveDevice(int profile)

3.1 dcddif 版(CachedBluetoothDevice.java:677-691)

    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public boolean isActiveDevice(int bluetoothProfile) {
        switch (bluetoothProfile) {
            case BluetoothProfile.A2DP:
                return mIsActiveDeviceA2dp;
            case BluetoothProfile.HEADSET:
                return mIsActiveDeviceHeadset;
            case BluetoothProfile.HEARING_AID:
                return mIsActiveDeviceHearingAid;
            default:
                Log.w(TAG, "getActiveDevice: unknown profile " + bluetoothProfile);
                break;
        }
        return false;
    }

逐段拆解:

方法签名与注解(:677-678):入参是 BluetoothProfile 常量(A2DP=2、HEADSET=1、HEARING_AID=21、LE_AUDIO=22),不是 LocalBluetoothProfile 对象——调用方只需一个 int。@VisibleForTesting(otherwise = PACKAGE_PRIVATE) 表示它本来是包私有,开放为 public 仅为可测试。

三个 case(:680-685):纯读缓存,O(1),无任何 binder 调用。这就是”缓存 active 位”的意义:UI 每次刷新列表都要问一遍”我是不是 active 设备”,如果每次都穿透到蓝牙服务查一遍,列表滚动就会卡。

default 分支(:686-688):未知 profile 打 warning。注意一个日志文案瑕疵:打出来的前缀是 getActiveDevice:,但方法名是 isActiveDevice——搜日志时别被误导,这条 log 出现说明调用方传了不认识的 profile id,不是 getActiveDevice 出错。

兜底 return false(:690):default 落出来后到方法末尾统一 return false。语义是”不认识的 profile 一律回答’不是 active‘“而不是抛异常。这决定了调用侧永远不需要 try-catch。

3.2 xcddif 版(CachedBluetoothDevice.java:882-898)

    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public boolean isActiveDevice(int bluetoothProfile) {
        switch (bluetoothProfile) {
            case BluetoothProfile.A2DP:
                return mIsActiveDeviceA2dp;
            case BluetoothProfile.HEADSET:
                return mIsActiveDeviceHeadset;
            case BluetoothProfile.HEARING_AID:
                return mIsActiveDeviceHearingAid;
            case BluetoothProfile.LE_AUDIO:
                return mIsActiveDeviceLeAudio;
            default:
                Log.w(TAG, "getActiveDevice: unknown profile " + bluetoothProfile);
                break;
        }
        return false;
    }

唯一 diff:多一个 case BluetoothProfile.LE_AUDIO: return mIsActiveDeviceLeAudio;(:891-892)。default 的日志文案瑕疵原样保留(两份副本是独立演化的拷贝,这类”化石特征”正好可以用来核对副本的 fork 时间点)。

本仓消费情况:全仓 grep 显示 app 各模块(micarConnectionSettings 等)没有直接调用 isActiveDevice(...);它的直接消费者是 settingslib 内部的 summary 生成(§7)以及框架侧的 BluetoothDevicePreference(不在本仓)。也就是说在 MiCarSettings 里,这个方法主要是”给 UI 摘要供料”的内部管道。


4. 底层 Profile 实现:null 语义逐个讲

这一层是”查询侧真正发生 binder 调用”的地方。四个 profile 两两一组:A2DP/Headset 是单 active(返回单个 device 或 null),HearingAid/LeAudio 是组 active(返回 List)。

4.1 dcddif:走 mService(profile 代理对象)

A2dpProfile.getActiveDevice() — dcddif A2dpProfile.java:175-178

    public BluetoothDevice getActiveDevice() {
        if (mService == null) return null;
        return mService.getActiveDevice();
    }
  • mServiceBluetoothA2dp 代理对象,在构造函数里通过 mBluetoothAdapter.getProfileProxy(context, new A2dpServiceListener(), BluetoothProfile.A2DP) 异步获取(dcddif A2dpProfile.java:108)。蓝牙服务未就绪/代理已死时为 null。
  • null 语义:mService 为 null → 返回 null → 上层 mDevice.equals(null) = false → 该维 active 位被刷成 false。合理:连 profile 代理都没有,谈不上 active。
  • mService.getActiveDevice() 是 binder 调用,穿透到 com.android.bluetooth 进程的 A2dpService,返回它记录的唯一 active sink;无 active 时框架返回 null。

HeadsetProfile.getActiveDevice() — dcddif HeadsetProfile.java:137-142

    public BluetoothDevice getActiveDevice() {
        if (mService == null) {
            return null;
        }
        return mService.getActiveDevice();
    }

与 A2dpProfile 完全同构(唯一差别是换行风格),null 语义相同。

HearingAidProfile.getActiveDevices() — dcddif HearingAidProfile.java:178-181

    public List<BluetoothDevice> getActiveDevices() {
        if (mService == null) return new ArrayList<>();
        return mService.getActiveDevices();
    }
  • 关键差异:mService 为 null 时返回空 List,不是 null。为什么这样设计?因为调用方是 getActiveDevices().contains(mDevice)——如果这里返回 null,.contains() 直接 NPE。返回空 List,contains 安全地给出 false。
  • mService.getActiveDevices()(BluetoothHearingAid binder 接口)按框架契约返回非 null List(0/1/2 个元素;两只配对的助听器会同时出现在里面)。⚠️ 待核验:这一”非 null”是框架源码契约,MiCarSettings 仓内无法直接验证;若某魔改框架违反契约返回 null,:781 的 contains 会 NPE——排障时若见此处崩溃,先查框架侧返回值。

小结 dcddif 的 null 安全链条

profilemService == null 时返回CBD 侧判断式结果
A2DPnullmDevice.equals(null)false,无 NPE
HeadsetnullmDevice.equals(null)false,无 NPE
HearingAidnew ArrayList<>()emptyList.contains(mDevice)false,无 NPE

两条路都到 false,但机制不同:单 active 靠 equals() 对 null 参数天然返回 false(Object.equals 契约);组 active 靠”永远不返回 null 集合”的编码约定。写新 profile 封装时必须二选一对齐,混用(比如组 active 返回 null)必炸。

4.2 xcddif:走 mBluetoothAdapter(adapter 级新 API)

xcddif 的四个 profile 查询全部改走 BluetoothAdapter.getActiveDevices(int profile)——Android T 引入的统一入口,由 adapter 按 profile 分发,不再依赖各 profile 的 mService 代理是否就绪。

A2dpProfile.getActiveDevice() — xcddif A2dpProfile.java:180-185

    public BluetoothDevice getActiveDevice() {
        if (mBluetoothAdapter == null) return null;
        final List<BluetoothDevice> activeDevices = mBluetoothAdapter
                .getActiveDevices(BluetoothProfile.A2DP);
        return (activeDevices.size() > 0) ? activeDevices.get(0) : null;
    }

注意这里的降维:adapter API 本身返回 List(统一形态),A2DP 的”单 active”语义靠 取 get(0) 否则 null 还原。判空对象从 mService 换成 mBluetoothAdapter(构造函数里由 BluetoothAdapterUtil.getAdapterByProfile(getProfileId()) 赋值,MICAR-PORTING 块 xcddif A2dpProfile.java:110-112,赋值行 :111)。BluetoothAdapter.getActiveDevices() 框架契约返回非 null List(无 active 时为空表),所以 activeDevices.size() 不会 NPE。

HeadsetProfile.getActiveDevice() — xcddif HeadsetProfile.java:137-144

    public BluetoothDevice getActiveDevice() {
        if (mBluetoothAdapter == null) {
            return null;
        }
        final List<BluetoothDevice> activeDevices = mBluetoothAdapter
                .getActiveDevices(BluetoothProfile.HEADSET);
        return (activeDevices.size() > 0) ? activeDevices.get(0) : null;
    }

与 A2DP 同构。

HearingAidProfile.getActiveDevices() — xcddif HearingAidProfile.java:212-217

    public List<BluetoothDevice> getActiveDevices() {
        if (mBluetoothAdapter == null) {
            return new ArrayList<>();
        }
        return mBluetoothAdapter.getActiveDevices(BluetoothProfile.HEARING_AID);
    }

组 active 直接透传 List,null 时同样返回空表——与 dcddif 的”空表约定”一致。

LeAudioProfile.getActiveDevices() — xcddif LeAudioProfile.java:174-179

    public List<BluetoothDevice> getActiveDevices() {
        if (mBluetoothAdapter == null) {
            return new ArrayList<>();
        }
        return mBluetoothAdapter.getActiveDevices(BluetoothProfile.LE_AUDIO);
    }

同构。这个类里还有一段对理解 LeAudio active 很有价值的注释(xcddif LeAudioProfile.java:181-199,节选):

Lead device is the device that can be used as an active device in the system. Active devices points to the Audio Device for the Le Audio group. … Note: getActiveDevice() returns the Lead device for the currently active LE Audio group.

即 LE Audio 的”active 设备”实际是”active 组”,组有一个 lead device,UI 侧 setActive 时要对 lead 设备操作。另一个细节:LeAudioProfile 构造用的是 BluetoothAdapter.getDefaultAdapter()xcddif LeAudioProfile.java:114),而 A2DP/Headset/HearingAid 用 BluetoothAdapterUtil.getAdapterByProfile(...)——LE Audio 未接入 MICAR 双蓝牙适配体系, ⚠️ 待核验是否为有意为之(若后续做双适配器 LeAudio,这里是改造点)。

4.3 两 flavor 底层实现对照总表

dcddifxcddif(xcd/global 共用)
A2dpProfile.getActiveDevice:175-178,mService null→null,否则 mService.getActiveDevice():180-185,adapter null→null,否则 adapter.getActiveDevices(A2DP) 取 [0]
HeadsetProfile.getActiveDevice:137-142,同上模式:137-144,adapter 模式取 [0]
HearingAidProfile.getActiveDevices:178-181,mService null→空表,否则 mService.getActiveDevices():212-217,adapter null→空表,否则 adapter.getActiveDevices(HEARING_AID)
LeAudioProfile.getActiveDevices不存在(无此类):174-179,adapter null→空表,否则 adapter.getActiveDevices(LE_AUDIO)
数据通道profile 代理 binder(就绪才可用)BluetoothAdapter.getActiveDevices(profile)(T 代统一入口)
未就绪时 A2DP/Headset 返回nullnull
未就绪时组 active 返回空 List空 List

5. 单 active vs 组 active:协议层原因

为什么 A2DP/HFP 用 equals(getActiveDevice()),助听器/LeAudio 用 getActiveDevices().contains(mDevice)?这不是代码风格差异,是两类蓝牙音频协议的拓扑差异

A2DP(Advanced Audio Distribution Profile)— 单点流。 A2DP 的音频拓扑是”一个 Source → 一个 Sink”的点对点流。手机/车机作为 Source,同一时刻只有一条 AVDTP 音频流在跑,所以框架里 BluetoothA2dp.getActiveDevice() 天生返回一个 device(或 null)。切换 active = 拆旧流建新流。

HFP(Headset Profile)— 单点 SCO。 通话音频走 SCO/ESCO 同步链路,一条链路只指向一个 AG↔HF 对,所以同样是单 active。

HearingAid(ASHA)— 左右一对。 助听器永远成对出售:左右两只耳机共享一个 hiSyncId(CBD 里有对应的 mHiSyncId/mSubDevice 机制,见 67 篇),框架会同时把两只都设为 active(左右声道各自一条流)。因此 BluetoothHearingAid.getActiveDevices() 返回 List,最多两个元素。如果用 equals(getActiveDevice()) 的单点写法,同一对里总有一只被误判为”非 active”——UI 上左右耳状态就会打架。

LE Audio — CIS 组。 LE Audio 用 CIS(Connected Isochronous Stream)做多设备同步音频,一个协调组(CSIP coordinated set,比如一副 TWS 耳机的主从两台)可以共享一组同步流,active 的语义落到” active”上,组成员(至少 lead)都会出现在 active 列表里。所以 LeAudio 从 API 形态上就抄了助听器的作业:getActiveDevices() 返回 List。

一个漂亮的佐证是 xcddif 的 A2DP/Headset:底层 API 已经统一成 List 形态(adapter.getActiveDevices(profile)),但代码仍然取 get(0) 否则 null主动降维回单 device(§4.2)——因为协议层语义就是单 active,保留单点形态让上层 equals() 判断不变形。


6. fetchActiveDevices() 的全部调用点

grep 两份副本,调用点各 3 处(方法定义本身除外):

调用点dcddifxcddif触发场景
onProfileStateChanged() 末尾CachedBluetoothDevice.java:289CachedBluetoothDevice.java:348任一 profile 连接态变化(CONNECTED/CONNECTING/…)回调时
fillData()CachedBluetoothDevice.java:497CachedBluetoothDevice.java:667CBD 构造函数首次填充数据时(dcddif :188 调用;xcddif 主构造 :192、拷贝构造 :206 各调一次)
switchSubDeviceContent() 末尾CachedBluetoothDevice.java:1328CachedBluetoothDevice.java:1751助听器主/副设备内容互换后

逐个说人话:

  1. fillData(出生时对齐):CBD 对象刚创建时,active 位全是 false。如果这台设备在对象创建前就已经是 active(比如设置应用进程重启、列表重建),不主动拉一次,UI 会错误显示”未在使用”。fillData() 在构造函数里调用,保证出生即对齐现状。
  2. onProfileStateChanged(状态变化时对齐):profile 连接态变化是 active 变化的前导事件——设备连上后才可能被选为 active。蓝牙服务选 active 的时机与设置进程收到连接广播的时机没有严格先后保证,连接态回调到了就顺手重查一遍 active,可以把”广播乱序漏掉的 active 变化”补回来。这就是 pull 模型”兜底”价值的直接体现(§8)。
  3. switchSubDeviceContent(身份互换后对齐):助听器主/副设备互换 mDevice 后,“我”指向了另一个 BluetoothDevice,active 判断必须基于新身份重算。注意 dcddif :1328 / xcddif :1751 都在互换完字段的最后一步调用。

再次修正:refreshBluetoothClass()(dcddif :595-598 / xcddif :788-792)只做 initAdapterDevice() + refresh()不在调用点列表里。它触发的 refresh() 只是重画(dispatchAttributesChanged),不重查 active——所以”蓝牙类刷新后 active 位陈旧”在理论上是可能的窗口(见 §7.2)。


7. 消费点:布尔位如何变成 UI 文案

四个布尔位在 CBD 内部有两个读出口,都服务于”设备列表/详情页的那一行摘要”。

7.1 getConnectionSummary(dcddif :1123-1136;xcddif :1429-1469)

dcddif 的核心判定(dcddif CachedBluetoothDevice.java:1123-1127):

            if (a2dpConnected || hfpConnected || hearingAidConnected) {
                final boolean isOnCall = Utils.isAudioModeOngoingCall(mContext);
                if ((mIsActiveDeviceHearingAid)
                        || (mIsActiveDeviceHeadset && isOnCall)
                        || (mIsActiveDeviceA2dp && !isOnCall)) {

三分支语义(配合上方注释 :1119-1122”1. Hearing Aid device active. 2. Headset device active with in-calling state. 3. A2DP device active without in-calling state”):

  • mIsActiveDeviceHearingAid:助听器 active,无条件算”在使用”(助听器永远在用);
  • mIsActiveDeviceHeadset && isOnCall:HFP active 当前正在通话——通话中 HFP 才是出声方;
  • mIsActiveDeviceA2dp && !isOnCall:A2DP active 不在通话——非通话时媒体流才是出声方。

isOnCallUtils.isAudioModeOngoingCall)是音频模式(MODE_IN_CALL 等)查询,它把”同一时刻两个位都可能是 true(A2DP+HFP 双 active 很常见)“消解成”按当前场景挑一个说”。

xcddif 的对应段(xcddif CachedBluetoothDevice.java:1429-1434)加了第四项:

            if (a2dpConnected || hfpConnected || hearingAidConnected || leAudioConnected) {
                final boolean isOnCall = Utils.isAudioModeOngoingCall(mContext);
                if ((mIsActiveDeviceHearingAid)
                        || (mIsActiveDeviceHeadset && isOnCall)
                        || (mIsActiveDeviceA2dp && !isOnCall)
                        || mIsActiveDeviceLeAudio) {

mIsActiveDeviceLeAudio 与助听器一样不受 isOnCall 约束(LeAudio 同时承载媒体与通话音频,active 即在使用)。此外 xcddif :1446-1469 还有一段”LeAudio 助听器(HapClient)active 时显示左/右耳”的细化,用 mIsActiveDeviceLeAudio && isConnectedHapClientDevice() 区分(:1447-1448)。

命中后选用的字符串资源(dcddif :1128-1134):有 TWS 双电池 → bluetooth_active_battery_level_untethered;有普通电量 → bluetooth_active_battery_level;否则 bluetooth_active_no_battery_level——即用户看到的”正在使用(电量 80%)“。

7.2 getCarConnectionSummary(dcddif :1213-1229;xcddif :1581-1603)

车机版摘要。dcddif(dcddif CachedBluetoothDevice.java:1214-1229):

        String[] activeDeviceStringsArray = mContext.getResources().getStringArray(
                R.array.bluetooth_audio_active_device_summaries);
        String activeDeviceString = activeDeviceStringsArray[0];  // Default value: not active
        if (mIsActiveDeviceA2dp && mIsActiveDeviceHeadset) {
            activeDeviceString = activeDeviceStringsArray[1];     // Active for Media and Phone
        } else {
            if (mIsActiveDeviceA2dp) {
                activeDeviceString = activeDeviceStringsArray[2]; // Active for Media only
            }
            if (mIsActiveDeviceHeadset) {
                activeDeviceString = activeDeviceStringsArray[3]; // Active for Phone only
            }
        }
        if (!hearingAidNotConnected && mIsActiveDeviceHearingAid) {
            activeDeviceString = activeDeviceStringsArray[1];
            return mContext.getString(R.string.bluetooth_connected, activeDeviceString);
        }

逻辑:数组 bluetooth_audio_active_device_summaries 有 4 个模板(未 active / 媒体+电话 / 仅媒体 / 仅电话)。A2DP 与 Headset 两个位自由组合出四种状态(车机上”连着但只有电话 active”很常见);助听器 active 则直接归并为”媒体+电话”(ASHA 双流,全占)。!hearingAidNotConnected 的前置条件排除”profile 已断开但位还没刷掉”的假阳性。

xcddif 对应段结构相同(:1585-1594),并在助听器分支后追加了 LeAudio 分支xcddif CachedBluetoothDevice.java:1600-1603):

        if (!leAudioNotConnected && mIsActiveDeviceLeAudio) {
            activeDeviceString = activeDeviceStringsArray[1];
            return mContext.getString(R.string.bluetooth_connected, activeDeviceString);
        }

同样归并为”媒体+电话”,同样有 !leAudioNotConnected 防陈旧位。

消费面总结:本仓 app 模块没有绕过这两个方法直接读布尔位的代码(全仓 grep 验证)——四维 active 位的最终出口就是这两条摘要管道,再由框架侧 BluetoothDevicePreference / 车机连接页面渲染。


8. pull vs push:双轨模型的设计动机

查询侧(本篇)只是闭环的一半。另一半是推送侧:

push 链路(简述,详见 72 篇):蓝牙服务 active 设备变化时发系统广播(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED 等),BluetoothEventManager 的 ActiveDeviceChangedHandler 接住(dcddif BluetoothEventManager.java:431-453;xcddif :706-732,xcddif 多注册了 BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED,:140),把 action 映射成 profile id,然后 dispatchActiveDeviceChanged()(dcddif :224-234)遍历所有 cached device,对每一台调用 cachedDevice.onActiveDeviceChanged(isActive, profile)——isActive 的计算就是 Objects.equals(cachedDevice, activeDevice)。CBD 侧的 onActiveDeviceChanged(dcddif CachedBluetoothDevice.java:640-663)只改对应那一维布尔位,且仅在值真的变化时 dispatchAttributesChanged 刷 UI。xcddif 的 dispatch(:310-336)还多了 CSIP 组员提升:如果广播里的 active 设备是某组的 member,会把 isActive 记到该组 main 设备头上(:316-329)。

为什么需要两条轨?

pull(fetchActiveDevices)push(onActiveDeviceChanged)
方向CBD 主动问蓝牙服务”现在谁是 active”蓝牙服务变化时广播推给 CBD
实时性差——只在 3 个时机拉(§6)好——变化即达
可靠性高——查询的是当下事实,天然自愈依赖广播必达;进程重启、注册空窗、广播丢失则状态陈旧
成本每次 3-4 个 binder 调用平摊,几乎零成本
解决什么错过广播:出生时、连接态变化时对齐事实实时性:正常使用中毫秒级跟进

典型协作场景:设置应用进程被杀重启 → 列表重建 → 每个 CBD 构造走 fillData → fetchActiveDevices 把 active 位一次性拉对——这期间没有任何 active 广播会重发,纯靠 push 的话状态永远是 false。反过来,用户在别处(比如语音助手)切换了音频输出设备 → ACTION_ACTIVE_DEVICE_CHANGED 广播 → push 毫秒级更新角标——此时若等下一次 pull 时机(下一次 profile 状态变化)就太迟了。

两者写的是同一组布尔位,写法不同:pull 用查询结果整维覆盖(true/false 都写),push 用广播参数整维覆盖,changed 检测避免无谓刷新。两轨在任何顺序下交错写都是幂等的——因为都收敛到”与事实一致”。

⚠️ 幂等性的一个不对称边界(红队核验补充):上述结论只对在顶层缓存列表里的设备成立。助听器副耳(dcddif 的 subDevice,被 setSubDeviceIfNeeded 吸收后不入 mCachedDevices,CachedBluetoothDeviceManager.java:107-110)与 xcddif 的部分 CSIP 组员,push 链遍历不到它们(dispatchActiveDeviceChanged 只遍历顶层列表)——这些设备的 active 位自出生 fillData 拉取一次后,push 永不更新,只能等下一次 pull 时机(该对象自身的 onProfileStateChanged/fillData)。这是一个”永不收敛窗口”:pull 有兜底、push 没有。详见 72 篇 §4.1 的边界分析。


9. 全链路图

flowchart TD
    subgraph BT["com.android.bluetooth 进程(系统蓝牙服务)"]
        AES["A2dpService<br/>单一 active sink"]
        HSS["HeadsetService<br/>单一 active HF"]
        HAS["HearingAidService<br/>左右耳一组 active"]
        LES["LeAudioService<br/>CIS 组 active(xcddif)"]
    end

    subgraph PL["settingslib Profile 包装层"]
        A2["A2dpProfile.getActiveDevice()<br/>dcddif: mService 判 null<br/>xcddif: adapter 取 List[0]"]
        HS["HeadsetProfile.getActiveDevice()<br/>同 A2DP 模式"]
        HA["HearingAidProfile.getActiveDevices()<br/>null→空 List 约定"]
        LE["LeAudioProfile.getActiveDevices()<br/>仅 xcddif 有此类"]
    end

    subgraph CBD["CachedBluetoothDevice"]
        FAD["fetchActiveDevices()<br/>dcddif :767-783 三维<br/>xcddif :976-993 四维"]
        B1["mIsActiveDeviceA2dp"]
        B2["mIsActiveDeviceHeadset"]
        B3["mIsActiveDeviceHearingAid"]
        B4["mIsActiveDeviceLeAudio(xcddif)"]
        IAD["isActiveDevice(profile)<br/>default→false"]
        GCS["getConnectionSummary<br/>三分支+isOnCall 消解"]
        CCS["getCarConnectionSummary<br/>数组 4 模板组合"]
    end

    CALL["调用点×3<br/>fillData / onProfileStateChanged /<br/>switchSubDeviceContent"]
    PUSH["push 侧对照:<br/>ActiveDeviceChangedHandler 广播<br/>→ onActiveDeviceChanged(72 篇)"]

    AES --> A2
    HSS --> HS
    HAS --> HA
    LES --> LE
    CALL --> FAD
    FAD -->|"equals(getActiveDevice())"| B1
    FAD -->|"equals(getActiveDevice())"| B2
    FAD -->|"getActiveDevices().contains()"| B3
    FAD -->|"getActiveDevices().contains()"| B4
    PUSH -.->|"同一组位的另一写者"| B1
    B1 & B2 & B3 & B4 --> IAD
    B1 & B2 & B3 --> GCS
    B1 & B2 & B3 & B4 --> CCS
    GCS --> UI["设备列表摘要文案"]
    CCS --> UI

10. 排障要点

  1. mService / mBluetoothAdapter 为 null 时的行为:A2DP/Headset 查询返回 null → 该维刷成 false;HearingAid/LeAudio 返回空表 → 同样 false。表现是”蓝牙刚开启的头几秒,active 角标全部消失,随后恢复”。如果用户报”角标闪灭”,先查 profile proxy 就绪时序,而不是 active 逻辑本身。
  2. active 位陈旧怎么发生:(a) pull 只在 §6 的 3 个时机执行,且 profile 包装对象为 null 时跳过赋值(保持旧值,dcddif :772-774 的 if 判空是”跳过”不是”置 false”);(b) push 侧广播丢失(进程重启空窗、action 未注册——dcddif 没注册 LeAudio action,收到也不会处理);(c) refreshBluetoothClass() 等路径只重画不重查。任一情况叠加,就出现”设备显示未在使用但音频其实在它上面”。自愈时机:下一次任一 profile 状态变化触发 pull。
  3. getCarConnectionSummary 的防陈旧设计!hearingAidNotConnected && mIsActiveDeviceHearingAid(dcddif :1227)说明作者已经意识到”位可能比连接态活得久”,用连接态做二次校验。排障时若见”断开的设备还显示 active”,对照检查这两个输入哪边滞后。
  4. default 分支静默 falseisActiveDevice(未知 profile) 只打一条 getActiveDevice: unknown profile 的 warning(注意文案名不副实,§3.1)就返回 false。如果上层传错 profile id,症状是”永远查不到 active”,log 里搜上述关键字。
  5. 组 active 的 NPE 边界contains() 的安全建立在”实现永远返回空表而非 null”的约定上(§4.1)。新增组 active 型 profile 封装时必须遵守;review 类似代码时第一件事就是查 null 返回路径。
  6. dcddif 与 xcddif 判空守卫差异:dcddif fetchActiveDevices 有 mProfileManager null 守卫(:768-770),xcddif 没有(:977 直接用)。xcddif 若在 profileManager 为 null 的测试环境调用会 NPE——只影响测试脚手架,不影响产线。
  7. LeAudio 查询走默认 adapter(xcddif LeAudioProfile.java:114 用 getDefaultAdapter(),不走 BluetoothAdapterUtil.getAdapterByProfile)——双蓝牙场景下若 LeAudio 需要跟非默认适配器,此处是已知差异点。⚠️ 待核验:是否有车型规划双适配器 LeAudio。

11. 自测清单

  • 能说出四个 active 布尔字段的定义行号(dcddif :147-149 / xcddif :126-129),并解释 active 与 connected 是两套状态
  • 能默写 fetchActiveDevices 两份 diff:dcddif 三维 + mProfileManager 守卫;xcddif 四维 + 无守卫
  • 能解释 A2DP/Headset 用 equals(getActiveDevice()) 而 HearingAid/LeAudio 用 getActiveDevices().contains(mDevice) 的协议层原因(单点流 vs 左右耳成对/CIS 组)
  • 能画出 mService == null 时的完整链条:A2DP→null→equals(false);HearingAid→空表→contains(false),且都不 NPE
  • 能说出 isActiveDevice 的 default 分支行为(warning + return false)及其日志文案陷阱
  • 能列举 fetchActiveDevices 的 3 个调用点,并解释为什么 refreshBluetoothClass 不是调用点
  • 能解释 getConnectionSummary 三分支中 isOnCall 如何消解 A2DP/HFP 双 active,以及 LeAudio 位为何不受 isOnCall 约束
  • 能说清 pull 与 push 各自解决什么问题(错过广播 vs 实时性),以及两者写同一组位为何幂等
  • 知道本仓只有 dcddif/xcddif 两份副本、xcd 与 global 共用 xcddif(build.gradle :46-67)

12. 交叉引用


源码基线:MiCarSettings dev 分支(2026-08-23 检出),所有 file:line 锚点均按本仓实际文件核对;dcddif 与 xcddif 行号不可混用。