智能眼镜开发:获取真实的音频路由

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 中。连接两台蓝牙音频设备后,用户可以在面板里同时选择两台设备。

第三方应用面对这个面板时有几项限制:

  1. 它是用户界面,不是状态 API。 应用无法通过公开 API 稳定读取完整勾选列表。
  2. 选中不等于正在播放。 面板可以保留双设备选择,但暂停时两台设备都不会处于 STATE_PLAYING
  3. ActiveDevice 仍然是单值。 它只能表示主设备或最近活动设备,不能代表完整双路由。
  4. 系统 Output Switcher 不等于三星 Media output。 Android 的公开入口只能请求系统展示选择器,界面和能力由系统决定。
  5. 没有稳定的公开深链。 直接启动 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)
                )
            }
        }
    }
}

这段代码有几个重要边界:

  1. showSystemOutputSwitcher() 需要前台可见的 Activity
  2. 返回 true 只表示选择器已展示,不表示用户已经完成切换。
  3. Android 13 及以下直接进入蓝牙设置。
  4. 三星手机展示的系统选择器不一定等同于 Quick Panel 中完整的 Media output 面板。
  5. 不依赖三星私有 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 只负责让用户操作。

注:部分内容由AI根据项目代码总结生成。

参考资料

相关推荐
pengyu1 小时前
【Kotlin 协程修仙录 · 渡劫境 · 中阶】 | 造化神兵:自定义 CoroutineDispatcher 与调度器的终极定制
android·kotlin
pengyu2 小时前
【Kotlin 协程修仙录 · 渡劫境 · 初阶】 | 飞升雷劫:CPS 变换与挂起函数字节码终极透视
android·kotlin
TimeFine2 小时前
智能眼镜开发:眼镜Touch后收音与触发播放系统音乐的矛盾处理
android
TimeFine2 小时前
智能眼镜开发:眼镜侧收集音频
android
又见情义3 小时前
Android 系统设置从平板版迁移至TV版实践
android
杉氧6 小时前
打破边界(一):实战编写 Android/iOS 原生模块 (Native Modules)
android·react native·前端框架
baidu_247438616 小时前
Android 35适配
android
mmsx7 小时前
osmdroid 踩坑清单:大级别崩溃/路径消失/低内存卡顿/范围缩放不准
android·源码·地图·osmdroid