From bfe88758cfd9386b54f5ddc40857128265259d4d Mon Sep 17 00:00:00 2001 From: tekstrand Date: Wed, 2 Sep 2026 17:47:37 -0500 Subject: [PATCH 1/2] Moved the audio input list, the stored choice and the live device switch out of the Monitor fragment into a shared AudioDevices object. The fragment keeps its spinner but only renders what the object reports: the list comes from AudioDevices.list, the restored selection from AudioDevices.selected, and a user pick goes through AudioDevices.select, which stores the choice and moves a running capture onto it. The choice is now stored even while the engine is stopped, so a pick made before pressing Start survives the fragment being recreated; before, an unsaved pick silently reverted on a tab switch. Start reads the stored choice instead of the spinner position, which also removes the TruSDX coercion from the start path: with a TruSDX rig the list itself holds only the two TruSDX inputs, so a stale non-TruSDX selection resolves to TruSDX Serial by construction instead of being forced there. The Settings screen offers the same audio choice next, which is what the shared object is for. The extraction absorbed the duplicates around it. The service's getDeviceName carried a verbatim copy of the device name table, so it delegates to AudioDevices.nameFor and the table exists once; the TruSDX display labels became constants beside the ids they name instead of literals in three files; and the trusdx check deciding whether the mic permission applies reads the same predicate the list uses. select skips the store and the dispatch when the choice is unchanged and reports whether it moved a live capture, so the switching snackbar keys off that answer and the fragment dropped its own copy of the selected id. Two leftovers went with it: the isUpdatingSpinner flag guarded programmatic selections that the user-initiated check already absorbs, because setSelection fires its callback only after the flag has been reset, and the refresh at the end of spinner setup duplicated the one onResume always makes. refreshAudioDevices resolves the saved choice against the list it just built instead of enumerating the devices twice, and selected cannot return null because the list is never empty. --- .../example/service/JS8EngineService.kt | 25 +--- .../com/js8call/example/ui/AudioDevices.kt | 106 +++++++++++++ .../com/js8call/example/ui/MonitorFragment.kt | 141 ++---------------- 3 files changed, 126 insertions(+), 146 deletions(-) create mode 100644 android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt 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..cacd3fb3 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt @@ -0,0 +1,106 @@ +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 live switch was dispatched: an unchanged choice is a no-op, and a + * stopped engine reads the saved choice when it next starts. + */ + 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) return false + 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" } } From 477eaf3f7b5c4227aab4b947ce1ac94e82819fad Mon Sep 17 00:00:00 2001 From: tekstrand Date: Sat, 5 Sep 2026 15:04:50 -0500 Subject: [PATCH 2/2] Fixed select() skipping the live switch when the pick matches the saved preference. The saved device and the device the engine is capturing on can differ: a saved input that is unplugged at startup resolves to a fallback without touching the preference, so when it comes back and gets picked, the early return concluded there was nothing to do and the engine stayed on the fallback while the UI showed the pick. The preference comparison now gates only the preference write, and a running engine always gets the switch request, since the service is the only party that knows the active device and already ignores a request for it. --- .../src/main/java/com/js8call/example/ui/AudioDevices.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index cacd3fb3..5bcc3b71 100644 --- a/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt +++ b/android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt @@ -84,13 +84,14 @@ object AudioDevices { /** * Remember the choice, and move a live capture onto it. Returns true when - * a live switch was dispatched: an unchanged choice is a no-op, and a - * stopped engine reads the saved choice when it next starts. + * 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) return false - prefs.edit().putInt(PREF_SELECTED_ID, device.id).apply() + 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 {