radio: PTT-радио в каналах (src/radio + chatgui + chatgui-android)

Новый модуль src/radio (radio/radio_audio/radio_headless) — push-to-talk
радио поверх topo-групп, доставка аудио в канал, headless-режим и тесты.
Интеграция в chatgui (ptt_hotkey_manager, radio_audio_engine, радионастройки,
вызовы) и chatgui-android (RadioAudioEngine, RadioOverlayService, UI).
This commit is contained in:
evgeny committed 2026-09-19 17:09:00 +03:00
1 parent 398078a28f
commit b96ed8bd00
69 files changed
+5372 -51

No files matched your search

@@ -5,6 +5,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
@@ -32,6 +33,7 @@ import com.utun.chat.data.InviteData
import com.utun.chat.data.LogManager
import com.utun.chat.data.Message
import com.utun.chat.data.NativeLib
import com.utun.chat.data.RadioAudioEngine
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.components.ActiveCallBanner
import com.utun.chat.ui.screens.*
@@ -140,6 +142,7 @@ class MainActivity : ComponentActivity() {
Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat
Screen.PhotoRecord -> screen = Screen.Chat
Screen.Chat -> { vm.radioOff(); screen = Screen.ChannelList }
Screen.Dm -> { vm.closeDm(); screen = Screen.ChannelList }
Screen.Call -> screen = screenBeforeCall ?: Screen.ChannelList
else -> screen = Screen.ChannelList
@@ -399,4 +402,17 @@ class MainActivity : ComponentActivity() {
super.onStop()
if (NativeLib.isReady()) NativeLib.setActive(false)
}
/** Volume-up (удержание) → PTT рации, при активной рации и включённой опции. */
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP &&
ChatApplication.instance.configProvider.getRadioVolumeUpPtt() &&
RadioAudioEngine.instance().isActive()) {
when (event.action) {
KeyEvent.ACTION_DOWN -> { RadioAudioEngine.instance().talkBegin(); return true }
KeyEvent.ACTION_UP -> { RadioAudioEngine.instance().talkEnd(); return true }
}
}
return super.dispatchKeyEvent(event)
}
}
@@ -53,6 +53,8 @@ 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 radioVolumeUpPttKey = booleanPreferencesKey("radio_volume_up_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@@ -259,6 +261,24 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[callAudioRouteKey] = r.name }
}
/* ── Рация: volume-up PTT и оверлей ── */
fun getRadioVolumeUpPtt(): Boolean = runBlocking {
context.dataStore.data.first()[radioVolumeUpPttKey] ?: false
}
suspend fun setRadioVolumeUpPtt(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[radioVolumeUpPttKey] = enabled }
}
fun getRadioOverlay(): Boolean = runBlocking {
context.dataStore.data.first()[radioOverlayKey] ?: false
}
suspend fun setRadioOverlay(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled }
}
/* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {
@@ -152,6 +152,16 @@ object NativeLib {
fun callAudioPull(callId: Long, out: ShortArray): Int = nativeCallAudioPull(callId, out)
fun callAudioStop() { nativeCallAudioStop() }
/* ── Рация (PTT walkie-talkie) ── */
fun radioSetActive(groupId: Long, on: Boolean) { nativeRadioSetActive(groupId, on) }
fun radioSubscribers(groupId: Long): Int = nativeRadioSubscribers(groupId)
fun radioAudioStart(groupId: Long): Boolean = nativeRadioAudioStart(groupId)
fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples)
fun radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out)
fun radioAudioStop() { nativeRadioAudioStop() }
fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) }
fun radioTalkEnd(groupId: Long) { nativeRadioTalkEnd(groupId) }
/* ── DM (личные сообщения) ── */
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) }
fun dmSend(convId: String, text: String) { nativeDmSend(convId, text) }
@@ -247,6 +257,16 @@ object NativeLib {
private external fun nativeCallAudioPull(callId: Long, out: ShortArray): Int
private external fun nativeCallAudioStop()
/* ── Рация JNI ── */
private external fun nativeRadioSetActive(groupId: Long, on: Boolean)
private external fun nativeRadioSubscribers(groupId: Long): Int
private external fun nativeRadioAudioStart(groupId: Long): Boolean
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 nativeRadioTalkBegin(groupId: Long)
private external fun nativeRadioTalkEnd(groupId: Long)
/* ── DM JNI ── */
private external fun nativeDmStart(nodeId: Long, sourceChId: String, peerName: String)
private external fun nativeDmSend(convId: String, text: String)
@@ -0,0 +1,205 @@
package com.utun.chat.data
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import com.utun.chat.ChatApplication
/**
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT).
*
* Потоки:
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (только при зажатом PTT)
* - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write
*
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека
* в C (radio_audio): per-source jitter + time-stretch + микшер.
*/
class RadioAudioEngine {
companion object {
const val SAMPLE_RATE = 48000
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
@Volatile private var inst: RadioAudioEngine? = null
fun instance(): RadioAudioEngine = inst ?: synchronized(this) {
inst ?: RadioAudioEngine().also { inst = it }
}
}
private var audioRecord: AudioRecord? = null
private var captureThread: Thread? = null
private var audioTrack: AudioTrack? = null
private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false
@Volatile private var active = false
@Volatile private var groupId = 0L
@Volatile private var talking = false
fun isActive(): Boolean = active
fun isTalking(): Boolean = talking
/** Начать прослушивание канала (переключатель рации вкл). */
fun start(group: Long): Boolean {
stop()
if (!NativeLib.radioAudioStart(group)) {
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
return false
}
groupId = group
talking = false
val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode
prevSpeaker = am.isSpeakerphoneOn
try {
am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = true
} catch (e: Exception) {
LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}")
}
// ── AudioRecord (захват) ──
val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
audioRecord = 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", "RadioAudio", "AudioRecord create failed: ${e.message}")
NativeLib.radioAudioStop(); restoreAudioMode(); return false
}
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized")
audioRecord?.release(); audioRecord = null
NativeLib.radioAudioStop(); restoreAudioMode(); return false
}
// ── AudioTrack (воспроизведение) ──
val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
audioTrack = try {
AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(),
AudioFormat.Builder().setSampleRate(SAMPLE_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(),
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
audioRecord?.release(); audioRecord = null
audioTrack = null
NativeLib.radioAudioStop(); restoreAudioMode(); return false
}
active = true
audioRecord?.startRecording()
audioTrack?.play()
captureThread = Thread { captureLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
}
playThread = Thread { playLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
}
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf")
return true
}
fun stop() {
if (!active) return
active = false
talking = false
captureThread?.join(1000); captureThread = null
playThread?.join(1000); playThread = null
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() }
audioRecord = null
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() }
audioTrack = null
NativeLib.radioAudioStop()
restoreAudioMode()
groupId = 0L
LogManager.addLog("INFO", "RadioAudio", "stopped")
}
/** PTT зажата — начинаем передачу. */
fun talkBegin() {
if (!active || talking) return
talking = true
NativeLib.radioTalkBegin(groupId)
}
/** PTT отпущена — завершаем передачу (FIN). */
fun talkEnd() {
if (!active || !talking) return
talking = false
NativeLib.radioTalkEnd(groupId)
}
private fun captureLoop() {
val buf = ShortArray(FRAME_SAMPLES)
while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
var read = 0
while (read < FRAME_SAMPLES) {
if (!active) return
val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return
if (n <= 0) return
read += n
}
if (talking && !NativeLib.radioAudioFeed(groupId, buf)) {
LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false")
}
}
LogManager.addLog("DEBUG", "RadioAudio", "capture loop done")
}
private fun playLoop() {
val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES)
while (active) {
val t0 = System.nanoTime()
val track = audioTrack ?: return
val n = NativeLib.radioAudioPull(groupId, buf)
if (n > 0) track.write(buf, 0, n)
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n)
val elapsedMs = (System.nanoTime() - t0) / 1_000_000
val sleepMs = FRAME_MS - elapsedMs
if (sleepMs > 0) {
try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return }
}
}
LogManager.addLog("DEBUG", "RadioAudio", "play loop done")
}
private fun restoreAudioMode() {
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = prevSpeaker
am.mode = prevMode
} catch (e: Exception) {
LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}")
}
}
}
@@ -0,0 +1,169 @@
package com.utun.chat.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.PixelFormat
import android.os.Build
import android.os.IBinder
import android.provider.Settings
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.TextView
import androidx.core.app.NotificationCompat
import kotlin.math.abs
/**
* Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop.
* Удержание активирует передачу (RadioAudioEngine.talkBegin/talkEnd), отпускание —
* завершает. Работает пока включена опция «PTT поверх приложений» и разрешено
* «поверх других приложений» (SYSTEM_ALERT_WINDOW).
*/
class RadioOverlayService : Service() {
companion object {
const val CHANNEL_ID = "radio_overlay"
const val NOTIF_ID = 1002
fun canDrawOverlays(ctx: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
fun start(ctx: Context) {
val i = Intent(ctx, RadioOverlayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
ctx.startForegroundService(i)
else
ctx.startService(i)
}
fun stop(ctx: Context) {
ctx.stopService(Intent(ctx, RadioOverlayService::class.java))
}
}
private var windowManager: WindowManager? = null
private var overlayView: View? = null
private var params: WindowManager.LayoutParams? = null
private var talking = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannel()
startForeground(NOTIF_ID, buildNotification())
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
if (!canDrawOverlays(this)) {
LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping")
stopSelf()
return
}
addOverlay()
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL_ID, "Рация PTT", NotificationManager.IMPORTANCE_LOW).apply {
description = "Оверлей PTT рации"
}
getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
}
}
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
.setContentTitle("Рация активна")
.setContentText("PTT-кнопка поверх приложений")
.setOngoing(true)
.build()
private fun addOverlay() {
val wm = windowManager ?: return
val v = TextView(this).apply {
text = "\uD83C\uDF99"
textSize = 30f
gravity = android.view.Gravity.CENTER
setBackgroundColor(0xFFE74C3C.toInt())
}
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
@Suppress("DEPRECATION") WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
).apply {
width = dp(96); height = dp(96)
gravity = Gravity.TOP or Gravity.START
x = 16; y = 200
}
v.setOnTouchListener(object : View.OnTouchListener {
private var startX = 0; private var startY = 0
private var touchX = 0f; private var touchY = 0f
private var dragging = false
override fun onTouch(view: View, event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
startX = params?.x ?: lp.x
startY = params?.y ?: lp.y
touchX = event.rawX
touchY = event.rawY
dragging = false
beginTalk()
return true
}
MotionEvent.ACTION_MOVE -> {
if (abs(event.rawX - touchX) > 8 || abs(event.rawY - touchY) > 8) {
dragging = true
endTalk()
lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt()
wm.updateViewLayout(v, lp)
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
endTalk()
return true
}
}
return false
}
})
wm.addView(v, lp)
overlayView = v
params = lp
LogManager.addLog("INFO", "RadioOverlay", "overlay shown")
}
private fun beginTalk() {
if (talking) return
talking = true
RadioAudioEngine.instance().talkBegin()
}
private fun endTalk() {
if (!talking) return
talking = false
RadioAudioEngine.instance().talkEnd()
}
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
override fun onDestroy() {
endTalk()
overlayView?.let { windowManager?.removeView(it) }
overlayView = null
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy()
}
}
@@ -131,6 +131,15 @@ fun CallScreen(
color = Color(0xFF9BA6AD),
fontSize = 16.sp
)
val bgpPath = st?.bgpPath ?: ""
if (bgpPath.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
bgpPath,
color = Color(0xFF9BA6AD),
fontSize = 13.sp
)
}
Spacer(Modifier.height(40.dp))
if (phase == CallPhase.ENDED) {
@@ -6,17 +6,21 @@ import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -57,6 +61,10 @@ fun ChatScreen(
val context = LocalContext.current
val scope = rememberCoroutineScope()
/* рация */
val radioActive by viewModel.radioActive.collectAsState()
val radioTalking by viewModel.radioTalking.collectAsState()
/* attach menu */
var showAttachMenu by remember { mutableStateOf(false) }
@@ -127,29 +135,50 @@ fun ChatScreen(
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
},
actions = {
IconButton(onClick = { viewModel.toggleRadio() }) {
Text(if (radioActive) "\uD83D\uDCFB" else "\uD83D\uDD07", fontSize = 18.sp)
}
IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, "Invite") }
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
}
)
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
state = listState,
reverseLayout = true
) {
item { Spacer(Modifier.height(8.dp)) }
items(messages.reversed()) { msg ->
MessageBubble(msg, playbackState = playbackState,
onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) },
onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) },
onDownloadFile = { msg -> viewModel.downloadAttachment(msg) },
onOpenFile = { msg ->
if (msg.filePath.isNotEmpty()) {
pendingSaveFilePath = msg.filePath
saveFileLauncher.launch(msg.text) /* suggest original filename */
}
},
onOpenImage = { msg -> onViewPhoto(msg) },
onPlayVideo = { msg -> onPlayVideo(msg) })
if (radioTalking != null) {
Surface(color = MaterialTheme.colorScheme.primaryContainer) {
Text(
text = "\uD83C\uDF99 ${viewModel.radioName(radioTalking!!)} говорит",
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true
) {
item { Spacer(Modifier.height(8.dp)) }
items(messages.reversed()) { msg ->
MessageBubble(msg, playbackState = playbackState,
onPlayVoice = { path, durMs -> viewModel.playVoiceMessage(path, durMs) },
onSeekVoice = { path, seekMs, durMs -> viewModel.seekVoice(path, seekMs, durMs) },
onDownloadFile = { msg -> viewModel.downloadAttachment(msg) },
onOpenFile = { msg ->
if (msg.filePath.isNotEmpty()) {
pendingSaveFilePath = msg.filePath
saveFileLauncher.launch(msg.text) /* suggest original filename */
}
},
onOpenImage = { msg -> onViewPhoto(msg) },
onPlayVideo = { msg -> onPlayVideo(msg) })
}
}
if (radioActive) {
RadioPttButton(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
onTalkBegin = { viewModel.radioTalkBegin() },
onTalkEnd = { viewModel.radioTalkEnd() }
)
}
}
InputBar(
@@ -226,3 +255,32 @@ fun ChatScreen(
}
}
}
/* Круглая PTT-кнопка рации: держим — говорим. */
@Composable
private fun RadioPttButton(
modifier: Modifier = Modifier,
onTalkBegin: () -> Unit,
onTalkEnd: () -> Unit
) {
Surface(
modifier = modifier
.size(96.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
onTalkBegin()
tryAwaitRelease()
onTalkEnd()
}
)
},
shape = CircleShape,
color = MaterialTheme.colorScheme.error,
shadowElevation = 6.dp
) {
Box(contentAlignment = Alignment.Center) {
Text("\uD83C\uDF99", fontSize = 36.sp)
}
}
}
@@ -19,6 +19,7 @@ import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.RadioOverlayService
import com.utun.chat.data.LogManager
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
@@ -358,6 +359,49 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* ── Рация (PTT) ── */
Text("Рация (PTT)", style = MaterialTheme.typography.titleMedium)
var volumeUpPtt by remember { mutableStateOf(provider.getRadioVolumeUpPtt()) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Volume-up (удержание) — передача", modifier = Modifier.weight(1f))
Switch(checked = volumeUpPtt, onCheckedChange = {
volumeUpPtt = it
scope.launch { provider.setRadioVolumeUpPtt(it) }
})
}
var overlayEnabled by remember { mutableStateOf(provider.getRadioOverlay()) }
val canOverlay = RadioOverlayService.canDrawOverlays(ChatApplication.instance)
Row(
modifier = Modifier.fillMaxWidth().clickable(enabled = !canOverlay) {
if (!canOverlay) {
try {
ChatApplication.instance.startActivity(
Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${ChatApplication.instance.packageName}")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "SETTINGS", "open overlay permission failed: ${e.message}")
}
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text("PTT-кнопка поверх приложений")
if (!canOverlay) Text("Требуется разрешение «поверх других приложений» — нажмите",
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = {
overlayEnabled = it
scope.launch { provider.setRadioOverlay(it) }
if (it) RadioOverlayService.start(ChatApplication.instance)
else RadioOverlayService.stop(ChatApplication.instance)
})
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* Debug */
Text("Debug", style = MaterialTheme.typography.titleMedium)
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)
@@ -44,7 +44,8 @@ class ChatViewModel : ViewModel() {
val peerName: String = "",
val phase: CallPhase = CallPhase.NONE,
val reason: Int = -1,
val durationMs: Int = 0
val durationMs: Int = 0,
val bgpPath: String = ""
)
private var repo: ChatRepository? = null
@@ -55,6 +56,15 @@ class ChatViewModel : ViewModel() {
val callAudio = CallAudioEngine()
val radioAudio = RadioAudioEngine.instance()
/* рация: активна ли для текущего канала, кто говорит (src node_id, null = никто) */
private val _radioActive = MutableStateFlow(false)
val radioActive: StateFlow<Boolean> = _radioActive
private val _radioTalking = MutableStateFlow<Long?>(null)
val radioTalking: StateFlow<Long?> = _radioTalking
private var callStartedMs = 0L
private var callIncomingAtMs = 0L
@@ -608,6 +618,29 @@ class ChatViewModel : ViewModel() {
LogManager.addLog("ERROR", "VM", "CALL_ERROR id=0x%016x err=$err $text".format(callId))
finishCall(callId, 7, "ERROR")
}
42 -> { /* CALL_PATH: [call_id:8][text:var] — BGP-маршрут до пира */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val text = String(data, 8, data.size - 8)
val st = _callState.value
if (st != null && st.callId == callId) {
_callState.value = st.copy(bgpPath = text)
LogManager.addLog("INFO", "VM", "CALL_PATH id=0x%016x %s".format(callId, text))
}
}
43 -> { /* RADIO_TALK: [group_id:8][src:8][stream:2][on:1] */
if (data == null || data.size < 19) return
val bb = java.nio.ByteBuffer.wrap(data, 0, 19).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val groupId = bb.getLong()
val src = bb.getLong()
bb.getShort() /* stream */
val on = bb.get()
val cur = _currentChannel.value
if (cur != null && cur.id.toULongOrNull()?.toLong() == groupId) {
_radioTalking.value = if (on.toInt() != 0) src else null
LogManager.addLog("INFO", "VM", "RADIO_TALK ${if (on.toInt() != 0) "start" else "stop"} src=0x%016x".format(src))
}
}
}
}
@@ -693,6 +726,47 @@ class ChatViewModel : ViewModel() {
_callState.value = null
}
/* ── рация ── */
fun toggleRadio() {
val ch = _currentChannel.value ?: return
val groupId = ch.id.toULongOrNull()?.toLong() ?: return
val next = !_radioActive.value
NativeLib.radioSetActive(groupId, next)
_radioActive.value = next
if (next) {
radioAudio.start(groupId)
} else {
_radioTalking.value = null
radioAudio.stop()
}
LogManager.addLog("INFO", "VM", "toggleRadio -> ${if (next) "on" else "off"} group=$groupId")
}
fun radioTalkBegin() {
if (!_radioActive.value) return
radioAudio.talkBegin()
}
fun radioTalkEnd() {
if (!_radioActive.value) return
radioAudio.talkEnd()
}
fun radioOff() {
if (!_radioActive.value && !radioAudio.isActive()) return
val ch = _currentChannel.value ?: return
val groupId = ch.id.toULongOrNull()?.toLong() ?: return
NativeLib.radioSetActive(groupId, false)
_radioActive.value = false
_radioTalking.value = null
radioAudio.stop()
}
fun radioName(nodeId: Long): String =
members.value.firstOrNull { it.nodeId == nodeId }?.name
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId)
fun toggleMute() {
val m = !callAudio.isMuted()
callAudio.setMuted(m)
@@ -760,6 +834,7 @@ class ChatViewModel : ViewModel() {
fun selectChannel(channel: Channel) {
val r = repo ?: return
radioOff() /* рацию выключаем при переключении канала */
_currentChannel.value = channel
r.connectChannel(channel.id)
refreshMessages(channel.id)