Improve Android radio capture lifecycle, VAD and PTT controls
This commit is contained in:
1 parent
52ffb91101
commit
0d44ada922
14 files changed
+839
-385
No files matched your search
@@ -78,6 +78,7 @@ class CallAudioRouter {
|
||||
private val am: AudioManager =
|
||||
ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
|
||||
private var preferWiredHeadset = false
|
||||
private var started = false
|
||||
private var deviceCallback: AudioDeviceCallback? = null
|
||||
private var btHeadset: BluetoothHeadset? = null
|
||||
@@ -121,13 +122,15 @@ class CallAudioRouter {
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "stopped")
|
||||
}
|
||||
|
||||
fun setRoute(r: AudioRoute) {
|
||||
fun setRoute(r: AudioRoute, preferWired: Boolean = false) {
|
||||
if (r == AudioRoute.HEADSET && !headsetAvailable) {
|
||||
LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available")
|
||||
return
|
||||
}
|
||||
autoHeadset = false
|
||||
if (route == r) return
|
||||
val preferenceChanged = preferWiredHeadset != preferWired
|
||||
preferWiredHeadset = preferWired
|
||||
if (route == r && !preferenceChanged) return
|
||||
route = r
|
||||
preferredRoute = r
|
||||
applyRoute(r)
|
||||
@@ -143,7 +146,10 @@ class CallAudioRouter {
|
||||
|
||||
/** Включить/выключить автоследование за гарнитурой (рация включает, звонок — нет). */
|
||||
fun setAutoHeadset(enabled: Boolean) {
|
||||
val resetPreference = enabled && preferWiredHeadset
|
||||
autoHeadset = enabled
|
||||
if (enabled) preferWiredHeadset = false
|
||||
if (resetPreference && started && route == AudioRoute.HEADSET) applyRoute(route)
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled")
|
||||
}
|
||||
|
||||
@@ -183,7 +189,9 @@ class CallAudioRouter {
|
||||
}
|
||||
AudioRoute.HEADSET -> {
|
||||
am.isSpeakerphoneOn = false
|
||||
val bt = findBtScoDevice()
|
||||
val wired = findWiredDevice()
|
||||
val useWired = preferWiredHeadset && wired != null
|
||||
val bt = if (useWired) null else findBtScoDevice()
|
||||
if (bt != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
am.setCommunicationDevice(bt)
|
||||
@@ -192,12 +200,13 @@ class CallAudioRouter {
|
||||
}
|
||||
dev = bt
|
||||
inDev = btInputDevice()
|
||||
} else if (isBtHeadsetProfileConnected()) {
|
||||
} else if (!useWired && isBtHeadsetProfileConnected()) {
|
||||
/* классический BT: SCO ещё не поднят — запускаем и ждём
|
||||
* AudioDeviceCallback, который пере-применит маршрут */
|
||||
startBluetoothSco()
|
||||
} else {
|
||||
val wired = findWiredDevice()
|
||||
stopBluetoothSco()
|
||||
am.isBluetoothScoOn = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) {
|
||||
am.setCommunicationDevice(wired)
|
||||
} else {
|
||||
@@ -214,7 +223,8 @@ class CallAudioRouter {
|
||||
currentInputDevice = inDev
|
||||
onInputDevice?.invoke(inDev)
|
||||
LogManager.addLog("INFO", "CallAudioRouter",
|
||||
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}")
|
||||
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} " +
|
||||
"preferWired=$preferWiredHeadset mode=${am.mode} speaker=${am.isSpeakerphoneOn}")
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}")
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ class ConfigProvider(private val context: Context) {
|
||||
private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
|
||||
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
|
||||
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
|
||||
private val radioVadEnabledKey = intPreferencesKey("radio_vad_enabled")
|
||||
private val radioVadThresholdKey = intPreferencesKey("radio_vad_threshold")
|
||||
private val radioVadHangoverMsKey = intPreferencesKey("radio_vad_hangover_ms")
|
||||
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
|
||||
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
|
||||
private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
|
||||
@@ -141,6 +144,9 @@ class ConfigProvider(private val context: Context) {
|
||||
key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
|
||||
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
|
||||
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1
|
||||
key == "radio.vad_enabled" -> context.dataStore.data.first()[radioVadEnabledKey] ?: 0
|
||||
key == "radio.vad_threshold" -> context.dataStore.data.first()[radioVadThresholdKey] ?: 50
|
||||
key == "radio.vad_hangover_ms" -> context.dataStore.data.first()[radioVadHangoverMsKey] ?: 200
|
||||
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
|
||||
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
|
||||
key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0
|
||||
@@ -178,6 +184,9 @@ class ConfigProvider(private val context: Context) {
|
||||
"chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
|
||||
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
|
||||
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1
|
||||
"radio.vad_enabled" -> prefs[radioVadEnabledKey] = value.toIntOrNull() ?: 0
|
||||
"radio.vad_threshold" -> prefs[radioVadThresholdKey] = value.toIntOrNull() ?: 50
|
||||
"radio.vad_hangover_ms" -> prefs[radioVadHangoverMsKey] = value.toIntOrNull() ?: 200
|
||||
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
|
||||
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
|
||||
"radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0
|
||||
@@ -302,6 +311,8 @@ class ConfigProvider(private val context: Context) {
|
||||
context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled }
|
||||
}
|
||||
|
||||
val radioOverlayEnabled: Flow<Boolean> = context.dataStore.data.map { it[radioOverlayKey] ?: false }
|
||||
|
||||
@Volatile private var radioOverlayCached = false
|
||||
@Volatile private var radioOverlayLoaded = false
|
||||
|
||||
@@ -511,6 +522,9 @@ class ConfigProvider(private val context: Context) {
|
||||
appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb")
|
||||
appendLine("storage_maxsize_gb=$chatMaxGb")
|
||||
appendLine("opus_codec_preset=$chatOpusPreset")
|
||||
appendLine("radio_vad_enabled=${getInt("radio.vad_enabled")}")
|
||||
appendLine("radio_vad_threshold=${getInt("radio.vad_threshold")}")
|
||||
appendLine("radio_vad_hangover_ms=${getInt("radio.vad_hangover_ms")}")
|
||||
appendLine("radio_opus_mode=$radioOpusMode")
|
||||
appendLine("radio_opus_bitrate=$radioOpusBitrate")
|
||||
appendLine("radio_capture_stereo=$radioCaptureStereo")
|
||||
|
||||
@@ -167,6 +167,9 @@ object NativeLib {
|
||||
fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples)
|
||||
fun radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out)
|
||||
fun radioAudioStop() { nativeRadioAudioStop() }
|
||||
fun radioCaptureStart(groupId: Long, channels: Int, vad: Boolean): Boolean = nativeRadioCaptureStart(groupId, channels, vad)
|
||||
fun radioCaptureStop() { nativeRadioCaptureStop() }
|
||||
fun radioTransmitting(): Boolean = nativeRadioTransmitting()
|
||||
fun radioVadMode(): Boolean = nativeRadioVadMode()
|
||||
fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) }
|
||||
fun radioTalkEnd(groupId: Long) { nativeRadioTalkEnd(groupId) }
|
||||
@@ -277,6 +280,9 @@ object NativeLib {
|
||||
private external fun nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean
|
||||
private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray): Int
|
||||
private external fun nativeRadioAudioStop()
|
||||
private external fun nativeRadioCaptureStart(groupId: Long, channels: Int, vad: Boolean): Boolean
|
||||
private external fun nativeRadioCaptureStop()
|
||||
private external fun nativeRadioTransmitting(): Boolean
|
||||
private external fun nativeRadioVadMode(): Boolean
|
||||
private external fun nativeRadioTalkBegin(groupId: Long)
|
||||
private external fun nativeRadioTalkEnd(groupId: Long)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioDeviceInfo
|
||||
@@ -11,6 +14,8 @@ import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import com.utun.chat.ChatApplication
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -20,7 +25,7 @@ import java.util.concurrent.Executors
|
||||
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT).
|
||||
*
|
||||
* Потоки:
|
||||
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (только при зажатом PTT)
|
||||
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (PTT либо активный VAD)
|
||||
* - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write
|
||||
*
|
||||
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека
|
||||
@@ -36,8 +41,8 @@ class RadioAudioEngine {
|
||||
/** Воспроизведение всегда стерео (моно-пиры апмиксуются Opus-декодером). */
|
||||
const val PLAY_CHANNELS = 2
|
||||
|
||||
/** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */
|
||||
const val RELEASE_HOLD_MS = 200L
|
||||
const val RELEASE_HOLD_MS = 250L
|
||||
const val VAD_ACTIVE_MS = 15 * 60 * 1000L
|
||||
|
||||
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
|
||||
@Volatile private var inst: RadioAudioEngine? = null
|
||||
@@ -58,13 +63,26 @@ class RadioAudioEngine {
|
||||
private var playThread: Thread? = null
|
||||
private var prevMode: Int = AudioManager.MODE_NORMAL
|
||||
private var prevSpeaker: Boolean = false
|
||||
private var audioModeOwned = false
|
||||
|
||||
@Volatile private var active = false
|
||||
@Volatile private var groupId = 0L
|
||||
@Volatile private var talking = false
|
||||
@Volatile private var micDead = false
|
||||
@Volatile private var captureRunning = false
|
||||
@Volatile private var playbackRunning = false
|
||||
@Volatile private var requestedTalk = false
|
||||
@Volatile private var talkRequestNs = 0L
|
||||
@Volatile private var generation = 0L
|
||||
private var audioGroup = 0L
|
||||
@Volatile private var vadDeadline = 0L
|
||||
private var vadConfigured = false
|
||||
private var screenReceiver: BroadcastReceiver? = null
|
||||
@Volatile private var screenInteractive = false
|
||||
private var configuredInput = INPUT_AUTO
|
||||
private var configuredMicSource = 0
|
||||
private var configuredStereo = false
|
||||
private var configuredVad = false
|
||||
@Volatile private var vadMode = false /* VAD авто-PTT: фидим PCM постоянно */
|
||||
@Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */
|
||||
|
||||
/** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */
|
||||
val router = CallAudioRouter()
|
||||
@@ -77,13 +95,23 @@ class RadioAudioEngine {
|
||||
Thread(r, "radio-audio-io").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
/* Отложенный FIN после релиза PTT (все talkBegin/talkEnd зовутся с main-потока). */
|
||||
/* Таймер окна VAD; управление ресурсами всегда на io-потоке. */
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val releaseRunnable = Runnable { finishTalk() }
|
||||
private val releaseTalk = Runnable { finishTalk() }
|
||||
private val vadTimeout = Runnable {
|
||||
io.execute {
|
||||
if (active && SystemClock.elapsedRealtime() >= vadDeadline) {
|
||||
LogManager.addLog("INFO", "RadioAudio", "VAD window expired (15 minutes)")
|
||||
updateCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
private val _vadActiveFlow = MutableStateFlow(false)
|
||||
val vadActiveFlow: StateFlow<Boolean> = _vadActiveFlow
|
||||
|
||||
fun isActive(): Boolean = active
|
||||
|
||||
fun isTalking(): Boolean = talking
|
||||
fun isTalking(): Boolean = _talkingFlow.value
|
||||
|
||||
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */
|
||||
fun route(): AudioRoute = router.route
|
||||
@@ -95,61 +123,63 @@ class RadioAudioEngine {
|
||||
fun headsetAvailable(): Boolean = router.headsetAvailable
|
||||
|
||||
/** Ручной выбор выхода (отключает автоследование за гарнитурой). */
|
||||
fun setRoute(r: AudioRoute) = router.setRoute(r)
|
||||
fun setRoute(r: AudioRoute, preferWired: Boolean = false) = router.setRoute(r, preferWired)
|
||||
|
||||
/** Начать прослушивание канала (переключатель рации вкл).
|
||||
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
|
||||
fun start(group: Long) {
|
||||
if (active && groupId == group) return
|
||||
mainHandler.removeCallbacks(releaseTalk)
|
||||
val version = ++generation
|
||||
active = true
|
||||
groupId = group
|
||||
talking = false
|
||||
io.execute { startBlocking(group) }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!active) return
|
||||
active = false
|
||||
talking = false
|
||||
requestedTalk = false
|
||||
_talkingFlow.value = false
|
||||
mainHandler.removeCallbacks(releaseRunnable)
|
||||
io.execute { stopBlocking() }
|
||||
}
|
||||
|
||||
/* ── io-поток (сериализовано): setup/teardown аудио ── */
|
||||
|
||||
private fun startBlocking(group: Long) {
|
||||
if (!active || groupId != group) return
|
||||
|
||||
/* Переключение группы: гасим старое аудио (active=false даёт старым
|
||||
* capture/play-потокам выйти до release, иначе UAF AudioTrack в playLoop). */
|
||||
active = false
|
||||
releaseLocked(false)
|
||||
active = true
|
||||
router.setAutoHeadset(true) /* дефолт «авто» только на свежий старт/смену группы */
|
||||
setupAudio()
|
||||
}
|
||||
|
||||
/* Полный перезапуск аудио на том же канале — применение новых настроек на лету. */
|
||||
fun reconfigure() {
|
||||
val g = groupId
|
||||
if (g == 0L) return
|
||||
io.execute {
|
||||
if (groupId != g) return@execute
|
||||
if (talking) finishTalk()
|
||||
if (active) { active = false; releaseLocked(false) }
|
||||
active = true
|
||||
setupAudio()
|
||||
if (!active || generation != version) return@execute
|
||||
releaseLocked()
|
||||
audioGroup = group
|
||||
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
|
||||
router.setAutoHeadset(true)
|
||||
if (active && generation == version) {
|
||||
try { setupAudio() } catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "audio setup failed: ${e.message}")
|
||||
releaseLocked()
|
||||
if (generation == version) active = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */
|
||||
fun stop() {
|
||||
mainHandler.removeCallbacks(releaseTalk)
|
||||
active = false
|
||||
requestedTalk = false
|
||||
_talkingFlow.value = false
|
||||
++generation
|
||||
io.execute { releaseLocked() }
|
||||
}
|
||||
|
||||
/** Смена настроек перезапускает только захват, сохраняя приём и окно VAD. */
|
||||
fun reconfigure() {
|
||||
val version = generation
|
||||
io.execute {
|
||||
if (!active || generation != version || audioTrack == null) return@execute
|
||||
stopCapture()
|
||||
loadCaptureSettings()
|
||||
updateCapture()
|
||||
}
|
||||
}
|
||||
|
||||
/* Приём создаётся независимо от микрофона (только io-поток). */
|
||||
private fun setupAudio() {
|
||||
val group = groupId
|
||||
loadCaptureSettings()
|
||||
val group = audioGroup
|
||||
val ctx = ChatApplication.instance
|
||||
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
prevMode = am.mode
|
||||
prevSpeaker = am.isSpeakerphoneOn
|
||||
audioModeOwned = true
|
||||
try {
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
} catch (e: Exception) {
|
||||
@@ -160,24 +190,12 @@ class RadioAudioEngine {
|
||||
router.fallbackRoute = AudioRoute.SPEAKER
|
||||
router.start()
|
||||
|
||||
// ── AudioRecord (захват) с откатом стерео→моно ──
|
||||
val (rec, capCh) = createAudioRecord()
|
||||
if (rec == null) {
|
||||
router.stop(); restoreAudioMode(); active = false; return
|
||||
}
|
||||
captureChannels = capCh
|
||||
|
||||
if (!NativeLib.radioAudioStart(group, capCh)) {
|
||||
rec.release()
|
||||
if (!NativeLib.radioAudioStart(group, 0)) {
|
||||
router.stop(); restoreAudioMode(); active = false
|
||||
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh")
|
||||
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
|
||||
return
|
||||
}
|
||||
|
||||
/* VAD авто-PTT: сервисный слой решил, что режим включён — фидим PCM постоянно. */
|
||||
vadMode = NativeLib.radioVadMode()
|
||||
if (vadMode) LogManager.addLog("INFO", "RadioAudio", "VAD auto-PTT enabled group=$group")
|
||||
|
||||
// ── AudioTrack (воспроизведение, всегда стерео) ──
|
||||
val trackMin = AudioTrack.getMinBufferSize(
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
@@ -193,13 +211,10 @@ class RadioAudioEngine {
|
||||
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
|
||||
rec.release()
|
||||
NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return
|
||||
}
|
||||
|
||||
audioRecord = rec
|
||||
audioTrack = track
|
||||
micDead = false
|
||||
|
||||
/* Прямое роутирование трека на выбранное устройство (обход глючной глобальной
|
||||
* маршрутизации), как в CallAudioEngine. */
|
||||
@@ -211,27 +226,33 @@ class RadioAudioEngine {
|
||||
if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure()
|
||||
}
|
||||
|
||||
rec.startRecording()
|
||||
track.play()
|
||||
|
||||
captureThread = Thread { captureLoop() }.apply {
|
||||
priority = Thread.MAX_PRIORITY; start()
|
||||
}
|
||||
playbackRunning = true
|
||||
playThread = Thread { playLoop() }.apply {
|
||||
priority = Thread.MAX_PRIORITY; start()
|
||||
}
|
||||
|
||||
LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf")
|
||||
registerScreenReceiver()
|
||||
updateCapture()
|
||||
LogManager.addLog("INFO", "RadioAudio", "listening group=$group trackBuf=$trackBuf")
|
||||
}
|
||||
|
||||
/* Настройки загружаются при включении/перенастройке, без DataStore в пути PTT. */
|
||||
private fun loadCaptureSettings() {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
configuredInput = provider.getRadioInputSource()
|
||||
configuredMicSource = provider.getRadioMicSource()
|
||||
configuredStereo = provider.getRadioCaptureStereo()
|
||||
configuredVad = provider.getInt("radio.vad_enabled") != 0
|
||||
}
|
||||
|
||||
/* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон
|
||||
* (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC),
|
||||
* авто — системный дефолт. Возвращает (record, каналы). */
|
||||
private fun createAudioRecord(): Pair<AudioRecord?, Int> {
|
||||
val ctx = ChatApplication.instance
|
||||
val inputSource = ctx.configProvider.getRadioInputSource()
|
||||
val pref = ctx.configProvider.getRadioMicSource()
|
||||
val wantStereo = ctx.configProvider.getRadioCaptureStereo()
|
||||
val inputSource = configuredInput
|
||||
val pref = configuredMicSource
|
||||
val wantStereo = configuredStereo
|
||||
|
||||
/* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает»
|
||||
* микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за
|
||||
@@ -272,6 +293,10 @@ class RadioAudioEngine {
|
||||
for (mask in masks) {
|
||||
val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1
|
||||
val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT)
|
||||
if (recMin <= 0) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "unsupported capture format ch=$channels minBuffer=$recMin")
|
||||
continue
|
||||
}
|
||||
val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4)
|
||||
for (src in sources) {
|
||||
val rec = tryCreateRecord(src, bufSize, mask)
|
||||
@@ -298,7 +323,7 @@ class RadioAudioEngine {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}")
|
||||
false
|
||||
}
|
||||
LogManager.addLog("WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok")
|
||||
LogManager.addLog(if (ok) "DEBUG" else "WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok")
|
||||
}
|
||||
|
||||
private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? {
|
||||
@@ -316,89 +341,224 @@ class RadioAudioEngine {
|
||||
return rec
|
||||
}
|
||||
|
||||
private fun stopBlocking() {
|
||||
releaseLocked(true)
|
||||
private fun screenOn(): Boolean =
|
||||
(ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive
|
||||
|
||||
private fun registerScreenReceiver() {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
screenInteractive = intent.action == Intent.ACTION_SCREEN_ON
|
||||
val version = generation
|
||||
io.execute {
|
||||
if (!active || generation != version) return@execute
|
||||
if (intent.action == Intent.ACTION_SCREEN_OFF) {
|
||||
vadDeadline = 0L
|
||||
LogManager.addLog("INFO", "RadioAudio", "screen off: VAD window closed")
|
||||
}
|
||||
if (intent.action == Intent.ACTION_SCREEN_ON) {
|
||||
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
|
||||
LogManager.addLog("INFO", "RadioAudio", "screen on: new VAD window (15 minutes)")
|
||||
}
|
||||
updateCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}
|
||||
val ctx = ChatApplication.instance
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||
ctx.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
else ctx.registerReceiver(receiver, filter)
|
||||
screenReceiver = receiver
|
||||
screenInteractive = screenOn()
|
||||
}
|
||||
|
||||
/* Освобождение аудио-ресурсов (только io-поток): join потоков → release → restore. */
|
||||
private fun releaseLocked(resetGroup: Boolean) {
|
||||
captureThread?.join(1000); captureThread = null
|
||||
playThread?.join(1000); playThread = null
|
||||
/** Выбор захвата: PTT либо включённый VAD в пределах окна при горящем экране. */
|
||||
private fun updateCapture() {
|
||||
mainHandler.removeCallbacks(vadTimeout)
|
||||
val configured = configuredVad
|
||||
if (configured && !vadConfigured) vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
|
||||
vadConfigured = configured
|
||||
val wantVad = active && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline && configured
|
||||
if (wantVad) mainHandler.postDelayed(vadTimeout, (vadDeadline - SystemClock.elapsedRealtime()).coerceAtLeast(1L))
|
||||
if (!active || (!requestedTalk && !wantVad)) {
|
||||
stopCapture()
|
||||
return
|
||||
}
|
||||
if (audioRecord != null && vadMode != wantVad) stopCapture()
|
||||
if (audioTrack == null) return
|
||||
if (audioRecord == null && !startCapture(wantVad)) {
|
||||
requestedTalk = false
|
||||
_talkingFlow.value = false
|
||||
return
|
||||
}
|
||||
if (requestedTalk && !talking) {
|
||||
NativeLib.radioTalkBegin(audioGroup)
|
||||
talking = true
|
||||
_talkingFlow.value = true
|
||||
} else if (!requestedTalk && talking) {
|
||||
NativeLib.radioTalkEnd(audioGroup)
|
||||
talking = false
|
||||
_talkingFlow.value = false
|
||||
}
|
||||
}
|
||||
|
||||
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() }
|
||||
audioRecord = null
|
||||
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() }
|
||||
private fun startCapture(vad: Boolean): Boolean {
|
||||
val startNs = SystemClock.elapsedRealtimeNanos()
|
||||
val (rec, channels) = createAudioRecord()
|
||||
if (rec == null) return false
|
||||
val createdNs = SystemClock.elapsedRealtimeNanos()
|
||||
if (!NativeLib.radioCaptureStart(audioGroup, channels, vad)) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels vad=$vad")
|
||||
rec.release()
|
||||
return false
|
||||
}
|
||||
vadMode = NativeLib.radioVadMode()
|
||||
if (vad && !vadMode) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "VAD unavailable: capture cancelled")
|
||||
NativeLib.radioCaptureStop(); rec.release()
|
||||
return false
|
||||
}
|
||||
val preparedNs = SystemClock.elapsedRealtimeNanos()
|
||||
try {
|
||||
rec.startRecording()
|
||||
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) error("recording did not start")
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "capture start failed: ${e.message}")
|
||||
NativeLib.radioCaptureStop(); rec.release()
|
||||
vadMode = false
|
||||
return false
|
||||
}
|
||||
val recordingNs = SystemClock.elapsedRealtimeNanos()
|
||||
audioRecord = rec
|
||||
captureRunning = true
|
||||
_vadActiveFlow.value = vadMode
|
||||
// BEGIN должен попасть в uasync раньше первого кадра ручной передачи.
|
||||
if (requestedTalk) {
|
||||
NativeLib.radioTalkBegin(audioGroup)
|
||||
talking = true
|
||||
_talkingFlow.value = true
|
||||
}
|
||||
val captureGroup = audioGroup
|
||||
captureThread = Thread {
|
||||
try { captureLoop(rec, captureGroup, channels) }
|
||||
catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "capture failed: ${e.message}") }
|
||||
finally {
|
||||
if (captureRunning) io.execute {
|
||||
if (audioRecord === rec) {
|
||||
requestedTalk = false
|
||||
stopCapture()
|
||||
LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released")
|
||||
}
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
name = "radio-capture"; priority = Thread.MAX_PRIORITY; start()
|
||||
}
|
||||
LogManager.addLog("INFO", "RadioAudio",
|
||||
"capture started group=$audioGroup ch=$channels vad=$vadMode " +
|
||||
"createMs=${(createdNs - startNs) / 1_000_000} prepareMs=${(preparedNs - createdNs) / 1_000_000} " +
|
||||
"startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}")
|
||||
return true
|
||||
}
|
||||
|
||||
private fun stopCapture() {
|
||||
captureRunning = false
|
||||
val rec = audioRecord
|
||||
if (rec != null) {
|
||||
try { rec.stop() } catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "capture stop failed: ${e.message}")
|
||||
}
|
||||
// stop разблокирует read; release только после выхода потока.
|
||||
captureThread?.join()
|
||||
captureThread = null
|
||||
rec.release()
|
||||
audioRecord = null
|
||||
NativeLib.radioCaptureStop()
|
||||
LogManager.addLog("INFO", "RadioAudio", "capture released group=$audioGroup")
|
||||
}
|
||||
vadMode = false
|
||||
talking = false
|
||||
_talkingFlow.value = requestedTalk
|
||||
_vadActiveFlow.value = false
|
||||
}
|
||||
|
||||
private fun releaseLocked() {
|
||||
mainHandler.removeCallbacks(vadTimeout)
|
||||
screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) }
|
||||
screenReceiver = null
|
||||
stopCapture()
|
||||
playbackRunning = false
|
||||
audioTrack?.let { track ->
|
||||
try { track.stop() } catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "playback stop failed: ${e.message}")
|
||||
}
|
||||
playThread?.interrupt()
|
||||
playThread?.join()
|
||||
playThread = null
|
||||
track.release()
|
||||
}
|
||||
audioTrack = null
|
||||
|
||||
NativeLib.radioAudioStop()
|
||||
router.onOutputDevice = null
|
||||
router.onInputDevice = null
|
||||
router.stop()
|
||||
restoreAudioMode()
|
||||
|
||||
if (resetGroup) {
|
||||
groupId = 0L
|
||||
LogManager.addLog("INFO", "RadioAudio", "stopped")
|
||||
}
|
||||
}
|
||||
|
||||
/** PTT зажата — начинаем передачу. */
|
||||
fun talkBegin() {
|
||||
if (!active) return
|
||||
mainHandler.removeCallbacks(releaseRunnable)
|
||||
if (talking) return
|
||||
if (micDead) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)")
|
||||
}
|
||||
talking = true
|
||||
mainHandler.removeCallbacks(releaseTalk)
|
||||
if (requestedTalk) return
|
||||
talkRequestNs = SystemClock.elapsedRealtimeNanos()
|
||||
requestedTalk = true
|
||||
_talkingFlow.value = true
|
||||
NativeLib.radioTalkBegin(groupId)
|
||||
val version = generation
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "PTT requested group=$groupId")
|
||||
io.execute {
|
||||
if (active && generation == version) {
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "PTT io queueMs=${(SystemClock.elapsedRealtimeNanos() - talkRequestNs) / 1_000_000}")
|
||||
updateCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** PTT отпущена — добиваем хвост RELEASE_HOLD_MS, затем завершаем (FIN). */
|
||||
/** Отпускание: 250мс передачи, повторное касание отменяет завершение. */
|
||||
fun talkEnd() {
|
||||
if (!active || !talking) return
|
||||
mainHandler.removeCallbacks(releaseRunnable)
|
||||
mainHandler.postDelayed(releaseRunnable, RELEASE_HOLD_MS)
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "talk release scheduled +${RELEASE_HOLD_MS}ms")
|
||||
}
|
||||
|
||||
/** Немедленное завершение передачи без хвоста (drag кнопки рации). */
|
||||
fun talkEndImmediate() {
|
||||
if (!active || !talking) return
|
||||
mainHandler.removeCallbacks(releaseRunnable)
|
||||
finishTalk()
|
||||
if (!active || !requestedTalk) return
|
||||
mainHandler.removeCallbacks(releaseTalk)
|
||||
mainHandler.postDelayed(releaseTalk, RELEASE_HOLD_MS)
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms")
|
||||
}
|
||||
|
||||
private fun finishTalk() {
|
||||
if (!talking) return
|
||||
talking = false
|
||||
if (!requestedTalk) return
|
||||
requestedTalk = false
|
||||
_talkingFlow.value = false
|
||||
NativeLib.radioTalkEnd(groupId)
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)")
|
||||
val version = generation
|
||||
io.execute { if (active && generation == version) updateCapture() }
|
||||
}
|
||||
|
||||
private fun captureLoop() {
|
||||
val ch = captureChannels
|
||||
private fun captureLoop(rec: AudioRecord, group: Long, ch: Int) {
|
||||
val frame = FRAME_SAMPLES * ch
|
||||
val buf = ShortArray(frame)
|
||||
var frameN = 0L
|
||||
while (active) {
|
||||
val rec = audioRecord ?: break
|
||||
var reportedRequestNs = 0L
|
||||
while (captureRunning) {
|
||||
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
if (active) {
|
||||
micDead = true
|
||||
if (captureRunning) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "capture loop: recordingState=${rec.recordingState}, mic stopped")
|
||||
}
|
||||
break
|
||||
}
|
||||
var read = 0
|
||||
while (read < frame) {
|
||||
if (!active) return
|
||||
if (!captureRunning) return
|
||||
val n = rec.read(buf, read, frame - read)
|
||||
if (n <= 0) {
|
||||
if (active) {
|
||||
micDead = true
|
||||
if (captureRunning) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost")
|
||||
}
|
||||
return
|
||||
@@ -408,11 +568,19 @@ class RadioAudioEngine {
|
||||
/* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */
|
||||
if (frameN < 10 || frameN % 100L == 0L) {
|
||||
LogManager.addLog("DEBUG", "RadioAudio",
|
||||
"cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=${inputSource()} route=${route()}")
|
||||
"cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=$configuredInput route=${route()}")
|
||||
}
|
||||
frameN++
|
||||
if ((talking || vadMode) && !NativeLib.radioAudioFeed(groupId, buf)) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false")
|
||||
val vadAllowed = vadMode && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline
|
||||
if (active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)) {
|
||||
val requestNs = talkRequestNs
|
||||
val fed = NativeLib.radioAudioFeed(group, buf)
|
||||
if (!fed) LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false")
|
||||
if (fed && requestedTalk && requestNs != reportedRequestNs) {
|
||||
reportedRequestNs = requestNs
|
||||
LogManager.addLog("INFO", "RadioAudio", "PTT first frame queued elapsedMs=${(SystemClock.elapsedRealtimeNanos() - requestNs) / 1_000_000}")
|
||||
}
|
||||
if (captureRunning) _talkingFlow.value = requestedTalk || NativeLib.radioTransmitting()
|
||||
}
|
||||
}
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "capture loop done")
|
||||
@@ -432,9 +600,9 @@ class RadioAudioEngine {
|
||||
val silence = ShortArray(frame)
|
||||
val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */
|
||||
var nextNs = System.nanoTime()
|
||||
while (active) {
|
||||
while (playbackRunning) {
|
||||
val track = audioTrack ?: return
|
||||
val n = NativeLib.radioAudioPull(groupId, buf)
|
||||
val n = NativeLib.radioAudioPull(audioGroup, buf)
|
||||
if (n > 0) track.write(buf, 0, n)
|
||||
if (n < frame) track.write(silence, 0, frame - n)
|
||||
|
||||
@@ -452,6 +620,8 @@ class RadioAudioEngine {
|
||||
}
|
||||
|
||||
private fun restoreAudioMode() {
|
||||
if (!audioModeOwned) return
|
||||
audioModeOwned = false
|
||||
try {
|
||||
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
am.isSpeakerphoneOn = prevSpeaker
|
||||
|
||||
+126
-48
@@ -9,6 +9,7 @@ import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.Point
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
@@ -24,16 +25,24 @@ import android.widget.TextView
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.doOnLayout
|
||||
import com.utun.chat.ChatApplication
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop.
|
||||
* Удержание > PTT_HOLD_MS активирует передачу (RadioAudioEngine.talkBegin/talkEnd),
|
||||
* отпускание — завершает. Двойной тап фиксирует передачу (latch) без удержания,
|
||||
* Касание сразу активирует передачу (RadioAudioEngine.talkBegin/talkEnd),
|
||||
* отпускание — завершает с хвостом 250мс. Двойной тап фиксирует передачу (latch) без удержания,
|
||||
* повторный двойной тап или drag — отбой. Работает пока включена опция
|
||||
* «PTT поверх приложений» и разрешено «поверх других приложений» (SYSTEM_ALERT_WINDOW).
|
||||
* При включённом VAD сервис сохраняет доступ к микрофону без плавающей кнопки.
|
||||
*/
|
||||
class RadioOverlayService : Service() {
|
||||
|
||||
@@ -43,7 +52,16 @@ class RadioOverlayService : Service() {
|
||||
const val COLOR_IDLE = 0xFF81C784.toInt()
|
||||
const val COLOR_TALKING = 0xFFE53935.toInt()
|
||||
const val DRAG_LOCK_MS = 300L
|
||||
const val PTT_HOLD_MS = 250L
|
||||
|
||||
private var defaultCenter: Point? = null
|
||||
private var currentService: RadioOverlayService? = null
|
||||
|
||||
// Координаты центра обычной PTT в пикселях экрана; обновляются из layout чата.
|
||||
fun setDefaultCenter(x: Int, y: Int) {
|
||||
if (defaultCenter?.x == x && defaultCenter?.y == y) return
|
||||
defaultCenter = Point(x, y)
|
||||
currentService?.placeAtDefaultCenter()
|
||||
}
|
||||
|
||||
private val _isShown = MutableStateFlow(false)
|
||||
val isShown: StateFlow<Boolean> = _isShown
|
||||
@@ -51,12 +69,20 @@ class RadioOverlayService : Service() {
|
||||
/* Гонка start/stop: stop() до вызова startForeground() роняет FGS.
|
||||
* Пока сервис стартует (starting) — взводим pendingStop, а onCreate
|
||||
* после startForeground() сам вызовет stopSelf(). */
|
||||
@Volatile private var running = false
|
||||
@Volatile private var starting = false
|
||||
@Volatile private var pendingStop = false
|
||||
|
||||
fun canDrawOverlays(ctx: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
|
||||
|
||||
fun sync(ctx: Context) {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
if (RadioAudioEngine.instance().isActive() &&
|
||||
(provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0)) start(ctx)
|
||||
else stop(ctx)
|
||||
}
|
||||
|
||||
fun start(ctx: Context) {
|
||||
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.start isShown=${_isShown.value} t=${System.currentTimeMillis()}")
|
||||
pendingStop = false
|
||||
@@ -66,13 +92,16 @@ class RadioOverlayService : Service() {
|
||||
LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted")
|
||||
return
|
||||
}
|
||||
if (starting || _isShown.value) return
|
||||
starting = true
|
||||
if (starting) return
|
||||
starting = !running
|
||||
val i = Intent(ctx, RadioOverlayService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
ctx.startForegroundService(i)
|
||||
else
|
||||
ctx.startService(i)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
|
||||
else ctx.startService(i)
|
||||
} catch (e: Exception) {
|
||||
starting = false
|
||||
LogManager.addLog("ERROR", "RadioOverlay", "service start failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
@@ -89,6 +118,9 @@ class RadioOverlayService : Service() {
|
||||
private var overlayView: View? = null
|
||||
private var params: WindowManager.LayoutParams? = null
|
||||
private var talking = false
|
||||
private var positionDragged = false
|
||||
private var positioningPending = false
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@@ -103,14 +135,12 @@ class RadioOverlayService : Service() {
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
}
|
||||
|
||||
running = true
|
||||
currentService = this
|
||||
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
|
||||
if (!canDrawOverlays(this)) {
|
||||
LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping")
|
||||
starting = false
|
||||
stopSelf()
|
||||
return
|
||||
serviceScope.launch {
|
||||
RadioAudioEngine.instance().talkingFlow.collect { updateButtonColor() }
|
||||
}
|
||||
addOverlay()
|
||||
starting = false
|
||||
if (pendingStop) {
|
||||
pendingStop = false
|
||||
@@ -118,6 +148,25 @@ class RadioOverlayService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (pendingStop || !RadioAudioEngine.instance().isActive()) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val wantOverlay = provider.getRadioOverlay() && canDrawOverlays(this)
|
||||
if (wantOverlay && overlayView == null) addOverlay()
|
||||
if (!wantOverlay && overlayView != null) {
|
||||
endTalk()
|
||||
windowManager?.removeView(overlayView)
|
||||
overlayView = null
|
||||
_isShown.value = false
|
||||
}
|
||||
if (!wantOverlay && provider.getInt("radio.vad_enabled") == 0) stopSelf()
|
||||
LogManager.addLog("INFO", "RadioOverlay", "service active overlay=$wantOverlay vad=${provider.getInt("radio.vad_enabled")}")
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val ch = NotificationChannel(CHANNEL_ID, "Рация PTT", NotificationManager.IMPORTANCE_LOW).apply {
|
||||
@@ -130,7 +179,7 @@ class RadioOverlayService : Service() {
|
||||
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
|
||||
.setContentTitle("Рация активна")
|
||||
.setContentText("PTT-кнопка поверх приложений")
|
||||
.setContentText("Рация: PTT / автоматическая передача по голосу")
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
@@ -146,7 +195,7 @@ class RadioOverlayService : Service() {
|
||||
}
|
||||
}
|
||||
val touchSlop = (ViewConfiguration.get(this).scaledTouchSlop * 2.5f).toInt()
|
||||
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
|
||||
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong().coerceAtMost(RadioAudioEngine.RELEASE_HOLD_MS)
|
||||
val lp = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
@@ -158,8 +207,9 @@ class RadioOverlayService : Service() {
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
width = dp(67); height = dp(67)
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
x = 16; y = 200
|
||||
gravity = Gravity.TOP or Gravity.LEFT
|
||||
x = defaultCenter?.let { it.x - width / 2 } ?: ((resources.displayMetrics.widthPixels - width) / 2)
|
||||
y = defaultCenter?.let { it.y - height / 2 } ?: dp(200)
|
||||
}
|
||||
v.setOnTouchListener(object : View.OnTouchListener {
|
||||
private var startX = 0; private var startY = 0
|
||||
@@ -168,7 +218,8 @@ class RadioOverlayService : Service() {
|
||||
private var dragAllowed = true
|
||||
private var latched = false
|
||||
private var lastTapUpTime = 0L
|
||||
private val talkBeginRunnable = Runnable { beginTalk() }
|
||||
private var wasLatched = false
|
||||
private var secondTap = false
|
||||
|
||||
override fun onTouch(view: View, event: MotionEvent): Boolean {
|
||||
when (event.action) {
|
||||
@@ -179,8 +230,13 @@ class RadioOverlayService : Service() {
|
||||
touchY = event.rawY
|
||||
dragging = false
|
||||
dragAllowed = true
|
||||
view.removeCallbacks(talkBeginRunnable)
|
||||
view.postDelayed(talkBeginRunnable, PTT_HOLD_MS)
|
||||
wasLatched = latched
|
||||
secondTap = lastTapUpTime != 0L && event.eventTime - lastTapUpTime <= doubleTapTimeout
|
||||
if (wasLatched) {
|
||||
latched = false
|
||||
lastTapUpTime = 0L
|
||||
endTalk()
|
||||
} else beginTalk()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
@@ -193,9 +249,10 @@ class RadioOverlayService : Service() {
|
||||
dragAllowed = false
|
||||
} else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) {
|
||||
dragging = true
|
||||
view.removeCallbacks(talkBeginRunnable)
|
||||
positionDragged = true
|
||||
latched = false
|
||||
endTalkImmediate()
|
||||
lastTapUpTime = 0L
|
||||
endTalk()
|
||||
lp.x = startX + (event.rawX - touchX).toInt()
|
||||
lp.y = startY + (event.rawY - touchY).toInt()
|
||||
wm.updateViewLayout(v, lp)
|
||||
@@ -204,26 +261,20 @@ class RadioOverlayService : Service() {
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
view.removeCallbacks(talkBeginRunnable)
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val isDouble = lastTapUpTime != 0L && (now - lastTapUpTime) <= doubleTapTimeout
|
||||
if (latched) {
|
||||
latched = false
|
||||
lastTapUpTime = 0L
|
||||
endTalk()
|
||||
} else if (isDouble) {
|
||||
val tapped = !dragging && event.eventTime - event.downTime <= RadioAudioEngine.RELEASE_HOLD_MS
|
||||
if (!wasLatched && tapped && secondTap) {
|
||||
latched = true
|
||||
lastTapUpTime = 0L
|
||||
beginTalk()
|
||||
} else {
|
||||
lastTapUpTime = now
|
||||
lastTapUpTime = if (!wasLatched && tapped) event.eventTime else 0L
|
||||
endTalk()
|
||||
}
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
view.removeCallbacks(talkBeginRunnable)
|
||||
if (!latched) endTalk()
|
||||
latched = false
|
||||
lastTapUpTime = 0L
|
||||
endTalk()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -233,46 +284,73 @@ class RadioOverlayService : Service() {
|
||||
wm.addView(v, lp)
|
||||
overlayView = v
|
||||
params = lp
|
||||
positionDragged = false
|
||||
placeAtDefaultCenter()
|
||||
_isShown.value = true
|
||||
LogManager.addLog("INFO", "RadioOverlay", "overlay shown")
|
||||
}
|
||||
|
||||
/* Поправка по фактическим координатам окна учитывает системные отступы оверлея. */
|
||||
private fun placeAtDefaultCenter() {
|
||||
val v = overlayView ?: return
|
||||
if (positionDragged) return
|
||||
if (!v.isLaidOut) {
|
||||
v.doOnLayout { placeAtDefaultCenter() }
|
||||
return
|
||||
}
|
||||
if (positioningPending) return
|
||||
positioningPending = true
|
||||
v.postOnAnimation {
|
||||
positioningPending = false
|
||||
val target = defaultCenter ?: return@postOnAnimation
|
||||
val lp = params ?: return@postOnAnimation
|
||||
if (overlayView !== v || positionDragged) return@postOnAnimation
|
||||
val actual = IntArray(2)
|
||||
v.getLocationOnScreen(actual)
|
||||
val dx = target.x - actual[0] - v.width / 2
|
||||
val dy = target.y - actual[1] - v.height / 2
|
||||
if (dx == 0 && dy == 0) return@postOnAnimation
|
||||
lp.x += dx
|
||||
lp.y += dy
|
||||
try {
|
||||
windowManager?.updateViewLayout(v, lp)
|
||||
LogManager.addLog("DEBUG", "RadioOverlay", "default position center=${target.x},${target.y} x=${lp.x} y=${lp.y}")
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioOverlay", "default position failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun beginTalk() {
|
||||
if (talking) return
|
||||
talking = true
|
||||
updateButtonColor()
|
||||
RadioAudioEngine.instance().talkBegin()
|
||||
updateButtonColor()
|
||||
}
|
||||
|
||||
private fun endTalk() {
|
||||
endTalkInternal(immediate = false)
|
||||
}
|
||||
|
||||
private fun endTalkImmediate() {
|
||||
endTalkInternal(immediate = true)
|
||||
}
|
||||
|
||||
private fun endTalkInternal(immediate: Boolean) {
|
||||
if (!talking) return
|
||||
talking = false
|
||||
RadioAudioEngine.instance().talkEnd()
|
||||
updateButtonColor()
|
||||
if (immediate) RadioAudioEngine.instance().talkEndImmediate()
|
||||
else RadioAudioEngine.instance().talkEnd()
|
||||
}
|
||||
|
||||
private fun updateButtonColor() {
|
||||
(overlayView?.background as? GradientDrawable)?.setColor(
|
||||
if (talking) COLOR_TALKING else COLOR_IDLE
|
||||
if (RadioAudioEngine.instance().talkingFlow.value) COLOR_TALKING else COLOR_IDLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceScope.cancel()
|
||||
endTalk()
|
||||
overlayView?.let { windowManager?.removeView(it) }
|
||||
overlayView = null
|
||||
starting = false
|
||||
running = false
|
||||
if (currentService === this) currentService = null
|
||||
_isShown.value = false
|
||||
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
|
||||
super.onDestroy()
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import android.provider.OpenableColumns
|
||||
import android.view.ViewConfiguration
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
@@ -16,21 +20,34 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AutoMode
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.HeadsetMic
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.AudioRoute
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.Message
|
||||
@@ -44,7 +61,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -70,6 +86,8 @@ fun ChatScreen(
|
||||
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
|
||||
var recordingLevel by remember { mutableStateOf(0f) }
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
/* рация */
|
||||
@@ -173,7 +191,16 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth().onGloballyPositioned { coordinates ->
|
||||
val windowPosition = coordinates.positionInWindow()
|
||||
val screenOrigin = IntArray(2)
|
||||
val windowOrigin = IntArray(2)
|
||||
view.getLocationOnScreen(screenOrigin)
|
||||
view.getLocationInWindow(windowOrigin)
|
||||
RadioOverlayService.setDefaultCenter(
|
||||
(windowPosition.x + coordinates.size.width / 2f + screenOrigin[0] - windowOrigin[0]).toInt(),
|
||||
(windowPosition.y + pttCenterOffset + screenOrigin[1] - windowOrigin[1]).toInt())
|
||||
}) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
@@ -279,9 +306,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/* Круглая PTT-кнопка рации: удержание > PTT_HOLD_MS — говорим, двойной тап —
|
||||
* фиксирует передачу (latch) без удержания, следующий тап/двойной тап — отбой. */
|
||||
private const val PTT_HOLD_MS = 250L
|
||||
/* Передача сразу при касании; двойной тап фиксирует, следующий тап завершает. */
|
||||
|
||||
@Composable
|
||||
private fun RadioPttButton(
|
||||
@@ -290,7 +315,7 @@ private fun RadioPttButton(
|
||||
onTalkBegin: () -> Unit,
|
||||
onTalkEnd: () -> Unit
|
||||
) {
|
||||
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
|
||||
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong().coerceAtMost(RadioAudioEngine.RELEASE_HOLD_MS)
|
||||
var latched by remember { mutableStateOf(false) }
|
||||
var lastTapUp by remember { mutableStateOf(0L) }
|
||||
|
||||
@@ -298,51 +323,46 @@ private fun RadioPttButton(
|
||||
modifier = modifier
|
||||
.size(96.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val downId = down.id
|
||||
down.consume()
|
||||
try {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val downId = down.id
|
||||
down.consume()
|
||||
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout
|
||||
|
||||
/* быстрое отпускание (до PTT_HOLD_MS) — это тап */
|
||||
var tapped = false
|
||||
withTimeoutOrNull(PTT_HOLD_MS) {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val c = ev.changes.firstOrNull { it.id == downId } ?: return@withTimeoutOrNull
|
||||
if (!c.pressed) { tapped = true; return@withTimeoutOrNull }
|
||||
c.consume()
|
||||
}
|
||||
}
|
||||
|
||||
if (tapped) {
|
||||
if (latched) {
|
||||
val wasLatched = latched
|
||||
if (wasLatched) {
|
||||
latched = false
|
||||
lastTapUp = 0L
|
||||
onTalkEnd()
|
||||
} else if (isSecondTap) {
|
||||
latched = true
|
||||
lastTapUp = 0L
|
||||
onTalkBegin()
|
||||
} else {
|
||||
lastTapUp = SystemClock.uptimeMillis()
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else onTalkBegin()
|
||||
|
||||
/* удержание — говорим, пока не отпустят */
|
||||
onTalkBegin()
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val c = ev.changes.firstOrNull { it.id == downId } ?: break
|
||||
c.consume()
|
||||
if (!c.pressed) break
|
||||
var released = false
|
||||
try {
|
||||
while (true) {
|
||||
val ev = awaitPointerEvent()
|
||||
val c = ev.changes.firstOrNull { it.id == downId } ?: break
|
||||
c.consume()
|
||||
if (!c.pressed) { released = true; break }
|
||||
}
|
||||
val upTime = SystemClock.uptimeMillis()
|
||||
val tapped = released && upTime - now <= RadioAudioEngine.RELEASE_HOLD_MS
|
||||
if (!wasLatched && tapped && isSecondTap) {
|
||||
latched = true
|
||||
lastTapUp = 0L
|
||||
} else lastTapUp = if (!wasLatched && tapped) upTime else 0L
|
||||
} finally {
|
||||
if (!wasLatched && !latched) onTalkEnd()
|
||||
}
|
||||
}
|
||||
onTalkEnd()
|
||||
}
|
||||
} finally {
|
||||
latched = false
|
||||
lastTapUp = 0L
|
||||
onTalkEnd()
|
||||
}
|
||||
},
|
||||
shape = CircleShape,
|
||||
@@ -355,7 +375,7 @@ private fun RadioPttButton(
|
||||
}
|
||||
}
|
||||
|
||||
/* Строка аудиоисточников рации: вход (авто/встроенный/проводная/беспроводная) + выход. */
|
||||
/* Единый выбор входа/выхода и переключатель плавающей PTT-кнопки. */
|
||||
@Composable
|
||||
private fun RadioSourceBar(viewModel: ChatViewModel) {
|
||||
val radioAuto by viewModel.radioAuto.collectAsState()
|
||||
@@ -363,61 +383,125 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
|
||||
val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState()
|
||||
val btInputAvailable by viewModel.btInputAvailable.collectAsState()
|
||||
val radioRoute by viewModel.radioRoute.collectAsState()
|
||||
val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val scope = rememberCoroutineScope()
|
||||
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
|
||||
val overlayPermission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (RadioOverlayService.canDrawOverlays(context)) {
|
||||
scope.launch {
|
||||
provider.setRadioOverlay(true)
|
||||
RadioOverlayService.sync(context)
|
||||
LogManager.addLog("INFO", "RadioUI", "floating PTT enabled after permission grant")
|
||||
}
|
||||
} else LogManager.addLog("WARN", "RadioUI", "floating PTT permission not granted")
|
||||
}
|
||||
|
||||
Surface(color = Color(0xFF0F1B22)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioSourceGroupLabel("Вход")
|
||||
RadioSourceChip("Авто", radioAuto, { viewModel.setRadioAuto() })
|
||||
RadioSourceChip("Встроенный", radioInputSource == RadioAudioEngine.INPUT_BUILTIN,
|
||||
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) })
|
||||
if (wiredInputAvailable) {
|
||||
RadioSourceChip("Проводная", radioInputSource == RadioAudioEngine.INPUT_WIRED,
|
||||
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) })
|
||||
Row(
|
||||
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp
|
||||
RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}",
|
||||
radioAuto, centerIcon = currentDeviceIcon) {
|
||||
viewModel.setRadioAuto()
|
||||
}
|
||||
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона",
|
||||
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) {
|
||||
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN)
|
||||
}
|
||||
if (wiredInputAvailable) {
|
||||
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры",
|
||||
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) {
|
||||
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED)
|
||||
}
|
||||
}
|
||||
if (btInputAvailable) {
|
||||
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
|
||||
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
|
||||
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (btInputAvailable) {
|
||||
RadioSourceChip("Беспроводная", radioInputSource == RadioAudioEngine.INPUT_BT,
|
||||
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) })
|
||||
}
|
||||
RadioSourceGroupLabel("Выход")
|
||||
RadioSourceChip("Динамик", radioRoute == AudioRoute.SPEAKER,
|
||||
{ viewModel.setRadioRoute(AudioRoute.SPEAKER) })
|
||||
if (radioHeadsetAvailable) {
|
||||
RadioSourceChip("Гарнитура", radioRoute == AudioRoute.HEADSET,
|
||||
{ viewModel.setRadioRoute(AudioRoute.HEADSET) })
|
||||
VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550))
|
||||
RadioControlButton(null, "Плавающая PTT-кнопка", overlayEnabled, floating = true) {
|
||||
if (!overlayEnabled && !RadioOverlayService.canDrawOverlays(context)) {
|
||||
try {
|
||||
overlayPermission.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:${context.packageName}")))
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioUI", "open overlay permission failed: ${e.message}")
|
||||
}
|
||||
} else scope.launch {
|
||||
provider.setRadioOverlay(!overlayEnabled)
|
||||
RadioOverlayService.sync(context)
|
||||
LogManager.addLog("INFO", "RadioUI", "floating PTT enabled=${provider.getRadioOverlay()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadioSourceChip(
|
||||
label: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit
|
||||
private fun RadioControlButton(
|
||||
icon: ImageVector?, label: String, active: Boolean,
|
||||
centerIcon: ImageVector? = null, bluetooth: Boolean = false, floating: Boolean = false, onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D)
|
||||
IconToggleButton(
|
||||
checked = active,
|
||||
onCheckedChange = { onClick() },
|
||||
modifier = Modifier.size(48.dp),
|
||||
colors = IconButtonDefaults.iconToggleButtonColors(
|
||||
containerColor = Color(0xFF24323D), contentColor = Color(0xFF9BA6AD),
|
||||
checkedContainerColor = Color(0xFF1F6FEB), checkedContentColor = Color.White)
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (active) Color.White else Color(0xFF9BA6AD),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
)
|
||||
if (floating) {
|
||||
RadioFloatingIcon(label)
|
||||
return@IconToggleButton
|
||||
}
|
||||
val background = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D)
|
||||
Box(modifier = Modifier.size(34.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(requireNotNull(icon), contentDescription = label, modifier = Modifier.size(if (centerIcon != null) 32.dp else 28.dp))
|
||||
if (centerIcon != null) {
|
||||
Box(Modifier.size(20.dp).background(background, CircleShape), contentAlignment = Alignment.Center) {
|
||||
Icon(centerIcon, contentDescription = null, modifier = Modifier.size(15.dp))
|
||||
}
|
||||
}
|
||||
if (bluetooth) {
|
||||
Box(Modifier.align(Alignment.BottomEnd).size(16.dp).background(background, CircleShape),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Icon(Icons.Default.Bluetooth, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Кружок и четыре стрелки наружу — плавающая/перемещаемая PTT-кнопка. */
|
||||
@Composable
|
||||
private fun RadioSourceGroupLabel(text: String) {
|
||||
Text(text, color = Color(0xFF5A6A75), fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
private fun RadioFloatingIcon(label: String) {
|
||||
val color = LocalContentColor.current
|
||||
Canvas(Modifier.size(28.dp).semantics { contentDescription = label }) {
|
||||
val c = center
|
||||
val radius = 4.dp.toPx()
|
||||
val stroke = 1.7.dp.toPx()
|
||||
val start = 6.dp.toPx()
|
||||
val end = 13.dp.toPx()
|
||||
val head = 3.dp.toPx()
|
||||
drawCircle(color, radius, c, style = Stroke(stroke))
|
||||
for (direction in listOf(Offset(1f, 0f), Offset(-1f, 0f), Offset(0f, 1f), Offset(0f, -1f))) {
|
||||
val tip = c + direction * end
|
||||
val perpendicular = Offset(-direction.y, direction.x)
|
||||
drawLine(color, c + direction * start, tip, strokeWidth = stroke)
|
||||
drawLine(color, tip, tip - direction * head + perpendicular * head, strokeWidth = stroke)
|
||||
drawLine(color, tip, tip - direction * head - perpendicular * head, strokeWidth = stroke)
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-11
@@ -46,7 +46,7 @@ private val CATEGORIES = listOf(
|
||||
"config", "tun", "routing", "timers", "bgp", "chat",
|
||||
"socket", "control", "dump", "traffic", "debug", "general",
|
||||
"nat", "keepalive", "media", "etcp_route", "etcp_dump",
|
||||
"chat_sync", "member_sync", "proxy", "radio"
|
||||
"chat_sync", "member_sync", "proxy", "radio", "vad"
|
||||
)
|
||||
private val PROTOCOLS = listOf("tcp", "udp")
|
||||
|
||||
@@ -481,6 +481,45 @@ private fun RadioSettingsTab() {
|
||||
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
var vadEnabled by remember { mutableStateOf(provider.getInt("radio.vad_enabled") != 0) }
|
||||
var vadThreshold by remember { mutableStateOf(provider.getInt("radio.vad_threshold")) }
|
||||
var vadHangover by remember { mutableStateOf(provider.getInt("radio.vad_hangover_ms")) }
|
||||
val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState()
|
||||
Text("Автоматическая передача по голосу (VAD)", style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Включить VAD", modifier = Modifier.weight(1f))
|
||||
Switch(checked = vadEnabled, onCheckedChange = { enabled ->
|
||||
vadEnabled = enabled
|
||||
scope.launch {
|
||||
provider.setValue("radio.vad_enabled", if (enabled) "1" else "0")
|
||||
NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0")
|
||||
RadioOverlayService.sync(ChatApplication.instance)
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
}
|
||||
})
|
||||
}
|
||||
Text(if (vadActive) "VAD активен: микрофон включён" else "VAD неактивен",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
Text("Работает 15 минут при включённом экране. При следующем включении экрана запускается автоматически.",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
LabeledSlider("Порог речи:", vadThreshold.toFloat(), 10f..95f, 84, "$vadThreshold%",
|
||||
{ vadThreshold = it.toInt() }, onValueChangeFinished = {
|
||||
scope.launch {
|
||||
provider.setValue("radio.vad_threshold", vadThreshold.toString())
|
||||
NativeLib.setChatSetting("radio_vad_threshold", vadThreshold.toString())
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
}
|
||||
})
|
||||
LabeledSlider("Задержка после тишины:", vadHangover.toFloat(), 50f..2000f, 38, "$vadHangover мс",
|
||||
{ vadHangover = it.toInt() }, onValueChangeFinished = {
|
||||
scope.launch {
|
||||
provider.setValue("radio.vad_hangover_ms", vadHangover.toString())
|
||||
NativeLib.setChatSetting("radio_vad_hangover_ms", vadHangover.toString())
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
}
|
||||
})
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Volume-кнопки PTT ── */
|
||||
var volumePtt by remember { mutableStateOf(provider.getRadioVolumePtt()) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -522,11 +561,10 @@ private fun RadioSettingsTab() {
|
||||
}
|
||||
Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = {
|
||||
overlayEnabled = it
|
||||
scope.launch { provider.setRadioOverlay(it) }
|
||||
if (it && RadioAudioEngine.instance().isActive())
|
||||
RadioOverlayService.start(ChatApplication.instance)
|
||||
else
|
||||
RadioOverlayService.stop(ChatApplication.instance)
|
||||
scope.launch {
|
||||
provider.setRadioOverlay(it)
|
||||
RadioOverlayService.sync(ChatApplication.instance)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -547,8 +585,10 @@ private fun RadioSettingsTab() {
|
||||
micSources.forEachIndexed { i, n ->
|
||||
DropdownMenuItem(text = { Text(n) }, onClick = {
|
||||
micSource = i
|
||||
scope.launch { provider.setRadioMicSource(i) }
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
scope.launch {
|
||||
provider.setRadioMicSource(i)
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
}
|
||||
micExpanded = false
|
||||
})
|
||||
}
|
||||
@@ -567,9 +607,11 @@ private fun RadioSettingsTab() {
|
||||
}
|
||||
Switch(checked = captureStereo, onCheckedChange = {
|
||||
captureStereo = it
|
||||
scope.launch { provider.setRadioCaptureStereo(it) }
|
||||
NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0")
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
scope.launch {
|
||||
provider.setRadioCaptureStereo(it)
|
||||
NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0")
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -898,7 +898,7 @@ class ChatViewModel : ViewModel() {
|
||||
else -> null
|
||||
}
|
||||
if (linkedRoute != null) {
|
||||
radioAudio.setRoute(linkedRoute)
|
||||
radioAudio.setRoute(linkedRoute, preferWired = clamped == RadioAudioEngine.INPUT_WIRED)
|
||||
_radioRoute.value = radioAudio.route()
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -949,10 +949,7 @@ class ChatViewModel : ViewModel() {
|
||||
LogManager.addLog("DEBUG", "RADIO_DBG",
|
||||
"syncRadioOverlay radioOn=$radioOn channel=$radioChannelId overlayEnabled=$overlayEnabled " +
|
||||
"isShown=${RadioOverlayService.isShown.value} t=${System.currentTimeMillis()}")
|
||||
if (radioOn && overlayEnabled)
|
||||
RadioOverlayService.start(ctx)
|
||||
else
|
||||
RadioOverlayService.stop(ctx)
|
||||
RadioOverlayService.sync(ctx)
|
||||
}
|
||||
|
||||
fun radioName(nodeId: Long): String =
|
||||
|
||||
@@ -2444,6 +2444,17 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioSta
|
||||
return (utun_bridge_radio_audio_start((uint64_t)groupId, (int)channels) == 0) ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStart(
|
||||
JNIEnv* env, jobject thiz, jlong groupId, jint channels, jboolean vad) {
|
||||
(void)env; (void)thiz;
|
||||
return radio_audio_capture_start((uint64_t)groupId, (int)channels, vad ? 1 : 0) == 0 ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStop(JNIEnv* env, jobject thiz) {
|
||||
(void)env; (void)thiz;
|
||||
radio_audio_capture_stop();
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioFeed(
|
||||
JNIEnv* env, jobject thiz, jlong groupId, jshortArray samples) {
|
||||
(void)thiz;
|
||||
@@ -2461,6 +2472,11 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioStop(
|
||||
utun_bridge_radio_audio_stop();
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioTransmitting(JNIEnv* env, jobject thiz) {
|
||||
(void)env; (void)thiz;
|
||||
return radio_audio_transmitting() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioVadMode(
|
||||
JNIEnv* env, jobject thiz) {
|
||||
(void)env; (void)thiz;
|
||||
|
||||
Reference in new issue
Block a user