diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index c57271c2..620afd93 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -31,6 +31,7 @@ import com.js8call.core.TruSdxDirectSerial import com.js8call.core.UsbSerialBridge import com.js8call.core.UsbSerialPortCatalog import com.js8call.example.MainActivity +import com.js8call.example.ui.AudioDevices import com.js8call.example.MessageLogWriter import com.js8call.example.R import com.js8call.example.BuildConfig @@ -622,9 +623,9 @@ class JS8EngineService : Service() { if (rigControlMode == "trusdx_serial") { Log.i(TAG, "TruSDX mode active: skipping microphone capture") val label = if (selectedAudioDeviceId == TRUSDX_AUDIO_SPEAKER_ID) { - "TruSDX Speaker" + TRUSDX_AUDIO_SPEAKER_NAME } else { - "TruSDX Serial" + TRUSDX_AUDIO_SERIAL_NAME } broadcastAudioDevice(label) broadcastEngineState(STATE_RUNNING) @@ -1853,21 +1854,7 @@ class JS8EngineService : Service() { } private fun getDeviceName(device: AudioDeviceInfo): String { - return when (device.type) { - AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Internal Microphone" - AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset" - AudioDeviceInfo.TYPE_USB_DEVICE -> { - // Try to get product name for USB devices - device.productName?.toString() ?: "USB Audio Device" - } - AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB Audio Accessory" - AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset" - AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset" - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "Bluetooth Audio" - AudioDeviceInfo.TYPE_LINE_ANALOG -> "Line Input" - AudioDeviceInfo.TYPE_LINE_DIGITAL -> "Digital Line Input" - else -> "Unknown Device" - } + return AudioDevices.nameFor(device) ?: "Unknown Device" } private fun switchAudioDevice(deviceId: Int) { @@ -1885,7 +1872,7 @@ class JS8EngineService : Service() { if (!ok) { broadcastError("Failed to update TruSDX speaker mode") } - val label = if (speakerEnabled) "TruSDX Speaker" else "TruSDX Serial" + val label = if (speakerEnabled) TRUSDX_AUDIO_SPEAKER_NAME else TRUSDX_AUDIO_SERIAL_NAME broadcastAudioDevice(label) return } @@ -4076,6 +4063,8 @@ class JS8EngineService : Service() { const val TRUSDX_TX_SAMPLE_RATE_HZ = 11520 const val TRUSDX_AUDIO_SERIAL_ID = -2001 const val TRUSDX_AUDIO_SPEAKER_ID = -2002 + const val TRUSDX_AUDIO_SERIAL_NAME = "TruSDX Serial" + const val TRUSDX_AUDIO_SPEAKER_NAME = "TruSDX Speaker" private const val TRUSDX_RX_FRAME_QUEUE_MAX = 512 private const val TRUSDX_RX_WATCHDOG_INTERVAL_MS = 1200L private const val TRUSDX_RX_STALL_REARM_NS = 2_000_000_000L diff --git a/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt b/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt new file mode 100644 index 00000000..5bcc3b71 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt @@ -0,0 +1,107 @@ +package com.js8call.example.ui + +import android.content.Context +import android.content.Intent +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Build +import androidx.preference.PreferenceManager +import com.js8call.example.service.JS8EngineService + +/** + * The capture inputs the engine can listen on. The list, the stored selection + * and the live switch live together here; screens only render the choice. + */ +object AudioDevices { + + data class Device(val id: Int, val name: String) { + override fun toString(): String = name + } + + /** True when rig audio arrives over the serial link instead of a microphone. */ + fun usesSerialAudio(context: Context): Boolean = + PreferenceManager.getDefaultSharedPreferences(context) + .getString("rig_type", "none") == "trusdx_serial" + + /** The display name for a capture device, or null for types not offered. */ + fun nameFor(device: AudioDeviceInfo): String? = when (device.type) { + AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Internal Microphone" + AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset" + AudioDeviceInfo.TYPE_USB_DEVICE -> device.productName?.toString() ?: "USB Audio Device" + AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB Audio Accessory" + AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset" + AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset" + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "Bluetooth Audio" + AudioDeviceInfo.TYPE_LINE_ANALOG -> "Line Input" + AudioDeviceInfo.TYPE_LINE_DIGITAL -> "Digital Line Input" + else -> null + } + + /** + * Inputs available right now, in the order they are offered. + * + * A TruSDX rig replaces the list: its audio arrives over the serial link, + * so the phone's own inputs cannot carry it. + */ + fun list(context: Context): List { + if (usesSerialAudio(context)) { + return listOf( + Device(JS8EngineService.TRUSDX_AUDIO_SERIAL_ID, JS8EngineService.TRUSDX_AUDIO_SERIAL_NAME), + Device(JS8EngineService.TRUSDX_AUDIO_SPEAKER_ID, JS8EngineService.TRUSDX_AUDIO_SPEAKER_NAME) + ) + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return listOf(Device(DEFAULT_DEVICE_ID, "Default Microphone")) + } + + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val devices = mutableListOf() + for (device in audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + if (!device.isSource) continue + val name = nameFor(device) ?: continue + devices.add(Device(device.id, name)) + } + + if (devices.isEmpty()) { + devices.add(Device(DEFAULT_DEVICE_ID, "Default Microphone")) + } + return devices + } + + /** + * The device the engine will capture from. A saved device that has since + * been unplugged falls back to the first one available. + */ + fun selected(context: Context): Device = selected(context, list(context)) + + /** The same resolution against a list the caller already holds. */ + fun selected(context: Context, devices: List): Device { + val savedId = PreferenceManager.getDefaultSharedPreferences(context) + .getInt(PREF_SELECTED_ID, DEFAULT_DEVICE_ID) + return devices.firstOrNull { it.id == savedId } ?: devices.first() + } + + /** + * Remember the choice, and move a live capture onto it. Returns true when + * a switch request was dispatched; the service ignores a request for the + * device it is already capturing on, since only it knows the active one. + */ + fun select(context: Context, device: Device, engineRunning: Boolean): Boolean { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + if (prefs.getInt(PREF_SELECTED_ID, DEFAULT_DEVICE_ID) != device.id) { + prefs.edit().putInt(PREF_SELECTED_ID, device.id).apply() + } + + if (!engineRunning) return false + val intent = Intent(context, JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_SWITCH_AUDIO_DEVICE + putExtra(JS8EngineService.EXTRA_AUDIO_DEVICE_ID, device.id) + } + context.startService(intent) + return true + } + + private const val PREF_SELECTED_ID = "last_audio_device_id" + private const val DEFAULT_DEVICE_ID = -1 +} diff --git a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt index 77f28224..8ccd73cb 100644 --- a/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt +++ b/android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt @@ -1,12 +1,8 @@ package com.js8call.example.ui import android.Manifest -import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.media.AudioDeviceInfo -import android.media.AudioManager -import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -47,11 +43,9 @@ class MonitorFragment : Fragment() { private lateinit var monitorVersionText: TextView // Audio device management - private var audioDeviceAdapter: ArrayAdapter? = null - private var availableDevices = mutableListOf() - private var isUpdatingSpinner = false + private var audioDeviceAdapter: ArrayAdapter? = null + private var availableDevices = mutableListOf() private var userInitiatedAudioSelection = false - private var lastSelectedAudioDeviceId = -1 // Frequency management // Spinner position last applied programmatically; onItemSelected skips it @@ -233,9 +227,7 @@ class MonitorFragment : Fragment() { } private fun startMonitoring() { - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) - val rigType = prefs.getString("rig_type", "none") - val skipMicPermission = rigType == "trusdx_serial" + val skipMicPermission = AudioDevices.usesSerialAudio(requireContext()) // Check permission if (!skipMicPermission && !hasAudioPermission()) { @@ -249,24 +241,10 @@ class MonitorFragment : Fragment() { // Start service with selected audio device val intent = Intent(requireContext(), JS8EngineService::class.java).apply { action = JS8EngineService.ACTION_START - // Pass selected device ID if any - if (availableDevices.isNotEmpty()) { - val selectedPos = audioDeviceSpinner.selectedItemPosition - if (selectedPos >= 0 && selectedPos < availableDevices.size) { - var selectedDevice = availableDevices[selectedPos] - if (rigType == "trusdx_serial" && - selectedDevice.id != JS8EngineService.TRUSDX_AUDIO_SERIAL_ID && - selectedDevice.id != JS8EngineService.TRUSDX_AUDIO_SPEAKER_ID - ) { - selectedDevice = availableDevices.firstOrNull { - it.id == JS8EngineService.TRUSDX_AUDIO_SERIAL_ID - } ?: selectedDevice - } - putExtra(JS8EngineService.EXTRA_AUDIO_DEVICE_ID, selectedDevice.id) - android.util.Log.d("MonitorFragment", - "Starting with device: ${selectedDevice.name} (ID: ${selectedDevice.id})") - } - } + val device = AudioDevices.selected(requireContext()) + putExtra(JS8EngineService.EXTRA_AUDIO_DEVICE_ID, device.id) + android.util.Log.d("MonitorFragment", + "Starting with device: ${device.name} (ID: ${device.id})") } ContextCompat.startForegroundService(requireContext(), intent) } @@ -346,19 +324,14 @@ class MonitorFragment : Fragment() { val userInitiated = userInitiatedAudioSelection userInitiatedAudioSelection = false if (!userInitiated) return - if (isUpdatingSpinner) return if (position < 0 || position >= availableDevices.size) return val selectedDevice = availableDevices[position] android.util.Log.d("MonitorFragment", "Audio device selected: ${selectedDevice.name} (ID: ${selectedDevice.id})") - // Only switch if engine is running - if (viewModel.isRunning.value == true) { - if (selectedDevice.id == lastSelectedAudioDeviceId) return - lastSelectedAudioDeviceId = selectedDevice.id - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) - prefs.edit().putInt(PREF_LAST_AUDIO_DEVICE_ID, selectedDevice.id).apply() - switchAudioDevice(selectedDevice.id) + // Stored even while stopped, so the next start uses the pick. + if (AudioDevices.select(requireContext(), selectedDevice, viewModel.isRunning.value == true)) { + Snackbar.make(requireView(), "Switching audio device...", Snackbar.LENGTH_SHORT).show() } } @@ -366,91 +339,14 @@ class MonitorFragment : Fragment() { // Do nothing } } - - // Populate with available devices - refreshAudioDevices() } private fun refreshAudioDevices() { - val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(requireContext()) - val rigType = prefs.getString("rig_type", "none") - if (rigType == "trusdx_serial") { - availableDevices.clear() - availableDevices.add(AudioDeviceItem(JS8EngineService.TRUSDX_AUDIO_SERIAL_ID, "TruSDX Serial")) - availableDevices.add(AudioDeviceItem(JS8EngineService.TRUSDX_AUDIO_SPEAKER_ID, "TruSDX Speaker")) - audioDeviceAdapter?.notifyDataSetChanged() - - val savedDeviceId = prefs.getInt(PREF_LAST_AUDIO_DEVICE_ID, JS8EngineService.TRUSDX_AUDIO_SERIAL_ID) - val selectedIndex = availableDevices.indexOfFirst { it.id == savedDeviceId } - .takeIf { it >= 0 } ?: 0 - isUpdatingSpinner = true - audioDeviceSpinner.setSelection(selectedIndex) - isUpdatingSpinner = false - lastSelectedAudioDeviceId = availableDevices[selectedIndex].id - return - } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // Fallback for older versions - availableDevices.clear() - availableDevices.add(AudioDeviceItem(-1, "Default Microphone")) - audioDeviceAdapter?.notifyDataSetChanged() - return - } - - val audioManager = requireContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager - val devices = audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS) - availableDevices.clear() - - for (device in devices) { - if (!device.isSource) continue - val deviceName = when (device.type) { - AudioDeviceInfo.TYPE_BUILTIN_MIC -> "Internal Microphone" - AudioDeviceInfo.TYPE_WIRED_HEADSET -> "Wired Headset" - AudioDeviceInfo.TYPE_USB_DEVICE -> { - device.productName?.toString() ?: "USB Audio Device" - } - AudioDeviceInfo.TYPE_USB_ACCESSORY -> "USB Audio Accessory" - AudioDeviceInfo.TYPE_USB_HEADSET -> "USB Headset" - AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "Bluetooth Headset" - AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "Bluetooth Audio" - AudioDeviceInfo.TYPE_LINE_ANALOG -> "Line Input" - AudioDeviceInfo.TYPE_LINE_DIGITAL -> "Digital Line Input" - else -> continue // Skip unknown types - } - - availableDevices.add(AudioDeviceItem(device.id, deviceName)) - android.util.Log.d("MonitorFragment", "Found audio device: $deviceName (ID: ${device.id})") - } - - // Add default option if no devices found - if (availableDevices.isEmpty()) { - availableDevices.add(AudioDeviceItem(-1, "Default Microphone")) - } - + availableDevices.addAll(AudioDevices.list(requireContext())) audioDeviceAdapter?.notifyDataSetChanged() - - if (availableDevices.isNotEmpty()) { - val savedDeviceId = prefs.getInt(PREF_LAST_AUDIO_DEVICE_ID, -1) - val selectedIndex = availableDevices.indexOfFirst { it.id == savedDeviceId } - .takeIf { it >= 0 } ?: 0 - isUpdatingSpinner = true - audioDeviceSpinner.setSelection(selectedIndex) - isUpdatingSpinner = false - lastSelectedAudioDeviceId = availableDevices[selectedIndex].id - } - } - - private fun switchAudioDevice(deviceId: Int) { - // Send intent to service to switch audio device - val intent = Intent(requireContext(), JS8EngineService::class.java).apply { - action = JS8EngineService.ACTION_SWITCH_AUDIO_DEVICE - putExtra(JS8EngineService.EXTRA_AUDIO_DEVICE_ID, deviceId) - } - requireContext().startService(intent) - - Snackbar.make(requireView(), "Switching audio device...", Snackbar.LENGTH_SHORT).show() + val selected = AudioDevices.selected(requireContext(), availableDevices) + audioDeviceSpinner.setSelection(availableDevices.indexOf(selected)) } private fun updateFrequencyFromRadio(frequencyHz: Long) { @@ -599,18 +495,7 @@ class MonitorFragment : Fragment() { return band in bands } - /** - * Data class for audio device items in spinner. - */ - private data class AudioDeviceItem( - val id: Int, - val name: String - ) { - override fun toString(): String = name - } - companion object { private const val REQUEST_AUDIO_PERMISSION = 1 - private const val PREF_LAST_AUDIO_DEVICE_ID = "last_audio_device_id" } }