From f4db27b565d3f2784d3250e74ec076d3732ff9d3 Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 20 May 2026 16:26:07 +0900 Subject: [PATCH] fix(android): expose audio output devices --- .../AndroidAudioOutputController.kt | 245 ++++++++++++++++++ .../chanora/chanora_flutter/MainActivity.kt | 25 ++ .../chanora/chanora_flutter/MethodChannels.kt | 7 + 3 files changed, 277 insertions(+) create mode 100644 apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioOutputController.kt diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioOutputController.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioOutputController.kt new file mode 100644 index 0000000..b979f29 --- /dev/null +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/AndroidAudioOutputController.kt @@ -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) { + emitDeviceChanged() + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + 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("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> { + 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> { + 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 = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.availableCommunicationDevices + } else { + emptyList() + } + + private fun AudioDeviceInfo.toDto( + selectedId: String?, + communicationIds: Set, + ): Map { + 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, + val availableTypes: Set, + ) + + private companion object { + const val TAG = "ChanoraAudioOutput" + } +} diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt index 113d530..10e73a6 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MainActivity.kt @@ -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() } } diff --git a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt index 0c91561..98c95f2 100644 --- a/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt +++ b/apps/chanora_flutter/android/app/src/main/kotlin/app/chanora/chanora_flutter/MethodChannels.kt @@ -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" }