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:
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)
|
||||
|
||||
Reference in new issue
Block a user