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

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根据项目代码总结生成。

参考资料

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui