fix(android): expose audio output devices
This commit is contained in:
+245
@@ -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"
|
||||
}
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user