call: P2P аудиозвонки — сигналинг, релей медиа, аудио-контур, headless/desktop/android

- src/call/: сигналинг (call.c, call_proto.h, ETCP_RT_ID_CALL 0x36) + релей медиа
  + headless аудио-сокет (call_headless.c, call_audio_bind) + Opus/jitter/тоны
- chat: события CHAT_EVT_CALL_* (34–40), CHAT_EVT_DM_MESSAGES (41), DM-трамплины
- chatgui: call window, miniaudio I/O, audio_frame_ring, test_audio_jitter
- chatgui-android: CallScreen/IncomingCallDialog, аудио-роутинг, JNI, рингтон
- tests: test_call, test_call_headless
This commit is contained in:
evgeny committed 2026-09-16 10:17:57 +03:00
1 parent 0157f7f96b
commit b11e77e4c0
78 files changed
+7053 -70

No files matched your search

+2
View File
@@ -252,11 +252,13 @@ log_udp_port = 9999
- `libutun_lite/invite_link_c.h/c` — Кодирование и декодирование invite-ссылок `utun://` в бинарный формат. Совместим с десктопной версией
- `libutun_lite/utun_config_api.h/c` — Поставщик конфигурации из Kotlin в C-ядро через callback-интерфейс (get_string, get_int64, get_int)
- `libutun_lite/voice_recorder.h/c` — Запись голосовых сообщений: накопление PCM-сэмплов с компрессором, кодирование в Opus-файл, отправка в канал через chat_core
- `libutun_lite/call_audio.h/c` — Аудио-движок P2P-звонка: Opus encode (PCM→peer через call_send_media) и decode (peer→PCM→коллбэк). Аудио I/O в Kotlin (AudioRecord/AudioTrack), кодеки в C
- `libutun_lite/attachment_sender.h/c` — Отправка файлов в канал: копирование в media-директорию и регистрация через chat_core (media_index → db_sync)
- `libutun_lite/utun_sources.cmake` — Список всех .c файлов, компилируемых в libutun_lite. Новые файлы добавлять сюда
### Kotlin-слой
- `data/NativeLib.kt` — Kotlin-обёртка над C-библиотекой: все вызовы из Kotlin транслируются в JNI-функции
- `data/CallAudioEngine.kt` — Аудио-движок звонка: AudioRecord/AudioTrack, jitter-буфер, audio-focus, потоки захвата/воспроизведения
- `data/ChatRepository.kt` — Хранилище данных: буферизация сообщений и каналов между C-ядром и UI через StateFlow
- `data/InviteLink.kt` — Разбор invite-ссылок: извлекает ID канала, публичный ключ, адреса для подключения
- `data/ConfigProvider.kt` — Поставщик конфигурации из Android DataStore в C-ядро через callback-интерфейс
@@ -5,9 +5,17 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -21,6 +29,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@@ -37,5 +46,9 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="p2p_chat_network_service" />
</service>
<receiver
android:name=".data.IncomingCallActionReceiver"
android:exported="false" />
</application>
</manifest>
@@ -6,6 +6,7 @@ import com.utun.chat.data.LogManager
import com.utun.chat.data.ConfigProvider
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.ChatEventCallback
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -37,6 +38,9 @@ class ChatApplication : Application() {
/* One-time event callback registration — survives ViewModel recreation */
NativeLib.setEventCallback(AppEventHandler)
/* Incoming-call system effects (wake + ringtone + notification) — app-scoped */
IncomingCallManager.init()
/* First launch config */
if (configProvider.isFirstLaunch()) {
kotlinx.coroutines.runBlocking {
@@ -1,7 +1,11 @@
package com.utun.chat
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
@@ -19,6 +23,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import com.utun.chat.data.Channel
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.InviteData
import com.utun.chat.data.LogManager
import com.utun.chat.data.Message
@@ -29,11 +34,16 @@ import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord }
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord, Call }
private var overLock = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermission()
handleIncomingCallIntent(intent)
/* Edge-to-edge with dark status/navigation bar icons (light app theme) */
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT),
@@ -63,9 +73,38 @@ class MainActivity : ComponentActivity() {
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(null) }
var hadCall by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val callState by vm.callState.collectAsState()
/* Пока идёт звонок — держим экран включённым; после завершения снимаем
и showWhenLocked (если он был выставлен для входящего поверх блокировки). */
LaunchedEffect(callState) {
val st = callState
val inCall = st != null && st.phase != ChatViewModel.CallPhase.NONE &&
st.phase != ChatViewModel.CallPhase.ENDED
if (inCall) {
hadCall = true
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (hadCall) releaseLockScreen()
}
}
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE/ENDED → экран Call,
INCOMING → диалог поверх текущего экрана. */
LaunchedEffect(callState?.phase) {
when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING,
ChatViewModel.CallPhase.ACTIVE, ChatViewModel.CallPhase.ENDED -> screen = Screen.Call
null -> if (screen == Screen.Call) screen = Screen.ChannelList
else -> {}
}
}
val showInvite: (Channel) -> Unit = { ch ->
if (ch.id.toULongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
@@ -81,6 +120,7 @@ class MainActivity : ComponentActivity() {
Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat
Screen.PhotoRecord -> screen = Screen.Chat
Screen.Call -> { /* блокируем back во время звонка — только явная кнопка */ }
else -> screen = Screen.ChannelList
}
}
@@ -156,6 +196,10 @@ class MainActivity : ComponentActivity() {
}
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.Call -> CallScreen(
viewModel = vm,
onClose = { vm.dismissCall(); screen = Screen.ChannelList }
)
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
@@ -203,6 +247,10 @@ class MainActivity : ComponentActivity() {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
}
}
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm)
}
}
/* Show join dialog after QR scan returns */
@@ -250,6 +298,49 @@ class MainActivity : ComponentActivity() {
if (NativeLib.isReady()) NativeLib.setActive(true)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingCallIntent(intent)
}
/** Full-screen intent входящего звонка: показать activity поверх блокировки. */
private fun handleIncomingCallIntent(intent: Intent?) {
val callId = intent?.getLongExtra(IncomingCallManager.EXTRA_CALL_ID, 0L) ?: 0L
if (callId == 0L) return
LogManager.addLog("INFO", "MAIN", "launched for incoming call 0x%016x".format(callId))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
overLock = true
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
}
}
/** Снять «показ поверх блокировки», выставленный для входящего звонка. */
private fun releaseLockScreen() {
if (!overLock) return
overLock = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
}
}
override fun onStop() {
super.onStop()
if (NativeLib.isReady()) NativeLib.setActive(false)
@@ -0,0 +1,263 @@
package com.utun.chat.data
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
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
/**
* Полнодуплексный аудио-движок P2P-звонка (аналог десктопного CallAudioEngine).
*
* Потоки:
* - capture-поток: AudioRecord.read(960) → NativeLib.callAudioFeed (C: Opus encode → peer)
* - play-поток: jitter-буфер → AudioTrack.write (кадры декодированы в C, приходят через onCallPcm)
* - uasync-поток: onCallPcm(callId, PCM) ← C (call_audio media_recv)
*
* Jitter-буфер: очередь PCM-кадров (~50 кадров = 1с), pre-buffer 3 кадра (60мс),
* при опустошении — тишина (комфортный шум) + счётчик gap для диагностики.
*/
class CallAudioEngine {
companion object {
const val SAMPLE_RATE = 48000
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
const val JITTER_TARGET_FRAMES = 3 // pre-buffer 60мс
const val JITTER_MAX_FRAMES = 50 // ~1с
}
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
/** Маршрутизация аудио-выхода (динамик/громкая/гарнитура). */
val router = CallAudioRouter()
private val jitter = ArrayDeque<ShortArray>()
private val lock = Object()
@Volatile private var active = false
@Volatile private var callId = 0L
@Volatile private var muted = false
@Volatile private var callbackRegistered = false
fun isActive(): Boolean = active
/** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
fun setMuted(m: Boolean) {
if (muted == m) return
muted = m
LogManager.addLog("INFO", "CallAudio", "mute ${if (m) "on" else "off"}")
}
fun isMuted(): Boolean = muted
/** Запустить полнодуплекс для звонка. Возвращает true при успехе. */
fun start(id: Long): Boolean {
stop()
if (!callbackRegistered) {
NativeLib.setCallAudioCallback(object : CallAudioCallback {
override fun onCallPcm(callId: Long, samples: ShortArray) = onPcm(callId, samples)
})
callbackRegistered = true
}
if (!NativeLib.callAudioStart(id)) {
LogManager.addLog("ERROR", "CallAudio", "native callAudioStart failed id=$id")
return false
}
callId = id
muted = 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", "CallAudio", "set audio mode failed: ${e.message}")
}
router.start()
// ── 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", "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
}
// ── 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", "CallAudio", "AudioTrack create failed: ${e.message}")
audioRecord?.release(); audioRecord = null
audioTrack = null
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
}
synchronized(lock) { jitter.clear() }
/* Прямое роутирование трека на выбранное устройство вывода (обход глючной
глобальной маршрутизации на ряде устройств). */
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
applyTrackOutputDevice(router.currentOutputDevice)
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", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf")
return true
}
fun stop() {
if (!active) return
active = 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
synchronized(lock) { jitter.clear() }
NativeLib.callAudioStop()
router.onOutputDevice = null
router.stop()
restoreAudioMode()
callId = 0L
muted = false
LogManager.addLog("INFO", "CallAudio", "stopped")
}
/** uasync-поток: принятый и декодированный в C PCM-кадр пира. */
private fun onPcm(cid: Long, samples: ShortArray) {
if (!active || cid != callId) return
synchronized(lock) {
if (jitter.size >= JITTER_MAX_FRAMES) jitter.removeFirst() // дроп старейшего
jitter.addLast(samples)
}
}
private fun captureLoop() {
val buf = ShortArray(FRAME_SAMPLES)
val silence = 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
}
val out = if (muted) silence else buf
if (!NativeLib.callAudioFeed(callId, out)) {
LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false")
}
}
LogManager.addLog("DEBUG", "CallAudio", "capture loop done")
}
private fun playLoop() {
val silence = ShortArray(FRAME_SAMPLES)
var gaps = 0
// pre-buffer: ждём накопления jitter (до 250мс), чтобы сгладить стартовую задержку
val deadline = System.currentTimeMillis() + 250
while (active && jitter.size < JITTER_TARGET_FRAMES && System.currentTimeMillis() < deadline) {
try { Thread.sleep(10) } catch (_: InterruptedException) { return }
}
while (active) {
val t0 = System.nanoTime()
val frame = synchronized(lock) { if (jitter.isNotEmpty()) jitter.removeFirst() else null }
val track = audioTrack ?: return
if (frame != null) {
track.write(frame, 0, frame.size)
if (gaps > 0) {
LogManager.addLog("DEBUG", "CallAudio", "jitter recovered after $gaps gaps")
gaps = 0
}
} else {
track.write(silence, 0, silence.size)
gaps++
if (gaps == 1) LogManager.addLog("WARN", "CallAudio", "playback underrun (jitter empty)")
}
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", "CallAudio", "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", "CallAudio", "restore audio mode failed: ${e.message}")
}
}
private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) {
val track = audioTrack ?: return
if (dev == null) return
try {
track.setPreferredDevice(dev)
LogManager.addLog("DEBUG", "CallAudio", "track preferred device -> type=${dev.type}")
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "setPreferredDevice failed: ${e.message}")
}
}
}
@@ -0,0 +1,305 @@
package com.utun.chat.data
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
/** Аудио-выход звонка: динамик (earpiece) ↔ громкая связь (speaker) ↔ гарнитура. */
enum class AudioRoute { EARPIECE, SPEAKER, HEADSET }
/**
* Маршрутизация аудио звонка поверх AudioManager + мониторинг гарнитуры.
*
* - EARPIECE: разговорный динамик (isSpeakerphoneOn=false).
* - SPEAKER: громкая связь (isSpeakerphoneOn=true).
* - HEADSET: проводная/USB гарнитура или Bluetooth (SCO) — если подключена.
*
* Гарнитура детектится через AudioDeviceCallback (проводная, USB, SCO) и
* BluetoothHeadset profile (классический BT до установки SCO). Без BLUETOOTH_CONNECT
* (Android 12+) BT-часть деградирует до «не видно» — остаётся проводная.
*
* Владелец — CallAudioEngine (start/stop в начале/конце звонка), т.к. именно он
* управляет режимом AudioManager (MODE_IN_COMMUNICATION).
*/
class CallAudioRouter {
@Volatile var route = AudioRoute.SPEAKER
private set
@Volatile var headsetAvailable = false
private set
@Volatile var hasEarpiece = true
private set
/** Уведомление об изменении маршрута или доступности гарнитуры. */
var onChanged: ((AudioRoute, Boolean) -> Unit)? = null
/** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */
var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null
@Volatile var currentOutputDevice: AudioDeviceInfo? = null
private set
private val am: AudioManager =
ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var started = false
private var deviceCallback: AudioDeviceCallback? = null
private var btHeadset: BluetoothHeadset? = null
private var btProfileProxyBound = false
fun start() {
if (started) return
started = true
val cb = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged()
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged()
}
am.registerAudioDeviceCallback(cb, Handler(Looper.getMainLooper()))
deviceCallback = cb
bindBtHeadsetProfile()
hasEarpiece = findBuiltinEarpiece() != null
route = AudioRoute.SPEAKER
applyRoute(route)
refresh()
LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece)")
}
fun stop() {
if (!started) return
started = false
deviceCallback?.let { am.unregisterAudioDeviceCallback(it) }
deviceCallback = null
unbindBtHeadsetProfile()
try {
stopBluetoothSco()
am.isBluetoothScoOn = false
clearCommunicationDevice()
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "stop cleanup failed: ${e.message}")
}
LogManager.addLog("INFO", "CallAudioRouter", "stopped")
}
fun setRoute(r: AudioRoute) {
if (r == AudioRoute.HEADSET && !headsetAvailable) {
LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available")
return
}
if (route == r) return
route = r
applyRoute(r)
LogManager.addLog("INFO", "CallAudioRouter", "route -> $r (headsetAvailable=$headsetAvailable)")
onChanged?.invoke(route, headsetAvailable)
}
/* ── применение маршрута ── */
private fun applyRoute(r: AudioRoute) {
try {
var dev: AudioDeviceInfo? = null
when (r) {
AudioRoute.EARPIECE -> {
stopBluetoothSco()
am.isBluetoothScoOn = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dev = findBuiltinEarpiece() ?: findBuiltinSpeaker()
if (dev != null) am.setCommunicationDevice(dev)
else am.isSpeakerphoneOn = false
} else {
am.isSpeakerphoneOn = false
}
}
AudioRoute.SPEAKER -> {
stopBluetoothSco()
am.isBluetoothScoOn = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dev = findBuiltinSpeaker()
if (dev != null) am.setCommunicationDevice(dev)
else am.isSpeakerphoneOn = true
} else {
am.isSpeakerphoneOn = true
}
}
AudioRoute.HEADSET -> {
am.isSpeakerphoneOn = false
val bt = findBtScoDevice()
if (bt != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.setCommunicationDevice(bt)
} else {
startBluetoothSco()
}
dev = bt
} else if (isBtHeadsetProfileConnected()) {
/* классический BT: SCO ещё не поднят — запускаем и ждём
* AudioDeviceCallback, который пере-применит маршрут */
startBluetoothSco()
} else {
val wired = findWiredDevice()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) {
am.setCommunicationDevice(wired)
} else {
/* API<31: проводная гарнитура роутится автоматически при speakerphone=off */
am.isSpeakerphoneOn = false
}
dev = wired
}
}
}
currentOutputDevice = dev
onOutputDevice?.invoke(dev)
LogManager.addLog("INFO", "CallAudioRouter",
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}")
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}")
}
}
private fun findBuiltinSpeaker(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
}
private fun findBuiltinEarpiece(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
}
/* ── мониторинг гарнитуры ── */
private fun onDevicesChanged() {
/* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут,
* иначе SCO не замаршрутизируется автоматически */
if (route == AudioRoute.HEADSET) applyRoute(route)
refresh()
}
private fun refresh() {
val now = isHeadsetConnected()
val prev = headsetAvailable
headsetAvailable = now
if (now != prev) {
LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now")
}
/* гарнитуру отключили — откатываемся на динамик */
if (!now && route == AudioRoute.HEADSET) {
route = AudioRoute.EARPIECE
applyRoute(route)
LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> EARPIECE")
}
if (now != prev || route == AudioRoute.HEADSET) {
onChanged?.invoke(route, now)
}
}
private fun isHeadsetConnected(): Boolean =
findWiredDevice() != null || findBtScoDevice() != null || isBtHeadsetProfileConnected()
private fun findWiredDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { isWiredType(it.type) }
private fun findBtScoDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
}
private fun isWiredType(type: Int): Boolean = when (type) {
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY -> true
else -> false
}
/* ── Bluetooth headset profile ── */
private fun bindBtHeadsetProfile() {
try {
val adapter = BluetoothAdapter.getDefaultAdapter() ?: return
adapter.getProfileProxy(ChatApplication.instance, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
btHeadset = proxy as BluetoothHeadset
btProfileProxyBound = true
LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile connected")
onDevicesChanged()
}
}
override fun onServiceDisconnected(profile: Int) {
if (profile == BluetoothProfile.HEADSET) {
btHeadset = null
btProfileProxyBound = false
LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile disconnected")
onDevicesChanged()
}
}
}, BluetoothProfile.HEADSET)
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "bt profile proxy failed: ${e.message}")
}
}
private fun unbindBtHeadsetProfile() {
try {
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter != null && btProfileProxyBound) {
adapter.closeProfileProxy(BluetoothProfile.HEADSET, btHeadset)
}
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "unbind bt profile failed: ${e.message}")
}
btHeadset = null
btProfileProxyBound = false
}
private fun hasBtConnectPermission(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ChatApplication.instance.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
private fun isBtHeadsetProfileConnected(): Boolean {
if (!hasBtConnectPermission()) return false
return try {
btHeadset?.connectedDevices?.isNotEmpty() == true
} catch (e: SecurityException) {
LogManager.addLog("WARN", "CallAudioRouter", "bt connectedDevices denied: ${e.message}")
false
}
}
/* ── helpers ── */
private fun clearCommunicationDevice() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.clearCommunicationDevice()
}
}
private fun startBluetoothSco() {
try {
am.startBluetoothSco()
am.isBluetoothScoOn = true
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "startBluetoothSco failed: ${e.message}")
}
}
private fun stopBluetoothSco() {
try { am.stopBluetoothSco() } catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "stopBluetoothSco failed: ${e.message}")
}
}
}
@@ -0,0 +1,25 @@
package com.utun.chat.data
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Кнопка «Завершить» на уведомлении входящего звонка. */
class IncomingCallActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_DECLINE = "com.utun.chat.action.DECLINE_CALL"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_DECLINE) return
val callId = intent.getLongExtra(IncomingCallManager.EXTRA_CALL_ID, 0L)
if (callId == 0L) {
LogManager.addLog("ERROR", "InCall", "DECLINE action: bad call_id")
return
}
LogManager.addLog("INFO", "InCall", "DECLINE action callId=0x%016x".format(callId))
NativeLib.callDecline(callId)
IncomingCallManager.stopRinging()
}
}
@@ -0,0 +1,216 @@
package com.utun.chat.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.utun.chat.AppEventHandler
import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Системные эффекты входящего звонка, независимые от жизненного цикла Activity:
* пробуждение устройства, полноэкранное уведомление (с кнопкой «Завершить») и мелодия
* через профиль звонка. Подписывается на AppEventHandler.events в app-скопе — работает
* и при убитом Activity, пока жив foreground-сервис с C-ядром.
*
* UI-состоянием (accept/decline/отрисовка) по-прежнему владеет ChatViewModel;
* этот объект лишь будит устройство и хранит отложенный входящий звонок,
* который VM забирает через consumePendingIncoming() при (пере)создании.
*/
object IncomingCallManager {
const val CHANNEL_CALL = "incoming_call"
const val NOTIF_ID = 1001
const val EXTRA_CALL_ID = "call_id"
const val EXTRA_CHANNEL_ID = "channel_id"
const val EXTRA_PEER_NODE_ID = "peer_node_id"
data class PendingCall(val callId: Long, val channelId: String, val peerNodeId: Long)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val ringtone = RingtonePlayer()
@Volatile private var initialized = false
@Volatile private var activeCallId = 0L
@Volatile private var pendingCall: PendingCall? = null
private var wakeLock: PowerManager.WakeLock? = null
fun init() {
if (initialized) return
initialized = true
createChannel(ChatApplication.instance)
scope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
}
LogManager.addLog("INFO", "InCall", "manager initialized")
}
fun currentIncomingCallId(): Long = activeCallId
/** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */
fun canFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
val nm = ChatApplication.instance.getSystemService(NotificationManager::class.java)
return nm.canUseFullScreenIntent()
}
/** VM забирает отложенный входящий (сценарий «Activity убит, звонок пришёл»). */
@Synchronized
fun consumePendingIncoming(): PendingCall? {
val p = pendingCall
pendingCall = null
return p
}
/** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */
fun stopRinging() {
val id = activeCallId
if (id == 0L) return
activeCallId = 0L
ringtone.stop()
releaseWakeLock()
cancelNotification()
pendingCall = null
LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id))
}
private fun handleEvent(type: Int, data: ByteArray?) {
when (type) {
34 -> onIncoming(data) /* CALL_INCOMING */
36, 37, 38, 40 -> onCallStop(data) /* ACCEPTED/ENDED/DECLINED/ERROR */
}
}
private fun onIncoming(data: ByteArray?) {
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 16) return
val chId = String(data, 1, cl)
val buf = ByteBuffer.wrap(data, 1 + cl, 16).order(ByteOrder.LITTLE_ENDIAN)
val caller = buf.long
val callId = buf.long
if (activeCallId != 0L) {
LogManager.addLog("WARN", "InCall", "busy (0x%016x), skip incoming 0x%016x".format(activeCallId, callId))
return
}
activeCallId = callId
pendingCall = PendingCall(callId, chId, caller)
LogManager.addLog("INFO", "InCall", "incoming call 0x%016x from 0x%016x ch=%s".format(callId, caller, chId))
acquireWakeLock()
ringtone.play()
showNotification(callId, chId, caller)
}
private fun onCallStop(data: ByteArray?) {
if (data == null || data.size < 8) return
val callId = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
if (activeCallId == 0L || callId != activeCallId) return
stopRinging()
}
/* ── пробуждение устройства ── */
private fun acquireWakeLock() {
val pm = ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = try {
pm.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP
or PowerManager.ON_AFTER_RELEASE,
"utun:incoming_call"
).apply { acquire(30_000) }
} catch (e: Exception) {
LogManager.addLog("WARN", "InCall", "wake lock failed: ${e.message}")
null
}
}
private fun releaseWakeLock() {
wakeLock?.let {
try { if (it.isHeld) it.release() } catch (_: Exception) {}
}
wakeLock = null
}
/* ── уведомление ── */
private fun createChannel(ctx: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL_CALL, "Входящие звонки", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Уведомление о входящем звонке"
enableVibration(false)
setSound(null, null)
}
ctx.getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
}
}
private fun showNotification(callId: Long, channelId: String, peerNodeId: Long) {
val ctx = ChatApplication.instance
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
LogManager.addLog("WARN", "InCall", "notifications disabled — skip (still ringing)")
return
}
val launchIntent = Intent(ctx, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(EXTRA_CALL_ID, callId)
putExtra(EXTRA_CHANNEL_ID, channelId)
putExtra(EXTRA_PEER_NODE_ID, peerNodeId)
}
val contentPi = PendingIntent.getActivity(ctx, 0, launchIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val declinePi = PendingIntent.getBroadcast(ctx, 0,
Intent(ctx, IncomingCallActionReceiver::class.java).apply {
action = IncomingCallActionReceiver.ACTION_DECLINE
putExtra(EXTRA_CALL_ID, callId)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(ctx, CHANNEL_CALL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Входящий звонок")
.setContentText("0x%016x".format(peerNodeId))
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(contentPi)
.addAction(0, "Завершить", declinePi)
val nm = ctx.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || nm.canUseFullScreenIntent()) {
builder.setFullScreenIntent(contentPi, true)
} else {
LogManager.addLog("WARN", "InCall", "full-screen intent not allowed — heads-up only")
}
try {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, builder.build())
LogManager.addLog("INFO", "InCall", "notification posted callId=0x%016x".format(callId))
} catch (e: SecurityException) {
LogManager.addLog("WARN", "InCall", "notify denied: ${e.message}")
}
}
private fun cancelNotification() {
try {
NotificationManagerCompat.from(ChatApplication.instance).cancel(NOTIF_ID)
} catch (e: Exception) {
LogManager.addLog("WARN", "InCall", "cancel notification failed: ${e.message}")
}
}
}
@@ -6,6 +6,11 @@ interface ChatEventCallback {
fun onEvent(type: Int, data: ByteArray?)
}
/** Приём декодированного PCM-кадра пира (вызывается из uasync-потока). */
interface CallAudioCallback {
fun onCallPcm(callId: Long, samples: ShortArray)
}
object NativeLib {
init {
@@ -38,6 +43,12 @@ object NativeLib {
})
}
fun setCallAudioCallback(cb: CallAudioCallback) {
nativeSetCallAudioCallback(object : CallAudioCallback {
override fun onCallPcm(callId: Long, samples: ShortArray) { cb.onCallPcm(callId, samples) }
})
}
fun destroy() { nativeDestroy() }
fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
@@ -139,6 +150,18 @@ object NativeLib {
fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf)
fun voiceDecodeClose(handle: Long) { nativeVoiceDecodeClose(handle) }
/* ── P2P аудио-звонок (сигналинг) ── */
fun callStart(channelId: String, peerNodeId: Long, callId: Long) { nativeCallStart(channelId, peerNodeId, callId) }
fun callAccept(callId: Long) { nativeCallAccept(callId) }
fun callDecline(callId: Long) { nativeCallDecline(callId) }
fun callHangup(callId: Long) { nativeCallHangup(callId) }
fun callIsOnline(channelId: String, peerNodeId: Long): Boolean = nativeCallIsOnline(channelId, peerNodeId)
/* ── P2P аудио-звонок (медиа) ── */
fun callAudioStart(callId: Long): Boolean = nativeCallAudioStart(callId)
fun callAudioFeed(callId: Long, samples: ShortArray): Boolean = nativeCallAudioFeed(callId, samples)
fun callAudioStop() { nativeCallAudioStop() }
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
private external fun nativeStop()
@@ -215,4 +238,15 @@ object NativeLib {
private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int
private external fun nativeVoiceDecodeClose(handle: Long)
/* ── P2P аудио-звонок JNI ── */
private external fun nativeSetCallAudioCallback(cb: Any)
private external fun nativeCallStart(channelId: String, peerNodeId: Long, callId: Long)
private external fun nativeCallAccept(callId: Long)
private external fun nativeCallDecline(callId: Long)
private external fun nativeCallHangup(callId: Long)
private external fun nativeCallIsOnline(channelId: String, peerNodeId: Long): Boolean
private external fun nativeCallAudioStart(callId: Long): Boolean
private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray): Boolean
private external fun nativeCallAudioStop()
}
@@ -0,0 +1,158 @@
package com.utun.chat.data
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.Ringtone
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.provider.Settings
import com.utun.chat.ChatApplication
import com.utun.chat.R
/**
* Мелодия входящего звонка через профиль звонка (STREAM_RING):
* - громкость — настройка «громкость вызова»;
* - вибрация — по системной настройке «вибрировать при вызове» (VIBRATE_WHEN_RINGING).
*
* API 28+ : Ringtone (RingtoneManager) — громкость и вибрация автоматически.
* API 26–27: MediaPlayer(STREAM_RING, loop) + ручная вибрация по системной настройке.
*/
class RingtonePlayer {
private var ringtone: Ringtone? = null
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var focusRequest: AudioFocusRequest? = null
fun play() {
stop()
val ctx = ChatApplication.instance
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val uri = Uri.parse("android.resource://${ctx.packageName}/${R.raw.ringtone}")
val r = try {
RingtoneManager.getRingtone(ctx, uri)
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "getRingtone failed: ${e.message}")
null
} ?: return
try {
r.isLooping = true
r.play()
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "play failed: ${e.message}")
return
}
ringtone = r
LogManager.addLog("INFO", "Ringtone", "started (Ringtone, STREAM_RING + system vibrate)")
} else {
val mp = try {
MediaPlayer.create(ctx, R.raw.ringtone)
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer.create failed: ${e.message}")
null
} ?: return
try {
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_RING)
.build()
)
mp.isLooping = true
requestAudioFocus()
mp.start()
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer start failed: ${e.message}")
try { mp.release() } catch (_: Exception) {}
abandonAudioFocus()
return
}
mediaPlayer = mp
startVibrate()
LogManager.addLog("INFO", "Ringtone", "started (MediaPlayer fallback, STREAM_RING)")
}
}
fun stop() {
ringtone?.let { try { it.stop() } catch (_: Exception) {} }
ringtone = null
mediaPlayer?.let {
try { it.stop() } catch (_: Exception) {}
try { it.release() } catch (_: Exception) {}
}
mediaPlayer = null
stopVibrate()
abandonAudioFocus()
LogManager.addLog("INFO", "Ringtone", "stopped")
}
/* ── вибрация (fallback API 26–27) по системной настройке ── */
private fun systemVibrateWhenRinging(): Boolean = try {
Settings.System.getInt(
ChatApplication.instance.contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0) != 0
} catch (e: Exception) {
false
}
private fun startVibrate() {
if (!systemVibrateWhenRinging()) return
val v = try {
ChatApplication.instance.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibrator unavailable: ${e.message}")
null
} ?: return
if (!v.hasVibrator()) return
try {
v.vibrate(VibrationEffect.createWaveform(longArrayOf(1000, 1000), 0))
vibrator = v
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibrate failed: ${e.message}")
}
}
private fun stopVibrate() {
vibrator?.let { try { it.cancel() } catch (_: Exception) {} }
vibrator = null
}
/* ── audio focus (fallback API 26–27) ── */
private fun requestAudioFocus() {
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(attrs)
.build()
am.requestAudioFocus(req)
focusRequest = req
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "audio focus failed: ${e.message}")
}
}
private fun abandonAudioFocus() {
val req = focusRequest ?: return
focusRequest = null
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.abandonAudioFocusRequest(req)
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "abandon focus failed: ${e.message}")
}
}
}
@@ -0,0 +1,194 @@
package com.utun.chat.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.utun.chat.data.AudioRoute
import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
private fun fmtDuration(sec: Int): String {
val m = sec / 60
val s = sec % 60
return "%02d:%02d".format(m, s)
}
private fun callStatusText(phase: CallPhase): String = when (phase) {
CallPhase.OUTGOING -> "Звоним…"
CallPhase.RINGING -> "Гудки…"
CallPhase.ACTIVE -> "Разговор"
CallPhase.ENDED -> "Завершён"
else -> ""
}
@Composable
private fun CallControlButton(
icon: ImageVector,
contentDescription: String,
label: String,
active: Boolean,
activeColor: Color = Color(0xFF1F6FEB),
onClick: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(60.dp)
.background(if (active) activeColor else Color(0xFF24323D), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = onClick) {
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
}
}
Spacer(Modifier.height(6.dp))
Text(label, color = if (active) Color.White else Color(0xFF9BA6AD), fontSize = 11.sp)
}
}
@Composable
fun CallScreen(
viewModel: ChatViewModel,
onClose: () -> Unit
) {
val st = viewModel.callState.collectAsState().value
val muted by viewModel.muted.collectAsState()
val route by viewModel.audioRoute.collectAsState()
val headsetAvailable by viewModel.headsetAvailable.collectAsState()
val earpieceAvailable by viewModel.earpieceAvailable.collectAsState()
val phase = st?.phase ?: CallPhase.NONE
val duration = st?.durationMs ?: 0
val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
val context = LocalContext.current
val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
val btPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET)
}
Box(
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),
contentAlignment = Alignment.Center
) {
Text(
name.take(1).uppercase(),
fontSize = 40.sp,
color = Color.White,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(24.dp))
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text(
if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase),
color = Color(0xFF9BA6AD),
fontSize = 16.sp
)
Spacer(Modifier.height(40.dp))
if (phase == CallPhase.ENDED) {
Button(onClick = { viewModel.dismissCall(); onClose() }) {
Text("Закрыть")
}
} else {
if (phase == CallPhase.ACTIVE) {
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
if (earpieceAvailable) {
CallControlButton(
icon = Icons.Filled.PhoneInTalk,
contentDescription = "Динамик",
label = "Динамик",
active = route == AudioRoute.EARPIECE,
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
)
}
CallControlButton(
icon = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Громкая связь",
label = "Громкая",
active = route == AudioRoute.SPEAKER,
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
)
if (headsetAvailable) {
CallControlButton(
icon = Icons.Filled.Headset,
contentDescription = "Гарнитура",
label = "Гарнитура",
active = route == AudioRoute.HEADSET,
onClick = {
if (hasBtPermission) {
viewModel.setAudioRoute(AudioRoute.HEADSET)
} else {
btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
}
)
}
}
Spacer(Modifier.height(28.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(48.dp), verticalAlignment = Alignment.CenterVertically) {
if (phase == CallPhase.ACTIVE) {
CallControlButton(
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
contentDescription = if (muted) "Включить микрофон" else "Выключить микрофон",
label = if (muted) "Вкл. микрофон" else "Mute",
active = muted,
activeColor = Color(0xFFE53935),
onClick = { viewModel.toggleMute() }
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(72.dp).background(Color(0xFFE53935), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.hangupCall() }) {
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(36.dp))
}
}
Spacer(Modifier.height(6.dp))
Text("Завершить", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
}
}
}
}
}
@@ -0,0 +1,76 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.utun.chat.viewmodel.ChatViewModel
@Composable
fun IncomingCallDialog(
viewModel: ChatViewModel
) {
val st = viewModel.callState.collectAsState().value ?: return
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
Dialog(onDismissRequest = { }) {
Surface(
shape = MaterialTheme.shapes.large,
color = Color(0xFF1B2733)
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.size(80.dp).background(Color(0xFF1F6FEB), CircleShape),
contentAlignment = Alignment.Center
) {
Text(name.take(1).uppercase(), fontSize = 34.sp, color = Color.White, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(20.dp))
Text(name, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(6.dp))
Text("Входящий звонок…", color = Color(0xFF9BA6AD), fontSize = 14.sp)
Spacer(Modifier.height(28.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(64.dp).background(Color(0xFFE53935), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.declineCall() }) {
Icon(Icons.Filled.CallEnd, "Отклонить", tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Отклонить", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.acceptCall() }) {
Icon(Icons.Filled.Call, "Принять", tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Принять", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
}
}
}
}
}
@@ -84,6 +84,7 @@ fun MemberListScreen(
isSelected = selectedMember?.nodeId == m.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onConnect = { viewModel.connectMemberNode(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onEdit = {
editMember = m
showEditDialog = true
@@ -135,6 +136,7 @@ private fun MemberListItem(
isSelected: Boolean = false,
onClick: () -> Unit,
onConnect: () -> Unit,
onCall: () -> Unit,
onEdit: () -> Unit
) {
val onlineColor = when {
@@ -227,6 +229,10 @@ private fun MemberListItem(
if (!member.isSelf) {
TextButton(onClick = onConnect) { Text("Connect") }
TextButton(
onClick = onCall,
enabled = member.online
) { Text("Call") }
}
TextButton(onClick = onEdit) { Text("Edit") }
}
@@ -18,12 +18,18 @@ import androidx.compose.ui.unit.dp
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.LogManager
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.headless.HeadlessService
import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -519,6 +525,43 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Входящие звонки поверх блокировки ── */
Text("Incoming calls", style = MaterialTheme.typography.titleMedium)
var fsiEnabled by remember { mutableStateOf(IncomingCallManager.canFullScreenIntent()) }
LifecycleResumeEffect(Unit) {
fsiEnabled = IncomingCallManager.canFullScreenIntent()
onPauseOrDispose { }
}
Row(
modifier = Modifier.fillMaxWidth().clickable(
enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !fsiEnabled
) {
val ctx = ChatApplication.instance
try {
ctx.startActivity(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
Uri.parse("package:${ctx.packageName}")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "SETTINGS", "open full-screen-intent settings failed: ${e.message}")
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text("Show over lock screen")
Text(
when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> "Always allowed"
fsiEnabled -> "Allowed"
else -> "Disabled — tap to enable"
},
style = MaterialTheme.typography.bodySmall,
color = if (fsiEnabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Microphone test ── */
Text("Microphone test", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -33,9 +33,40 @@ class ChatViewModel : ViewModel() {
val durMs: Int = 0
)
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
data class CallState(
val callId: Long = 0,
val channelId: String = "",
val peerNodeId: Long = 0,
val peerName: String = "",
val phase: CallPhase = CallPhase.NONE,
val reason: Int = -1,
val durationMs: Int = 0
)
private var repo: ChatRepository? = null
private var dbReady = false
private val _callState = MutableStateFlow<CallState?>(null)
val callState: StateFlow<CallState?> = _callState
val callAudio = CallAudioEngine()
private var callStartedMs = 0L
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted
private val _audioRoute = MutableStateFlow(AudioRoute.SPEAKER)
val audioRoute: StateFlow<AudioRoute> = _audioRoute
private val _headsetAvailable = MutableStateFlow(false)
val headsetAvailable: StateFlow<Boolean> = _headsetAvailable
private val _earpieceAvailable = MutableStateFlow(true)
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState
@@ -138,6 +169,16 @@ class ChatViewModel : ViewModel() {
refreshChannels()
}
}
viewModelScope.launch {
delay(300)
val p = IncomingCallManager.consumePendingIncoming() ?: return@launch
val cur = _callState.value
if (cur != null && cur.callId == p.callId) return@launch
LogManager.addLog("INFO", "VM", "restore pending incoming call 0x%016x ch=%s".format(p.callId, p.channelId))
_callState.value = CallState(callId = p.callId, channelId = p.channelId, peerNodeId = p.peerNodeId,
peerName = "0x%016x".format(p.peerNodeId), phase = CallPhase.INCOMING)
resolveCallerName(p.channelId, p.peerNodeId)
}
viewModelScope.launch {
_foreground.collectLatest { fg ->
while (fg && isActive) {
@@ -180,6 +221,16 @@ class ChatViewModel : ViewModel() {
}
}
}
viewModelScope.launch {
while (isActive) {
val st = _callState.value
if (st != null && st.phase == CallPhase.ACTIVE) {
val dur = ((System.currentTimeMillis() - callStartedMs) / 1000).toInt()
_callState.value = st.copy(durationMs = dur)
}
delay(500)
}
}
}
private fun handleEvent(type: Int, data: ByteArray?) {
@@ -417,9 +468,170 @@ class ChatViewModel : ViewModel() {
}
refreshChannels()
}
34 -> { /* CALL_INCOMING: [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 8 + 8) return
val chId = String(data, 1, cl)
val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 16).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val caller = buf.getLong(); val callId = buf.getLong()
LogManager.addLog("INFO", "VM", "CALL_INCOMING ch=$chId caller=0x%016x id=0x%016x".format(caller, callId))
val cur = _callState.value
if (cur != null && cur.callId == callId) return // уже обрабатываем этот звонок (replay/restore)
if (cur != null && cur.callId != callId && cur.phase != CallPhase.ENDED) {
// заняты другим звонком — авто-отклоняем
LogManager.addLog("WARN", "VM", "busy with another call, auto-decline id=0x%016x".format(callId))
NativeLib.callDecline(callId)
return
}
_callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller,
peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING)
resolveCallerName(chId, caller)
}
35 -> { /* CALL_RINGING: [call_id:8] */
if (data == null || data.size < 8) return
val callId = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val st = _callState.value
if (st != null && st.callId == callId) {
_callState.value = st.copy(phase = CallPhase.RINGING)
LogManager.addLog("INFO", "VM", "CALL_RINGING id=0x%016x".format(callId))
}
}
36 -> { /* CALL_ACCEPTED: [call_id:8] */
if (data == null || data.size < 8) return
val callId = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val st = _callState.value
if (st != null && st.callId == callId) {
LogManager.addLog("INFO", "VM", "CALL_ACCEPTED id=0x%016x".format(callId))
if (!callAudio.start(callId)) {
LogManager.addLog("ERROR", "VM", "call audio engine start failed")
NativeLib.callHangup(callId)
_callState.value = st.copy(phase = CallPhase.ENDED, reason = 7)
} else {
callStartedMs = System.currentTimeMillis()
_muted.value = false
callAudio.router.onChanged = { r, headset ->
_audioRoute.value = r
_headsetAvailable.value = headset
}
_audioRoute.value = callAudio.router.route
_headsetAvailable.value = callAudio.router.headsetAvailable
_earpieceAvailable.value = callAudio.router.hasEarpiece
_callState.value = st.copy(phase = CallPhase.ACTIVE, durationMs = 0)
}
}
}
37 -> { /* CALL_ENDED: [call_id:8][reason:1] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val reason = data[8].toInt() and 0xFF
finishCall(callId, reason, "ENDED")
}
38 -> { /* CALL_DECLINED: [call_id:8][reason:1] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val reason = data[8].toInt() and 0xFF
finishCall(callId, reason, "DECLINED")
}
40 -> { /* CALL_ERROR: [call_id:8][err:1][text:var] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val err = data[8].toInt() and 0xFF
val text = if (data.size > 9) String(data, 9, data.size - 9) else ""
LogManager.addLog("ERROR", "VM", "CALL_ERROR id=0x%016x err=$err $text".format(callId))
finishCall(callId, 7, "ERROR")
}
}
}
private fun resolveCallerName(chId: String, caller: Long) {
val r = repo ?: return
viewModelScope.launch {
val name = withContext(Dispatchers.IO) { r.getMembers(chId) }
.firstOrNull { it.nodeId == caller }?.name?.takeIf { it.isNotEmpty() }
val st = _callState.value
if (name != null && st != null && st.peerNodeId == caller && st.phase == CallPhase.INCOMING) {
_callState.value = st.copy(peerName = name)
}
}
}
private fun finishCall(callId: Long, reason: Int, tag: String) {
val st = _callState.value
LogManager.addLog("INFO", "VM", "CALL_$tag id=0x%016x reason=$reason".format(callId))
if (st == null || st.callId != callId) return
callAudio.stop()
IncomingCallManager.stopRinging()
_muted.value = false
_audioRoute.value = AudioRoute.SPEAKER
_headsetAvailable.value = false
_earpieceAvailable.value = true
_callState.value = st.copy(phase = CallPhase.ENDED, reason = reason)
viewModelScope.launch {
delay(2500)
if (_callState.value?.callId == callId) _callState.value = null
}
}
fun startCall(channelId: String, peerNodeId: Long, peerName: String) {
val existing = _callState.value
if (existing != null && existing.phase != CallPhase.ENDED) {
LogManager.addLog("WARN", "VM", "startCall: call already active")
return
}
var callId = java.security.SecureRandom().nextLong()
if (callId == 0L) callId = 1
val name = peerName.ifEmpty { "0x%016x".format(peerNodeId) }
_callState.value = CallState(callId = callId, channelId = channelId, peerNodeId = peerNodeId,
peerName = name, phase = CallPhase.OUTGOING)
NativeLib.callStart(channelId, peerNodeId, callId)
LogManager.addLog("INFO", "VM", "startCall ch=$channelId peer=0x%016x id=0x%016x".format(peerNodeId, callId))
}
fun acceptCall() {
val st = _callState.value ?: return
if (st.phase != CallPhase.INCOMING) return
IncomingCallManager.stopRinging()
NativeLib.callAccept(st.callId)
LogManager.addLog("INFO", "VM", "acceptCall id=0x%016x".format(st.callId))
}
fun declineCall() {
val st = _callState.value ?: return
if (st.phase == CallPhase.INCOMING) {
NativeLib.callDecline(st.callId)
finishCall(st.callId, 2, "DECLINE")
} else {
NativeLib.callHangup(st.callId)
finishCall(st.callId, 0, "HANGUP")
}
}
fun hangupCall() {
val st = _callState.value ?: return
if (st.phase == CallPhase.OUTGOING || st.phase == CallPhase.RINGING || st.phase == CallPhase.ACTIVE) {
NativeLib.callHangup(st.callId)
finishCall(st.callId, 0, "HANGUP")
}
}
fun dismissCall() {
callAudio.stop()
_callState.value = null
}
fun toggleMute() {
val m = !callAudio.isMuted()
callAudio.setMuted(m)
_muted.value = m
LogManager.addLog("INFO", "VM", "toggleMute -> ${if (m) "on" else "off"}")
}
fun setAudioRoute(r: AudioRoute) {
callAudio.router.setRoute(r)
LogManager.addLog("INFO", "VM", "setAudioRoute -> $r")
}
fun requestRegenerateKeys() {
_generatingKeys.value = true
NativeLib.regenerateKeys()
@@ -811,6 +1023,8 @@ class ChatViewModel : ViewModel() {
}
fun clearState() {
callAudio.stop()
_callState.value = null
repo?.close()
repo = null
dbReady = false
@@ -16,6 +16,8 @@
#include "../libutun_lite/video_sender.h"
#include "../libutun_lite/photo_sender.h"
#include "../libutun_lite/standby.h"
#include "../libutun_lite/call_audio.h"
#include "../../../src/call/call.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/u_async.h"
#include "../../../lib/mem.h"
@@ -1320,6 +1322,69 @@ void utun_bridge_voice_decode_close(void* handle) {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "voice_decode_close");
}
/* ──────────────────────────────────────────────────────────────────
* P2P аудио-звонок (сигналинг)
* ────────────────────────────────────────────────────────────────── */
void utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id) {
if (!ch_id || !ch_id[0] || peer_node_id == 0 || call_id == 0) {
bridge_log(BLEV_ERROR, "call_start: bad args ch=%s peer=0x%llx id=0x%llx",
ch_id ? ch_id : "(null)",
(unsigned long long)peer_node_id, (unsigned long long)call_id);
return;
}
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "call_start: uasync not running"); return; }
struct call_start_arg* a = (struct call_start_arg*)u_calloc(1, sizeof(*a));
if (!a) { bridge_log(BLEV_ERROR, "call_start: OOM"); return; }
a->inst = instance_lite_get_instance();
snprintf(a->ch_id, sizeof(a->ch_id), "%s", ch_id);
a->peer_node_id = peer_node_id;
a->call_id = call_id;
bridge_log(BLEV_INFO, "call_start ch=%s peer=0x%llx id=0x%llx",
ch_id, (unsigned long long)peer_node_id, (unsigned long long)call_id);
uasync_post(ua, call_start_trampoline, a);
}
static void bridge_call_id_action(uint64_t call_id, const char* name,
void (*trampoline)(void*)) {
if (call_id == 0) { bridge_log(BLEV_ERROR, "call_%s: bad call_id=0", name); return; }
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "call_%s: uasync not running", name); return; }
struct call_id_arg* a = (struct call_id_arg*)u_calloc(1, sizeof(*a));
if (!a) { bridge_log(BLEV_ERROR, "call_%s: OOM", name); return; }
a->inst = instance_lite_get_instance();
a->call_id = call_id;
bridge_log(BLEV_INFO, "call_%s id=0x%llx", name, (unsigned long long)call_id);
uasync_post(ua, trampoline, a);
}
void utun_bridge_call_accept(uint64_t call_id) { bridge_call_id_action(call_id, "accept", call_accept_trampoline); }
void utun_bridge_call_decline(uint64_t call_id) { bridge_call_id_action(call_id, "decline", call_decline_trampoline); }
void utun_bridge_call_hangup(uint64_t call_id) { bridge_call_id_action(call_id, "hangup", call_hangup_trampoline); }
int utun_bridge_call_is_online(const char* ch_id, uint64_t peer_node_id) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
if (!inst) return 0;
return call_is_online(inst, ch_id, peer_node_id);
}
/* ──────────────────────────────────────────────────────────────────
* P2P аудио-звонок (медиа: call_audio.c)
* ────────────────────────────────────────────────────────────────── */
int utun_bridge_call_audio_start(uint64_t call_id) {
return call_audio_start(call_id);
}
int utun_bridge_call_audio_feed(uint64_t call_id, const int16_t* pcm, int count) {
return call_audio_feed_pcm(call_id, pcm, count);
}
void utun_bridge_call_audio_stop(void) {
call_audio_stop();
}
/* ──────────────────────────────────────────────────────────────────
* JNI functions (compiled only for Android via NDK)
* ────────────────────────────────────────────────────────────────── */
@@ -1331,6 +1396,8 @@ static jobject g_config_callback = NULL;
static jobject g_log_callback = NULL;
static jobject g_event_callback = NULL;
static jmethodID g_event_mid = NULL;
static jobject g_call_audio_callback = NULL;
static jmethodID g_call_audio_mid = NULL;
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
(void)reserved;
@@ -1396,6 +1463,29 @@ void instance_event_handler(int type, const uint8_t* data, int len) {
jni_on_event(type, data, len);
}
/* ── JNI call-audio PCM callback → CallAudioEngine.onCallPcm() ──
* Вызывается из uasync-потока (call.c media_recv → call_audio_media_recv). */
static void jni_call_audio_pcm(uint64_t call_id, const int16_t* pcm, int count, void* arg) {
(void)arg;
if (!g_jvm || !g_call_audio_callback || !g_call_audio_mid || !pcm || count <= 0) return;
JNIEnv* env = NULL;
int attached = 0;
jint res = (*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6);
if (res == JNI_EDETACHED) {
if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) return;
attached = 1;
} else if (res != JNI_OK) {
return;
}
jshortArray jpcm = (*env)->NewShortArray(env, count);
if (jpcm) {
(*env)->SetShortArrayRegion(env, jpcm, 0, count, (const jshort*)pcm);
(*env)->CallVoidMethod(env, g_call_audio_callback, g_call_audio_mid, (jlong)call_id, jpcm);
(*env)->DeleteLocalRef(env, jpcm);
}
if (attached) (*g_jvm)->DetachCurrentThread(g_jvm);
}
static jstring jni_get_string_callback(JNIEnv* env, const char* key) {
if (!g_config_callback) return NULL;
jclass cls = (*env)->GetObjectClass(env, g_config_callback);
@@ -1445,6 +1535,7 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStart(
/* Set up event forwarder (bridge config/log are set by nativeInit) */
instance_lite_set_event_handler(instance_event_handler);
call_audio_set_pcm_recv_cb(jni_call_audio_pcm, NULL);
int rc = utun_bridge_start(cfg);
(*env)->ReleaseStringUTFChars(env, configText, cfg);
@@ -1579,6 +1670,8 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDestroy(
if (g_config_callback) { (*env)->DeleteGlobalRef(env, g_config_callback); g_config_callback = NULL; }
if (g_log_callback) { (*env)->DeleteGlobalRef(env, g_log_callback); g_log_callback = NULL; }
if (g_event_callback) { (*env)->DeleteGlobalRef(env, g_event_callback); g_event_callback = NULL; }
if (g_call_audio_callback) { (*env)->DeleteGlobalRef(env, g_call_audio_callback); g_call_audio_callback = NULL; }
g_call_audio_mid = NULL;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSendMessage(
@@ -2045,4 +2138,82 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCollectConnMetric
utun_bridge_collect_conn_metrics((uint64_t)peerNodeId);
}
/* ── P2P аудио-звонок (сигналинг) ── */
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallStart(
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId, jlong callId) {
(void)thiz;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
utun_bridge_call_start(ch, (uint64_t)peerNodeId, (uint64_t)callId);
if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAccept(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_accept((uint64_t)callId);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallDecline(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_decline((uint64_t)callId);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallHangup(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_hangup((uint64_t)callId);
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallIsOnline(
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId) {
(void)thiz;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
int online = utun_bridge_call_is_online(ch, (uint64_t)peerNodeId);
if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
return online ? JNI_TRUE : JNI_FALSE;
}
/* ── P2P аудио-звонок (медиа) ── */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioStart(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
return (utun_bridge_call_audio_start((uint64_t)callId) == 0) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioFeed(
JNIEnv* env, jobject thiz, jlong callId, jshortArray samples) {
(void)env; (void)thiz;
if (!samples) return JNI_FALSE;
jsize len = (*env)->GetArrayLength(env, samples);
jshort* data = (*env)->GetShortArrayElements(env, samples, NULL);
int rc = utun_bridge_call_audio_feed((uint64_t)callId, (const int16_t*)data, (int)len);
(*env)->ReleaseShortArrayElements(env, samples, data, JNI_ABORT);
return (rc == 0) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioStop(
JNIEnv* env, jobject thiz) {
(void)env; (void)thiz;
utun_bridge_call_audio_stop();
}
/* ── nativeSetCallAudioCallback(cb) ── */
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetCallAudioCallback(
JNIEnv* env, jobject thiz, jobject callback) {
(void)thiz;
if (g_call_audio_callback) (*env)->DeleteGlobalRef(env, g_call_audio_callback);
g_call_audio_callback = callback ? (*env)->NewGlobalRef(env, callback) : NULL;
if (g_call_audio_callback) {
jclass cls = (*env)->GetObjectClass(env, g_call_audio_callback);
g_call_audio_mid = (*env)->GetMethodID(env, cls, "onCallPcm", "(J[S)V");
if (!g_call_audio_mid) bridge_log(BLEV_ERROR, "onCallPcm method not found on callback");
} else {
g_call_audio_mid = NULL;
}
}
#endif /* __ANDROID__ */
@@ -118,6 +118,20 @@ void* utun_bridge_voice_decode_open(const char* file_path,
int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples);
void utun_bridge_voice_decode_close(void* handle);
/* ── P2P аудио-звонок (сигналинг + медиа) ── */
void utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id);
void utun_bridge_call_accept(uint64_t call_id);
void utun_bridge_call_decline(uint64_t call_id);
void utun_bridge_call_hangup(uint64_t call_id);
int utun_bridge_call_is_online(const char* ch_id, uint64_t peer_node_id);
/* Аудио-движок звонка (call_audio.c): PCM-кадры из AudioRecord → Opus → peer;
* принятый Opus → PCM → коллбэк (настраивается через call_audio_set_pcm_recv_cb). */
int utun_bridge_call_audio_start(uint64_t call_id);
int utun_bridge_call_audio_feed(uint64_t call_id, const int16_t* pcm, int count);
void utun_bridge_call_audio_stop(void);
/* ── Invite link (for sharing) — асинхронно через uasync-поток.
Результат приходит событиями CHAT_EVT_INVITE_CANDIDATES / CHAT_EVT_INVITE_LINK_READY. ── */
void utun_bridge_request_invite_candidates(const char* channel_id);
@@ -0,0 +1,156 @@
#include "call_audio.h"
#include "instance_lite.h"
#include "../../../lib/opus_codec.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/mem.h"
#include "../../../lib/u_async.h"
#include "../../../src/call/call.h"
#include <string.h>
#include <pthread.h>
#define CALL_AUDIO_BITRATE 32000
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static int g_active = 0;
static uint64_t g_call_id = 0;
static opus_codec_encoder_t* g_encoder = NULL;
static opus_codec_decoder_t* g_decoder = NULL;
static call_audio_pcm_recv_fn g_pcm_fn = NULL;
static void* g_pcm_arg = NULL;
void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg) {
pthread_mutex_lock(&g_mtx);
g_pcm_fn = fn;
g_pcm_arg = arg;
pthread_mutex_unlock(&g_mtx);
}
/* uasync-поток: принятый Opus-кадр пира (из call.c media_recv). */
static void call_audio_media_recv(struct UTUN_INSTANCE* inst, uint64_t call_id,
const uint8_t* opus, int len, void* arg) {
(void)inst; (void)arg;
int16_t pcm[CALL_AUDIO_FRAME_SAMPLES];
call_audio_pcm_recv_fn fn = NULL;
void* fn_arg = NULL;
int n = 0;
pthread_mutex_lock(&g_mtx);
if (g_active && call_id == g_call_id && g_decoder && opus && len > 0) {
n = opus_codec_decode(g_decoder, opus, len, pcm, CALL_AUDIO_FRAME_SAMPLES);
fn = g_pcm_fn;
fn_arg = g_pcm_arg;
}
pthread_mutex_unlock(&g_mtx);
if (n > 0 && fn)
fn(call_id, pcm, n, fn_arg);
}
int call_audio_start(uint64_t call_id) {
if (call_id == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: bad call_id=0");
return -1;
}
pthread_mutex_lock(&g_mtx);
if (g_active && g_call_id == call_id) {
pthread_mutex_unlock(&g_mtx);
return 0; /* уже запущен для этого звонка */
}
if (g_active) {
/* другой звонок активен — сбрасываем его */
pthread_mutex_unlock(&g_mtx);
call_audio_stop();
pthread_mutex_lock(&g_mtx);
}
opus_codec_encoder_t* enc = opus_codec_encoder_create(CALL_AUDIO_SAMPLE_RATE, 1);
if (!enc) {
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: encoder create failed");
return -1;
}
opus_codec_encoder_bitrate_set(enc, CALL_AUDIO_BITRATE);
opus_codec_decoder_t* dec = opus_codec_decoder_create(CALL_AUDIO_SAMPLE_RATE, 1);
if (!dec) {
opus_codec_encoder_destroy(enc);
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: decoder create failed");
return -1;
}
g_encoder = enc;
g_decoder = dec;
g_call_id = call_id;
g_active = 1;
pthread_mutex_unlock(&g_mtx);
call_set_media_recv_cb(instance_lite_get_instance(), call_audio_media_recv, NULL);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_start: ok call=0x%016llx",
(unsigned long long)call_id);
return 0;
}
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) {
if (!pcm || count != CALL_AUDIO_FRAME_SAMPLES)
return -1;
uint8_t opus[256];
struct UASYNC* ua = NULL;
struct UTUN_INSTANCE* inst = NULL;
int len = -1;
pthread_mutex_lock(&g_mtx);
if (!g_active || call_id != g_call_id || !g_encoder) {
pthread_mutex_unlock(&g_mtx);
return -1;
}
len = opus_codec_encode(g_encoder, pcm, count, opus, (int)sizeof(opus));
ua = instance_lite_get_uasync();
inst = instance_lite_get_instance();
pthread_mutex_unlock(&g_mtx);
if (len <= 0 || !ua || !uasync_is_running(ua) || !inst)
return -1;
struct call_media_arg* a = (struct call_media_arg*)u_malloc(sizeof(*a));
if (!a) return -1;
a->inst = inst;
a->call_id = call_id;
a->len = (uint16_t)len;
memcpy(a->opus, opus, (size_t)len);
uasync_post(ua, call_send_media_trampoline, a);
return 0;
}
void call_audio_stop(void) {
opus_codec_encoder_t* enc = NULL;
opus_codec_decoder_t* dec = NULL;
int was_active = 0;
pthread_mutex_lock(&g_mtx);
if (g_active) {
was_active = 1;
g_active = 0;
g_call_id = 0;
enc = g_encoder; g_encoder = NULL;
dec = g_decoder; g_decoder = NULL;
}
pthread_mutex_unlock(&g_mtx);
if (!was_active)
return;
call_set_media_recv_cb(instance_lite_get_instance(), NULL, NULL);
if (enc) opus_codec_encoder_destroy(enc);
if (dec) opus_codec_decoder_destroy(dec);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_stop");
}
int call_audio_is_active(uint64_t call_id) {
pthread_mutex_lock(&g_mtx);
int active = g_active && call_id == g_call_id;
pthread_mutex_unlock(&g_mtx);
return active;
}
@@ -0,0 +1,52 @@
#ifndef CALL_AUDIO_H
#define CALL_AUDIO_H
/* call_audio.h — аудио-движок P2P-звонка для Android (аналог десктопного CallAudioEngine)
*
* Держит один активный звонок: Opus encoder (PCM→Opus, отправка пиру) и
* decoder (Opus→PCM, приём от пира). Аудио I/O живёт в Kotlin (AudioRecord/
* AudioTrack), кодеки — здесь (Opus есть только в C).
*
* Потоки:
* - feed_pcm: из AudioRecord-потока Kotlin (кодирование + uasync_post)
* - media_recv: из uasync-потока (call.c) — декодирование + коллбэк PCM
* - start/stop: из GUI-потока Kotlin (по ACCEPTED/ENDED)
* Все общие поля защищены мьютексом; encoder/decoder используются строго
* одним потоком каждый, но освобождаются в stop под тем же мьютексом. */
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Параметры аудио: Opus 48kHz mono, кадр 20мс = 960 сэмплов. */
#define CALL_AUDIO_SAMPLE_RATE 48000
#define CALL_AUDIO_FRAME_MS 20
#define CALL_AUDIO_FRAME_SAMPLES (CALL_AUDIO_SAMPLE_RATE * CALL_AUDIO_FRAME_MS / 1000)
/* Запустить полнодуплексное аудио для активного звонка (после ACCEPTED).
* Создаёт encoder+decoder, регистрирует приём медиа в call.c.
* Повторный start с тем же call_id — no-op; с другим — сперва stop.
* Возвращает 0 при успехе, <0 при ошибке. */
int call_audio_start(uint64_t call_id);
/* Отправить один кадр PCM (ровно CALL_AUDIO_FRAME_SAMPLES) пиру.
* Кодируется в Opus и уходит через call_send_media (uasync-пост).
* Можно звать из любого потока. 0 — успех, <0 — звонок не активен/ошибка. */
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count);
/* Остановить аудио: снять recv-cb, освободить encoder/decoder. */
void call_audio_stop(void);
/* Активен ли звонок с данным call_id (для логов/диагностики). */
int call_audio_is_active(uint64_t call_id);
/* Коллбэк приёма декодированного PCM пира (960 сэмплов/кадр).
* Вызывается в uasync-потоке на каждый принятый Opus-кадр. */
typedef void (*call_audio_pcm_recv_fn)(uint64_t call_id, const int16_t* pcm, int count, void* arg);
void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg);
#ifdef __cplusplus
}
#endif
#endif /* CALL_AUDIO_H */
@@ -29,6 +29,10 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
list(FILTER _src_src EXCLUDE REGEX "/lwip_orig/")
list(FILTER _src_src EXCLUDE REGEX "/uip/")
list(FILTER _src_src EXCLUDE REGEX "/transport_layer/BBR/")
# аудио-контур звонка (call_audio/call_tones/audio_jitter) — отдельная C++ задача,
# Android использует собственный libutun_lite/call_audio.c
list(FILTER _src_src EXCLUDE REGEX "/call/call_audio\\.c$")
list(FILTER _src_src EXCLUDE REGEX "/call/call_tones\\.c$")
list(REMOVE_ITEM _src_src "${SRC_DIR}/utun.c")
# ── Our added files ──
@@ -40,6 +44,7 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
"${CONFIG_DIR}/video_sender.c"
"${CONFIG_DIR}/photo_sender.c"
"${CONFIG_DIR}/standby.c"
"${CONFIG_DIR}/call_audio.c"
)
# ── Export sources ──
+45 -3
View File
@@ -83,6 +83,30 @@ endif()
# uTun core library (C, built from ../src/ and ../lib/)
add_subdirectory(libutun)
# SoundTouch (time-stretch) — статическая C++ либа для джиттер-буфера звонка
set(SOUNDTOUCH_DIR "${CMAKE_SOURCE_DIR}/../../lib/soundtouch-master")
set(SOUNDTOUCH_SOURCES
${SOUNDTOUCH_DIR}/source/SoundTouch/SoundTouch.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/TDStretch.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/RateTransposer.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/FIFOSampleBuffer.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/AAFilter.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/FIRFilter.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateLinear.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateCubic.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/InterpolateShannon.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/cpu_detect_x86.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/mmx_optimized.cpp
${SOUNDTOUCH_DIR}/source/SoundTouch/sse_optimized.cpp
)
add_library(soundtouch STATIC ${SOUNDTOUCH_SOURCES})
target_include_directories(soundtouch PUBLIC
${SOUNDTOUCH_DIR}/include
${SOUNDTOUCH_DIR}/include/soundtouch
)
set_target_properties(soundtouch PROPERTIES POSITION_INDEPENDENT_CODE ON)
add_executable(vibechat
src/main.cpp
src/mainwindow.cpp
@@ -127,6 +151,11 @@ add_executable(vibechat
src/audiodevicesettingspage.cpp
src/storagesettingspage.cpp
src/connmonitorwindow.cpp
src/callwindow.cpp
src/call_audio_engine.cpp
../../src/call/call_audio.c
../../src/call/call_tones.c
../../src/call/audio_jitter.cpp
transport/utun_node.cpp
transport/node_config.cpp
transport/config_updater.cpp
@@ -142,11 +171,11 @@ target_compile_definitions(vibechat PRIVATE SQLITE_THREADSAFE=1 USE_SQLITE)
if(FFMPEG_FOUND)
target_compile_definitions(vibechat PRIVATE HAVE_FFMPEG)
endif()
set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C)
set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c ../../src/call/call_audio.c ../../src/call/call_tones.c PROPERTIES LANGUAGE C)
if(WIN32)
target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun soundtouch pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
else()
target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun soundtouch pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES})
endif()
# ============================================================================
@@ -183,6 +212,19 @@ target_include_directories(test_config_updater PRIVATE ${CMAKE_SOURCE_DIR})
target_link_libraries(test_config_updater PRIVATE ${QT_CORE} pthread)
add_test(NAME test_config_updater COMMAND test_config_updater)
# тест адаптивного джиттер-буфера (SoundTouch time-stretch)
add_executable(test_audio_jitter
tests/test_audio_jitter.cpp
../../src/call/audio_jitter.cpp
)
target_include_directories(test_audio_jitter PRIVATE
${CMAKE_SOURCE_DIR}/../../src
${SOUNDTOUCH_DIR}/include
${SOUNDTOUCH_DIR}/include/soundtouch
)
target_link_libraries(test_audio_jitter PRIVATE soundtouch pthread)
add_test(NAME test_audio_jitter COMMAND test_audio_jitter)
# smoke-тест видео-декодера (FFmpeg). Ручной запуск: test_video_engine <mp4>
add_executable(test_video_engine
tests/test_video_engine.cpp
+23
View File
@@ -302,6 +302,29 @@ MessageRow DbManager::getMessageById(const QString& chId, qint64 msgId) const {
return m;
}
/* ── DM-беседы (UI, read-only) ── */
QList<DmConversation> DbManager::getDmConversations() const {
QList<DmConversation> list;
if (!m_db) return list;
sqlite3_stmt* stmt = prepareOrNull(
"SELECT conv_id, peer_node_id, COALESCE(peer_name,''), last_ts, last_out_seq, last_in_seq"
" FROM dm_conversations ORDER BY last_ts DESC");
if (!stmt) return list;
while (sqlite3_step(stmt) == SQLITE_ROW) {
DmConversation c;
c.convId = colText(stmt, 0);
c.peerNodeId = (quint64)sqlite3_column_int64(stmt, 1);
c.peerName = colText(stmt, 2);
c.lastTs = sqlite3_column_int64(stmt, 3);
c.lastOutSeq = sqlite3_column_int64(stmt, 4);
c.lastInSeq = sqlite3_column_int64(stmt, 5);
list.append(c);
}
sqlite3_finalize(stmt);
return list;
}
/* ── peers (UI) ── */
QMap<quint64, QString> DbManager::getLocalNicknames(const QString& chId) const {
+12
View File
@@ -67,6 +67,15 @@ struct IntegrityResult {
int elapsedMs = 0;
};
struct DmConversation {
QString convId;
quint64 peerNodeId = 0;
QString peerName;
qint64 lastTs = 0;
qint64 lastOutSeq = 0;
qint64 lastInSeq = 0;
};
class DbManager : public QObject {
Q_OBJECT
public:
@@ -93,6 +102,9 @@ public:
QList<MessageRow> getMessagesSince(const QString& chId, qint64 sinceId) const;
MessageRow getMessageById(const QString& chId, qint64 msgId) const;
/* ── DM-беседы (UI, read-only) ── */
QList<DmConversation> getDmConversations() const;
/* ── Участники канала (UI) ── */
void cleanOrphanedPeers(const QString& chId) const;
QString getDisplayName(quint64 nodeId) const;
+4
View File
@@ -77,6 +77,9 @@ file(GLOB_RECURSE UTUN_COMMON_SOURCES CONFIGURE_DEPENDS "${SRC_DIR}/*.c")
list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/lwip_orig/")
list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/uip/")
list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/transport_layer/BBR/")
# аудио-контур звонка зависит от C++ (SoundTouch/audio_jitter) — собирается в vibechat
list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/call/call_audio\\.c$")
list(FILTER UTUN_COMMON_SOURCES EXCLUDE REGEX "/call/call_tones\\.c$")
list(REMOVE_ITEM UTUN_COMMON_SOURCES "${SRC_DIR}/utun.c")
# Platform-specific TUN implementation
@@ -98,6 +101,7 @@ target_include_directories(utun PUBLIC
${SRC_DIR}/routing_layer
${SRC_DIR}/media_delivery
${SRC_DIR}/media_async
${SRC_DIR}/call
${LIB_DIR}
${SRC_DIR}/uip
${CMAKE_SOURCE_DIR}/db
+14
View File
@@ -471,6 +471,10 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
QString name = m_model->data(index, Qt::DisplayRole).toString();
QMenu menu(m_listView);
QAction* pmAction = menu.addAction(QString::fromUtf8("Написать сообщение"));
if (nodeId == m_db->myNodeId()) pmAction->setEnabled(false);
QAction* callAction = menu.addAction(QString::fromUtf8("Позвонить"));
if (nodeId == m_db->myNodeId() || !m_model->isOnlineAt(index.row())) callAction->setEnabled(false);
QAction* connectAction = menu.addAction("Connect");
if (nodeId == m_db->myNodeId()) connectAction->setEnabled(false);
QAction* renameAction = menu.addAction("Rename");
@@ -478,6 +482,16 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
QAction* editAction = menu.addAction("Edit");
QAction* chosen = menu.exec(m_listView->viewport()->mapToGlobal(pos));
if (chosen == pmAction) {
emit pmRequested(m_channelId, nodeId, name);
return;
}
if (chosen == callAction) {
emit callRequested(m_channelId, nodeId, name);
return;
}
if (chosen == connectAction) {
struct chat_connect_node_req* r = (struct chat_connect_node_req*)u_malloc(sizeof(*r));
if (!r) return;
+2
View File
@@ -67,6 +67,8 @@ public:
signals:
void memberRenamed();
void callRequested(const QString& channelId, quint64 nodeId, const QString& name);
void pmRequested(const QString& channelId, quint64 nodeId, const QString& name);
private slots:
void onMemberClicked(const QModelIndex& index);
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <mutex>
#include <vector>
/* Кольцевой буфер кадров переменной длины (SPSC, mutex-protected).
*
* Producer (uasync-поток) и consumer (аудио-поток). Память предвыделена одним
* блоком — ноль аллокаций на кадр. При переполнении дропается старейший кадр
* (семантика джиттер-буфера).
*/
class AudioFrameRing {
public:
AudioFrameRing(int capacity, int slotSize)
: cap_(capacity), slotSize_(slotSize),
buf_((size_t)capacity * (size_t)slotSize, 0),
lens_((size_t)capacity, 0) {}
/* Producer: положить кадр. Возвращает false если len > slotSize.
* При переполнении дропает старейший кадр (успех). */
bool push(const uint8_t* data, int len) {
if (len <= 0 || len > slotSize_) return false;
std::lock_guard<std::mutex> lk(mtx_);
if (count_ == cap_) {
head_ = (head_ + 1) % cap_; /* drop oldest */
count_--;
}
std::memcpy(buf_.data() + (size_t)tail_ * slotSize_, data, (size_t)len);
lens_[(size_t)tail_] = (uint16_t)len;
tail_ = (tail_ + 1) % cap_;
count_++;
return true;
}
/* Consumer: взять старейший кадр. Возвращает длину (0 = пусто). */
int pop(uint8_t* out, int outCap) {
std::lock_guard<std::mutex> lk(mtx_);
if (count_ == 0) return 0;
int len = (int)lens_[(size_t)head_];
if (out && len > 0 && len <= outCap)
std::memcpy(out, buf_.data() + (size_t)head_ * slotSize_, (size_t)len);
head_ = (head_ + 1) % cap_;
count_--;
return len;
}
void clear() {
std::lock_guard<std::mutex> lk(mtx_);
head_ = tail_ = count_ = 0;
}
int size() const {
std::lock_guard<std::mutex> lk(mtx_);
return count_;
}
int capacity() const { return cap_; }
private:
int cap_, slotSize_;
int head_ = 0, tail_ = 0, count_ = 0;
std::vector<uint8_t> buf_;
std::vector<uint16_t> lens_;
mutable std::mutex mtx_;
};
+183
View File
@@ -0,0 +1,183 @@
#include "call_audio_engine.h"
#include "sound_manager.h"
#include "../transport/gui_bridge.h"
#include "miniaudio.h"
extern "C" {
#include "call/call_audio.h"
#include "../../lib/debug_config.h"
}
#include <QTimer>
#include <cstring>
static CallAudioEngine* g_callAudioEngine = nullptr;
static void callAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) {
CallAudioEngine* e = (CallAudioEngine*)dev->pUserData;
if (e) e->dataCallback(in, out, frames);
}
CallAudioEngine* CallAudioEngine::instance() {
if (!g_callAudioEngine) g_callAudioEngine = new CallAudioEngine();
return g_callAudioEngine;
}
int CallAudioEngine::start(quint64 callId) {
if (m_active) {
if (m_callId == callId) return 0;
stop();
}
m_callId = callId;
struct UTUN_INSTANCE* inst = gui_bridge_get_inst();
if (!inst) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no instance, cannot start");
return -1;
}
if (call_audio_init(inst) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_init failed");
return -1;
}
if (call_audio_start(callId) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_start failed call=%016llx", (unsigned long long)callId);
return -1;
}
m_captureBuf.assign(CALL_AUDIO_FRAME_SAMPLES, 0);
m_capturePos = 0;
m_muted = false;
if (initDevice() != 0) {
call_audio_release(callId);
return -1;
}
m_active = true;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: started call=%016llx", (unsigned long long)callId);
return 0;
}
int CallAudioEngine::initDevice() {
ma_context* ctx = SoundManager::instance()->context();
if (!ctx) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no audio context");
return -1;
}
m_device = new ma_device;
std::memset(m_device, 0, sizeof(*m_device));
ma_device_config config = ma_device_config_init(ma_device_type_duplex);
config.capture.format = ma_format_s16;
config.capture.channels = 1;
config.playback.format = ma_format_s16;
config.playback.channels = 1;
config.sampleRate = CALL_AUDIO_SAMPLE_RATE;
config.periodSizeInFrames = 480;
config.dataCallback = callAudioDataCallback;
config.pUserData = this;
if (m_playbackDeviceIndex >= 0) {
ma_device_info* pPlaybackDevices = nullptr;
ma_uint32 playbackCount = 0;
if (ma_context_get_devices(ctx, &pPlaybackDevices, &playbackCount, nullptr, nullptr) == MA_SUCCESS &&
(ma_uint32)m_playbackDeviceIndex < playbackCount) {
config.playback.pDeviceID = &pPlaybackDevices[m_playbackDeviceIndex].id;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "CallAudio: using playback device[%d]: %s",
m_playbackDeviceIndex, pPlaybackDevices[m_playbackDeviceIndex].name);
} else {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: playback device[%d] not found, using default",
m_playbackDeviceIndex);
}
}
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device init failed, trying default");
config.playback.pDeviceID = nullptr;
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: default device init also failed");
delete m_device; m_device = nullptr;
return -1;
}
}
if (ma_device_start(m_device) != MA_SUCCESS) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device start failed");
ma_device_uninit(m_device);
delete m_device; m_device = nullptr;
return -1;
}
m_deviceStarted = true;
m_capturePos = 0;
return 0;
}
void CallAudioEngine::setMuted(bool muted) {
if (m_muted.load() == muted) return;
m_muted = muted;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: mute %s", muted ? "on" : "off");
}
void CallAudioEngine::setPlaybackDeviceIndex(int index) {
if (index == m_playbackDeviceIndex) return;
int prev = m_playbackDeviceIndex;
m_playbackDeviceIndex = index;
if (m_active) {
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device %d -> %d", prev, index);
teardownDevice();
if (initDevice() != 0)
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device failed (kept index=%d)", index);
}
}
void CallAudioEngine::beginEnd(quint64 callId) {
if (!m_active || m_callId != callId) return;
call_audio_begin_end(callId); /* старт тона завершения */
QTimer::singleShot(500, this, [this, callId]() {
if (m_callId == callId) stop();
});
}
void CallAudioEngine::stop() {
if (!m_active && !m_device && !m_deviceStarted) return;
m_active = false;
m_muted = false;
quint64 cid = m_callId.load();
teardownDevice();
call_audio_release(cid);
m_callId = 0;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: stopped");
}
void CallAudioEngine::teardownDevice() {
if (m_deviceStarted) {
ma_device_stop(m_device);
ma_device_uninit(m_device);
m_deviceStarted = false;
}
if (m_device) { delete m_device; m_device = nullptr; }
}
void CallAudioEngine::dataCallback(const void* in, void* out, unsigned int frames) {
const int16_t* in16 = (const int16_t*)in;
int16_t* out16 = (int16_t*)out;
/* захват: копим 960 samples → encode+send (сервисный слой). mute → тишина. */
if (in16) {
bool muted = m_muted.load();
for (unsigned int i = 0; i < frames; i++) {
m_captureBuf[m_capturePos++] = muted ? 0 : in16[i];
if (m_capturePos >= (int)m_captureBuf.size()) {
call_audio_feed_pcm(m_callId.load(), m_captureBuf.data(), (int)m_captureBuf.size());
m_capturePos = 0;
}
}
}
/* воспроизведение: финальный PCM из сервисного слоя (jitter+stretch+тоны) */
int n = call_audio_pull_pcm(m_callId.load(), out16, (int)frames);
if (n < (int)frames)
std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t));
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <QObject>
#include <atomic>
#include <cstdint>
#include <vector>
struct ma_device;
/* Аудио-контур звонка (desktop): только miniaudio I/O + захват/воспроизведение.
* Весь аудиопроцессинг (Opus, джиттер-буфер, time-stretch, тоны) — в сервисном
* слое src/call/call_audio.c; сюда приходит/отсюда уходит финальный PCM.
*
* Потоки:
* - аудио-поток (miniaudio callback): capture → call_audio_feed_pcm,
* call_audio_pull_pcm → playback;
* - GUI-поток: start()/beginEnd()/stop() по ACCEPTED/ENDED. */
class CallAudioEngine : public QObject {
Q_OBJECT
public:
static CallAudioEngine* instance();
/* Запустить полнодуплекс для звонка (после ACCEPTED). 0 = успех. */
int start(quint64 callId);
/* По CALL_ENDED: тон завершения (~450мс), затем авто-teardown. */
void beginEnd(quint64 callId);
/* Немедленный teardown (device + освобождение call_audio). */
void stop();
bool isActive() const { return m_active.load(); }
quint64 callId() const { return m_callId.load(); }
/* Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
void setMuted(bool muted);
bool isMuted() const { return m_muted.load(); }
/* Устройство вывода звонка (-1 = default). Смена на лету пересоздаёт ma_device. */
void setPlaybackDeviceIndex(int index);
int playbackDeviceIndex() const { return m_playbackDeviceIndex; }
/* аудио-поток: miniaudio data callback (захват + воспроизведение) */
void dataCallback(const void* in, void* out, unsigned int frames);
private:
CallAudioEngine() = default;
void teardownDevice();
int initDevice();
std::atomic<bool> m_active{false};
std::atomic<uint64_t> m_callId{0};
std::atomic<bool> m_muted{false};
int m_playbackDeviceIndex = -1; /* только GUI-поток */
ma_device* m_device = nullptr;
bool m_deviceStarted = false;
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */
int m_capturePos = 0;
};
+179
View File
@@ -0,0 +1,179 @@
#include "callwindow.h"
#include "../transport/gui_bridge.h"
#include "call_audio_engine.h"
#include "sound_manager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QComboBox>
#include <QTimer>
#include <QDateTime>
#include <QCloseEvent>
#include <cstring>
extern "C" {
#include "call/call.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
}
CallWindow::CallWindow(QWidget* parent)
: QWidget(parent, Qt::Window) {
setWindowTitle(QStringLiteral("Звонок"));
setFixedSize(320, 300);
setAttribute(Qt::WA_DeleteOnClose, false);
m_peerLabel = new QLabel(this);
m_peerLabel->setAlignment(Qt::AlignCenter);
m_statusLabel = new QLabel(this);
m_statusLabel->setAlignment(Qt::AlignCenter);
m_durationLabel = new QLabel(QStringLiteral("00:00"), this);
m_durationLabel->setAlignment(Qt::AlignCenter);
m_acceptBtn = new QPushButton(QStringLiteral("Принять"), this);
m_declineBtn = new QPushButton(QStringLiteral("Отклонить"), this);
m_hangupBtn = new QPushButton(QStringLiteral("Завершить"), this);
m_muteBtn = new QPushButton(QStringLiteral("Mute"), this);
m_muteBtn->setCheckable(true);
m_muteBtn->setStyleSheet(
"QPushButton { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 4px 12px; font-size: 12px; background: palette(button); }"
"QPushButton:checked { background: #c0392b; color: white; }");
m_deviceCombo = new QComboBox(this);
m_deviceCombo->setStyleSheet(
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 10px; font-size: 13px; background: palette(base); }");
QVBoxLayout* lay = new QVBoxLayout(this);
lay->addWidget(m_peerLabel);
lay->addWidget(m_statusLabel);
lay->addWidget(m_durationLabel);
QHBoxLayout* btn = new QHBoxLayout();
btn->addWidget(m_acceptBtn);
btn->addWidget(m_declineBtn);
btn->addWidget(m_hangupBtn);
lay->addLayout(btn);
lay->addWidget(m_muteBtn);
lay->addWidget(m_deviceCombo);
connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept);
connect(m_declineBtn, &QPushButton::clicked, this, &CallWindow::onDecline);
connect(m_hangupBtn, &QPushButton::clicked, this, &CallWindow::onHangup);
connect(m_muteBtn, &QPushButton::toggled, this, &CallWindow::onMuteToggled);
connect(m_deviceCombo, QOverload<int>::of(&QComboBox::activated), this, &CallWindow::onDeviceChanged);
m_durationTimer = new QTimer(this);
m_durationTimer->setInterval(1000);
connect(m_durationTimer, &QTimer::timeout, this, &CallWindow::updateDuration);
setState(Outgoing);
}
CallWindow::~CallWindow() = default;
void CallWindow::setPeerName(const QString& name) {
m_peerLabel->setText(name);
}
void CallWindow::setState(State s) {
m_state = s;
switch (s) {
case Outgoing:
m_statusLabel->setText(QStringLiteral("Звоним…"));
m_durationLabel->hide();
break;
case Incoming:
m_statusLabel->setText(QStringLiteral("Входящий звонок…"));
m_durationLabel->hide();
break;
case Active:
m_statusLabel->setText(QStringLiteral("Разговор"));
m_durationLabel->setText(QStringLiteral("00:00"));
m_durationLabel->show();
m_startMs = QDateTime::currentMSecsSinceEpoch();
m_durationTimer->start();
populateDeviceCombo();
m_muteBtn->setChecked(false);
break;
case Ended:
m_statusLabel->setText(QStringLiteral("Завершён"));
m_durationTimer->stop();
break;
}
updateButtons();
}
void CallWindow::populateDeviceCombo() {
m_deviceCombo->blockSignals(true);
m_deviceCombo->clear();
m_deviceCombo->addItem(QStringLiteral("Default (auto)"), -1);
const auto devs = SoundManager::enumeratePlaybackDevices();
for (int i = 0; i < devs.size(); i++) {
QString label = devs[i].name;
if (devs[i].isDefault) label += QStringLiteral(" (default)");
m_deviceCombo->addItem(label, i);
}
int cur = CallAudioEngine::instance()->playbackDeviceIndex();
for (int i = 0; i < m_deviceCombo->count(); i++) {
if (m_deviceCombo->itemData(i).toInt() == cur) { m_deviceCombo->setCurrentIndex(i); break; }
}
m_deviceCombo->blockSignals(false);
}
void CallWindow::updateButtons() {
m_acceptBtn->setVisible(m_state == Incoming);
m_declineBtn->setVisible(m_state == Incoming);
m_hangupBtn->setVisible(m_state == Outgoing || m_state == Active);
m_muteBtn->setVisible(m_state == Active);
m_deviceCombo->setVisible(m_state == Active);
}
void CallWindow::postCallFn(void (*fn)(void*), uint64_t callId) {
struct call_id_arg* a = (struct call_id_arg*)u_malloc(sizeof(*a));
if (!a) return;
a->inst = gui_bridge_get_inst();
a->call_id = callId;
gui_bridge_post_uasync_fn(fn, a);
}
void CallWindow::onAccept() {
setState(Active);
postCallFn(call_accept_trampoline, m_callId);
}
void CallWindow::onDecline() {
postCallFn(call_decline_trampoline, m_callId);
}
void CallWindow::onHangup() {
postCallFn(call_hangup_trampoline, m_callId);
}
void CallWindow::onMuteToggled(bool checked) {
CallAudioEngine::instance()->setMuted(checked);
m_muteBtn->setText(checked ? QStringLiteral("Вкл. микрофон") : QStringLiteral("Mute"));
}
void CallWindow::onDeviceChanged(int index) {
int devIdx = m_deviceCombo->itemData(index).toInt();
CallAudioEngine::instance()->setPlaybackDeviceIndex(devIdx);
}
void CallWindow::closeEvent(QCloseEvent* event) {
if (m_state == Active) postCallFn(call_hangup_trampoline, m_callId);
emit closed(m_callId);
event->accept();
}
void CallWindow::updateDuration() {
if (m_state != Active) return;
qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startMs;
qint64 s = ms / 1000;
m_durationLabel->setText(QStringLiteral("%1:%2")
.arg(s / 60, 2, 10, QChar('0'))
.arg(s % 60, 2, 10, QChar('0')));
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <QWidget>
#include <cstdint>
class QLabel;
class QPushButton;
class QComboBox;
class QTimer;
/* Окно звонка: состояния Outgoing (звоним), Incoming (принять/отклонить),
* Active (идёт разговор, таймер), Ended (завершён). */
class CallWindow : public QWidget {
Q_OBJECT
public:
enum State { Outgoing, Incoming, Active, Ended };
explicit CallWindow(QWidget* parent = nullptr);
~CallWindow() override;
void setCallId(quint64 id) { m_callId = id; }
quint64 callId() const { return m_callId; }
void setPeerName(const QString& name);
void setState(State s);
State state() const { return m_state; }
signals:
void closed(quint64 callId);
protected:
void closeEvent(QCloseEvent* event) override;
private slots:
void onAccept();
void onDecline();
void onHangup();
void updateDuration();
void onMuteToggled(bool checked);
void onDeviceChanged(int index);
private:
void updateButtons();
void populateDeviceCombo();
void postCallFn(void (*fn)(void*), uint64_t callId);
quint64 m_callId = 0;
State m_state = Outgoing;
qint64 m_startMs = 0;
QLabel* m_peerLabel;
QLabel* m_statusLabel;
QLabel* m_durationLabel;
QPushButton* m_acceptBtn;
QPushButton* m_declineBtn;
QPushButton* m_hangupBtn;
QPushButton* m_muteBtn;
QComboBox* m_deviceCombo;
QTimer* m_durationTimer;
};
+14
View File
@@ -30,6 +30,20 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
const QModelIndex &index) const {
painter->save();
/* ── заголовок секции (например, «Личные») ── */
if (index.data(ChannelIsSectionRole).toBool()) {
painter->fillRect(option.rect, option.palette.window());
painter->setPen(option.palette.mid().color());
QFont f = option.font;
f.setBold(true);
painter->setFont(f);
QRect tr = option.rect.adjusted(12, 0, -8, 0);
painter->drawText(tr, Qt::AlignLeft | Qt::AlignVCenter,
index.data(ChannelNameRole).toString());
painter->restore();
return;
}
const int barWidth = 4;
const int margin = 8;
const int iconSize = 32;
+2 -1
View File
@@ -13,7 +13,8 @@ enum ChannelDataRole {
ChannelInviteByLinkBtnHoveredRole = Qt::UserRole + 8, /* bool: hover for invite-by-link button */
ChannelSettingsBtnHoveredRole = Qt::UserRole + 9, /* bool: hover for per-channel settings gear */
ChannelUnreadCountRole = Qt::UserRole + 6, /* int: unread message count, 0 if none */
ChannelLastMsgAtRole = Qt::UserRole + 7 /* qint64: last message timestamp for sorting */
ChannelLastMsgAtRole = Qt::UserRole + 7, /* qint64: last message timestamp for sorting */
ChannelIsSectionRole = Qt::UserRole + 10 /* bool: 1 = заголовок секции (не выбирается) */
};
class ChannelDelegate : public QStyledItemDelegate {
+32 -1
View File
@@ -28,6 +28,7 @@ protected:
void mousePressEvent(QMouseEvent* e) override {
QModelIndex idx = indexAt(e->pos());
if (!idx.isValid()) return;
if (idx.data(ChannelIsSectionRole).toBool()) return;
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
if (gearRect.contains(e->pos())) {
emit settingsClicked(idx.data(ChannelChannelIdRole).toString());
@@ -53,7 +54,7 @@ protected:
m_hoveredInviteByLinkRow = -1;
m_hoveredSettingsRow = -1;
QModelIndex idx = indexAt(e->pos());
if (idx.isValid()) {
if (idx.isValid() && !idx.data(ChannelIsSectionRole).toBool()) {
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
QRect invRect = ChannelDelegate::inviteByLinkButtonRect(visualRect(idx));
QRect plusRect = ChannelDelegate::inviteButtonRect(visualRect(idx));
@@ -81,6 +82,8 @@ protected:
QListView::mouseMoveEvent(e);
}
QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex& idx, const QEvent* e) const override {
if (idx.isValid() && idx.data(ChannelIsSectionRole).toBool())
return QItemSelectionModel::NoUpdate;
if (idx.isValid())
return QItemSelectionModel::ClearAndSelect;
return QItemSelectionModel::NoUpdate;
@@ -236,6 +239,34 @@ void ChannelList::loadChannels() {
(long long)ch.lastMsgAt);
ci++;
}
/* ── секция «Личные» (DM-беседы) ── */
auto dms = m_db->getDmConversations();
if (!dms.isEmpty()) {
auto *section = new QStandardItem();
section->setData(QStringLiteral("Личные"), ChannelNameRole);
section->setData(true, ChannelIsSectionRole);
section->setFlags(Qt::NoItemFlags);
m_model->appendRow(section);
static const QColor dmColor(0x8E, 0x44, 0xAD);
for (const auto& dm : dms) {
QString name = dm.peerName;
if (name.isEmpty()) name = QString("0x%1").arg(dm.peerNodeId, 4, 16, QChar('0')).toUpper();
auto letter = name.mid(0, 1).toUpper();
if (letter.isEmpty()) letter = "@";
auto *item = new QStandardItem();
item->setData(name, ChannelNameRole);
item->setData(QStringLiteral("dm:") + dm.convId, ChannelChannelIdRole);
item->setData(QString(), ChannelLastMessageRole);
item->setData(0, ChannelUnreadCountRole);
item->setData(dm.lastTs, ChannelLastMsgAtRole);
item->setData(makeChannelIcon(dmColor, letter), ChannelIconRole);
m_model->appendRow(item);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "ChannelList::loadChannels dm row=%d conv='%s' peer='%s'",
m_model->rowCount()-1, qPrintable(dm.convId), qPrintable(name));
}
}
}
sel->blockSignals(false);
+5
View File
@@ -277,6 +277,11 @@ void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& c
m_channelIdForRecord = channelId;
}
void InputBar::setMediaEnabled(bool enabled) {
if (m_pttBtn) m_pttBtn->setEnabled(enabled);
if (m_attachBtn) m_attachBtn->setEnabled(enabled);
}
void InputBar::onPttPressed() {
if (!m_recorder || !m_recorder->isInitialized()) return;
m_recorder->startRecording();
+3
View File
@@ -18,6 +18,9 @@ public:
void setRecordingContext(const QString& mediaDirBase, const QString& channelId);
/* Включить/выключить медиа-кнопки (PTT/attach). Для DM (text-only) — выключить. */
void setMediaEnabled(bool enabled);
signals:
void sendMessage(const QString &text);
void sendVoiceMessage(const QString& mediaSrc, const QString& mediaDest,
+146
View File
@@ -17,6 +17,8 @@
#include "voiceplayback.h"
#include "audiorecorder.h"
#include "voicemessageencoder.h"
#include "callwindow.h"
#include "call_audio_engine.h"
extern "C" {
#include "../../lib/audio_compressor.h"
@@ -39,10 +41,13 @@ extern "C" {
#include <cmath>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <csignal>
extern "C" {
#include "chat/chat_core.h"
#include "call/call.h"
#include "dm/dm_core.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
void chat_core_attachment_download_trampoline(void* arg);
@@ -417,6 +422,38 @@ void MainWindow::setupBridgeCallbacks() {
if (s_mainWindow->m_currentChannelId == chId)
s_mainWindow->m_accountList->setChannel(chId);
});
/* ── звонки ── */
gui_bridge_set_call_incoming_cb([](const char* ch_id, int ch_id_len, uint64_t caller, uint64_t call_id) {
if (!s_mainWindow) return;
s_mainWindow->onCallIncoming(QString::fromUtf8(ch_id, ch_id_len), caller, call_id);
});
gui_bridge_set_call_ringing_cb([](uint64_t call_id) {
if (s_mainWindow) s_mainWindow->onCallRinging(call_id);
});
gui_bridge_set_call_accepted_cb([](uint64_t call_id) {
if (s_mainWindow) s_mainWindow->onCallAccepted(call_id);
});
gui_bridge_set_call_ended_cb([](uint64_t call_id, uint8_t reason) {
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
});
gui_bridge_set_call_declined_cb([](uint64_t call_id, uint8_t reason) {
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
});
/* ── DM (p2p чат) ── */
gui_bridge_set_dm_conv_updated_cb([](const char* conv_id, int conv_id_len) {
if (s_mainWindow)
s_mainWindow->onDmConvUpdated(QString::fromUtf8(conv_id, conv_id_len));
});
gui_bridge_set_dm_msg_received_cb([](const char* conv_id, int conv_id_len, uint64_t author) {
if (s_mainWindow)
s_mainWindow->onDmMsgReceived(QString::fromUtf8(conv_id, conv_id_len), author);
});
gui_bridge_set_dm_messages_cb([](const uint8_t* data, int len) {
if (s_mainWindow)
s_mainWindow->m_messageList->onDmMessages(data, len);
});
}
void MainWindow::setupConnects() {
@@ -425,6 +462,12 @@ void MainWindow::setupConnects() {
saveLastChannelId(channelId);
m_currentChannelId = channelId;
m_messageList->loadChannel(channelId);
if (channelId.startsWith(QStringLiteral("dm:"))) {
/* DM: беседа уже создана/обслуживается dm_core */
m_accountList->setChannel(QString());
m_channelList->setChannelUnreadCount(channelId, 0);
return;
}
m_accountList->setChannel(channelId);
struct chat_create_auto_arg* arg = (struct chat_create_auto_arg*)u_calloc(1, sizeof(*arg));
if (arg) {
@@ -442,6 +485,8 @@ void MainWindow::setupConnects() {
connect(m_channelList, &ChannelList::channelSettingsRequested, this, &MainWindow::showChannelSettings);
connect(m_accountList, &AccountList::memberRenamed,
m_messageList, &MessageList::reloadLocalNicks);
connect(m_accountList, &AccountList::callRequested, this, &MainWindow::onCallRequested);
connect(m_accountList, &AccountList::pmRequested, this, &MainWindow::onPmRequested);
connect(m_messageList, &MessageList::readPositionChanged,
this, [this](const QString& channelId, qint64 lastReadMsgId) {
Q_UNUSED(lastReadMsgId);
@@ -453,6 +498,107 @@ void MainWindow::setupConnects() {
connect(m_messageList, &MessageList::openImageRequested, this, &MainWindow::openImage);
}
/* ── звонки ── */
void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QString& name) {
quint64 callId = QRandomGenerator::global()->generate64();
if (!callId) callId = 1;
struct call_start_arg* a = (struct call_start_arg*)u_malloc(sizeof(*a));
if (!a) return;
memset(a, 0, sizeof(*a));
a->inst = gui_bridge_get_inst();
strncpy(a->ch_id, chId.toUtf8().constData(), sizeof(a->ch_id) - 1);
a->peer_node_id = nodeId;
a->call_id = callId;
gui_bridge_post_uasync_fn(call_start_trampoline, a);
CallWindow* w = new CallWindow(this);
w->setCallId(callId);
w->setPeerName(name);
w->setState(CallWindow::Outgoing);
connect(w, &CallWindow::closed, this, &MainWindow::closeCallWindow);
m_activeCalls.insert(callId, w);
w->show();
}
void MainWindow::onCallIncoming(const QString& chId, quint64 caller, quint64 callId) {
Q_UNUSED(chId);
if (m_activeCalls.contains(callId)) return;
QString name = m_db->getDisplayName(caller);
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(caller, 16, 16, QChar('0'));
CallWindow* w = new CallWindow(this);
w->setCallId(callId);
w->setPeerName(name);
w->setState(CallWindow::Incoming);
connect(w, &CallWindow::closed, this, &MainWindow::closeCallWindow);
m_activeCalls.insert(callId, w);
w->show();
w->raise();
w->activateWindow();
}
void MainWindow::onCallRinging(quint64 callId) {
/* caller слышит гудок — окно уже показано в Outgoing */
Q_UNUSED(callId);
}
void MainWindow::onCallAccepted(quint64 callId) {
CallWindow* w = m_activeCalls.value(callId, nullptr);
if (w) w->setState(CallWindow::Active);
if (m_db)
CallAudioEngine::instance()->setPlaybackDeviceIndex(m_db->getUiStateInt("audio_output_device", -1));
CallAudioEngine::instance()->start(callId);
}
void MainWindow::onCallEnded(quint64 callId, uint8_t reason) {
Q_UNUSED(reason);
/* тон завершения (~450мс) → авто-teardown аудио-движка */
if (CallAudioEngine::instance()->callId() == callId)
CallAudioEngine::instance()->beginEnd(callId);
closeCallWindow(callId);
}
void MainWindow::closeCallWindow(quint64 callId) {
CallWindow* w = m_activeCalls.take(callId);
if (!w) return;
w->disconnect(this);
w->deleteLater();
}
/* ── DM (p2p чат) ── */
void MainWindow::onPmRequested(const QString& chId, quint64 nodeId, const QString& name) {
struct dm_start_req* r = (struct dm_start_req*)u_malloc(sizeof(*r));
if (!r) return;
memset(r, 0, sizeof(*r));
r->inst = gui_bridge_get_inst();
r->peer_node_id = nodeId;
strncpy(r->source_ch_id, chId.toUtf8().constData(), sizeof(r->source_ch_id) - 1);
strncpy(r->peer_name, name.toUtf8().constData(), sizeof(r->peer_name) - 1);
gui_bridge_post_uasync_fn(dm_start_trampoline, r);
/* открытие беседы произойдёт по CHAT_EVT_DM_CONV_UPDATED (onDmConvUpdated) */
}
void MainWindow::onDmConvUpdated(const QString& convId) {
if (!m_channelList) return;
QString key = QStringLiteral("dm:") + convId;
m_channelList->loadChannels();
m_channelList->selectChannel(key);
}
void MainWindow::onDmMsgReceived(const QString& convId, quint64 author) {
QString key = QStringLiteral("dm:") + convId;
if (m_currentChannelId == key) {
m_messageList->refresh();
return;
}
if (author == m_db->myNodeId()) return;
if (m_channelList)
m_channelList->setChannelUnreadCount(key, m_channelList->getChannelUnreadCount(key) + 1);
}
void MainWindow::setupTray() {
m_trayIcon = new QSystemTrayIcon(this);
m_trayIcon->setIcon(style()->standardIcon(QStyle::SP_ComputerIcon));
+18
View File
@@ -5,6 +5,7 @@
#include <QMenu>
#include <QQueue>
#include <QSet>
#include <QHash>
#include <QTimer>
class ChannelList;
@@ -17,6 +18,8 @@ class AudioRecorder;
class ConnMonitorWindow;
class VideoPlayerWindow;
class ImageViewerWindow;
class CallWindow;
class CallAudioEngine;
struct AutoPlayItem {
QString channelId;
@@ -63,6 +66,19 @@ public:
void requestShutdown();
UtunNode* utunNode() const { return m_node; }
/* звонки */
void onCallRequested(const QString& chId, quint64 nodeId, const QString& name);
void onCallIncoming(const QString& chId, quint64 caller, quint64 callId);
void onCallRinging(quint64 callId);
void onCallAccepted(quint64 callId);
void onCallEnded(quint64 callId, uint8_t reason);
void closeCallWindow(quint64 callId);
/* DM (p2p чат) */
void onPmRequested(const QString& chId, quint64 nodeId, const QString& name);
void onDmConvUpdated(const QString& convId);
void onDmMsgReceived(const QString& convId, quint64 author);
private:
void setupUi();
void saveWindowGeometry();
@@ -101,4 +117,6 @@ private:
QTimer* m_autoPlayTimer = nullptr;
QTimer* m_shutdownTimer = nullptr;
QSet<int64_t> m_autoPlayDownloading; /* msgIds with download in progress */
QHash<quint64, CallWindow*> m_activeCalls; /* call_id → окно звонка */
};
+5
View File
@@ -92,6 +92,11 @@ quint64 MemberListModel::nodeIdAt(int row) const {
return it != m_cache.end() ? it->nodeId : 0;
}
bool MemberListModel::isOnlineAt(int row) const {
auto it = m_cache.find(row);
return it != m_cache.end() ? it->online : false;
}
static void deserialize_member(const uint8_t* src, MemberListItem& item) {
memcpy(&item.nodeId, src, 8);
uint8_t online = src[8];
+1
View File
@@ -34,6 +34,7 @@ public:
void refresh();
void refreshRtt();
quint64 nodeIdAt(int row) const;
bool isOnlineAt(int row) const;
/* Called from bridge callbacks (GUI thread) */
void onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen);
+107 -1
View File
@@ -35,6 +35,7 @@ static std::vector<std::unique_ptr<LottieIcon>> s_icons;
extern "C" {
#include "../transport/gui_bridge.h"
#include "chat/chat_core.h"
#include "dm/dm_core.h"
#include "video/video.h"
#include "../../lib/u_async.h"
#include "../../lib/mem.h"
@@ -357,6 +358,21 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
return;
}
if (isDmChannel(m_currentChannelId)) {
QByteArray textData = text.toUtf8();
QString conv = dmConvId(m_currentChannelId);
struct dm_send_req* req = (struct dm_send_req*)u_malloc(sizeof(struct dm_send_req) + textData.size());
if (!req) return;
memset(req, 0, sizeof(*req));
req->inst = gui_bridge_get_inst();
strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1);
req->data = (uint8_t*)(req + 1);
req->data_len = (uint32_t)textData.size();
memcpy(req->data, textData.constData(), textData.size());
gui_bridge_post_uasync_fn(dm_send_trampoline, req);
return;
}
QByteArray textData = text.toUtf8();
auto ts = QDateTime::currentMSecsSinceEpoch();
@@ -545,6 +561,19 @@ void MessageList::loadChannel(const QString& channelId) {
m_currentChannelId = channelId;
m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId);
m_readDebounce->stop();
/* DM: только текст; сообщения расшифровываются в dm_core (uasync-поток) */
if (isDmChannel(channelId)) {
m_inputBar->setMediaEnabled(false);
requestDmMessages(channelId);
if (prevFocus)
prevFocus->setFocus();
else
m_inputBar->setFocus();
return;
}
m_inputBar->setMediaEnabled(true);
if (!m_db || !m_db->isOpen()) return;
m_localNicks = m_db->getLocalNicknames(channelId);
@@ -606,7 +635,14 @@ void MessageList::loadChannel(const QString& channelId) {
}
void MessageList::refresh() {
if (m_currentChannelId.isEmpty() || !m_db || !m_db->isOpen()) return;
if (m_currentChannelId.isEmpty()) return;
if (isDmChannel(m_currentChannelId)) {
requestDmMessages(m_currentChannelId);
return;
}
if (!m_db || !m_db->isOpen()) return;
qint64 maxId = 0;
for (int r = 0; r < m_model->rowCount(); r++)
@@ -909,3 +945,73 @@ void MessageList::onMessageContextMenu(const QPoint& pos) {
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req);
}
}
/* ── DM (p2p чат) ── */
bool MessageList::isDmChannel(const QString& channelId) {
return channelId.startsWith(QStringLiteral("dm:"));
}
QString MessageList::dmConvId(const QString& channelId) {
return channelId.mid(3);
}
void MessageList::requestDmMessages(const QString& channelId) {
QString conv = dmConvId(channelId);
struct dm_messages_req* req = (struct dm_messages_req*)u_malloc(sizeof(*req));
if (!req) return;
memset(req, 0, sizeof(*req));
req->inst = gui_bridge_get_inst();
strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1);
gui_bridge_post_uasync_fn(dm_messages_trampoline, req);
}
void MessageList::onDmMessages(const uint8_t* data, int len) {
if (len < 3) return;
uint8_t cl = data[0];
if (len < 1 + (int)cl + 2) return;
QString convId = QString::fromUtf8((const char*)data + 1, cl);
if (m_currentChannelId != QStringLiteral("dm:") + convId) return; /* stale response */
uint16_t count; memcpy(&count, data + 1 + cl, 2);
const uint8_t* p = data + 1 + cl + 2;
const uint8_t* end = data + len;
m_model->clear();
m_avatarCache.clear();
for (int i = 0; i < count; i++) {
if (p + 1 + 8 + 8 + 8 + 1 > end) break;
int dir = p[0]; p += 1;
uint64_t seq; memcpy(&seq, p, 8); p += 8;
uint64_t ts; memcpy(&ts, p, 8); p += 8;
quint64 author; memcpy(&author, p, 8); p += 8;
uint8_t ctl = *p; p += 1;
if (p + ctl > end) break;
p += ctl; /* ct игнорируем: DM v1 — текст */
if (p + 2 > end) break;
uint16_t dlen; memcpy(&dlen, p, 2); p += 2;
if (p + dlen > end) break;
QString text = QString::fromUtf8((const char*)p, dlen);
p += dlen;
AccountRow acc = m_db ? m_db->getAccount(author) : AccountRow();
QString name = authorName(author);
QString letter = acc.avatarLetter.isEmpty() ? name.mid(0, 1) : acc.avatarLetter;
QColor color(acc.avatarColor);
if (!m_avatarCache.contains(author))
m_avatarCache[author] = color;
auto avatar = makeAvatar(color, letter);
auto time = QDateTime::fromMSecsSinceEpoch((qint64)ts).toString("HH:mm");
auto *item = new QStandardItem();
setMsg(item, name, text, time, avatar, false,
false, QString(), QString(), QVariantList{});
item->setData((qint64)seq, MsgIdRole);
item->setData(QVariant::fromValue(author), MsgAuthorNodeIdRole);
item->setData(dir == 1 ? 1 : 0, MsgIsOutgoingRole);
m_model->appendRow(item);
}
m_view->scrollToBottom();
}
+4
View File
@@ -26,6 +26,7 @@ public:
static void cleanupAnimations();
void updateMessageProgress(const QString& chId, int64_t msgId, int blocksDone, int numBlocks);
void onAttachmentDownloaded(const QString& chId, int64_t msgId);
void onDmMessages(const uint8_t* data, int len);
signals:
void readPositionChanged(const QString& channelId, qint64 lastReadMsgId);
@@ -52,4 +53,7 @@ private:
void restoreScrollPosition(const QString& channelId);
void updateReadPosition();
void onMessageContextMenu(const QPoint& pos);
void requestDmMessages(const QString& channelId);
static bool isDmChannel(const QString& channelId);
static QString dmConvId(const QString& channelId);
};
+101
View File
@@ -0,0 +1,101 @@
// test_audio_jitter.cpp — проверка адаптивного джиттер-буфера (SoundTouch).
//
// 1. Нет потерь до ёмкости (50 кадров = 1000мс), дроп после переполнения.
// 2. Догон: глубокий буфер → темп растёт выше 1.0x (ускоренное воспроизведение).
// 3. Стационар: при неглубоком буфере темп остаётся ~1.0x (без runaway).
#include "call/audio_jitter.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
static int g_fail = 0;
#define CHECK(cond, ...) do { \
if (!(cond)) { g_fail++; printf("FAIL: " __VA_ARGS__); printf("\n"); } \
} while (0)
static void make_sine(std::vector<int16_t>& frame, double freq, double phase0) {
for (size_t i = 0; i < frame.size(); i++)
frame[i] = (int16_t)(sin(phase0 + 2.0 * M_PI * freq * (double)i / 48000.0) * 8000.0);
}
int main(void) {
const int FRAME = 960;
const int CAP = 50;
std::vector<int16_t> frame(FRAME, 0);
std::vector<int16_t> out(480, 0);
int buffer_ms = 0, tempo_x100 = 0;
/* ── 1. ёмкость и дроп ── */
{
ajb* j = ajb_create(48000, 1, FRAME, 20, CAP);
CHECK(j != nullptr, "create failed");
make_sine(frame, 440.0, 0.0);
for (int i = 0; i < CAP; i++) ajb_push(j, frame.data(), FRAME);
ajb_get_stats(j, &buffer_ms, &tempo_x100);
printf(" capacity: pushed %d frames, buffer_ms=%d dropped=%u\n", CAP, buffer_ms, (unsigned)ajb_dropped(j));
CHECK(ajb_dropped(j) == 0, "expected no drop at capacity, dropped=%u", (unsigned)ajb_dropped(j));
CHECK(buffer_ms == CAP * 20, "expected buffer_ms=%d, got %d", CAP * 20, buffer_ms);
for (int i = 0; i < 5; i++) ajb_push(j, frame.data(), FRAME);
printf(" overflow: dropped=%u (expect 5)\n", (unsigned)ajb_dropped(j));
CHECK(ajb_dropped(j) == 5, "expected 5 drops, got %u", (unsigned)ajb_dropped(j));
ajb_destroy(j);
}
/* ── 2. догон: глубокий буфер → tempo > 1.0x ── */
{
ajb* j = ajb_create(48000, 1, FRAME, 20, CAP);
make_sine(frame, 440.0, 0.0);
for (int i = 0; i < 40; i++) ajb_push(j, frame.data(), FRAME); /* 800мс burst */
int max_tempo = 100;
int total_out = 0;
for (int it = 0; it < 200; it++) {
ajb_get_stats(j, &buffer_ms, &tempo_x100);
if (buffer_ms == 0) break;
if (tempo_x100 > max_tempo) max_tempo = tempo_x100;
int n = ajb_pull(j, out.data(), (int)out.size());
total_out += n;
}
ajb_get_stats(j, &buffer_ms, &tempo_x100);
printf(" catch-up: max_tempo_x100=%d total_out=%d final_buffer_ms=%d\n",
max_tempo, total_out, buffer_ms);
CHECK(max_tempo > 110, "expected tempo to exceed 1.10x during deep buffer, got %d", max_tempo);
CHECK(total_out < 40 * FRAME, "expected output < input (stretch), out=%d in=%d", total_out, 40 * FRAME);
CHECK(buffer_ms == 0, "expected drained buffer, buffer_ms=%d", buffer_ms);
ajb_destroy(j);
}
/* ── 3. стационар: feed 1 frame → pull 1 frame, tempo ~1.0x ── */
{
ajb* j = ajb_create(48000, 1, FRAME, 20, CAP);
make_sine(frame, 440.0, 0.0);
int max_tempo = 100;
for (int i = 0; i < 60; i++) {
ajb_push(j, frame.data(), FRAME);
int got = 0;
while (got < FRAME) {
int n = ajb_pull(j, out.data(), (int)out.size());
if (n <= 0) break;
got += n;
}
ajb_get_stats(j, &buffer_ms, &tempo_x100);
if (tempo_x100 > max_tempo) max_tempo = tempo_x100;
}
printf(" steady-state: max_tempo_x100=%d (expect ~100)\n", max_tempo);
CHECK(max_tempo <= 110, "tempo should stay ~1.0x in steady state, got %d", max_tempo);
ajb_destroy(j);
}
if (g_fail == 0) {
printf("TEST PASSED\n");
return 0;
}
printf("TEST FAILED (%d checks)\n", g_fail);
return 1;
}
+37
View File
@@ -39,6 +39,15 @@ struct UTUN_INSTANCE;
#define GUI_EVT_ADMIN_KEY_RECEIVED 28 /* data: [ch_id_len:1][ch_id:var][from_node_id:8] — получен канальный ключ */
#define GUI_EVT_INVITE_LINK_READY 29 /* data: [ch_id_len:1][ch_id:var][link_len:2][link:var] — пустой link = ошибка */
#define GUI_EVT_INVITE_CANDIDATES 30 /* data: [ch_id_len:1][ch_id:var][auto_node_id:8][count:2][node_id:8]* — достижимые узлы для invite */
#define GUI_EVT_DM_MSG_RECEIVED 32 /* data: [conv_id_len:1][conv_id:var][author_node_id:8] */
#define GUI_EVT_DM_CONV_UPDATED 33 /* data: [conv_id_len:1][conv_id:var] — беседа создана/изменена */
#define GUI_EVT_CALL_INCOMING 34 /* data: [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
#define GUI_EVT_CALL_RINGING 35 /* data: [call_id:8] */
#define GUI_EVT_CALL_ACCEPTED 36 /* data: [call_id:8] */
#define GUI_EVT_CALL_ENDED 37 /* data: [call_id:8][reason:1] */
#define GUI_EVT_CALL_DECLINED 38 /* data: [call_id:8][reason:1] */
#define GUI_EVT_CALL_STATS 39 /* data: [call_id:8][rtt_ms:2][buffer_ms:2][tempo_x100:2] */
#define GUI_EVT_DM_MESSAGES 41 /* data: [conv_id_len:1][conv_id:var][count:2]([dir:1][seq:8][ts:8][author:8][ct_len:1][ct:var][data_len:2][data:var])* */
/* Размеры элементов пакета GUI_EVT_MEMBER_DETAIL (должны совпадать с chat_status.c) */
#define MEMBER_DETAIL_LINK_SIZE 64
@@ -165,6 +174,34 @@ void gui_bridge_set_invite_link_ready_cb(gui_invite_link_ready_fn cb);
typedef void (*gui_invite_candidates_fn)(const char* ch_id, int ch_id_len, const uint8_t* data, int len);
void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb);
/* Callback для входящего звонка (вызывается из GUI-потока) */
typedef void (*gui_call_incoming_fn)(const char* ch_id, int ch_id_len, uint64_t caller_node_id, uint64_t call_id);
void gui_bridge_set_call_incoming_cb(gui_call_incoming_fn cb);
/* Callback для звонкового события с call_id (ringing/accepted) */
typedef void (*gui_call_id_fn)(uint64_t call_id);
void gui_bridge_set_call_ringing_cb(gui_call_id_fn cb);
void gui_bridge_set_call_accepted_cb(gui_call_id_fn cb);
/* Callback для завершения/отклонения звонка (вызывается из GUI-потока) */
typedef void (*gui_call_ended_fn)(uint64_t call_id, uint8_t reason);
void gui_bridge_set_call_ended_cb(gui_call_ended_fn cb);
void gui_bridge_set_call_declined_cb(gui_call_ended_fn cb);
/* Callback для входящего DM-сообщения (вызывается из GUI-потока)
* conv_id_len/conv_id — идентификатор беседы (десятичная строка). */
typedef void (*gui_dm_msg_received_fn)(const char* conv_id, int conv_id_len, uint64_t author);
void gui_bridge_set_dm_msg_received_cb(gui_dm_msg_received_fn cb);
/* Callback: DM-беседа создана/изменена (вызывается из GUI-потока). */
typedef void (*gui_dm_conv_updated_fn)(const char* conv_id, int conv_id_len);
void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb);
/* Callback: расшифрованные сообщения DM-беседы (вызывается из GUI-потока)
* data: [conv_id_len:1][conv_id:var][count:2]([dir:1][seq:8][ts:8][author:8][ct_len:1][ct:var][data_len:2][data:var])* */
typedef void (*gui_dm_messages_fn)(const uint8_t* data, int len);
void gui_bridge_set_dm_messages_cb(gui_dm_messages_fn cb);
/* uTun nodeinfo callback — registered via utun_add_nodeinfo_cbk, called from uasync thread */
void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* nq, void* arg);
+113
View File
@@ -48,6 +48,14 @@ static gui_bgp_info_fn g_bgp_info_cb = nullptr;
static gui_admin_key_received_fn g_admin_key_received_cb = nullptr;
static gui_invite_link_ready_fn g_invite_link_ready_cb = nullptr;
static gui_invite_candidates_fn g_invite_candidates_cb = nullptr;
static gui_call_incoming_fn g_call_incoming_cb = nullptr;
static gui_call_id_fn g_call_ringing_cb = nullptr;
static gui_call_id_fn g_call_accepted_cb = nullptr;
static gui_call_ended_fn g_call_ended_cb = nullptr;
static gui_call_ended_fn g_call_declined_cb = nullptr;
static gui_dm_msg_received_fn g_dm_msg_received_cb = nullptr;
static gui_dm_conv_updated_fn g_dm_conv_updated_cb = nullptr;
static gui_dm_messages_fn g_dm_messages_cb = nullptr;
static struct UASYNC* g_ua = nullptr;
static struct UTUN_INSTANCE* g_inst = nullptr;
@@ -236,6 +244,79 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
}
}
break;
case GUI_EVT_DM_MSG_RECEIVED:
if (dlen >= 2) {
uint8_t cl = d[0];
if (dlen >= 1 + cl + 8) {
uint64_t author; memcpy(&author, d + 1 + cl, 8);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: DM_MSG_RECEIVED conv=%.*s author=%llu",
cl, (const char*)d + 1, (unsigned long long)author);
if (g_dm_msg_received_cb) g_dm_msg_received_cb((const char*)d + 1, cl, author);
}
}
break;
case GUI_EVT_DM_CONV_UPDATED:
if (dlen >= 2) {
uint8_t cl = d[0];
if (dlen >= 1 + cl) {
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: DM_CONV_UPDATED conv=%.*s",
cl, (const char*)d + 1);
if (g_dm_conv_updated_cb) g_dm_conv_updated_cb((const char*)d + 1, cl);
}
}
break;
case GUI_EVT_DM_MESSAGES:
if (g_dm_messages_cb) g_dm_messages_cb(d, dlen);
break;
case GUI_EVT_CALL_INCOMING:
if (dlen >= 1 + 8 + 8) {
uint8_t chLen = d[0];
if (dlen >= 1 + chLen + 8 + 8) {
uint64_t caller, callId;
memcpy(&caller, d + 1 + chLen, 8);
memcpy(&callId, d + 1 + chLen + 8, 8);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "gui_bridge: CALL_INCOMING ch=%.*s caller=0x%016llx id=0x%016llx",
chLen, (const char*)d + 1, (unsigned long long)caller, (unsigned long long)callId);
if (g_call_incoming_cb) g_call_incoming_cb((const char*)d + 1, chLen, caller, callId);
}
}
break;
case GUI_EVT_CALL_RINGING:
if (dlen >= 8) {
uint64_t callId; memcpy(&callId, d, 8);
if (g_call_ringing_cb) g_call_ringing_cb(callId);
}
break;
case GUI_EVT_CALL_ACCEPTED:
if (dlen >= 8) {
uint64_t callId; memcpy(&callId, d, 8);
if (g_call_accepted_cb) g_call_accepted_cb(callId);
}
break;
case GUI_EVT_CALL_ENDED:
if (dlen >= 9) {
uint64_t callId; memcpy(&callId, d, 8);
if (g_call_ended_cb) g_call_ended_cb(callId, d[8]);
}
break;
case GUI_EVT_CALL_DECLINED:
if (dlen >= 9) {
uint64_t callId; memcpy(&callId, d, 8);
if (g_call_declined_cb) g_call_declined_cb(callId, d[8]);
}
break;
case GUI_EVT_CALL_STATS:
if (dlen >= 14) {
uint64_t callId; memcpy(&callId, d, 8);
uint16_t rtt = (uint16_t)((d[8] << 8) | d[9]);
uint16_t buf = (uint16_t)((d[10] << 8) | d[11]);
uint16_t tempo = (uint16_t)((d[12] << 8) | d[13]);
DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC,
"gui_bridge: CALL_STATS id=0x%016llx rtt=%ums buffer=%ums tempo=%u.%02ux",
(unsigned long long)callId, (unsigned)rtt, (unsigned)buf,
(unsigned)(tempo / 100), (unsigned)(tempo % 100));
}
break;
default:
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "gui_bridge: unknown event type %d", eventType);
break;
@@ -377,6 +458,38 @@ void gui_bridge_set_invite_candidates_cb(gui_invite_candidates_fn cb) {
g_invite_candidates_cb = cb;
}
void gui_bridge_set_call_incoming_cb(gui_call_incoming_fn cb) {
g_call_incoming_cb = cb;
}
void gui_bridge_set_call_ringing_cb(gui_call_id_fn cb) {
g_call_ringing_cb = cb;
}
void gui_bridge_set_call_accepted_cb(gui_call_id_fn cb) {
g_call_accepted_cb = cb;
}
void gui_bridge_set_call_ended_cb(gui_call_ended_fn cb) {
g_call_ended_cb = cb;
}
void gui_bridge_set_call_declined_cb(gui_call_ended_fn cb) {
g_call_declined_cb = cb;
}
void gui_bridge_set_dm_msg_received_cb(gui_dm_msg_received_fn cb) {
g_dm_msg_received_cb = cb;
}
void gui_bridge_set_dm_conv_updated_cb(gui_dm_conv_updated_fn cb) {
g_dm_conv_updated_cb = cb;
}
void gui_bridge_set_dm_messages_cb(gui_dm_messages_fn cb) {
g_dm_messages_cb = cb;
}
} /* extern "C" */
/* ── uTun nodeinfo callback (called from uasync thread) ── */