fix(android): expose audio output devices

This commit is contained in:
Edison Jwa
2026-05-20 16:26:07 +09:00
parent 2382c2b569
commit f4db27b565
3 changed files with 277 additions and 0 deletions
@@ -0,0 +1,245 @@
package app.chanora.chanora_flutter
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
internal class AndroidAudioOutputController(context: Context) :
MethodChannel.MethodCallHandler,
EventChannel.StreamHandler {
private val appContext = context.applicationContext
private val audioManager: AudioManager = appContext.getSystemService(AudioManager::class.java)
private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null
private var callbackRegistered = false
private val deviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
emitDeviceChanged()
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
emitDeviceChanged()
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
when (call.method) {
MethodChannels.METHOD_GET_OUTPUT_DEVICES -> result.success(getOutputDevices())
MethodChannels.METHOD_GET_COMMUNICATION_DEVICES -> result.success(getCommunicationDevices())
MethodChannels.METHOD_SET_COMMUNICATION_DEVICE -> {
val deviceId = call.argument<String>("deviceId")
if (deviceId.isNullOrBlank()) {
result.error("missing_device_id", "deviceId is required", null)
} else {
result.success(setCommunicationDevice(deviceId))
}
}
MethodChannels.METHOD_CLEAR_COMMUNICATION_DEVICE -> {
clearCommunicationDevice()
result.success(null)
}
else -> result.notImplemented()
}
} catch (t: Throwable) {
result.error("audio_output_failed", t.message, null)
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
startObserveAudioDevices()
}
override fun onCancel(arguments: Any?) {
stopObserveAudioDevices()
eventSink = null
}
fun detach() {
stopObserveAudioDevices()
eventSink = null
}
private fun getOutputDevices(): List<Map<String, Any>> {
val communicationDevices = communicationDevices()
val communicationIds = CommunicationDeviceIds(
selectedId = selectedCommunicationDeviceId(),
availableIds = communicationDevices.map { it.id.toString() }.toSet(),
availableTypes = communicationDevices.map { it.type }.toSet(),
)
return audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.map { device ->
val dto = device.toDto(communicationIds.selectedId, communicationIds.availableIds)
dto + ("isAvailableForCommunication" to (
dto["isAvailableForCommunication"] == true ||
communicationIds.availableTypes.contains(device.type)
))
}
}
private fun getCommunicationDevices(): List<Map<String, Any>> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val selectedId = selectedCommunicationDeviceId()
val devices = communicationDevices()
val availableIds = devices.map { it.id.toString() }.toSet()
return devices.map { it.toDto(selectedId, availableIds) }
}
return audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.map { it.toDto(selectedId = null, communicationIds = emptySet()) }
}
private fun setCommunicationDevice(deviceId: String): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
Log.w(TAG, "setCommunicationDevice unsupported below API 31 deviceId=$deviceId")
return false
}
val communicationDevices = communicationDevices()
val outputDevice = audioManager
.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.firstOrNull { it.id.toString() == deviceId }
val target = communicationDevices.firstOrNull { it.id.toString() == deviceId }
?: outputDevice?.let { output ->
communicationDevices.firstOrNull { it.type == output.type }
}
if (target == null) {
Log.w(
TAG,
"setCommunicationDevice target not available deviceId=$deviceId outputs=${audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).joinToString { it.logLabel() }} communication=${communicationDevices.joinToString { it.logLabel() }}",
)
return false
}
val changed = audioManager.setCommunicationDevice(target)
Log.i(TAG, "setCommunicationDevice device=${target.logLabel()} changed=$changed selected=${audioManager.communicationDevice?.logLabel()}")
return changed
}
private fun clearCommunicationDevice() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
Log.i(TAG, "clearCommunicationDevice selected=${audioManager.communicationDevice?.logLabel()}")
}
}
private fun startObserveAudioDevices() {
if (callbackRegistered) return
audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
callbackRegistered = true
}
private fun stopObserveAudioDevices() {
if (!callbackRegistered) return
audioManager.unregisterAudioDeviceCallback(deviceCallback)
callbackRegistered = false
}
private fun emitDeviceChanged() {
mainHandler.post {
eventSink?.success(mapOf("type" to "audioDeviceChanged"))
}
}
private fun communicationDeviceIds(): CommunicationDeviceIds {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return CommunicationDeviceIds(
selectedId = null,
availableIds = emptySet(),
availableTypes = emptySet(),
)
}
val devices = communicationDevices()
return CommunicationDeviceIds(
selectedId = selectedCommunicationDeviceId(),
availableIds = devices.map { it.id.toString() }.toSet(),
availableTypes = devices.map { it.type }.toSet(),
)
}
private fun selectedCommunicationDeviceId(): String? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.communicationDevice?.id?.toString()
} else {
null
}
private fun communicationDevices(): List<AudioDeviceInfo> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.availableCommunicationDevices
} else {
emptyList()
}
private fun AudioDeviceInfo.toDto(
selectedId: String?,
communicationIds: Set<String>,
): Map<String, Any> {
val id = this.id.toString()
val normalizedType = normalizedType()
return mapOf(
"id" to id,
"name" to displayName(normalizedType),
"type" to normalizedType,
"isSelected" to (selectedId == id),
"isAvailableForCommunication" to communicationIds.contains(id),
)
}
private fun AudioDeviceInfo.displayName(normalizedType: String): String {
val product = productName?.toString()?.trim().orEmpty()
if (product.isNotEmpty()) return product
return when (normalizedType) {
"speaker" -> "Speaker"
"earpiece" -> "Earpiece"
"wiredHeadset", "wiredHeadphones" -> "Wired Headset"
"bluetoothA2dp", "bluetoothSco", "bluetoothLe" -> "Bluetooth Headset"
"usbHeadset" -> "USB Headset"
"hdmi" -> "HDMI"
else -> "Other Device"
}
}
private fun AudioDeviceInfo.normalizedType(): String = when (type) {
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker"
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "earpiece"
AudioDeviceInfo.TYPE_WIRED_HEADSET -> "wiredHeadset"
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "wiredHeadphones"
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "bluetoothA2dp"
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bluetoothSco"
AudioDeviceInfo.TYPE_USB_HEADSET -> "usbHeadset"
AudioDeviceInfo.TYPE_HDMI -> "hdmi"
else -> if (isBluetoothLe()) "bluetoothLe" else "unknown"
}
private fun AudioDeviceInfo.isBluetoothLe(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
return type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
type == AudioDeviceInfo.TYPE_BLE_SPEAKER ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
type == AudioDeviceInfo.TYPE_BLE_BROADCAST)
}
private fun AudioDeviceInfo.logLabel(): String =
"id=$id type=${normalizedType()} product=${productName?.toString()?.trim().orEmpty()}"
private data class CommunicationDeviceIds(
val selectedId: String?,
val availableIds: Set<String>,
val availableTypes: Set<Int>,
)
private companion object {
const val TAG = "ChanoraAudioOutput"
}
}
@@ -10,6 +10,7 @@ import app.chanora.chanora_flutter.BackIntentBridge
import app.chanora.chanora_flutter.MethodChannels
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
/**
@@ -71,6 +72,9 @@ class MainActivity : FlutterActivity() {
// SDD-106: Retained so onResume / onDestroy can forward state changes
// to Dart on MethodChannels.ANDROID_PERMISSIONS.
private var permissionsChannel: MethodChannel? = null
private var audioOutputChannel: MethodChannel? = null
private var audioOutputEvents: EventChannel? = null
private var audioOutputController: AndroidAudioOutputController? = null
// SDD-028 (M-3 strict-review fix): the back-intent bridge is owned
// by this Activity instance, not a process-wide `object`. Constructed
@@ -183,6 +187,21 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented()
}
}
val audioOutputController = AndroidAudioOutputController(applicationContext)
this.audioOutputController = audioOutputController
val audioOutputChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MethodChannels.AUDIO_OUTPUT,
)
this.audioOutputChannel = audioOutputChannel
audioOutputChannel.setMethodCallHandler(audioOutputController)
val audioOutputEvents = EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
MethodChannels.AUDIO_OUTPUT_EVENTS,
)
this.audioOutputEvents = audioOutputEvents
audioOutputEvents.setStreamHandler(audioOutputController)
}
override fun onResume() {
@@ -230,8 +249,14 @@ class MainActivity : FlutterActivity() {
// channel so a late invokeMethod from Dart cannot land on a
// dangling requester reference.
permissionsChannel?.setMethodCallHandler(null)
audioOutputChannel?.setMethodCallHandler(null)
audioOutputEvents?.setStreamHandler(null)
audioOutputController?.detach()
permissionRequester = null
permissionsChannel = null
audioOutputChannel = null
audioOutputEvents = null
audioOutputController = null
super.onDestroy()
}
}
@@ -64,4 +64,11 @@ internal object MethodChannels {
* Trace: SDD-028 §1. X-2 strict-review fix.
*/
const val METHOD_POP_TO_SYSTEM: String = "popToSystem"
const val AUDIO_OUTPUT: String = "app.audio_output"
const val AUDIO_OUTPUT_EVENTS: String = "app.audio_output/events"
const val METHOD_GET_OUTPUT_DEVICES: String = "getOutputDevices"
const val METHOD_GET_COMMUNICATION_DEVICES: String = "getCommunicationDevices"
const val METHOD_SET_COMMUNICATION_DEVICE: String = "setCommunicationDevice"
const val METHOD_CLEAR_COMMUNICATION_DEVICE: String = "clearCommunicationDevice"
}