74 · 消费侧 — 活跃状态如何变成界面文案
本篇讲清什么
active 设备闭环的最后一公里:CachedBluetoothDevice 内部那几个 mIsActiveDevice* 布尔位(生产侧见 67 篇),究竟在哪里被读出来、变成用户看得见的”使用中”三个字。
本篇按三层展开:
- settingslib 摘要方法层:
getConnectionSummary(boolean)与getCarConnectionSummary()(车机专用版)如何把布尔位翻译成字符串资源 ID——这是 AOSP 留下的完整”active→文案”翻译器; - MiCar UI 层:micarConnectionSettings 的已配对列表实际用了哪套文案( spoiler:不是上面两个方法);
- 刷新与排障:文案什么时候重算、界面上”使用中”与实际音频输出不一致时断在哪几环。
先给全篇最重要的一个结论(全仓核实过):在 MiCarSettings 当前代码里,getCarConnectionSummary() 没有任何调用者,getConnectionSummary() 的唯一调用点是 settingslib 内部的 CachedBluetoothDeviceManager.getSubDeviceSummary(),而 getSubDeviceSummary() 本身也没有任何调用者。也就是说,AOSP 这套”active→使用中文案”的管道在本仓是完好但未接线的库存件;MiCar 车机界面用的是自己的一套三态摘要(已连接/正在连接/已保存),完全不区分 active。理解这一点后,本篇前半部分要读成”管道本身如何工作”(它仍是排查蓝牙音频问题、理解 AOSP 血统、未来接线时必备的知识),后半部分读成”车机实际给用户看什么”。
另外先修正一个研究前提:本仓 base/settingsLibAndroid 下只有 dcddif 和 xcddif 两个 dif 源集目录,不存在 f3dif 目录(全仓 find -type d -name f3dif 为空)。build.gradle 的 sourceSets 定义为:dcd → src/dcddif、xcd → src/xcddif、global → src/xcddif + src/global(base/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 需要同时满足三个条件:
- 该 profile 出现在
getProfiles()里——即设备 UUID 已经上报、支持这个 profile(profile 列表怎么来的见 62 篇); profile.isProfileReady()为 true——profile 与 framework 服务的 proxy 已经连上。蓝牙刚打开、proxy 还没注册好的窗口期里,getProfileConnectionState()会返回STATE_DISCONNECTED,但这个返回值不可信,代码选择不信;- 状态确实是
STATE_DISCONNECTED。
所以三个布尔位的准确读法是一张三值表:
| 值 | 含义 |
|---|---|
false | 有正面证据断开:profile 存在、proxy 就绪、状态 DISCONNECTED |
true(默认) | 两种情况混在一起:① 确实连着;② 根本没有这个 profile 对象 / proxy 没就绪,“没查到断开” |
一个只有 A2DP UUID 的设备(getProfiles() 里没有 HeadsetProfile 对象),hfpConnected 会一直保持 true——不是它在通话,而是循环里根本没有机会把它翻成 false。这就是”初始化为 true”的设计动机:把”未知”和”已连接”折叠成同一个默认值,宁可漏报断开,不可误报断开(对应到 getCarConnectionSummary 的镜像设计见第二节)。
顺带注意类型判断的写法:A2dpProfile || A2dpSinkProfile、HeadsetProfile || HfpClientProfile 成对出现——车机端自己是 sink/client(A2dpSinkProfile、HfpClientProfile),手机端是 source/server(A2dpProfile、HeadsetProfile),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 && !isOnCall | A2DP 是 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)(dcddifCachedBluetoothDevice.java:640-663),只在值真正变化时dispatchAttributesChanged()(:661); - 查询驱动:
fetchActiveDevices()(dcddifCachedBluetoothDevice.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_COMMUNICATION | VoIP / 车机免提类通信(微信、会议 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_level | Active, <电池百分比> battery | 使用中,电池电量:<电池百分比> |
bluetooth_active_battery_level_untethered | Active, L: <左耳> battery, R: <右耳> battery | 已启用,左:目前电量为 <左耳>;右:目前电量为 <右耳> |
bluetooth_active_no_battery_level | Active | 使用中 |
选择逻辑(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:358、values-zh-rCN/arrays.xml:152;xcddif res/values/arrays.xml:261、values-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):
| a2dpNotConnected | hfpNotConnected | 有电量 | 文案(中文) |
|---|---|---|---|
| ✗ | ✗ | 无 | 已连接<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
已配对列表条目 MiCarBluetoothBondedDevicePreference(settingsPage/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-68 与 values-zh-rCN/strings.xml:66-68):
| 资源 ID | 英文 | 中文 |
|---|---|---|
bluetooth_bonded_device_connected | Connected | 已连接 |
bluetooth_bonded_device_connecting | Connecting… | 正在连接... |
bluetooth_bonded_device_saved | Saved | 已保存 |
摘要状态机的完整优先级(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 篇的落地端点):
NewBluetoothDevicePreference构造后由BluetoothBondedDevicesPrefController.createDevicePreference()创建并加入列表(controller/BluetoothBondedDevicesPrefController.java:142);- 条目 attach 时注册回调:
mCachedDevice.registerCallback(mDeviceCallback),其中mDeviceCallback = this::refreshDeviceUi(NewBluetoothDevicePreference.java:58、95);详情页侧的 controller 也各自注册(controller/BluetoothDevicePreferenceController.java:42、95); CachedBluetoothDevice.dispatchAttributesChanged()(dcddifCachedBluetoothDevice.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.java与BluetoothDeviceProfilesPreferenceController.java:无 summary/active 引用(profile 开关走 profile 连接状态,那套归 03 篇); CachedBluetoothDeviceManager.getSubDeviceSummary()(dcddifCachedBluetoothDeviceManager.java:124-129,内部调用getConnectionSummary()):全仓无调用者。
四、刷新时机:哪些事件会走到 dispatchAttributesChanged
完整回调分发机制(mCallbacks 注册表、线程、与 LocalBluetoothManager 的关系)见 65 篇。本节只列”事件 → CBD 内部 → UI 重读文案”的触发源清单。
dcddif CachedBluetoothDevice.java 内 dispatchAttributesChanged() 的全部调用点(grep 核实共 9 处 + 方法本体 :895-898):
| 调用点 | 宿主方法 | 触发场景 | 与 active 相关度 |
|---|---|---|---|
| :501 | fillData() | 设备对象初始化/UUID 变化后全量刷新 | 间接(fetchActiveDevices 在此链上重置布尔位) |
| :536 | setName() | 用户改名 | 无 |
| :574 | refreshName() | 收到 ACTION_NAME_CHANGED 等改名广播 | 无 |
| :613 | refresh() | bond 状态变化 | 无 |
| :621 | setJustDiscovered() | 扫描发现标记 | 无 |
| :661 | onActiveDeviceChanged() | active 设备切换广播(且布尔位真变了才发) | 核心 |
| :669 | onAudioModeChanged() | 音频模式变化(通话建立/挂断) | 核心(只影响手机版摘要的 isOnCall) |
| :696 | setRssi() | 信号强度变化 | 无 |
| :816 | onUuidChanged() | 设备 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 块核实)。
| 维度 | dcddif | xcddif | 锚点 |
|---|---|---|---|
| active 布尔位 | 3 个:A2dp / Headset / HearingAid | 4 个:+ mIsActiveDeviceLeAudio | dcddif CachedBluetoothDevice.java:147-149;xcddif :126-129 |
getConnectionSummary active 判定 | 三分支(助听器/通话+Headset/非通话+A2dp) | 三分支 **` | |
getConnectionSummary 助听器细分 | 无 | active 且无电量时按左右耳细分(bluetooth_hearing_aid_left_active / right_active / left_and_right_active) | xcddif :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 |
| 未连接且非配对中返回 | null | useDisconnectedString ? bluetooth_disconnected : null(默认重载传 true,即返回”已断开”文案) | dcddif :1270-1271;xcddif :1643-1646 |
| active 广播注册 | 3 个 action | 4 个(+ BluetoothLeAudio.ACTION_LE_AUDIO_ACTIVE_DEVICE_CHANGED) | dcddif BluetoothEventManager.java:114-116;xcddif :136-141 |
getSubDeviceSummary | 只查 hearing aid subDevice | 先查 CSIP memberDevices 再查 subDevice | dcddif 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_* 系列:
| 资源 ID | dcddif 中文 | 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/res 与 src/global(目录不存在实体 res)均无这些 key 的覆盖。
六、排障视角:界面”使用中”与实际音频输出不一致
先分清两种”不一致”,断环位置完全不同:
A. MiCar 现状型:界面显示”已连接”(高亮)但声音从别的设备出/没声音——这不是 active 管道的 bug,MiCar UI 根本不显示 active(第三节)。此时排的是”哪台设备 active”与”为什么是它”,工具见下。
B. 管道型(未来接线 getCarConnectionSummary、或排查其它使用这套 settingslib 的界面):显示”使用中”的设备不是实际出声的设备。按数据流断环排查,从上游到下游:
- 布尔位陈旧(最常见):
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(dcddifBluetoothEventManager.java:436、448;CachedBluetoothDevice.java:656)。 - 广播丢失:事件根本没到
BluetoothEventManager(动态注册的 receiver 被 kill/未注册)。验证:手动切换 active(adb shell cmd bluetooth_manager相关指令或设置内操作)后看 logcat 是否有对应分发;65 篇有注册链全图。 - 音频模式误判:
Utils.isAudioModeOngoingCall只认AudioManager.getMode()(dcddifUtils.java:418-424)。VoIP app 没 setMode / 模式没复位(挂断后停留在 MODE_IN_CALL)会让手机版摘要在”使用中”与普通电量文案间显示错分支。验证:adb shell dumpsys audio | grep -i mode对照界面;触发TelephonyManager.ACTION_PHONE_STATE_CHANGED或BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED应看到全列表设备重刷(第四节)。 - 字符串数组被覆盖:
bluetooth_audio_active_device_summaries依赖资源叠加顺序,运行时被 OEM/其它 srcDir 的同名数组覆盖(四下标数量还可能变,activeDeviceStringsArray[3]越界崩溃或后缀文案变样)。验证:反编译目标 APK 查resources.arsc里该 array 的最终值,或aapt2 dump对照;本仓内两 flavor 值一致(5.2 节),风险来自仓外叠加。 - 文案自相矛盾组合:“已连接(无媒体信号),使用中(媒体)”=
a2dpNotConnected与mIsActiveDeviceA2dp同时 true——布尔位与连接状态脱节的直接证据,按 1+2 排。 - 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 现状型不一致”与”管道型不一致”,并各给两个验证命令
交叉引用
- 34-已配对列表摘要状态机-AOSP与MiCar.md —— 摘要状态机全貌(本篇只切了 active 交集)
- 65-属性刷新与回调分发.md —— Callback→UI 刷新链全图
- 67-active设备与多设备.md —— active 布尔位的生产侧
- 70-总览与闭环地图.md —— 本篇是闭环地图的消费侧终点
- 32-双蓝牙上限与Picker.md —— 多设备同连场景
- 03-蓝牙-设备详情与状态控制.md —— 详情页 profile 开关