Browse Source

call/radio: mic AGC compressor + selectable input device; proximity lock

master
evgeny 3 days ago
parent
commit
556b0bc7b7
  1. 42
      src/call/call_audio.c
  2. 2
      src/chat/chat_setting.c
  3. 107
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  4. 56
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  5. 20
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  6. 67
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt
  7. 58
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  8. 62
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  9. 100
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  10. 79
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

42
src/call/call_audio.c

@ -18,6 +18,7 @@
#include "../../lib/mem.h" #include "../../lib/mem.h"
#include "../../lib/u_async.h" #include "../../lib/u_async.h"
#include "../../lib/speex_aec.h" #include "../../lib/speex_aec.h"
#include "../../lib/audio_compressor.h"
#include <pthread.h> #include <pthread.h>
#include <string.h> #include <string.h>
@ -43,6 +44,7 @@ static struct call_tones* g_tones = NULL;
static speex_aec_t* g_aec = NULL; static speex_aec_t* g_aec = NULL;
static int g_aec_delay_frames = CALL_AEC_DELAY_FRAMES; static int g_aec_delay_frames = CALL_AEC_DELAY_FRAMES;
static int g_aec_enabled = 1; /* по умолчанию: настройка aec_enabled */ static int g_aec_enabled = 1; /* по умолчанию: настройка aec_enabled */
static struct audio_compressor* g_compressor = NULL;
static const struct call_audio_ops g_ops = { static const struct call_audio_ops g_ops = {
.on_media = call_audio_on_media, .on_media = call_audio_on_media,
@ -153,11 +155,31 @@ int call_audio_start(uint64_t call_id) {
} }
} }
/* компрессор микрофона (AGC) — как в рации: выравнивает тихий проводной микрофон
* (HAL не применяет AGC на проводном входе). lookahead=0 — без задержки. */
struct audio_compressor* ac = audio_compressor_create();
if (ac) {
audio_compressor_config_t accfg = {0};
accfg.sample_rate = CALL_AUDIO_SAMPLE_RATE;
accfg.channels = 1;
accfg.block_duration_ms = CALL_AUDIO_FRAME_MS;
accfg.lookback_ms = 200;
accfg.lookahead_ms = 0;
accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "call_compressor_max_gain_db", 15);
accfg.rise_rate_per_sec = (float)chat_setting_get_int(g_inst, "compressor_rise_rate", 2);
accfg.target_level = 0.25f; /* -12 dBFS — как у голосовых сообщений */
audio_compressor_configure(ac, &accfg);
audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "call_compressor_enabled", 1));
} else {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: compressor create failed — call without AGC", CALL_AUDIO_ID);
}
g_encoder = enc; g_encoder = enc;
g_decoder = dec; g_decoder = dec;
g_vj = j; g_vj = j;
g_tones = tones; g_tones = tones;
g_aec = aec; g_aec = aec;
g_compressor = ac;
g_call_id = call_id; g_call_id = call_id;
g_active = 1; g_active = 1;
g_ending = 0; g_ending = 0;
@ -165,8 +187,10 @@ int call_audio_start(uint64_t call_id) {
g_last_media_tb = 0; g_last_media_tb = 0;
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx aec=%d delay=%d", CALL_AUDIO_ID, DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx aec=%d delay=%d compressor=%s gain=%d", CALL_AUDIO_ID,
(unsigned long long)call_id, g_aec ? 1 : 0, g_aec_delay_frames); (unsigned long long)call_id, g_aec ? 1 : 0, g_aec_delay_frames,
ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable",
chat_setting_get_int(g_inst, "call_compressor_max_gain_db", 15));
return 0; return 0;
} }
@ -185,6 +209,7 @@ void call_audio_release(uint64_t call_id) {
struct vj* j = NULL; struct vj* j = NULL;
struct call_tones* tones = NULL; struct call_tones* tones = NULL;
speex_aec_t* aec = NULL; speex_aec_t* aec = NULL;
struct audio_compressor* ac = NULL;
int was_active = 0; int was_active = 0;
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
@ -200,6 +225,7 @@ void call_audio_release(uint64_t call_id) {
j = g_vj; g_vj = NULL; j = g_vj; g_vj = NULL;
tones = g_tones; g_tones = NULL; tones = g_tones; g_tones = NULL;
aec = g_aec; g_aec = NULL; aec = g_aec; g_aec = NULL;
ac = g_compressor; g_compressor = NULL;
} }
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
@ -211,6 +237,7 @@ void call_audio_release(uint64_t call_id) {
if (j) vj_destroy(j); if (j) vj_destroy(j);
if (tones) call_tones_destroy(tones); if (tones) call_tones_destroy(tones);
if (aec) speex_aec_destroy(aec); if (aec) speex_aec_destroy(aec);
if (ac) audio_compressor_destroy(ac);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: released call=0x%016llx", CALL_AUDIO_ID, (unsigned long long)call_id); DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: released call=0x%016llx", CALL_AUDIO_ID, (unsigned long long)call_id);
} }
@ -281,7 +308,18 @@ int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) {
if (speex_aec_process_capture(g_aec, pcm, count, cleaned) == count) if (speex_aec_process_capture(g_aec, pcm, count, cleaned) == count)
tx = cleaned; tx = cleaned;
} }
/* компрессор (AGC): push → кодируем прямо из выхода до clear_output.
* lookahead=0 ⇒ выход ровно count сэмплов. */
if (g_compressor) {
audio_compressor_push(g_compressor, tx, (size_t)count);
const int16_t* out = audio_compressor_output(g_compressor);
if (out && audio_compressor_output_size(g_compressor) >= (size_t)count)
tx = out;
len = opus_codec_encode(g_encoder, tx, count, opus, (int)sizeof(opus)); len = opus_codec_encode(g_encoder, tx, count, opus, (int)sizeof(opus));
audio_compressor_clear_output(g_compressor);
} else {
len = opus_codec_encode(g_encoder, tx, count, opus, (int)sizeof(opus));
}
ua = g_inst ? g_inst->ua : NULL; ua = g_inst ? g_inst->ua : NULL;
inst = g_inst; inst = g_inst;
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);

2
src/chat/chat_setting.c

@ -57,6 +57,8 @@ static const struct chat_setting_def g_setting_defs[] = {
{"whisper_model_path", CHAT_SETTING_STRING, 0, 0, 0}, {"whisper_model_path", CHAT_SETTING_STRING, 0, 0, 0},
{"group_autoconnect", CHAT_SETTING_BOOL, 1, 0, 1}, {"group_autoconnect", CHAT_SETTING_BOOL, 1, 0, 1},
{"aec_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, /* AEC (SpeexDSP) в звонке, desktop GUI */ {"aec_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, /* AEC (SpeexDSP) в звонке, desktop GUI */
{"call_compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1},
{"call_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 100},
}; };
#define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0])) #define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0]))

107
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt

@ -52,6 +52,11 @@ class CallAudioEngine {
@Volatile private var muted = false @Volatile private var muted = false
/* Переключение источника захвата: проводная гарнитура → MIC + wired-устройство,
* иначе VOICE_COMMUNICATION (дефолт). captureLoop сам меняет запись при рассинхроне. */
@Volatile private var wantedWired = false
@Volatile private var wiredActive = false
/* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */ /* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */
@Volatile private var playFramesWritten = 0L @Volatile private var playFramesWritten = 0L
@Volatile private var capFramesRead = 0L @Volatile private var capFramesRead = 0L
@ -87,11 +92,14 @@ class CallAudioEngine {
prevSpeaker = am.isSpeakerphoneOn prevSpeaker = am.isSpeakerphoneOn
try { try {
am.mode = AudioManager.MODE_IN_COMMUNICATION am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = true
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}")
} }
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET,
* вынули → сохранённый маршрут, по умолчанию тихая связь). */
router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE
router.setAutoHeadset(true)
router.start() router.start()
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном /* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном
@ -101,20 +109,10 @@ class CallAudioEngine {
syncAecForRoute(router.route) syncAecForRoute(router.route)
// ── AudioRecord (захват) ── // ── AudioRecord (захват) ──
val recMin = AudioRecord.getMinBufferSize( wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) wantedWired = wiredActive
audioRecord = try { audioRecord = createAudioRecord(wiredActive)
AudioRecord( if (audioRecord == null) {
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed: ${e.message}")
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
}
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized")
audioRecord?.release(); audioRecord = null
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
} }
@ -143,6 +141,11 @@ class CallAudioEngine {
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
applyTrackOutputDevice(router.currentOutputDevice) applyTrackOutputDevice(router.currentOutputDevice)
/* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */
router.onInputDevice = { _ ->
wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null
}
active = true active = true
audioRecord?.startRecording() audioRecord?.startRecording()
audioTrack?.play() audioTrack?.play()
@ -157,7 +160,7 @@ class CallAudioEngine {
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */ /* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */
mainHandler.postDelayed(aecDelayRunnable, 300) mainHandler.postDelayed(aecDelayRunnable, 300)
LogManager.addLog("INFO", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf") LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf")
return true return true
} }
@ -176,6 +179,7 @@ class CallAudioEngine {
NativeLib.callAudioStop() NativeLib.callAudioStop()
router.onOutputDevice = null router.onOutputDevice = null
router.onInputDevice = null
router.stop() router.stop()
restoreAudioMode() restoreAudioMode()
@ -187,14 +191,37 @@ class CallAudioEngine {
private fun captureLoop() { private fun captureLoop() {
val buf = ShortArray(FRAME_SAMPLES) val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES)
while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { var rec = audioRecord
var frame = 0L
var sinceSwap = 0
while (active) {
/* проводная гарнитура появилась/исчезла — меняем источник захвата */
if (wantedWired != wiredActive) {
audioRecord = null /* защита measureAecDelay от UAF на освобождаемой записи */
rec?.apply { try { stop() } catch (_: Exception) {}; release() }
wiredActive = wantedWired
rec = createAudioRecord(wiredActive)
if (rec == null) return
audioRecord = rec
rec.startRecording()
sinceSwap = 0
}
val r = rec ?: return
if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) return
var read = 0 var read = 0
while (read < FRAME_SAMPLES) { while (read < FRAME_SAMPLES) {
if (!active) return if (!active) return
val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return val n = r.read(buf, read, FRAME_SAMPLES - read)
if (n <= 0) return if (n <= 0) return
read += n read += n
} }
/* диагностика: уровень захваченного сигнала (тишина vs звук) */
if (sinceSwap < 10 || frame % 100L == 0L) {
LogManager.addLog("WARN", "CallAudio",
"cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}")
}
sinceSwap++
frame++
val out = if (muted) silence else buf val out = if (muted) silence else buf
capFramesRead += FRAME_SAMPLES capFramesRead += FRAME_SAMPLES
if (!NativeLib.callAudioFeed(callId, out)) { if (!NativeLib.callAudioFeed(callId, out)) {
@ -204,6 +231,50 @@ class CallAudioEngine {
LogManager.addLog("DEBUG", "CallAudio", "capture loop done") LogManager.addLog("DEBUG", "CallAudio", "capture loop done")
} }
/** RMS захваченного кадра в dBFS (0 = полная шкала, ниже = тише; тишина ~ -90). */
private fun rmsDb(buf: ShortArray): Int {
var sum = 0L
for (s in buf) sum += s.toLong() * s.toLong()
val rms = Math.sqrt(sum.toDouble() / buf.size)
return if (rms < 1.0) -120 else (20.0 * Math.log10(rms / 32768.0)).toInt()
}
/** Создать AudioRecord: всегда VOICE_COMMUNICATION (звонковый тракт «запитывает»
* микрофон проводной гарнитуры; AudioSource.MIC на ней даёт тишину). Для проводной
* гарнитуры дополнительно роутим setPreferredDevice на её микрофон. */
private fun createAudioRecord(wired: Boolean): AudioRecord? {
val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val rec = try {
AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed (wired=$wired): ${e.message}")
return null
}
if (rec.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized (wired=$wired)")
rec.release()
return null
}
if (wired) {
val dev = router.wiredInputDevice()
val ok = try {
rec.setPreferredDevice(dev)
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "record setPreferredDevice(wired) threw: ${e.message}")
false
}
LogManager.addLog("WARN", "CallAudio",
"record input -> VOICE_COMMUNICATION dev=${dev?.type ?: "-"} setPreferredDevice=$ok")
} else {
LogManager.addLog("WARN", "CallAudio", "record input -> VOICE_COMMUNICATION (default)")
}
return rec
}
private fun playLoop() { private fun playLoop() {
val buf = ShortArray(FRAME_SAMPLES) val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES)

56
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt

@ -42,6 +42,12 @@ class CallAudioRouter {
@Volatile var hasEarpiece = true @Volatile var hasEarpiece = true
private set private set
/* Доступность входных устройств захвата (для строки аудиоисточников рации). */
@Volatile var wiredInputAvailable = false
private set
@Volatile var btInputAvailable = false
private set
/** Куда откатываться при потере гарнитуры (звонок — EARPIECE, рация — SPEAKER). */ /** Куда откатываться при потере гарнитуры (звонок — EARPIECE, рация — SPEAKER). */
@Volatile var fallbackRoute = AudioRoute.EARPIECE @Volatile var fallbackRoute = AudioRoute.EARPIECE
@ -53,12 +59,22 @@ class CallAudioRouter {
/** Уведомление об изменении маршрута или доступности гарнитуры. */ /** Уведомление об изменении маршрута или доступности гарнитуры. */
var onChanged: ((AudioRoute, Boolean) -> Unit)? = null var onChanged: ((AudioRoute, Boolean) -> Unit)? = null
/** Уведомление об изменении доступности входных устройств (проводная, BT). */
var onInputDevicesChanged: ((Boolean, Boolean) -> Unit)? = null
/** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */ /** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */
var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null
@Volatile var currentOutputDevice: AudioDeviceInfo? = null @Volatile var currentOutputDevice: AudioDeviceInfo? = null
private set private set
/** Целевое устройство захвата (для прямого роутирования AudioRecord через setPreferredDevice).
* non-null только для гарнитуры (проводная/USB/BT-SCO); для динамика/громкой — null (дефолт). */
var onInputDevice: ((AudioDeviceInfo?) -> Unit)? = null
@Volatile var currentInputDevice: AudioDeviceInfo? = null
private set
private val am: AudioManager = private val am: AudioManager =
ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@ -131,10 +147,17 @@ class CallAudioRouter {
LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled") LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled")
} }
/** Немедленно переоценить маршрут (например, после включения автоследования). */
fun reapply() {
if (!started) return
refresh()
}
/* ── применение маршрута ── */ /* ── применение маршрута ── */
private fun applyRoute(r: AudioRoute) { private fun applyRoute(r: AudioRoute) {
try { try {
var dev: AudioDeviceInfo? = null var dev: AudioDeviceInfo? = null
var inDev: AudioDeviceInfo? = null
when (r) { when (r) {
AudioRoute.EARPIECE -> { AudioRoute.EARPIECE -> {
stopBluetoothSco() stopBluetoothSco()
@ -168,6 +191,7 @@ class CallAudioRouter {
startBluetoothSco() startBluetoothSco()
} }
dev = bt dev = bt
inDev = btInputDevice()
} else if (isBtHeadsetProfileConnected()) { } else if (isBtHeadsetProfileConnected()) {
/* классический BT: SCO ещё не поднят — запускаем и ждём /* классический BT: SCO ещё не поднят — запускаем и ждём
* AudioDeviceCallback, который пере-применит маршрут */ * AudioDeviceCallback, который пере-применит маршрут */
@ -181,13 +205,16 @@ class CallAudioRouter {
am.isSpeakerphoneOn = false am.isSpeakerphoneOn = false
} }
dev = wired dev = wired
inDev = wiredInputDevice()
} }
} }
} }
currentOutputDevice = dev currentOutputDevice = dev
onOutputDevice?.invoke(dev) onOutputDevice?.invoke(dev)
currentInputDevice = inDev
onInputDevice?.invoke(inDev)
LogManager.addLog("INFO", "CallAudioRouter", LogManager.addLog("INFO", "CallAudioRouter",
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}") "route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}")
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}") LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}")
} }
@ -203,6 +230,32 @@ class CallAudioRouter {
it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
} }
/* ── входные устройства захвата (для строки аудиоисточников рации) ── */
fun builtinMicDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_MIC
}
fun wiredInputDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull { isWiredType(it.type) }
fun btInputDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_INPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
}
private fun refreshInputDevices() {
val wired = wiredInputDevice() != null
val bt = btInputDevice() != null || isBtHeadsetProfileConnected()
val changed = wired != wiredInputAvailable || bt != btInputAvailable
wiredInputAvailable = wired
btInputAvailable = bt
if (changed) {
LogManager.addLog("INFO", "CallAudioRouter", "inputDevices wired=$wired bt=$bt")
onInputDevicesChanged?.invoke(wired, bt)
}
}
/* ── мониторинг гарнитуры ── */ /* ── мониторинг гарнитуры ── */
private fun onDevicesChanged() { private fun onDevicesChanged() {
/* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут, /* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут,
@ -243,6 +296,7 @@ class CallAudioRouter {
if (now != prev || route == AudioRoute.HEADSET) { if (now != prev || route == AudioRoute.HEADSET) {
onChanged?.invoke(route, now) onChanged?.invoke(route, now)
} }
refreshInputDevices()
} }
private fun isHeadsetConnected(): Boolean = private fun isHeadsetConnected(): Boolean =

20
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt

@ -57,6 +57,7 @@ class ConfigProvider(private val context: Context) {
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt") private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay") private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
private val radioMicSourceKey = intPreferencesKey("radio_mic_source") private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
private val radioInputSourceKey = intPreferencesKey("radio_input_source")
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode") private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate") private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo") private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo")
@ -273,7 +274,7 @@ class ConfigProvider(private val context: Context) {
fun getCallAudioRoute(): AudioRoute = runBlocking { fun getCallAudioRoute(): AudioRoute = runBlocking {
val s = context.dataStore.data.first()[callAudioRouteKey] val s = context.dataStore.data.first()[callAudioRouteKey]
AudioRoute.entries.firstOrNull { it.name == s } ?: AudioRoute.SPEAKER AudioRoute.entries.firstOrNull { it.name == s } ?: AudioRoute.EARPIECE
} }
suspend fun setCallAudioRoute(r: AudioRoute) { suspend fun setCallAudioRoute(r: AudioRoute) {
@ -335,6 +336,23 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source } context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source }
} }
@Volatile private var radioInputSourceCached = 0
@Volatile private var radioInputSourceLoaded = false
fun getRadioInputSource(): Int {
if (!radioInputSourceLoaded) {
radioInputSourceCached = runBlocking { context.dataStore.data.first()[radioInputSourceKey] ?: 0 }
radioInputSourceLoaded = true
}
return radioInputSourceCached
}
suspend fun setRadioInputSource(source: Int) {
radioInputSourceCached = source
radioInputSourceLoaded = true
context.dataStore.edit { prefs -> prefs[radioInputSourceKey] = source }
}
@Volatile private var radioCaptureStereoCached = 0 @Volatile private var radioCaptureStereoCached = 0
@Volatile private var radioCaptureStereoLoaded = false @Volatile private var radioCaptureStereoLoaded = false

67
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ProximityManager.kt

@ -0,0 +1,67 @@
package com.utun.chat.data
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Датчик приближения: отслеживает «поднесли к уху» (near) / «отвели» (far).
*
* Используется во время звонка на разговорном динамике (EARPIECE), чтобы заблокировать
* экран, когда телефон у уха, и разблокировать, когда его отвели. Не требует
* runtime-разрешений. На устройствах без датчика — тихий no-op (WARN в лог).
*/
class ProximityManager(private val context: Context) {
private val sensorManager: SensorManager =
context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
private val _near = MutableStateFlow(false)
val near: StateFlow<Boolean> = _near
private var listener: SensorEventListener? = null
private var started = false
/** Возвращает true, если датчик присутствует и удалось зарегистрировать слушатель. */
val available: Boolean
get() = sensor != null
fun start() {
if (started) return
started = true
val s = sensor
if (s == null) {
LogManager.addLog("WARN", "Proximity", "no proximity sensor on this device")
return
}
val l = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
val nearNow = event.values[0] < event.sensor.maximumRange
if (_near.value != nearNow) {
_near.value = nearNow
LogManager.addLog("INFO", "Proximity",
"${if (nearNow) "NEAR" else "FAR"} v=${event.values[0]} max=${event.sensor.maximumRange}")
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
listener = l
sensorManager.registerListener(l, s, SensorManager.SENSOR_DELAY_NORMAL)
LogManager.addLog("INFO", "Proximity", "started (maxRange=${s.maximumRange})")
}
fun stop() {
if (!started) return
started = false
listener?.let { sensorManager.unregisterListener(it) }
listener = null
_near.value = false
LogManager.addLog("INFO", "Proximity", "stopped")
}
}

58
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt

@ -44,6 +44,12 @@ class RadioAudioEngine {
fun instance(): RadioAudioEngine = inst ?: synchronized(this) { fun instance(): RadioAudioEngine = inst ?: synchronized(this) {
inst ?: RadioAudioEngine().also { inst = it } inst ?: RadioAudioEngine().also { inst = it }
} }
/* Входное устройство захвата (значение radio_input_source). */
const val INPUT_AUTO = 0
const val INPUT_BUILTIN = 1
const val INPUT_WIRED = 2
const val INPUT_BT = 3
} }
@Volatile private var audioRecord: AudioRecord? = null @Volatile private var audioRecord: AudioRecord? = null
@ -82,6 +88,9 @@ class RadioAudioEngine {
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */ /** Текущий маршрут выхода рации (SPEAKER/HEADSET). */
fun route(): AudioRoute = router.route fun route(): AudioRoute = router.route
/** Текущее входное устройство захвата (INPUT_AUTO/BUILTIN/WIRED/BT). */
fun inputSource(): Int = ChatApplication.instance.configProvider.getRadioInputSource()
/** Доступна ли гарнитура (проводная/USB/BT). */ /** Доступна ли гарнитура (проводная/USB/BT). */
fun headsetAvailable(): Boolean = router.headsetAvailable fun headsetAvailable(): Boolean = router.headsetAvailable
@ -197,6 +206,11 @@ class RadioAudioEngine {
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
applyTrackOutputDevice(router.currentOutputDevice) applyTrackOutputDevice(router.currentOutputDevice)
/* AUTO-вход: смена маршрута выхода меняет и входное устройство — пересоздаём захват. */
router.onInputDevice = { _ ->
if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure()
}
rec.startRecording() rec.startRecording()
track.play() track.play()
@ -210,12 +224,28 @@ class RadioAudioEngine {
LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf") LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf")
} }
/* Создать AudioRecord: сначала желаемые каналы (стерео), при неудаче — откат на моно. /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон
* Источник: выбранный в настройках, UNPROCESSED с fallback на MIC. Возвращает (record, каналы). */ * (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC),
* авто — системный дефолт. Возвращает (record, каналы). */
private fun createAudioRecord(): Pair<AudioRecord?, Int> { private fun createAudioRecord(): Pair<AudioRecord?, Int> {
val ctx = ChatApplication.instance val ctx = ChatApplication.instance
val inputSource = ctx.configProvider.getRadioInputSource()
val pref = ctx.configProvider.getRadioMicSource() val pref = ctx.configProvider.getRadioMicSource()
val wantStereo = ctx.configProvider.getRadioCaptureStereo() val wantStereo = ctx.configProvider.getRadioCaptureStereo()
/* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает»
* микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за
* маршрутом выхода (currentInputDevice из applyRoute). */
val autoDev = router.currentInputDevice
val headsetInput = inputSource == INPUT_WIRED || inputSource == INPUT_BT ||
(inputSource == INPUT_AUTO && autoDev != null)
val preferredDev = when (inputSource) {
INPUT_BUILTIN -> router.builtinMicDevice()
INPUT_WIRED -> router.wiredInputDevice()
INPUT_BT -> router.btInputDevice()
else -> autoDev
}
val primary = when (pref) { val primary = when (pref) {
1 -> MediaRecorder.AudioSource.MIC 1 -> MediaRecorder.AudioSource.MIC
2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) 2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
@ -223,7 +253,9 @@ class RadioAudioEngine {
3 -> MediaRecorder.AudioSource.CAMCORDER 3 -> MediaRecorder.AudioSource.CAMCORDER
else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION
} }
val sources = if (wantStereo && pref == 0) { val sources = if (headsetInput) {
intArrayOf(MediaRecorder.AudioSource.VOICE_COMMUNICATION)
} else if (wantStereo && pref == 0) {
/* voice-источник (AEC/NS/beamforming) коллапсирует в моно/один луч — /* voice-источник (AEC/NS/beamforming) коллапсирует в моно/один луч —
* для стерео используем CAMCORDER (тот же тракт, что у видеозаписи). */ * для стерео используем CAMCORDER (тот же тракт, что у видеозаписи). */
LogManager.addLog("WARN", "RadioAudio", "voice source не даёт стерео — использую CAMCORDER") LogManager.addLog("WARN", "RadioAudio", "voice source не даёт стерео — использую CAMCORDER")
@ -233,7 +265,7 @@ class RadioAudioEngine {
} else { } else {
intArrayOf(primary) intArrayOf(primary)
} }
val masks = if (wantStereo) val masks = if (!headsetInput && wantStereo)
intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO) intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO)
else intArrayOf(AudioFormat.CHANNEL_IN_MONO) else intArrayOf(AudioFormat.CHANNEL_IN_MONO)
@ -246,7 +278,9 @@ class RadioAudioEngine {
if (rec != null) { if (rec != null) {
if (wantStereo && channels == 1) if (wantStereo && channels == 1)
LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono") LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono")
LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$src ch=$channels") applyRecordPreferredDevice(rec, preferredDev)
LogManager.addLog("INFO", "RadioAudio",
"AudioRecord source=$src ch=$channels input=$inputSource dev=${preferredDev?.type ?: "default"}")
return rec to channels return rec to channels
} }
} }
@ -254,6 +288,19 @@ class RadioAudioEngine {
return null to 1 return null to 1
} }
/* Прямое роутирование захвата на выбранное входное устройство (обход глючной
* глобальной маршрутизации); null — системный дефолт. */
private fun applyRecordPreferredDevice(rec: AudioRecord, dev: AudioDeviceInfo?) {
if (dev == null) return
val ok = try {
rec.setPreferredDevice(dev)
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}")
false
}
LogManager.addLog("WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok")
}
private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? { private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? {
val rec = try { val rec = try {
AudioRecord(source, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize) AudioRecord(source, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize)
@ -285,6 +332,7 @@ class RadioAudioEngine {
NativeLib.radioAudioStop() NativeLib.radioAudioStop()
router.onOutputDevice = null router.onOutputDevice = null
router.onInputDevice = null
router.stop() router.stop()
restoreAudioMode() restoreAudioMode()

62
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt

@ -1,8 +1,12 @@
package com.utun.chat.ui.screens package com.utun.chat.ui.screens
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -13,26 +17,39 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Headset import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PhoneInTalk import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.utun.chat.data.AudioRoute import com.utun.chat.data.AudioRoute
import com.utun.chat.data.LogManager
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase import com.utun.chat.viewmodel.ChatViewModel.CallPhase
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
private fun fmtDuration(sec: Int): String { private fun fmtDuration(sec: Int): String {
val m = sec / 60 val m = sec / 60
val s = sec % 60 val s = sec % 60
@ -82,12 +99,15 @@ fun CallScreen(
val route by viewModel.audioRoute.collectAsState() val route by viewModel.audioRoute.collectAsState()
val headsetAvailable by viewModel.headsetAvailable.collectAsState() val headsetAvailable by viewModel.headsetAvailable.collectAsState()
val earpieceAvailable by viewModel.earpieceAvailable.collectAsState() val earpieceAvailable by viewModel.earpieceAvailable.collectAsState()
val proximityNear by viewModel.proximityNear.collectAsState()
val phase = st?.phase ?: CallPhase.NONE val phase = st?.phase ?: CallPhase.NONE
val duration = st?.durationMs ?: 0 val duration = st?.durationMs ?: 0
val peerNodeId = st?.peerNodeId ?: 0L val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: "" val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
val locked = phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE && proximityNear
val context = LocalContext.current val context = LocalContext.current
val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
@ -98,6 +118,26 @@ fun CallScreen(
if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET) if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET)
} }
/* Пока телефон у уха (proximity) — глотаем Back, чтобы не свернуть звонок. */
BackHandler(enabled = locked) {}
/* При блокировке скрываем системные панели (иначе статус-бар светится поверх оверлея). */
DisposableEffect(locked) {
val act = context.findActivity()
val controller = act?.let { WindowCompat.getInsetsController(it.window, it.window.decorView) }
if (locked) {
controller?.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller?.hide(WindowInsetsCompat.Type.systemBars())
LogManager.addLog("INFO", "CallScreen", "system bars hidden (proximity lock)")
}
onDispose {
if (locked) {
controller?.show(WindowInsetsCompat.Type.systemBars())
LogManager.addLog("INFO", "CallScreen", "system bars restored (proximity unlock)")
}
}
}
Box( Box(
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)), modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@ -210,5 +250,27 @@ fun CallScreen(
} }
} }
} }
if (locked) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0F1B22))
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) awaitPointerEvent()
}
},
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Filled.Lock, "Экран заблокирован", tint = Color.White, modifier = Modifier.size(48.dp))
Spacer(Modifier.height(16.dp))
Text("Экран заблокирован", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text("Отведите телефон от уха, чтобы разблокировать", color = Color(0xFF9BA6AD), fontSize = 14.sp)
}
}
}
} }
} }

100
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -7,15 +7,16 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material3.* import androidx.compose.material3.*
@ -24,6 +25,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@ -72,8 +74,6 @@ fun ChatScreen(
val radioTalking by viewModel.radioTalking.collectAsState() val radioTalking by viewModel.radioTalking.collectAsState()
val overlayShown by RadioOverlayService.isShown.collectAsState() val overlayShown by RadioOverlayService.isShown.collectAsState()
val localTalking by RadioAudioEngine.instance().talkingFlow.collectAsState() val localTalking by RadioAudioEngine.instance().talkingFlow.collectAsState()
val radioRoute by viewModel.radioRoute.collectAsState()
val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState()
/* звук сообщений канала */ /* звук сообщений канала */
val soundOn by viewModel.soundOn.collectAsState() val soundOn by viewModel.soundOn.collectAsState()
@ -158,6 +158,9 @@ fun ChatScreen(
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") } IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
} }
) )
if (radioActive) {
RadioSourceBar(viewModel)
}
if (radioTalking != null) { if (radioTalking != null) {
Surface(color = MaterialTheme.colorScheme.primaryContainer) { Surface(color = MaterialTheme.colorScheme.primaryContainer) {
Text( Text(
@ -190,33 +193,14 @@ fun ChatScreen(
} }
} }
if (radioActive && !overlayShown) { if (radioActive && !overlayShown) {
Column(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
RadioRouteButton(
active = radioRoute == AudioRoute.SPEAKER,
icon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, "Динамик", tint = Color.White, modifier = Modifier.size(22.dp)) },
onClick = { viewModel.setRadioRoute(AudioRoute.SPEAKER) }
)
if (radioHeadsetAvailable) {
RadioRouteButton(
active = radioRoute == AudioRoute.HEADSET,
icon = { Icon(Icons.Filled.Headset, "Гарнитура", tint = Color.White, modifier = Modifier.size(22.dp)) },
onClick = { viewModel.setRadioRoute(AudioRoute.HEADSET) }
)
}
}
Spacer(Modifier.height(12.dp))
RadioPttButton( RadioPttButton(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
isTalking = localTalking, isTalking = localTalking,
onTalkBegin = { viewModel.radioTalkBegin() }, onTalkBegin = { viewModel.radioTalkBegin() },
onTalkEnd = { viewModel.radioTalkEnd() } onTalkEnd = { viewModel.radioTalkEnd() }
) )
} }
} }
}
InputBar( InputBar(
text = text, text = text,
onTextChange = { text = it }, onTextChange = { text = it },
@ -322,23 +306,69 @@ private fun RadioPttButton(
} }
} }
/* Круглая кнопка выбора выхода рации (динамик/гарнитура). */ /* Строка аудиоисточников рации: вход (авто/встроенный/проводная/беспроводная) + выход. */
@Composable
private fun RadioSourceBar(viewModel: ChatViewModel) {
val radioAuto by viewModel.radioAuto.collectAsState()
val radioInputSource by viewModel.radioInputSource.collectAsState()
val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState()
val btInputAvailable by viewModel.btInputAvailable.collectAsState()
val radioRoute by viewModel.radioRoute.collectAsState()
val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState()
Surface(color = Color(0xFF0F1B22)) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.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) })
}
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) })
}
}
}
}
@Composable @Composable
private fun RadioRouteButton( private fun RadioSourceChip(
label: String,
active: Boolean, active: Boolean,
icon: @Composable () -> Unit,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier.clickable(onClick = onClick),
.size(44.dp) shape = RoundedCornerShape(16.dp),
.clickable(onClick = onClick), color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D)
shape = CircleShape,
color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D),
shadowElevation = 3.dp
) { ) {
Box(contentAlignment = Alignment.Center) { Text(
icon() label,
color = if (active) Color.White else Color(0xFF9BA6AD),
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
} }
} }
@Composable
private fun RadioSourceGroupLabel(text: String) {
Text(text, color = Color(0xFF5A6A75), fontSize = 11.sp, fontWeight = FontWeight.Bold)
} }

79
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -56,6 +56,8 @@ class ChatViewModel : ViewModel() {
val callAudio = CallAudioEngine() val callAudio = CallAudioEngine()
val proximity = ProximityManager(ChatApplication.instance)
val radioAudio = RadioAudioEngine.instance() val radioAudio = RadioAudioEngine.instance()
/* рация: активна ли для текущего канала, кто говорит (src node_id, null = никто) */ /* рация: активна ли для текущего канала, кто говорит (src node_id, null = никто) */
@ -71,6 +73,18 @@ class ChatViewModel : ViewModel() {
private val _radioHeadsetAvailable = MutableStateFlow(false) private val _radioHeadsetAvailable = MutableStateFlow(false)
val radioHeadsetAvailable: StateFlow<Boolean> = _radioHeadsetAvailable val radioHeadsetAvailable: StateFlow<Boolean> = _radioHeadsetAvailable
private val _radioInputSource = MutableStateFlow(RadioAudioEngine.INPUT_AUTO)
val radioInputSource: StateFlow<Int> = _radioInputSource
private val _wiredInputAvailable = MutableStateFlow(false)
val wiredInputAvailable: StateFlow<Boolean> = _wiredInputAvailable
private val _btInputAvailable = MutableStateFlow(false)
val btInputAvailable: StateFlow<Boolean> = _btInputAvailable
private val _radioAuto = MutableStateFlow(false)
val radioAuto: StateFlow<Boolean> = _radioAuto
private var radioToggleCount = 0 private var radioToggleCount = 0
/* рация: активный канал (null = выключена). Одна рация — один канал. */ /* рация: активный канал (null = выключена). Одна рация — один канал. */
@ -91,6 +105,9 @@ class ChatViewModel : ViewModel() {
private val _earpieceAvailable = MutableStateFlow(true) private val _earpieceAvailable = MutableStateFlow(true)
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
private val _proximityNear = MutableStateFlow(false)
val proximityNear: StateFlow<Boolean> = _proximityNear
private val _playbackState = MutableStateFlow(PlaybackState()) private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState val playbackState: StateFlow<PlaybackState> = _playbackState
@ -291,6 +308,20 @@ class ChatViewModel : ViewModel() {
delay(1000) delay(1000)
} }
} }
viewModelScope.launch {
combine(_callState, _audioRoute) { st, route -> st to route }.collect { (st, route) ->
val monitor = st != null && st.phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE
if (monitor) {
proximity.start()
} else {
proximity.stop()
_proximityNear.value = false
}
}
}
viewModelScope.launch {
proximity.near.collect { _proximityNear.value = it }
}
} }
private fun handleEvent(type: Int, data: ByteArray?) { private fun handleEvent(type: Int, data: ByteArray?) {
@ -775,8 +806,16 @@ class ChatViewModel : ViewModel() {
_radioRoute.value = r _radioRoute.value = r
_radioHeadsetAvailable.value = headset _radioHeadsetAvailable.value = headset
} }
radioAudio.router.onInputDevicesChanged = { wired, bt ->
_wiredInputAvailable.value = wired
_btInputAvailable.value = bt
}
_radioRoute.value = radioAudio.route() _radioRoute.value = radioAudio.route()
_radioHeadsetAvailable.value = radioAudio.headsetAvailable() _radioHeadsetAvailable.value = radioAudio.headsetAvailable()
_wiredInputAvailable.value = radioAudio.router.wiredInputAvailable
_btInputAvailable.value = radioAudio.router.btInputAvailable
_radioInputSource.value = radioAudio.inputSource()
_radioAuto.value = _radioInputSource.value == RadioAudioEngine.INPUT_AUTO
} else { } else {
radioChannelId = null radioChannelId = null
NativeLib.radioSetActive(groupId, false) NativeLib.radioSetActive(groupId, false)
@ -784,6 +823,10 @@ class ChatViewModel : ViewModel() {
_radioTalking.value = null _radioTalking.value = null
_radioRoute.value = AudioRoute.SPEAKER _radioRoute.value = AudioRoute.SPEAKER
_radioHeadsetAvailable.value = false _radioHeadsetAvailable.value = false
_wiredInputAvailable.value = false
_btInputAvailable.value = false
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
_radioAuto.value = false
} }
_radioActive.value = next _radioActive.value = next
syncRadioOverlay() syncRadioOverlay()
@ -803,11 +846,43 @@ class ChatViewModel : ViewModel() {
/** Ручной выбор выхода рации (динамик/гарнитура). */ /** Ручной выбор выхода рации (динамик/гарнитура). */
fun setRadioRoute(r: AudioRoute) { fun setRadioRoute(r: AudioRoute) {
if (!_radioActive.value) return if (!_radioActive.value) return
_radioAuto.value = false
radioAudio.setRoute(r) radioAudio.setRoute(r)
_radioRoute.value = radioAudio.route() _radioRoute.value = radioAudio.route()
LogManager.addLog("INFO", "VM", "setRadioRoute -> ${radioAudio.route()}") LogManager.addLog("INFO", "VM", "setRadioRoute -> ${radioAudio.route()}")
} }
/** Ручной выбор входного устройства рации (выключает авто). */
fun setRadioInputSource(src: Int) {
if (!_radioActive.value) return
val clamped = src.coerceIn(RadioAudioEngine.INPUT_AUTO, RadioAudioEngine.INPUT_BT)
if (clamped == _radioInputSource.value) return
_radioAuto.value = false
_radioInputSource.value = clamped
viewModelScope.launch {
ChatApplication.instance.configProvider.setRadioInputSource(clamped)
radioAudio.reconfigure()
}
LogManager.addLog("INFO", "VM", "setRadioInputSource -> $clamped")
}
/** Авто: выход следует за гарнитурой, вход — автовыбор. */
fun setRadioAuto() {
if (!_radioActive.value) return
_radioAuto.value = true
radioAudio.router.setAutoHeadset(true)
radioAudio.router.reapply()
_radioRoute.value = radioAudio.route()
if (_radioInputSource.value != RadioAudioEngine.INPUT_AUTO) {
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
viewModelScope.launch {
ChatApplication.instance.configProvider.setRadioInputSource(RadioAudioEngine.INPUT_AUTO)
radioAudio.reconfigure()
}
}
LogManager.addLog("INFO", "VM", "setRadioAuto -> route=${radioAudio.route()}")
}
/* Полностью выключить рацию (используется при удалении активного канала). */ /* Полностью выключить рацию (используется при удалении активного канала). */
private fun turnRadioOff() { private fun turnRadioOff() {
val chId = radioChannelId ?: return val chId = radioChannelId ?: return
@ -817,6 +892,10 @@ class ChatViewModel : ViewModel() {
_radioTalking.value = null _radioTalking.value = null
_radioRoute.value = AudioRoute.SPEAKER _radioRoute.value = AudioRoute.SPEAKER
_radioHeadsetAvailable.value = false _radioHeadsetAvailable.value = false
_wiredInputAvailable.value = false
_btInputAvailable.value = false
_radioInputSource.value = RadioAudioEngine.INPUT_AUTO
_radioAuto.value = false
radioAudio.stop() radioAudio.stop()
syncRadioOverlay() syncRadioOverlay()
} }

Loading…
Cancel
Save