Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions android/app/src/main/java/com/js8call/example/ui/AudioDevices.kt
Original file line number Diff line number Diff line change
@@ -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<Device> {
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<Device>()
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>): 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
}
141 changes: 13 additions & 128 deletions android/app/src/main/java/com/js8call/example/ui/MonitorFragment.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -47,11 +43,9 @@ class MonitorFragment : Fragment() {
private lateinit var monitorVersionText: TextView

// Audio device management
private var audioDeviceAdapter: ArrayAdapter<AudioDeviceItem>? = null
private var availableDevices = mutableListOf<AudioDeviceItem>()
private var isUpdatingSpinner = false
private var audioDeviceAdapter: ArrayAdapter<AudioDevices.Device>? = null
private var availableDevices = mutableListOf<AudioDevices.Device>()
private var userInitiatedAudioSelection = false
private var lastSelectedAudioDeviceId = -1

// Frequency management
// Spinner position last applied programmatically; onItemSelected skips it
Expand Down Expand Up @@ -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()) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -346,111 +324,29 @@ 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()
}
}

override fun onNothingSelected(parent: AdapterView<*>?) {
// 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) {
Expand Down Expand Up @@ -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"
}
}
Loading