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:
1 parent
0157f7f96b
commit
b11e77e4c0
78 files changed
+7053
-70
No files matched your search
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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 ──
|
||||
|
||||
Reference in new issue
Block a user