Android 如何获取媒体音频路由:未播放、播放中与三星双音频
在 Android 上判断"声音会从哪里播放",比判断蓝牙是否连接复杂得多。
一台手机可以同时连接手机扬声器、有线耳机和多台蓝牙耳机。三星手机还支持 Dual Audio,让同一段媒体同时输出到两台经典蓝牙 A2DP 设备。系统设置里选中的设备、已经连接的设备,以及正在真正接收音频的设备,并不是同一个概念。
本文以 AI 眼镜或蓝牙耳机为目标设备,介绍一套经典蓝牙 A2DP 路由判断方案:
- 播放中,通过 PlayingState 和
isA2dpPlaying()获取实际输出集合; - 未播放时,通过 ActiveDevice 判断下一次播放的候选设备;
- 三星双音频仍然使用设备集合表达,不依赖厂商私有状态生成结论;
- 路由不符合预期时,引导用户进入系统输出选择器手动切换。
这套方案只处理媒体 A2DP,不覆盖通话或 VoIP 使用的 SCO,也不覆盖 LE Audio、助听器、Auracast、Cast 和 USB 音频。
产品只适配海外Android手机,主要包括三星、摩托罗拉、Google Pixel等。
一、先区分三种设备状态
1. 已连接设备
BluetoothA2dp.connectedDevices 可以告诉我们当前有哪些经典蓝牙音频设备已连接。
连接只代表设备可用,不能证明媒体正在从这台设备播放。两台蓝牙耳机都处于 Connected,也不代表系统正在向两台设备输出音频。
2. ActiveDevice
A2DP ActiveDevice 表示系统选中的主要活动设备。在普通单路由场景下,可以把它理解为"下一次媒体播放大概率会使用的蓝牙设备"。
它有两个明显限制:
- Android 公共 SDK 没有向普通第三方应用开放稳定的 A2DP ActiveDevice 查询接口;
- ActiveDevice 是单值,无法完整表达三星 Dual Audio 的两台输出设备。
3. 正在播放的设备
BluetoothA2dp.isA2dpPlaying(device) 可以判断某台已连接 A2DP 设备当前是否处于流传输状态。
三星双音频实际播放时,可能有两台设备同时返回 true。因此播放路由必须建模为集合,不能只保存单个设备。
text
Connected Devices = 当前可用的 A2DP 设备
ActiveDevice = 未播放时的单设备候选路由
Playing Devices = 播放中的实际 A2DP 输出集合
二、监听器的整体结构
路由监听器维护两类快照:
kotlin
data class RouteDevice(
val address: String,
val name: String?
)
private var activeDevice: RouteDevice? = null
private var hasActiveDeviceSnapshot = false
private var playingDevices: List<RouteDevice> = emptyList()
private var hasPlayingDevicesSnapshot = false
为什么还要单独保存两个 hasSnapshot?因为 null 和空集合可能是有效结果:
hasActiveDeviceSnapshot=false:还没有收到 ActiveDevice 证据;hasActiveDeviceSnapshot=true && activeDevice=null:已经确认当前没有 A2DP ActiveDevice;hasPlayingDevicesSnapshot=true && playingDevices.isEmpty():已经确认当前没有 A2DP 设备在播放。
如果只看 null 或空集合,就无法区分"尚未获取"和"已经确认没有"。
监听器的事件流如下:
text
PlayingState / ActiveDevice / ConnectionState 广播
↓
更新临时状态
↓
短暂防抖
↓
BluetoothA2dp.isA2dpPlaying() 校准
↓
生成统一的 A2DP 路由快照
三、播放中获取实际输出集合
播放状态变化可以监听:
kotlin
BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED
对应的 Action 字符串是:
text
android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED
广播包含发生变化的蓝牙设备,以及前后播放状态:
kotlin
private val pendingPlayingDevices = linkedMapOf<String, BluetoothDevice>()
private fun handlePlayingState(intent: Intent) {
val device = intent.bluetoothDeviceExtra() ?: return
val state = intent.getIntExtra(
BluetoothProfile.EXTRA_STATE,
Int.MIN_VALUE
)
when (state) {
BluetoothA2dp.STATE_PLAYING -> {
pendingPlayingDevices[device.address] = device
}
BluetoothA2dp.STATE_NOT_PLAYING -> {
pendingPlayingDevices.remove(device.address)
}
}
schedulePlayingRefresh(state)
}
private fun Intent.bluetoothDeviceExtra(): BluetoothDevice? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(
BluetoothDevice.EXTRA_DEVICE,
BluetoothDevice::class.java
)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
}
}
不要在收到一条广播后立刻发布最终结果。设备切换、暂停和双音频启停时,广播可能连续到达,顺序也不一定稳定。
这里使用两段稳定窗口:停止播放等待 350ms,开始播放等待 2000ms。
kotlin
private val playingRefreshRunnable = Runnable {
publishPlayingSnapshot()
}
private fun schedulePlayingRefresh(state: Int) {
handler.removeCallbacks(playingRefreshRunnable)
val delayMillis = if (state == BluetoothA2dp.STATE_NOT_PLAYING) {
350L
} else {
2_000L
}
handler.postDelayed(playingRefreshRunnable, delayMillis)
}
防抖结束后,不直接使用广播缓存,而是遍历 A2DP Profile 的全部已连接设备,再通过 isA2dpPlaying() 校准实际集合:
kotlin
private var a2dpProfile: BluetoothA2dp? = null
@SuppressLint("MissingPermission")
private fun queryPlayingDevices(): List<RouteDevice> {
val profile = a2dpProfile
return if (profile == null) {
pendingPlayingDevices.values.map { device ->
RouteDevice(device.address, device.name)
}
} else {
runCatching {
profile.connectedDevices
.filter(profile::isA2dpPlaying)
.map { device ->
RouteDevice(device.address, device.name)
}
}.getOrElse {
// Profile 暂时不可用时,退回广播维护的集合。
pendingPlayingDevices.values.map { device ->
RouteDevice(device.address, device.name)
}
}
}
}
Profile 通过 getProfileProxy() 获取:
kotlin
private val profileListener = object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(
profile: Int,
proxy: BluetoothProfile
) {
if (profile != BluetoothProfile.A2DP) return
a2dpProfile = proxy as BluetoothA2dp
publishPlayingSnapshot()
}
override fun onServiceDisconnected(profile: Int) {
if (profile == BluetoothProfile.A2DP) {
a2dpProfile = null
}
}
}
bluetoothAdapter.getProfileProxy(
context,
profileListener,
BluetoothProfile.A2DP
)
这套组合里,广播负责告诉应用"可能发生了变化",Profile 查询负责在状态稳定后给出完整播放集合。三星同时向两台耳机播放时,只要两台设备都能通过 isA2dpPlaying() 校准出来,就可以自然得到双设备结果。
防抖时间只是兼容性参数,不是 Android 协议保证。不同手机和耳机组合仍需真机验证。
四、未播放时获取候选路由
没有媒体播放时,所有设备的 isA2dpPlaying() 都会返回 false。此时只能使用 ActiveDevice 判断下一次播放的候选设备。
AOSP 中存在下面这个广播:
text
android.bluetooth.a2dp.profile.action.ACTIVE_DEVICE_CHANGED
对应常量没有出现在普通应用可直接使用的公共 SDK 中,因此只能把 Action 字符串作为兼容性信号:
kotlin
private const val ACTION_A2DP_ACTIVE_DEVICE_CHANGED =
"android.bluetooth.a2dp.profile.action.ACTIVE_DEVICE_CHANGED"
private var activeDevice: BluetoothDevice? = null
private var hasActiveDeviceSnapshot = false
private fun handleActiveDeviceChanged(intent: Intent) {
activeDevice = intent.bluetoothDeviceExtra()
hasActiveDeviceSnapshot = true
}
状态必须按三种情况处理:
text
false + null = 尚未收到快照,路由未知
true + null = 已确认当前没有 A2DP ActiveDevice
true + device = 已确认单个候选设备
不能把第一种情况直接判断为"声音不走目标耳机",否则监听刚启动就会产生错误提示。
这个广播通常不是粘性广播。如果 ActiveDevice 在监听注册前已经选定,应用可能收不到初始事件。公共 SDK 又没有对应的稳定查询接口,所以未播放路由必须允许 UNKNOWN,而不能伪造一个确定结果。
五、统一播放与未播放的路由决策
播放中的事实优先级高于未播放时的候选路由:
kotlin
enum class RouteSource {
ACTUAL_PLAYING_DEVICES,
IDLE_ACTIVE_DEVICE,
UNKNOWN
}
data class RouteDecision(
val devices: List<RouteDevice>,
val source: RouteSource,
val complete: Boolean
)
data class A2dpRouteSnapshot(
val playingDevices: List<RouteDevice>,
val hasPlayingDevicesSnapshot: Boolean,
val activeDevice: RouteDevice?,
val hasActiveDeviceSnapshot: Boolean
) {
fun resolve(): RouteDecision = when {
hasPlayingDevicesSnapshot && playingDevices.isNotEmpty() -> {
RouteDecision(
devices = playingDevices,
source = RouteSource.ACTUAL_PLAYING_DEVICES,
complete = true
)
}
hasActiveDeviceSnapshot -> {
RouteDecision(
devices = listOfNotNull(activeDevice),
source = RouteSource.IDLE_ACTIVE_DEVICE,
complete = true
)
}
else -> {
RouteDecision(
devices = emptyList(),
source = RouteSource.UNKNOWN,
complete = false
)
}
}
fun isRouteOnDevice(targetAddress: String): Boolean? {
val decision = resolve()
if (!decision.complete || targetAddress.isBlank()) return null
val normalizedTarget = targetAddress.normalizeAddress()
return decision.devices.any { device ->
device.address.normalizeAddress() == normalizedTarget
}
}
}
private fun String.normalizeAddress(): String =
replace(":", "").uppercase(Locale.US)
最终业务只需要一个三态结果:
true:目标眼镜位于已确认的 A2DP 路由中;false:已经取得完整结论,但目标眼镜不在路由中;null:证据不足,暂时不能判断。
播放集合非空时,即使 ActiveDevice 指向另一台设备,也应该以实际播放集合为准。这个规则正好覆盖三星双音频:只要目标眼镜出现在两台 Playing Devices 中,结果就是 true。
空的 A2DP 路由集合只表示"当前没有已确认的 A2DP 输出",不代表手机完全没有音频输出。声音还可能走手机扬声器、有线耳机或其他不在本文范围内的路由。
六、三星 Media output 的局限
三星把 Dual Audio 的用户入口整合在 Quick Panel 的 Media output 中。连接两台蓝牙音频设备后,用户可以在面板里同时选择两台设备。
第三方应用面对这个面板时有几项限制:
- 它是用户界面,不是状态 API。 应用无法通过公开 API 稳定读取完整勾选列表。
- 选中不等于正在播放。 面板可以保留双设备选择,但暂停时两台设备都不会处于
STATE_PLAYING。 - ActiveDevice 仍然是单值。 它只能表示主设备或最近活动设备,不能代表完整双路由。
- 系统 Output Switcher 不等于三星 Media output。 Android 的公开入口只能请求系统展示选择器,界面和能力由系统决定。
- 没有稳定的公开深链。 直接启动 One UI 私有 Activity、Service 或 Intent 会受到版本、包名和权限变化影响。
因此,Media output 适合让用户操作,不适合充当应用可读取的路由数据库。
三星 Dual Audio 广播
部分 One UI 版本中可以观察到厂商内部广播:
text
com.samsung.bluetooth.a2dp.intent.action.DUAL_PLAY_MODE_ENABLED
它通常携带一个布尔值:
text
extra: "enable"
这个广播没有被用于生成路由结论,因为它存在以下问题:
- Action 和 extra 不是 Android 或三星面向第三方应用的稳定 SDK;
- 不同机型、地区固件和 One UI 版本不保证都能收到;
- 广播只有
enable,没有双路由设备列表; enable=true不代表此刻有两台设备正在播放;- Dual Mode、ActiveDevice、PlayingState 和 ConnectionState 可能乱序到达;
- 广播通常只报告变化,不能提供可靠的初始化快照;
- 部分 One UI 版本在 ActiveDevice 被重新设置时可能退出 Dual Audio。
所以不能做出以下推断:
text
enable=true → 前两台已连接设备就是当前双路由
enable=false → 声音一定回到了手机扬声器
双路由的实际播放状态仍然通过 connectedDevices.filter(profile::isA2dpPlaying) 获取。这样不需要知道 Dual Audio 开关本身,只需要关心目标眼镜是否真的位于 Playing Devices 集合中。
三星手机在未播放的情况下,方案最后落地为只监听ActiveDevice。
七、ConnectionState 只负责清理缓存
连接状态不能证明媒体路由,但设备断开时要及时从播放缓存中移除:
kotlin
private fun handleConnectionState(intent: Intent) {
val device = intent.bluetoothDeviceExtra() ?: return
val state = intent.getIntExtra(
BluetoothProfile.EXTRA_STATE,
Int.MIN_VALUE
)
if (state == BluetoothProfile.STATE_DISCONNECTED) {
pendingPlayingDevices.remove(device.address)
handler.removeCallbacks(playingRefreshRunnable)
handler.postDelayed(playingRefreshRunnable, 350L)
}
}
这能避免漏收 STATE_NOT_PLAYING 时,已经断开的设备长期留在集合中。
不要反向推导:STATE_CONNECTED 只说明 A2DP Profile 已连接,不代表设备已经成为媒体输出。
八、权限、注册与生命周期
Android 12 及以上读取蓝牙设备、名称、地址和 Profile 状态需要 BLUETOOTH_CONNECT:
xml
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
@SuppressLint("MissingPermission") 只能关闭静态检查,不能代替运行时权限判断。启动监听前必须确认权限已经授予。
广播可以统一动态注册:
kotlin
private val filter = IntentFilter().apply {
addAction(ACTION_A2DP_ACTIVE_DEVICE_CHANGED)
addAction(BluetoothA2dp.ACTION_PLAYING_STATE_CHANGED)
addAction(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED)
}
ContextCompat.registerReceiver(
appContext,
receiver,
filter,
ContextCompat.RECEIVER_EXPORTED
)
广播来自应用进程之外,因此使用 RECEIVER_EXPORTED。这也意味着收到的 Intent 只能作为状态刷新信号,不能用于权限、安全或计费判断。
监听周期应绑定目标蓝牙设备的连接状态,而不是某一个页面:
kotlin
bluetoothState
.map { state -> state.isConnected to state.deviceAddress }
.distinctUntilChanged()
.collect { (isConnected, targetAddress) ->
if (isConnected && targetAddress.isNotBlank()) {
startRouteMonitoring(targetAddress)
} else {
stopRouteMonitoring()
}
}
这样多个页面可以共享同一个路由快照,页面切换也不会重复注册 Receiver。目标设备变化时,应先停止旧监听、清空快照,再开始新周期,避免继续使用上一台设备的结论。
停止监听时要同时移除延迟任务、注销 Receiver,并关闭 A2DP Profile:
kotlin
handler.removeCallbacks(playingRefreshRunnable)
runCatching { appContext.unregisterReceiver(receiver) }
a2dpProfile?.let { profile ->
bluetoothAdapter.closeProfileProxy(
BluetoothProfile.A2DP,
profile
)
}
a2dpProfile = null
activeDevice = null
playingDevices = emptyList()
hasActiveDeviceSnapshot = false
hasPlayingDevicesSnapshot = false
九、只在明确判断错误路由时提示用户
UI 不需要知道 ActiveDevice、PlayingState 或三星双音频细节,只需要消费前面的三态结果:
kotlin
data class GlassesAudioState(
val isConnected: Boolean = false,
val isAudioRoutedToGlasses: Boolean? = null
) {
val shouldShowAudioRouteTip: Boolean
get() = isConnected && isAudioRoutedToGlasses == false
}
这里使用 == false,而不是 != true:
- 路由在眼镜上时隐藏;
- 已经确认路由不在眼镜上时显示;
- 路由未知时隐藏,避免初始化阶段误报。
kotlin
binding.switchAudioTip.isVisible =
state.shouldShowAudioRouteTip
用户点击提示后,可以先展示一段说明,再进入系统输出选择入口。应用不应通过断开重连蓝牙来抢占路由,因为"设备重新连接"和"用户选择媒体输出"是两个不同操作。
十、打开系统音频输出选择器
Android 14 及以上可以调用 MediaRouter2.showSystemOutputSwitcher()。调用失败或系统没有展示时,依次降级到蓝牙设置和系统设置:
kotlin
object AudioOutputNavigator {
fun open(activity: Activity) {
val outputSwitcherShown =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
runCatching {
MediaRouter2.getInstance(activity)
.showSystemOutputSwitcher()
}.getOrDefault(false)
} else {
false
}
if (outputSwitcherShown) return
openSettings(
activity = activity,
intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
)
}
private fun openSettings(
activity: Activity,
intent: Intent
) {
runCatching {
activity.startActivity(intent)
}.onFailure {
if (intent.action != Settings.ACTION_SETTINGS) {
openSettings(
activity = activity,
intent = Intent(Settings.ACTION_SETTINGS)
)
}
}
}
}
这段代码有几个重要边界:
showSystemOutputSwitcher()需要前台可见的Activity。- 返回
true只表示选择器已展示,不表示用户已经完成切换。 - Android 13 及以下直接进入蓝牙设置。
- 三星手机展示的系统选择器不一定等同于 Quick Panel 中完整的 Media output 面板。
- 不依赖三星私有 Activity 或深链,避免 One UI 升级后入口失效。
打开系统界面后,不要提前把业务状态改成成功:
kotlin
// 错误:打开选择器不等于路由已经切换。
state = state.copy(isAudioRoutedToGlasses = true)
路由监听器应继续等待 PlayingState 或 ActiveDevice 信号,再更新最终结果。
如果系统切换后没有发送 ActiveDevice 广播,并且当前没有媒体播放,应用可能仍然无法确认未播放路由。这是公共 API 的能力边界,不能通过 startActivity() 的返回值解决。此时继续保持 UNKNOWN 比误报切换成功更安全。
总结
这套实现可以归纳为四条规则:
text
播放中:使用 PlayingState 触发刷新,isA2dpPlaying() 校准完整集合;
未播放:使用 ActiveDevice,但必须允许 UNKNOWN;
三星双音频:直接看 Playing Devices,不依赖 Dual Audio 私有广播生成结论;
切换路由:只打开系统入口,最终结果仍由路由信号确认。
真正实用的部分不是监听更多 API,而是明确不同信号的职责:连接状态只负责设备生命周期,Playing Devices 表示播放事实,ActiveDevice 表示未播放候选,系统 Media output 只负责让用户操作。