74 · 消费侧 — 活跃状态如何变成界面文案

本篇讲清什么

active 设备闭环的最后一公里:CachedBluetoothDevice 内部那几个 mIsActiveDevice* 布尔位(生产侧见 67 篇),究竟在哪里被读出来、变成用户看得见的”使用中”三个字。

本篇按三层展开:

  1. settingslib 摘要方法层getConnectionSummary(boolean)getCarConnectionSummary()(车机专用版)如何把布尔位翻译成字符串资源 ID——这是 AOSP 留下的完整”active→文案”翻译器;
  2. MiCar UI 层:micarConnectionSettings 的已配对列表实际用了哪套文案( spoiler:不是上面两个方法);
  3. 刷新与排障:文案什么时候重算、界面上”使用中”与实际音频输出不一致时断在哪几环。

先给全篇最重要的一个结论(全仓核实过):在 MiCarSettings 当前代码里,getCarConnectionSummary() 没有任何调用者,getConnectionSummary() 的唯一调用点是 settingslib 内部的 CachedBluetoothDeviceManager.getSubDeviceSummary(),而 getSubDeviceSummary() 本身也没有任何调用者。也就是说,AOSP 这套”active→使用中文案”的管道在本仓是完好但未接线的库存件;MiCar 车机界面用的是自己的一套三态摘要(已连接/正在连接/已保存),完全不区分 active。理解这一点后,本篇前半部分要读成”管道本身如何工作”(它仍是排查蓝牙音频问题、理解 AOSP 血统、未来接线时必备的知识),后半部分读成”车机实际给用户看什么”。

另外先修正一个研究前提:本仓 base/settingsLibAndroid只有 dcddifxcddif 两个 dif 源集目录,不存在 f3dif 目录(全仓 find -type d -name f3dif 为空)。build.gradle 的 sourceSets 定义为:dcd → src/dcddifxcd → src/xcddifglobal → src/xcddif + src/globalbase/settingsLibAndroid/build.gradle sourceSets 块)。因此本文的”flavor 对照”按 dcddif vs xcddif 做,global 与 xcddif 共用同一份蓝牙源码(src/global/ 下无蓝牙代码与资源覆盖)。


一、getConnectionSummary(boolean):active 分支精讲(dcddif 版)

位置:base/settingsLibAndroid/src/dcddif/java/com/android/settingslib/bluetooth/CachedBluetoothDevice.java:1045-1150。这是 AOSP 手机版设置的经典摘要方法,车机侧保留原样。

1.1 方法头部与”尚未发现断开”语义(1045-1055)

    public String getConnectionSummary(boolean shortSummary) {
        boolean profileConnected = false;    // Updated as long as BluetoothProfile is connected
        boolean a2dpConnected = true;        // A2DP is connected
        boolean hfpConnected = true;         // HFP is connected
        boolean hearingAidConnected = true;  // Hearing Aid is connected
        int leftBattery = -1;
        int rightBattery = -1;

(dcddif CachedBluetoothDevice.java:1045-1050

反直觉点,必须讲透a2dpConnected / hfpConnected / hearingAidConnected 三个布尔位初始化为 true,而注释写的却是 “A2DP is connected”——注释是误导的,真实语义是 “尚未发现它断开”(no evidence of disconnection),而不是”已确认连接”。

要理解为什么,看它们被置 false 的唯一场所(1071-1083):

                    case BluetoothProfile.STATE_DISCONNECTED:
                        if (profile.isProfileReady()) {
                            if (profile instanceof A2dpProfile
                                    || profile instanceof A2dpSinkProfile) {
                                a2dpConnected = false;
                            } else if (profile instanceof HeadsetProfile
                                    || profile instanceof HfpClientProfile) {
                                hfpConnected = false;
                            } else if (profile instanceof HearingAidProfile) {
                                hearingAidConnected = false;
                            }
                        }
                        break;

(dcddif CachedBluetoothDevice.java:1071-1083

false 需要同时满足三个条件:

  1. 该 profile 出现在 getProfiles() 里——即设备 UUID 已经上报、支持这个 profile(profile 列表怎么来的见 62 篇);
  2. profile.isProfileReady() 为 true——profile 与 framework 服务的 proxy 已经连上。蓝牙刚打开、proxy 还没注册好的窗口期里,getProfileConnectionState() 会返回 STATE_DISCONNECTED,但这个返回值不可信,代码选择不信;
  3. 状态确实是 STATE_DISCONNECTED

所以三个布尔位的准确读法是一张三值表:

含义
false有正面证据断开:profile 存在、proxy 就绪、状态 DISCONNECTED
true(默认)两种情况混在一起:① 确实连着;② 根本没有这个 profile 对象 / proxy 没就绪,“没查到断开”

一个只有 A2DP UUID 的设备(getProfiles() 里没有 HeadsetProfile 对象),hfpConnected 会一直保持 true——不是它在通话,而是循环里根本没有机会把它翻成 false。这就是”初始化为 true”的设计动机:把”未知”和”已连接”折叠成同一个默认值,宁可漏报断开,不可误报断开(对应到 getCarConnectionSummary 的镜像设计见第二节)。

顺带注意类型判断的写法:A2dpProfile || A2dpSinkProfileHeadsetProfile || HfpClientProfile 成对出现——车机端自己是 sink/client(A2dpSinkProfileHfpClientProfile),手机端是 source/server(A2dpProfileHeadsetProfile),settingslib 把两侧统一对待,所以这套代码手机车机通用。

还有一个前置早退分支(1053-1055):

        if (isProfileConnectedFail() && isConnected()) {
            return mContext.getString(R.string.profile_connect_timeout_subtext);
        }

连接超时/失败(连接发起后回不来)优先于一切 active 文案。isProfileConnectedFail()(dcddif CachedBluetoothDevice.java:1156-1159)= A2dp / 助听器 /(非 SAP 设备的)Headset 任一 profile 连接失败位为 true。它属于摘要状态机的地盘,本篇不展开,见 34 篇

另外,只要任一 profile 处于 CONNECTING/DISCONNECTING,方法直接返回”正在连接/正在断开”(1062-1065),active 判定根本不会执行——摘要状态机的优先级高于 active 文案

1.2 active 三分支判定(1119-1136)

            // Set active string in following device connected situation.
            //    1. Hearing Aid device active.
            //    2. Headset device active with in-calling state.
            //    3. A2DP device active without in-calling state.
            if (a2dpConnected || hfpConnected || hearingAidConnected) {
                final boolean isOnCall = Utils.isAudioModeOngoingCall(mContext);
                if ((mIsActiveDeviceHearingAid)
                        || (mIsActiveDeviceHeadset && isOnCall)
                        || (mIsActiveDeviceA2dp && !isOnCall)) {
                    if (isTwsBatteryAvailable(leftBattery, rightBattery) && !shortSummary) {
                        stringRes = R.string.bluetooth_active_battery_level_untethered;
                    } else if (batteryLevelPercentageString != null && !shortSummary) {
                        stringRes = R.string.bluetooth_active_battery_level;
                    } else {
                        stringRes = R.string.bluetooth_active_no_battery_level;
                    }
                }
            }

(dcddif CachedBluetoothDevice.java:1119-1136

外层守卫 if (a2dpConnected || hfpConnected || hearingAidConnected):结合 1.1 的语义,这个守卫几乎恒为 true(只有”三路音频全部有正面断开证据”才挡得住,而那种设备一般也不会是 active 设备)。它真正的作用是兜底语义正确性,实际干活的判定在内层 if 的三个或条件:

分支布尔位组合直觉解释
① 助听器mIsActiveDeviceHearingAid助听器只要 active 就显示”使用中”,不看通话状态(助听器常驻输出,无”媒体/通话”切换概念)
② 通话中mIsActiveDeviceHeadset && isOnCall耳机是 active 系统正在通话 → 通话走它,显示”使用中”
③ 非通话mIsActiveDeviceA2dp && !isOnCallA2DP 是 active 系统不在通话 → 媒体走它,显示”使用中”

②③合起来读才完整:同一副耳机,通话时看 Headset 位、听歌时看 A2DP 位,两个时刻都该亮”使用中”;但如果只 active 了一路(比如 A2DP active、Headset 不是),通话建立瞬间(isOnCall 翻 true)它就不亮了——这正是”active 但没在用”的边界:HFP 不 active 的设备抢不到通话路由,“使用中”三个字就不该给它。多设备场景下这个判定天然互斥(同一时刻每路 profile 只有一台 active 设备,见 67 篇)。

三个 mIsActiveDevice* 字段(dcddif CachedBluetoothDevice.java:147-149,初始 false)的更新途径有两条,生产侧细节归 67 篇,这里只列锚点:

  • 广播驱动:onActiveDeviceChanged(boolean, int)(dcddif CachedBluetoothDevice.java:640-663),只在值真正变化时 dispatchAttributesChanged()(:661);
  • 查询驱动:fetchActiveDevices()(dcddif CachedBluetoothDevice.java:767-783),直接向 profile proxy 问 getActiveDevice() 并覆盖布尔位(在 fillData() 初始化路径里调用)。

1.3 isOnCall 的来源:Utils.isAudioModeOngoingCall

    public static boolean isAudioModeOngoingCall(Context context) {
        final AudioManager audioManager = context.getSystemService(AudioManager.class);
        final int audioMode = audioManager.getMode();
        return audioMode == AudioManager.MODE_RINGTONE
                || audioMode == AudioManager.MODE_IN_CALL
                || audioMode == AudioManager.MODE_IN_COMMUNICATION;
    }

(dcddif com/android/settingslib/Utils.java:418-424,注意是 settingslib.Utils 不是 settingslib.bluetooth.Utils

判定”系统是否处于通话态”只看 AudioManager 的音频模式,三种算”通话中”:

模式场景
MODE_RINGTONE正在振铃(来电响铃也算通话中——所以来电一开始 Headset 分支就生效)
MODE_IN_CALL电路域通话(蜂窝电话)
MODE_IN_COMMUNICATIONVoIP / 车机免提类通信(微信、会议 app 正确 setMode 时落这里)

不依赖 TelephonyManager 的 CALL_STATE,是有意为之:VoIP 通话没有电信状态,但音频路由同样要抢 HFP。代价是完全依赖 app 老实调用 audioManager.setMode()——不守规矩的 app 会让”使用中”在媒体/通话分支间显示错位(排障见第六节)。

1.4 active 相关字符串资源(实际值)

dcddif 英文(base/settingsLibAndroid/src/dcddif/res/values/strings.xml)与中文(values-zh-rCN/strings.xml):

资源 ID英文(dcddif strings.xml:221/223/229)中文(dcddif values-zh-rCN/strings.xml:80/81/84)
bluetooth_active_battery_levelActive, <电池百分比> battery使用中,电池电量:<电池百分比>
bluetooth_active_battery_level_untetheredActive, L: <左耳> battery, R: <右耳> battery已启用,左:目前电量为 <左耳>;右:目前电量为 <右耳>
bluetooth_active_no_battery_levelActive使用中

选择逻辑(1128-1134):TWS 双耳电量齐全且非短摘要 → untethered 版;单电量存在且非短摘要 → battery_level 版;否则(无电量或 shortSummary == true)→ 无电量的 使用中shortSummary 参数的唯一作用就是压制电量后缀,语音播报等场景用短版。

注意一个翻译瑕疵:dcddif 的 untethered 中文是”已启用”而单电量版是”使用中”,同一台 TWS 耳机左右耳电量可用时和不可用时文案用词不一致(xcddif 已统一为”使用中”,见第五节对照表)。


二、getCarConnectionSummary():车机专用版精讲(dcddif 版)

位置:dcddif CachedBluetoothDevice.java:1164-1272,注释自述 “resource for android auto string”——这是 AOSP 为 Android Auto / 车机写的摘要,把 active 状态作为后缀拼进”已连接”文案,而不是像手机版那样整体替换成”使用中”。

2.1 结构总览

方法骨架与 getConnectionSummary 平行,但布尔位反相

        boolean profileConnected = false;       // at least one profile is connected
        boolean a2dpNotConnected = false;       // A2DP is preferred but not connected
        boolean hfpNotConnected = false;        // HFP is preferred but not connected
        boolean hearingAidNotConnected = false; // Hearing Aid is preferred but not connected

(dcddif CachedBluetoothDevice.java:1165-1168

注意注释里的关键词 “is preferred”:a2dpNotConnected = true 的充要条件是”这个 profile 在设备的 profile 列表里(UUID 支持,即’本应有’)+ proxy 就绪 + 状态 DISCONNECTED”(1184-1196 的赋值逻辑与 1.1 节同构,只是翻了个方向)。初始化为 false 的语义是”没有证据说缺这路音频”。所以:

  • 手机连上车但媒体音频没建立 → a2dpNotConnected = true → 文案出现”(无媒体信号)”;
  • 设备压根不支持 A2DP(如纯 HFP 的车载诊断 Dongle)→ profile 不在列表 → 永远不会显示”无媒体信号”——“缺”必须是”该有而没有”,不是”没有”

这是与 1.1 节对称的保守设计:宁可少报缺路,不误报缺路。

2.2 active 后缀数组与组装(1213-1229)

        // Prepare the string for the Active Device summary
        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);
        }

(dcddif CachedBluetoothDevice.java:1213-1230

数组 bluetooth_audio_active_device_summaries 的四个下标(两个 flavor 的值完全一致,dcddif res/values/arrays.xml:358values-zh-rCN/arrays.xml:152;xcddif res/values/arrays.xml:261values-zh-rCN/arrays.xml:174):

下标英文实际值中文实际值含义(代码注释)
[0](空字符串)(空字符串)默认:不活跃,无后缀
[1], active,使用中Media + Phone 双活跃
[2], active (media),使用中(媒体)仅 A2DP 活跃
[3], active (phone),使用中(手机)仅 Headset 活跃

组装逻辑逐行读:

  • 双位皆 true → [1]
  • 否则 A2DP 单独 true → [2];接着 Headset true → [3] 覆盖 [2]——但”A2DP 且 Headset”已被第一分支截走,实际走到覆盖的只有”仅 Headset”,所以 [2]/[3] 互斥,覆盖写法只是防御;
  • 都不活跃 → 保持 [0](空串),后面 bluetooth_connected 拼接时 %1$s 为空,界面就只显示”已连接”。

跨设备视角(红队核验补充,多设备读者最关心的格):[2]/[3] 的互斥是单台设备内的互斥;两台设备各查各的布尔位,A2DP active 指向 X、HFP active 指向 Y(双蓝牙下正常态,两个通道独立选主)时,X 的摘要显示”已连接,使用中(媒体)“、Y 显示”已连接,使用中(手机)“——两台各自正确,不矛盾。

与手机版的关键差异:车机版不看 isOnCall。手机版要用音频模式区分”通话中看 Headset 位/空闲看 A2DP 位”,车机版直接把两路 active 状态都摊开给用户(“使用中(媒体)“单独亮是合法状态)。车机上音频输出天然只有一套扬声器,“哪路 active”本身就是用户关心的信息,不需要按通话状态切换视角。

末尾的助听器早退(1227-1230):!hearingAidNotConnected && mIsActiveDeviceHearingAid → 后缀强制 [1]直接返回 bluetooth_connected 拼后缀,跳过后面整个”缺路+电量”矩阵——助听器设备不参与无媒体/无手机那套话术。⚠️ 这里的 !hearingAidNotConnected 守卫与手机版 1.2 节的外层守卫不同:手机版要求”助听器连接证据在”,车机版要求”没有断开证据”,语义更宽。

2.3 “已连接但缺某路音频”文案矩阵(1232-1267)

        if (profileConnected) {
            if (a2dpNotConnected && hfpNotConnected) {
                if (batteryLevelPercentageString != null) {
                    return mContext.getString(
                            R.string.bluetooth_connected_no_headset_no_a2dp_battery_level,
                            batteryLevelPercentageString, activeDeviceString);
                } else {
                    return mContext.getString(R.string.bluetooth_connected_no_headset_no_a2dp,
                            activeDeviceString);
                }
 
            } else if (a2dpNotConnected) {
                if (batteryLevelPercentageString != null) {
                    return mContext.getString(R.string.bluetooth_connected_no_a2dp_battery_level,
                            batteryLevelPercentageString, activeDeviceString);
                } else {
                    return mContext.getString(R.string.bluetooth_connected_no_a2dp,
                            activeDeviceString);
                }
 
            } else if (hfpNotConnected) {
                if (batteryLevelPercentageString != null) {
                    return mContext.getString(R.string.bluetooth_connected_no_headset_battery_level,
                            batteryLevelPercentageString, activeDeviceString);
                } else {
                    return mContext.getString(R.string.bluetooth_connected_no_headset,
                            activeDeviceString);
                }
            } else {
                if (batteryLevelPercentageString != null) {
                    return mContext.getString(R.string.bluetooth_connected_battery_level,
                            batteryLevelPercentageString, activeDeviceString);
                } else {
                    return mContext.getString(R.string.bluetooth_connected, activeDeviceString);
                }
            }
        }
 
        return getBondState() == BluetoothDevice.BOND_BONDING ?
                mContext.getString(R.string.bluetooth_pairing) : null;

(dcddif CachedBluetoothDevice.java:1232-1272

组合矩阵(中文实际值,dcddif values-zh-rCN/strings.xml:70-79,两 flavor 值一致;英文见 dcddif values/strings.xml:198-218):

a2dpNotConnectedhfpNotConnected有电量文案(中文)
已连接<active后缀>
已连接,电量为 80%<active后缀>
已连接(无手机信号)<active后缀>
已连接(无手机信号),电量为 80%<active后缀>
已连接(无媒体信号)<active后缀>
已连接(无媒体信号),电量为 80%<active后缀>
已连接(无手机或媒体信号)<active后缀>
已连接(无手机或媒体信号),电量为 80%<active后缀>

active 后缀取值回看 2.2 的四格表。真实观感示例:媒体+通话都活跃且有电 → “已连接,电量为 80%,使用中”;只挂了 HFP 且是通话 active → “已连接(无媒体信号),使用中(手机)”。

英文实际值(以 dcddif values/strings.xml 为准):Connected %1$s / Connected (no phone) %1$s / Connected (no media) %1$s / Connected (no phone or media) %1$s,电量版为 Connected, battery 80% %2$s 等。中英对照里”手机信号/媒体信号”的”信号”是 phone/media 的老翻译,与蜂窝信号无关,指 HFP/A2DP 通道。

一个值得注意的理论组合:a2dpNotConnected = true(媒体路确实断了)但 mIsActiveDeviceA2dp = true(布尔位还指着它)时,会拼出”已连接(无媒体信号),使用中(媒体)“这种自相矛盾的文案——这正是布尔位陈旧的直接 UI 症状(生产侧为何会陈旧见 67 篇,排障见第六节)。


三、MiCar UI 层消费点清单:车机实际用的是另一套

在 micarConnectionSettings 模块内 grep getCarConnectionSummary / getConnectionSummary / isActiveDevice / setActive / activeDevice,结论(每条都实际核实):

3.1 摘要:MiCar 三态文案,不区分 active

已配对列表条目 MiCarBluetoothBondedDevicePreferencesettingsPage/micarConnectionSettings/src/main/java/com/android/car/settings/miauto/bluetooth/preference/MiCarBluetoothBondedDevicePreference.kt)的副标题:

    override fun getDeviceSummary(): String {
        val isConnected = cachedDevice.isConnected
        val isBusy = cachedDevice.isBusy
        MLog.d(
            "refresh ui device[addr]: ${cachedDevice.address}, " +
                    "summary is $summary, isConn = $isConnected, " +
                    "isBusy = $isBusy",
        )
 
        return if (isConnected) {
            context.getString(R.string.bluetooth_bonded_device_connected)
        } else if (isBusy) {
            context.getString(R.string.bluetooth_bonded_device_connecting)
        } else {
            context.getString(R.string.bluetooth_bonded_device_saved)
        }
    }

MiCarBluetoothBondedDevicePreference.kt:92-108

字符串实际值(micarConnectionSettings src/main/res/values/strings.xml:66-68values-zh-rCN/strings.xml:66-68):

资源 ID英文中文
bluetooth_bonded_device_connectedConnected已连接
bluetooth_bonded_device_connectingConnecting…正在连接...
bluetooth_bonded_device_savedSaved已保存

摘要状态机的完整优先级(isBusy/配对中/超时等)归 34 篇,本篇只锁定与 active 的交集,交集就是:空集。多台手机同时连上车(双蓝牙,见 32 篇),列表里两台都显示”已连接”,从副标题无法分辨哪台正在出声——active 信息在 MiCar 摘要里被整体丢弃。

3.2 高亮与”角标”:由 isConnected 驱动,与 active 无关

    // 浅色已连接的设备下跟已配对的状态 文本和icon不一样,需要动态切换一下!!!
    private fun refreshBondedDeviceDisplay(holder: PreferenceViewHolder) {
        holder.run {
            val title = itemView.findViewById<TextView>(android.R.id.title)
            val titleTvColor = context.getColor(
                if(mIsConnected) {
                    R.color.micar_ui_color_list_item_title_selected
                } else {
                    R.color.micar_ui_color_list_item_title
                }
            )
            title.setTextColor(titleTvColor)

MiCarBluetoothBondedDevicePreference.kt:148-159,其后还有 summary 文案色 :160-165、icon 着色 :170-179、右侧箭头图标 micar_ic_view_detail_indicator_in_blue :181-185,全部以 mIsConnected 二选一)

mIsConnected 的来源在父类 NewBluetoothDevicePreference(同目录 NewBluetoothDevicePreference.java):

        boolean connectedStateChanged = mIsConnected != mCachedDevice.isConnected();
        mIsConnected = mCachedDevice.isConnected();

NewBluetoothDevicePreference.java:128-129,位于 refreshDeviceUi() :110-144 内)

布局证据:条目布局 micar_ui_pref_basic_bt_bonded_device.xml 里除 micar_ui_item_connecting_indicator_img(连接中动画,:23)、语音序号角标 item_index_for_voice(:43)外,没有任何 active 相关的视图 ID。所谓”高亮”= 已连接时标题/副标题/图标换成 selected 色 + 右箭头变蓝,判定条件是 isConnected()(至少一个 profile 连着,62 篇),不是 isActiveDevice。

3.3 谁在什么时机刷新副标题

链路(本节是 65 篇的落地端点):

  1. NewBluetoothDevicePreference 构造后由 BluetoothBondedDevicesPrefController.createDevicePreference() 创建并加入列表(controller/BluetoothBondedDevicesPrefController.java:142);
  2. 条目 attach 时注册回调:mCachedDevice.registerCallback(mDeviceCallback),其中 mDeviceCallback = this::refreshDeviceUiNewBluetoothDevicePreference.java:58、95);详情页侧的 controller 也各自注册(controller/BluetoothDevicePreferenceController.java:42、95);
  3. CachedBluetoothDevice.dispatchAttributesChanged()(dcddif CachedBluetoothDevice.java:895-898)遍历回调逐个 onDeviceAttributesChanged()refreshDeviceUi()setSummary(getDeviceSummary()) 重读三态文案。

即:副标题是”事件拉”模式,每次 CBD 属性变更事件整个重算,没有针对 active 的独立通道。MiCar 另有一套 mIsConnectingState/mStartToConnect 等连接过程状态(MiCarBluetoothBondedDevicePreference.kt:33-34、65-90),只影响 Toast 与连接动画,与 active 无交集,不展开。

3.4 收到了 active 广播但什么都不做

BluetoothPreferenceController(micarConnectionSettings 所有蓝牙 controller 的基类)实现了完整 BluetoothCallback,但 active 相关两个方法是空实现

    @Override
    public void onActiveDeviceChanged(CachedBluetoothDevice activeDevice, int bluetoothProfile) {
    }
 
    @Override
    public void onAudioModeChanged() {
    }

controller/BluetoothPreferenceController.java:162-168

广播确实到达了 UI 层(注册链见第四节),只是到达后被丢弃。综合 3.1-3.4:MiCarSettings 当前对 active 状态的 UI 消费量为零

3.5 其它排查过的候选消费点(均与 active 无关)

  • MiBluetoothExt.kt(全文 72 行):只有 performConnect/performDisConnect/performUnpair 扩展函数,执行连接断开配对,不读摘要不读 active;
  • BluetoothDeviceService.kt(全文 72 行):断连后更新 “last connected device” 记录供蓝牙音乐选源(BluetoothDeviceService.kt:12-49),用的是 isConnected + isPhoneDevice 过滤,“last connected”≠“active”,别混;
  • 详情页 MiCarBluetoothDeviceDetailFragment.javaBluetoothDeviceProfilesPreferenceController.java:无 summary/active 引用(profile 开关走 profile 连接状态,那套归 03 篇);
  • CachedBluetoothDeviceManager.getSubDeviceSummary()(dcddif CachedBluetoothDeviceManager.java:124-129,内部调用 getConnectionSummary()):全仓无调用者。

四、刷新时机:哪些事件会走到 dispatchAttributesChanged

完整回调分发机制(mCallbacks 注册表、线程、与 LocalBluetoothManager 的关系)见 65 篇。本节只列”事件 → CBD 内部 → UI 重读文案”的触发源清单。

dcddif CachedBluetoothDevice.javadispatchAttributesChanged() 的全部调用点(grep 核实共 9 处 + 方法本体 :895-898):

调用点宿主方法触发场景与 active 相关度
:501fillData()设备对象初始化/UUID 变化后全量刷新间接(fetchActiveDevices 在此链上重置布尔位)
:536setName()用户改名
:574refreshName()收到 ACTION_NAME_CHANGED 等改名广播
:613refresh()bond 状态变化
:621setJustDiscovered()扫描发现标记
:661onActiveDeviceChanged()active 设备切换广播(且布尔位真变了才发)核心
:669onAudioModeChanged()音频模式变化(通话建立/挂断)核心(只影响手机版摘要的 isOnCall)
:696setRssi()信号强度变化
:816onUuidChanged()设备 UUID 上报/刷新间接(profile 列表变 → 缺路判定变)

两条核心路径的广播注册(dcddif BluetoothEventManager.java:113-124):

        // Active device broadcasts
        addHandler(BluetoothA2dp.ACTION_ACTIVE_DEVICE_CHANGED, new ActiveDeviceChangedHandler());
        addHandler(BluetoothHeadset.ACTION_ACTIVE_DEVICE_CHANGED, new ActiveDeviceChangedHandler());
        addHandler(BluetoothHearingAid.ACTION_ACTIVE_DEVICE_CHANGED,
                new ActiveDeviceChangedHandler());
 
        // Headset state changed broadcasts
        addHandler(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED,
                new AudioModeChangedHandler());
        addHandler(TelephonyManager.ACTION_PHONE_STATE_CHANGED,
                new AudioModeChangedHandler());

分发侧(dcddif BluetoothEventManager.java:215-234):dispatchActiveDeviceChanged遍历所有 cached device 逐个 onActiveDeviceChanged(isActive, profile)——旧 active 设备收到 false、新 active 设备收到 true,一台切换两台刷新;dispatchAudioModeChanged 同样遍历全部设备(因为 isOnCall 是全局的,通话开始时所有设备的”手机版摘要”都可能换分支)。ActiveDeviceChangedHandler 按 action 字符串映射 profile ID(:432-457),未知 profile 打 Log.w


五、flavor 与资源差异对照

5.1 源码差异(dcddif vs xcddif,f3dif 不存在)

再次强调:本仓无 f3dif 源集,global flavor 复用 xcddif(base/settingsLibAndroid/build.gradle sourceSets 块核实)。

维度dcddifxcddif锚点
active 布尔位3 个:A2dp / Headset / HearingAid4 个:+ mIsActiveDeviceLeAudiodcddif CachedBluetoothDevice.java:147-149;xcddif :126-129
getConnectionSummary active 判定三分支(助听器/通话+Headset/非通话+A2dp)三分支 **`
getConnectionSummary 助听器细分active 且无电量时按左右耳细分(bluetooth_hearing_aid_left_active / right_active / left_and_right_activexcddif :1444-1469
getCarConnectionSummary 重载仅无参 1 个3 个:无参 / (shortSummary) / (shortSummary, useDisconnectedString)dcddif :1164;xcddif :1508-1525
getCarConnectionSummary LeAudio 早退!leAudioNotConnected && mIsActiveDeviceLeAudio → 同助听器一样早退返回 bluetooth_connected + [1]xcddif :1600-1603
shortSummary 行为仅压制电量后缀额外在 STATE_CONNECTED 早退返回”已连接”类短文案xcddif :1543-1546
未连接且非配对中返回nulluseDisconnectedString ? bluetooth_disconnected : null(默认重载传 true,即返回”已断开”文案)dcddif :1270-1271;xcddif :1643-1646
active 广播注册3 个 action4 个(+ BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGEDdcddif BluetoothEventManager.java:114-116;xcddif :136-141
getSubDeviceSummary只查 hearing aid subDevice先查 CSIP memberDevices 再查 subDevicedcddif CachedBluetoothDeviceManager.java:124-129;xcddif :150-166

回答调研问题”f3dif 是否有 LeAudio 文案”:f3dif 在本仓不存在;LeAudio 相关代码与文案在 xcddif(从而也是 global flavor)齐备。

5.2 字符串资源差异(实际值核对)

bluetooth_audio_active_device_summaries 数组:两 flavor 中英文完全一致(第一节 2.2 已贴全值)。

bluetooth_active_* 系列:

资源 IDdcddif 中文xcddif 中文英文(两 flavor 一致)
bluetooth_active_battery_level使用中,电池电量:<%1$s>同左Active, <%1$s> battery
bluetooth_active_battery_level_untethered已启用,左:目前电量为 <%1$s>;右:目前电量为 <%2$s>(dcddif values-zh-rCN/strings.xml:81使用中,左:<%1$s> 电量,右:<%2$s> 电量(xcddif :98)Active, L: <%1$s> battery, R: <%2$s> battery
bluetooth_active_no_battery_level使用中同左Active
bluetooth_hearing_aid_left_active / bluetooth_hearing_aid_right_active / bluetooth_hearing_aid_left_and_right_active无此资源使用中,仅左耳助听器 / 使用中,仅右耳助听器 / 使用中,左右耳助听器(xcddif values-zh-rCN/strings.xml:102-104有(对应英文)

唯一实质差异两处:① dcddif untethered 版用”已启用”而 xcddif 用”使用中”(dcddif 存在用词不一致的翻译瑕疵);② 助听器左右耳三资源仅 xcddif 有。bluetooth_connected 全家族两 flavor 值一致(第二节矩阵已贴)。src/main/ressrc/global(目录不存在实体 res)均无这些 key 的覆盖。


六、排障视角:界面”使用中”与实际音频输出不一致

先分清两种”不一致”,断环位置完全不同:

A. MiCar 现状型:界面显示”已连接”(高亮)但声音从别的设备出/没声音——这不是 active 管道的 bug,MiCar UI 根本不显示 active(第三节)。此时排的是”哪台设备 active”与”为什么是它”,工具见下。

B. 管道型(未来接线 getCarConnectionSummary、或排查其它使用这套 settingslib 的界面):显示”使用中”的设备不是实际出声的设备。按数据流断环排查,从上游到下游:

  1. 布尔位陈旧(最常见):mIsActiveDeviceA2dp 还指着旧设备。验证:adb shell dumpsys bluetooth_manager 看 A2DP/Headset 的 active device 是否与界面一致;不一致则问题在 framework 广播或 onActiveDeviceChanged(dcddif :640-663,注意 changed 为 false 时不重发通知,若外部靠通知同步状态会漏);同时抓 logcat | grep -i "ActiveDeviceChanged\|onActiveDeviceChanged",未知 action/profile 会有 Log.w(dcddif BluetoothEventManager.java:436、448CachedBluetoothDevice.java:656)。
  2. 广播丢失:事件根本没到 BluetoothEventManager(动态注册的 receiver 被 kill/未注册)。验证:手动切换 active(adb shell cmd bluetooth_manager 相关指令或设置内操作)后看 logcat 是否有对应分发;65 篇有注册链全图。
  3. 音频模式误判Utils.isAudioModeOngoingCall 只认 AudioManager.getMode()(dcddif Utils.java:418-424)。VoIP app 没 setMode / 模式没复位(挂断后停留在 MODE_IN_CALL)会让手机版摘要在”使用中”与普通电量文案间显示错分支。验证:adb shell dumpsys audio | grep -i mode 对照界面;触发 TelephonyManager.ACTION_PHONE_STATE_CHANGEDBluetoothHeadset.ACTION_AUDIO_STATE_CHANGED 应看到全列表设备重刷(第四节)。
  4. 字符串数组被覆盖bluetooth_audio_active_device_summaries 依赖资源叠加顺序,运行时被 OEM/其它 srcDir 的同名数组覆盖(四下标数量还可能变,activeDeviceStringsArray[3] 越界崩溃或后缀文案变样)。验证:反编译目标 APK 查 resources.arsc 里该 array 的最终值,或 aapt2 dump 对照;本仓内两 flavor 值一致(5.2 节),风险来自仓外叠加。
  5. 文案自相矛盾组合:“已连接(无媒体信号),使用中(媒体)”= a2dpNotConnectedmIsActiveDeviceA2dp 同时 true——布尔位与连接状态脱节的直接证据,按 1+2 排。
  6. MiCar 三态不刷新:列表”已连接/已保存”不动。验证:MiCarBluetoothBondedDevicePreference.getDeviceSummary() 每次重算都有 MLog.d "refresh ui device[addr]"MiCarBluetoothBondedDevicePreference.kt:95-99),logcat 有此行说明事件到了 UI,无此行按 65 篇查注册链。

七、自测清单

  • 能说出 a2dpConnected = true 的初始化语义是”尚未发现断开”而非”已连接”,并解释它为什么只在 profile.isProfileReady() && STATE_DISCONNECTED 时翻 false
  • 能默写手机版 active 三分支:助听器 active / Headset active 且 isOnCall / A2DP active 且非 isOnCall
  • 能说出 Utils.isAudioModeOngoingCall 认的三种音频模式及为什么不用 TelephonyManager
  • 能背出 bluetooth_audio_active_device_summaries 四下标含义:空 / 双活跃 / 仅媒体 / 仅手机
  • 能解释车机版为何不看 isOnCall,而手机版必须看
  • 能拼出”已连接(无媒体信号),使用中(手机)“对应的布尔位组合(hfpNotConnected=0,a2dpNotConnected=1,mIsActiveDeviceHeadset=1,mIsActiveDeviceA2dp=0)
  • 能说出 MiCar 已配对列表副标题三态(已连接/正在连接/已保存)及其与 active 的交集为空
  • 能指出 MiCar 列表”高亮”的判定条件是 CachedBluetoothDevice.isConnected() 而非 active
  • 能列出 onActiveDeviceChanged / onAudioModeChanged 两类事件从广播到 UI 的注册-分发链上的关键类
  • 能说出本仓只有 dcddif/xcddif 两个 dif 源集、global 复用 xcddif、f3dif 不存在
  • 排障时能区分”MiCar 现状型不一致”与”管道型不一致”,并各给两个验证命令

交叉引用