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

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

No files matched your search

+2
View File
@@ -252,11 +252,13 @@ log_udp_port = 9999
- `libutun_lite/invite_link_c.h/c` — Кодирование и декодирование invite-ссылок `utun://` в бинарный формат. Совместим с десктопной версией
- `libutun_lite/utun_config_api.h/c` — Поставщик конфигурации из Kotlin в C-ядро через callback-интерфейс (get_string, get_int64, get_int)
- `libutun_lite/voice_recorder.h/c` — Запись голосовых сообщений: накопление PCM-сэмплов с компрессором, кодирование в Opus-файл, отправка в канал через chat_core
- `libutun_lite/call_audio.h/c` — Аудио-движок P2P-звонка: Opus encode (PCM→peer через call_send_media) и decode (peer→PCM→коллбэк). Аудио I/O в Kotlin (AudioRecord/AudioTrack), кодеки в C
- `libutun_lite/attachment_sender.h/c` — Отправка файлов в канал: копирование в media-директорию и регистрация через chat_core (media_index → db_sync)
- `libutun_lite/utun_sources.cmake` — Список всех .c файлов, компилируемых в libutun_lite. Новые файлы добавлять сюда
### Kotlin-слой
- `data/NativeLib.kt` — Kotlin-обёртка над C-библиотекой: все вызовы из Kotlin транслируются в JNI-функции
- `data/CallAudioEngine.kt` — Аудио-движок звонка: AudioRecord/AudioTrack, jitter-буфер, audio-focus, потоки захвата/воспроизведения
- `data/ChatRepository.kt` — Хранилище данных: буферизация сообщений и каналов между C-ядром и UI через StateFlow
- `data/InviteLink.kt` — Разбор invite-ссылок: извлекает ID канала, публичный ключ, адреса для подключения
- `data/ConfigProvider.kt` — Поставщик конфигурации из Android DataStore в C-ядро через callback-интерфейс
@@ -5,9 +5,17 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" android:minSdkVersion="33" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -21,6 +29,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@@ -37,5 +46,9 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="p2p_chat_network_service" />
</service>
<receiver
android:name=".data.IncomingCallActionReceiver"
android:exported="false" />
</application>
</manifest>
@@ -6,6 +6,7 @@ import com.utun.chat.data.LogManager
import com.utun.chat.data.ConfigProvider
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.ChatEventCallback
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -37,6 +38,9 @@ class ChatApplication : Application() {
/* One-time event callback registration — survives ViewModel recreation */
NativeLib.setEventCallback(AppEventHandler)
/* Incoming-call system effects (wake + ringtone + notification) — app-scoped */
IncomingCallManager.init()
/* First launch config */
if (configProvider.isFirstLaunch()) {
kotlinx.coroutines.runBlocking {
@@ -1,7 +1,11 @@
package com.utun.chat
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
@@ -19,6 +23,7 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.compose.LifecycleStartEffect
import com.utun.chat.data.Channel
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.InviteData
import com.utun.chat.data.LogManager
import com.utun.chat.data.Message
@@ -29,11 +34,16 @@ import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord }
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan, VideoRecord, PhotoRecord, Call }
private var overLock = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermission()
handleIncomingCallIntent(intent)
/* Edge-to-edge with dark status/navigation bar icons (light app theme) */
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT),
@@ -63,9 +73,38 @@ class MainActivity : ComponentActivity() {
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var photoMessage by remember { mutableStateOf<Message?>(null) }
var hadCall by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val callState by vm.callState.collectAsState()
/* Пока идёт звонок — держим экран включённым; после завершения снимаем
и showWhenLocked (если он был выставлен для входящего поверх блокировки). */
LaunchedEffect(callState) {
val st = callState
val inCall = st != null && st.phase != ChatViewModel.CallPhase.NONE &&
st.phase != ChatViewModel.CallPhase.ENDED
if (inCall) {
hadCall = true
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (hadCall) releaseLockScreen()
}
}
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE/ENDED → экран Call,
INCOMING → диалог поверх текущего экрана. */
LaunchedEffect(callState?.phase) {
when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING,
ChatViewModel.CallPhase.ACTIVE, ChatViewModel.CallPhase.ENDED -> screen = Screen.Call
null -> if (screen == Screen.Call) screen = Screen.ChannelList
else -> {}
}
}
val showInvite: (Channel) -> Unit = { ch ->
if (ch.id.toULongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
@@ -81,6 +120,7 @@ class MainActivity : ComponentActivity() {
Screen.MemberList -> screen = Screen.Chat
Screen.VideoRecord -> screen = Screen.Chat
Screen.PhotoRecord -> screen = Screen.Chat
Screen.Call -> { /* блокируем back во время звонка — только явная кнопка */ }
else -> screen = Screen.ChannelList
}
}
@@ -156,6 +196,10 @@ class MainActivity : ComponentActivity() {
}
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
Screen.Call -> CallScreen(
viewModel = vm,
onClose = { vm.dismissCall(); screen = Screen.ChannelList }
)
Screen.QrScan -> {
QrScanScreen(
onResult = { link ->
@@ -203,6 +247,10 @@ class MainActivity : ComponentActivity() {
PhotoViewerScreen(filePath = msg.filePath, onClose = { photoMessage = null })
}
}
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm)
}
}
/* Show join dialog after QR scan returns */
@@ -250,6 +298,49 @@ class MainActivity : ComponentActivity() {
if (NativeLib.isReady()) NativeLib.setActive(true)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingCallIntent(intent)
}
/** Full-screen intent входящего звонка: показать activity поверх блокировки. */
private fun handleIncomingCallIntent(intent: Intent?) {
val callId = intent?.getLongExtra(IncomingCallManager.EXTRA_CALL_ID, 0L) ?: 0L
if (callId == 0L) return
LogManager.addLog("INFO", "MAIN", "launched for incoming call 0x%016x".format(callId))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
overLock = true
} else {
@Suppress("DEPRECATION")
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
}
}
/** Снять «показ поверх блокировки», выставленный для входящего звонка. */
private fun releaseLockScreen() {
if (!overLock) return
overLock = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
}
}
override fun onStop() {
super.onStop()
if (NativeLib.isReady()) NativeLib.setActive(false)
@@ -0,0 +1,263 @@
package com.utun.chat.data
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
import android.media.AudioTrack
import android.media.MediaRecorder
import com.utun.chat.ChatApplication
/**
* Полнодуплексный аудио-движок P2P-звонка (аналог десктопного CallAudioEngine).
*
* Потоки:
* - capture-поток: AudioRecord.read(960) → NativeLib.callAudioFeed (C: Opus encode → peer)
* - play-поток: jitter-буфер → AudioTrack.write (кадры декодированы в C, приходят через onCallPcm)
* - uasync-поток: onCallPcm(callId, PCM) ← C (call_audio media_recv)
*
* Jitter-буфер: очередь PCM-кадров (~50 кадров = 1с), pre-buffer 3 кадра (60мс),
* при опустошении — тишина (комфортный шум) + счётчик gap для диагностики.
*/
class CallAudioEngine {
companion object {
const val SAMPLE_RATE = 48000
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
const val JITTER_TARGET_FRAMES = 3 // pre-buffer 60мс
const val JITTER_MAX_FRAMES = 50 // ~1с
}
private var audioRecord: AudioRecord? = null
private var captureThread: Thread? = null
private var audioTrack: AudioTrack? = null
private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false
/** Маршрутизация аудио-выхода (динамик/громкая/гарнитура). */
val router = CallAudioRouter()
private val jitter = ArrayDeque<ShortArray>()
private val lock = Object()
@Volatile private var active = false
@Volatile private var callId = 0L
@Volatile private var muted = false
@Volatile private var callbackRegistered = false
fun isActive(): Boolean = active
/** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
fun setMuted(m: Boolean) {
if (muted == m) return
muted = m
LogManager.addLog("INFO", "CallAudio", "mute ${if (m) "on" else "off"}")
}
fun isMuted(): Boolean = muted
/** Запустить полнодуплекс для звонка. Возвращает true при успехе. */
fun start(id: Long): Boolean {
stop()
if (!callbackRegistered) {
NativeLib.setCallAudioCallback(object : CallAudioCallback {
override fun onCallPcm(callId: Long, samples: ShortArray) = onPcm(callId, samples)
})
callbackRegistered = true
}
if (!NativeLib.callAudioStart(id)) {
LogManager.addLog("ERROR", "CallAudio", "native callAudioStart failed id=$id")
return false
}
callId = id
muted = false
val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode
prevSpeaker = am.isSpeakerphoneOn
try {
am.mode = AudioManager.MODE_IN_COMMUNICATION
am.isSpeakerphoneOn = true
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}")
}
router.start()
// ── AudioRecord (захват) ──
val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
audioRecord = try {
AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed: ${e.message}")
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
}
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized")
audioRecord?.release(); audioRecord = null
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
}
// ── AudioTrack (воспроизведение) ──
val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
audioTrack = try {
AudioTrack(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(),
AudioFormat.Builder().setSampleRate(SAMPLE_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(),
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "AudioTrack create failed: ${e.message}")
audioRecord?.release(); audioRecord = null
audioTrack = null
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
}
synchronized(lock) { jitter.clear() }
/* Прямое роутирование трека на выбранное устройство вывода (обход глючной
глобальной маршрутизации на ряде устройств). */
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
applyTrackOutputDevice(router.currentOutputDevice)
active = true
audioRecord?.startRecording()
audioTrack?.play()
captureThread = Thread { captureLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
}
playThread = Thread { playLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
}
LogManager.addLog("INFO", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf")
return true
}
fun stop() {
if (!active) return
active = false
captureThread?.join(1000); captureThread = null
playThread?.join(1000); playThread = null
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() }
audioRecord = null
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() }
audioTrack = null
synchronized(lock) { jitter.clear() }
NativeLib.callAudioStop()
router.onOutputDevice = null
router.stop()
restoreAudioMode()
callId = 0L
muted = false
LogManager.addLog("INFO", "CallAudio", "stopped")
}
/** uasync-поток: принятый и декодированный в C PCM-кадр пира. */
private fun onPcm(cid: Long, samples: ShortArray) {
if (!active || cid != callId) return
synchronized(lock) {
if (jitter.size >= JITTER_MAX_FRAMES) jitter.removeFirst() // дроп старейшего
jitter.addLast(samples)
}
}
private fun captureLoop() {
val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES)
while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
var read = 0
while (read < FRAME_SAMPLES) {
if (!active) return
val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return
if (n <= 0) return
read += n
}
val out = if (muted) silence else buf
if (!NativeLib.callAudioFeed(callId, out)) {
LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false")
}
}
LogManager.addLog("DEBUG", "CallAudio", "capture loop done")
}
private fun playLoop() {
val silence = ShortArray(FRAME_SAMPLES)
var gaps = 0
// pre-buffer: ждём накопления jitter (до 250мс), чтобы сгладить стартовую задержку
val deadline = System.currentTimeMillis() + 250
while (active && jitter.size < JITTER_TARGET_FRAMES && System.currentTimeMillis() < deadline) {
try { Thread.sleep(10) } catch (_: InterruptedException) { return }
}
while (active) {
val t0 = System.nanoTime()
val frame = synchronized(lock) { if (jitter.isNotEmpty()) jitter.removeFirst() else null }
val track = audioTrack ?: return
if (frame != null) {
track.write(frame, 0, frame.size)
if (gaps > 0) {
LogManager.addLog("DEBUG", "CallAudio", "jitter recovered after $gaps gaps")
gaps = 0
}
} else {
track.write(silence, 0, silence.size)
gaps++
if (gaps == 1) LogManager.addLog("WARN", "CallAudio", "playback underrun (jitter empty)")
}
val elapsedMs = (System.nanoTime() - t0) / 1_000_000
val sleepMs = FRAME_MS - elapsedMs
if (sleepMs > 0) {
try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return }
}
}
LogManager.addLog("DEBUG", "CallAudio", "play loop done")
}
private fun restoreAudioMode() {
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = prevSpeaker
am.mode = prevMode
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "restore audio mode failed: ${e.message}")
}
}
private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) {
val track = audioTrack ?: return
if (dev == null) return
try {
track.setPreferredDevice(dev)
LogManager.addLog("DEBUG", "CallAudio", "track preferred device -> type=${dev.type}")
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudio", "setPreferredDevice failed: ${e.message}")
}
}
}
@@ -0,0 +1,305 @@
package com.utun.chat.data
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothHeadset
import android.bluetooth.BluetoothProfile
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication
/** Аудио-выход звонка: динамик (earpiece) ↔ громкая связь (speaker) ↔ гарнитура. */
enum class AudioRoute { EARPIECE, SPEAKER, HEADSET }
/**
* Маршрутизация аудио звонка поверх AudioManager + мониторинг гарнитуры.
*
* - EARPIECE: разговорный динамик (isSpeakerphoneOn=false).
* - SPEAKER: громкая связь (isSpeakerphoneOn=true).
* - HEADSET: проводная/USB гарнитура или Bluetooth (SCO) — если подключена.
*
* Гарнитура детектится через AudioDeviceCallback (проводная, USB, SCO) и
* BluetoothHeadset profile (классический BT до установки SCO). Без BLUETOOTH_CONNECT
* (Android 12+) BT-часть деградирует до «не видно» — остаётся проводная.
*
* Владелец — CallAudioEngine (start/stop в начале/конце звонка), т.к. именно он
* управляет режимом AudioManager (MODE_IN_COMMUNICATION).
*/
class CallAudioRouter {
@Volatile var route = AudioRoute.SPEAKER
private set
@Volatile var headsetAvailable = false
private set
@Volatile var hasEarpiece = true
private set
/** Уведомление об изменении маршрута или доступности гарнитуры. */
var onChanged: ((AudioRoute, Boolean) -> Unit)? = null
/** Целевое устройство вывода (для прямого роутирования AudioTrack через setPreferredDevice). */
var onOutputDevice: ((AudioDeviceInfo?) -> Unit)? = null
@Volatile var currentOutputDevice: AudioDeviceInfo? = null
private set
private val am: AudioManager =
ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var started = false
private var deviceCallback: AudioDeviceCallback? = null
private var btHeadset: BluetoothHeadset? = null
private var btProfileProxyBound = false
fun start() {
if (started) return
started = true
val cb = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged()
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) = onDevicesChanged()
}
am.registerAudioDeviceCallback(cb, Handler(Looper.getMainLooper()))
deviceCallback = cb
bindBtHeadsetProfile()
hasEarpiece = findBuiltinEarpiece() != null
route = AudioRoute.SPEAKER
applyRoute(route)
refresh()
LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece)")
}
fun stop() {
if (!started) return
started = false
deviceCallback?.let { am.unregisterAudioDeviceCallback(it) }
deviceCallback = null
unbindBtHeadsetProfile()
try {
stopBluetoothSco()
am.isBluetoothScoOn = false
clearCommunicationDevice()
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "stop cleanup failed: ${e.message}")
}
LogManager.addLog("INFO", "CallAudioRouter", "stopped")
}
fun setRoute(r: AudioRoute) {
if (r == AudioRoute.HEADSET && !headsetAvailable) {
LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available")
return
}
if (route == r) return
route = r
applyRoute(r)
LogManager.addLog("INFO", "CallAudioRouter", "route -> $r (headsetAvailable=$headsetAvailable)")
onChanged?.invoke(route, headsetAvailable)
}
/* ── применение маршрута ── */
private fun applyRoute(r: AudioRoute) {
try {
var dev: AudioDeviceInfo? = null
when (r) {
AudioRoute.EARPIECE -> {
stopBluetoothSco()
am.isBluetoothScoOn = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dev = findBuiltinEarpiece() ?: findBuiltinSpeaker()
if (dev != null) am.setCommunicationDevice(dev)
else am.isSpeakerphoneOn = false
} else {
am.isSpeakerphoneOn = false
}
}
AudioRoute.SPEAKER -> {
stopBluetoothSco()
am.isBluetoothScoOn = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dev = findBuiltinSpeaker()
if (dev != null) am.setCommunicationDevice(dev)
else am.isSpeakerphoneOn = true
} else {
am.isSpeakerphoneOn = true
}
}
AudioRoute.HEADSET -> {
am.isSpeakerphoneOn = false
val bt = findBtScoDevice()
if (bt != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.setCommunicationDevice(bt)
} else {
startBluetoothSco()
}
dev = bt
} else if (isBtHeadsetProfileConnected()) {
/* классический BT: SCO ещё не поднят — запускаем и ждём
* AudioDeviceCallback, который пере-применит маршрут */
startBluetoothSco()
} else {
val wired = findWiredDevice()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) {
am.setCommunicationDevice(wired)
} else {
/* API<31: проводная гарнитура роутится автоматически при speakerphone=off */
am.isSpeakerphoneOn = false
}
dev = wired
}
}
}
currentOutputDevice = dev
onOutputDevice?.invoke(dev)
LogManager.addLog("INFO", "CallAudioRouter",
"route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}")
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}")
}
}
private fun findBuiltinSpeaker(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
}
private fun findBuiltinEarpiece(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
}
/* ── мониторинг гарнитуры ── */
private fun onDevicesChanged() {
/* если в режиме HEADSET появилось SCO-устройство — пере-применяем маршрут,
* иначе SCO не замаршрутизируется автоматически */
if (route == AudioRoute.HEADSET) applyRoute(route)
refresh()
}
private fun refresh() {
val now = isHeadsetConnected()
val prev = headsetAvailable
headsetAvailable = now
if (now != prev) {
LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now")
}
/* гарнитуру отключили — откатываемся на динамик */
if (!now && route == AudioRoute.HEADSET) {
route = AudioRoute.EARPIECE
applyRoute(route)
LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> EARPIECE")
}
if (now != prev || route == AudioRoute.HEADSET) {
onChanged?.invoke(route, now)
}
}
private fun isHeadsetConnected(): Boolean =
findWiredDevice() != null || findBtScoDevice() != null || isBtHeadsetProfileConnected()
private fun findWiredDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { isWiredType(it.type) }
private fun findBtScoDevice(): AudioDeviceInfo? =
am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
}
private fun isWiredType(type: Int): Boolean = when (type) {
AudioDeviceInfo.TYPE_WIRED_HEADSET,
AudioDeviceInfo.TYPE_WIRED_HEADPHONES,
AudioDeviceInfo.TYPE_USB_HEADSET,
AudioDeviceInfo.TYPE_USB_DEVICE,
AudioDeviceInfo.TYPE_USB_ACCESSORY -> true
else -> false
}
/* ── Bluetooth headset profile ── */
private fun bindBtHeadsetProfile() {
try {
val adapter = BluetoothAdapter.getDefaultAdapter() ?: return
adapter.getProfileProxy(ChatApplication.instance, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
btHeadset = proxy as BluetoothHeadset
btProfileProxyBound = true
LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile connected")
onDevicesChanged()
}
}
override fun onServiceDisconnected(profile: Int) {
if (profile == BluetoothProfile.HEADSET) {
btHeadset = null
btProfileProxyBound = false
LogManager.addLog("INFO", "CallAudioRouter", "bt headset profile disconnected")
onDevicesChanged()
}
}
}, BluetoothProfile.HEADSET)
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "bt profile proxy failed: ${e.message}")
}
}
private fun unbindBtHeadsetProfile() {
try {
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter != null && btProfileProxyBound) {
adapter.closeProfileProxy(BluetoothProfile.HEADSET, btHeadset)
}
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "unbind bt profile failed: ${e.message}")
}
btHeadset = null
btProfileProxyBound = false
}
private fun hasBtConnectPermission(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ChatApplication.instance.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
private fun isBtHeadsetProfileConnected(): Boolean {
if (!hasBtConnectPermission()) return false
return try {
btHeadset?.connectedDevices?.isNotEmpty() == true
} catch (e: SecurityException) {
LogManager.addLog("WARN", "CallAudioRouter", "bt connectedDevices denied: ${e.message}")
false
}
}
/* ── helpers ── */
private fun clearCommunicationDevice() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.clearCommunicationDevice()
}
}
private fun startBluetoothSco() {
try {
am.startBluetoothSco()
am.isBluetoothScoOn = true
} catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "startBluetoothSco failed: ${e.message}")
}
}
private fun stopBluetoothSco() {
try { am.stopBluetoothSco() } catch (e: Exception) {
LogManager.addLog("WARN", "CallAudioRouter", "stopBluetoothSco failed: ${e.message}")
}
}
}
@@ -0,0 +1,25 @@
package com.utun.chat.data
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** Кнопка «Завершить» на уведомлении входящего звонка. */
class IncomingCallActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_DECLINE = "com.utun.chat.action.DECLINE_CALL"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION_DECLINE) return
val callId = intent.getLongExtra(IncomingCallManager.EXTRA_CALL_ID, 0L)
if (callId == 0L) {
LogManager.addLog("ERROR", "InCall", "DECLINE action: bad call_id")
return
}
LogManager.addLog("INFO", "InCall", "DECLINE action callId=0x%016x".format(callId))
NativeLib.callDecline(callId)
IncomingCallManager.stopRinging()
}
}
@@ -0,0 +1,216 @@
package com.utun.chat.data
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.utun.chat.AppEventHandler
import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Системные эффекты входящего звонка, независимые от жизненного цикла Activity:
* пробуждение устройства, полноэкранное уведомление (с кнопкой «Завершить») и мелодия
* через профиль звонка. Подписывается на AppEventHandler.events в app-скопе — работает
* и при убитом Activity, пока жив foreground-сервис с C-ядром.
*
* UI-состоянием (accept/decline/отрисовка) по-прежнему владеет ChatViewModel;
* этот объект лишь будит устройство и хранит отложенный входящий звонок,
* который VM забирает через consumePendingIncoming() при (пере)создании.
*/
object IncomingCallManager {
const val CHANNEL_CALL = "incoming_call"
const val NOTIF_ID = 1001
const val EXTRA_CALL_ID = "call_id"
const val EXTRA_CHANNEL_ID = "channel_id"
const val EXTRA_PEER_NODE_ID = "peer_node_id"
data class PendingCall(val callId: Long, val channelId: String, val peerNodeId: Long)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private val ringtone = RingtonePlayer()
@Volatile private var initialized = false
@Volatile private var activeCallId = 0L
@Volatile private var pendingCall: PendingCall? = null
private var wakeLock: PowerManager.WakeLock? = null
fun init() {
if (initialized) return
initialized = true
createChannel(ChatApplication.instance)
scope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
}
LogManager.addLog("INFO", "InCall", "manager initialized")
}
fun currentIncomingCallId(): Long = activeCallId
/** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */
fun canFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
val nm = ChatApplication.instance.getSystemService(NotificationManager::class.java)
return nm.canUseFullScreenIntent()
}
/** VM забирает отложенный входящий (сценарий «Activity убит, звонок пришёл»). */
@Synchronized
fun consumePendingIncoming(): PendingCall? {
val p = pendingCall
pendingCall = null
return p
}
/** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */
fun stopRinging() {
val id = activeCallId
if (id == 0L) return
activeCallId = 0L
ringtone.stop()
releaseWakeLock()
cancelNotification()
pendingCall = null
LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id))
}
private fun handleEvent(type: Int, data: ByteArray?) {
when (type) {
34 -> onIncoming(data) /* CALL_INCOMING */
36, 37, 38, 40 -> onCallStop(data) /* ACCEPTED/ENDED/DECLINED/ERROR */
}
}
private fun onIncoming(data: ByteArray?) {
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 16) return
val chId = String(data, 1, cl)
val buf = ByteBuffer.wrap(data, 1 + cl, 16).order(ByteOrder.LITTLE_ENDIAN)
val caller = buf.long
val callId = buf.long
if (activeCallId != 0L) {
LogManager.addLog("WARN", "InCall", "busy (0x%016x), skip incoming 0x%016x".format(activeCallId, callId))
return
}
activeCallId = callId
pendingCall = PendingCall(callId, chId, caller)
LogManager.addLog("INFO", "InCall", "incoming call 0x%016x from 0x%016x ch=%s".format(callId, caller, chId))
acquireWakeLock()
ringtone.play()
showNotification(callId, chId, caller)
}
private fun onCallStop(data: ByteArray?) {
if (data == null || data.size < 8) return
val callId = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
if (activeCallId == 0L || callId != activeCallId) return
stopRinging()
}
/* ── пробуждение устройства ── */
private fun acquireWakeLock() {
val pm = ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = try {
pm.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP
or PowerManager.ON_AFTER_RELEASE,
"utun:incoming_call"
).apply { acquire(30_000) }
} catch (e: Exception) {
LogManager.addLog("WARN", "InCall", "wake lock failed: ${e.message}")
null
}
}
private fun releaseWakeLock() {
wakeLock?.let {
try { if (it.isHeld) it.release() } catch (_: Exception) {}
}
wakeLock = null
}
/* ── уведомление ── */
private fun createChannel(ctx: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL_CALL, "Входящие звонки", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Уведомление о входящем звонке"
enableVibration(false)
setSound(null, null)
}
ctx.getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
}
}
private fun showNotification(callId: Long, channelId: String, peerNodeId: Long) {
val ctx = ChatApplication.instance
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
LogManager.addLog("WARN", "InCall", "notifications disabled — skip (still ringing)")
return
}
val launchIntent = Intent(ctx, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(EXTRA_CALL_ID, callId)
putExtra(EXTRA_CHANNEL_ID, channelId)
putExtra(EXTRA_PEER_NODE_ID, peerNodeId)
}
val contentPi = PendingIntent.getActivity(ctx, 0, launchIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val declinePi = PendingIntent.getBroadcast(ctx, 0,
Intent(ctx, IncomingCallActionReceiver::class.java).apply {
action = IncomingCallActionReceiver.ACTION_DECLINE
putExtra(EXTRA_CALL_ID, callId)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val builder = NotificationCompat.Builder(ctx, CHANNEL_CALL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Входящий звонок")
.setContentText("0x%016x".format(peerNodeId))
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(contentPi)
.addAction(0, "Завершить", declinePi)
val nm = ctx.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || nm.canUseFullScreenIntent()) {
builder.setFullScreenIntent(contentPi, true)
} else {
LogManager.addLog("WARN", "InCall", "full-screen intent not allowed — heads-up only")
}
try {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, builder.build())
LogManager.addLog("INFO", "InCall", "notification posted callId=0x%016x".format(callId))
} catch (e: SecurityException) {
LogManager.addLog("WARN", "InCall", "notify denied: ${e.message}")
}
}
private fun cancelNotification() {
try {
NotificationManagerCompat.from(ChatApplication.instance).cancel(NOTIF_ID)
} catch (e: Exception) {
LogManager.addLog("WARN", "InCall", "cancel notification failed: ${e.message}")
}
}
}
@@ -6,6 +6,11 @@ interface ChatEventCallback {
fun onEvent(type: Int, data: ByteArray?)
}
/** Приём декодированного PCM-кадра пира (вызывается из uasync-потока). */
interface CallAudioCallback {
fun onCallPcm(callId: Long, samples: ShortArray)
}
object NativeLib {
init {
@@ -38,6 +43,12 @@ object NativeLib {
})
}
fun setCallAudioCallback(cb: CallAudioCallback) {
nativeSetCallAudioCallback(object : CallAudioCallback {
override fun onCallPcm(callId: Long, samples: ShortArray) { cb.onCallPcm(callId, samples) }
})
}
fun destroy() { nativeDestroy() }
fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
@@ -139,6 +150,18 @@ object NativeLib {
fun voiceDecodeRead(handle: Long, buf: ShortArray): Int = nativeVoiceDecodeRead(handle, buf)
fun voiceDecodeClose(handle: Long) { nativeVoiceDecodeClose(handle) }
/* ── P2P аудио-звонок (сигналинг) ── */
fun callStart(channelId: String, peerNodeId: Long, callId: Long) { nativeCallStart(channelId, peerNodeId, callId) }
fun callAccept(callId: Long) { nativeCallAccept(callId) }
fun callDecline(callId: Long) { nativeCallDecline(callId) }
fun callHangup(callId: Long) { nativeCallHangup(callId) }
fun callIsOnline(channelId: String, peerNodeId: Long): Boolean = nativeCallIsOnline(channelId, peerNodeId)
/* ── P2P аудио-звонок (медиа) ── */
fun callAudioStart(callId: Long): Boolean = nativeCallAudioStart(callId)
fun callAudioFeed(callId: Long, samples: ShortArray): Boolean = nativeCallAudioFeed(callId, samples)
fun callAudioStop() { nativeCallAudioStop() }
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
private external fun nativeStart(configText: String): Boolean
private external fun nativeStop()
@@ -215,4 +238,15 @@ object NativeLib {
private external fun nativeVoiceDecodeOpen(filePath: String, outInfo: IntArray): Long
private external fun nativeVoiceDecodeRead(handle: Long, buf: ShortArray): Int
private external fun nativeVoiceDecodeClose(handle: Long)
/* ── P2P аудио-звонок JNI ── */
private external fun nativeSetCallAudioCallback(cb: Any)
private external fun nativeCallStart(channelId: String, peerNodeId: Long, callId: Long)
private external fun nativeCallAccept(callId: Long)
private external fun nativeCallDecline(callId: Long)
private external fun nativeCallHangup(callId: Long)
private external fun nativeCallIsOnline(channelId: String, peerNodeId: Long): Boolean
private external fun nativeCallAudioStart(callId: Long): Boolean
private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray): Boolean
private external fun nativeCallAudioStop()
}
@@ -0,0 +1,158 @@
package com.utun.chat.data
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.Ringtone
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.provider.Settings
import com.utun.chat.ChatApplication
import com.utun.chat.R
/**
* Мелодия входящего звонка через профиль звонка (STREAM_RING):
* - громкость — настройка «громкость вызова»;
* - вибрация — по системной настройке «вибрировать при вызове» (VIBRATE_WHEN_RINGING).
*
* API 28+ : Ringtone (RingtoneManager) — громкость и вибрация автоматически.
* API 26–27: MediaPlayer(STREAM_RING, loop) + ручная вибрация по системной настройке.
*/
class RingtonePlayer {
private var ringtone: Ringtone? = null
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var focusRequest: AudioFocusRequest? = null
fun play() {
stop()
val ctx = ChatApplication.instance
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val uri = Uri.parse("android.resource://${ctx.packageName}/${R.raw.ringtone}")
val r = try {
RingtoneManager.getRingtone(ctx, uri)
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "getRingtone failed: ${e.message}")
null
} ?: return
try {
r.isLooping = true
r.play()
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "play failed: ${e.message}")
return
}
ringtone = r
LogManager.addLog("INFO", "Ringtone", "started (Ringtone, STREAM_RING + system vibrate)")
} else {
val mp = try {
MediaPlayer.create(ctx, R.raw.ringtone)
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer.create failed: ${e.message}")
null
} ?: return
try {
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_RING)
.build()
)
mp.isLooping = true
requestAudioFocus()
mp.start()
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer start failed: ${e.message}")
try { mp.release() } catch (_: Exception) {}
abandonAudioFocus()
return
}
mediaPlayer = mp
startVibrate()
LogManager.addLog("INFO", "Ringtone", "started (MediaPlayer fallback, STREAM_RING)")
}
}
fun stop() {
ringtone?.let { try { it.stop() } catch (_: Exception) {} }
ringtone = null
mediaPlayer?.let {
try { it.stop() } catch (_: Exception) {}
try { it.release() } catch (_: Exception) {}
}
mediaPlayer = null
stopVibrate()
abandonAudioFocus()
LogManager.addLog("INFO", "Ringtone", "stopped")
}
/* ── вибрация (fallback API 26–27) по системной настройке ── */
private fun systemVibrateWhenRinging(): Boolean = try {
Settings.System.getInt(
ChatApplication.instance.contentResolver, Settings.System.VIBRATE_WHEN_RINGING, 0) != 0
} catch (e: Exception) {
false
}
private fun startVibrate() {
if (!systemVibrateWhenRinging()) return
val v = try {
ChatApplication.instance.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibrator unavailable: ${e.message}")
null
} ?: return
if (!v.hasVibrator()) return
try {
v.vibrate(VibrationEffect.createWaveform(longArrayOf(1000, 1000), 0))
vibrator = v
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibrate failed: ${e.message}")
}
}
private fun stopVibrate() {
vibrator?.let { try { it.cancel() } catch (_: Exception) {} }
vibrator = null
}
/* ── audio focus (fallback API 26–27) ── */
private fun requestAudioFocus() {
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val attrs = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(attrs)
.build()
am.requestAudioFocus(req)
focusRequest = req
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "audio focus failed: ${e.message}")
}
}
private fun abandonAudioFocus() {
val req = focusRequest ?: return
focusRequest = null
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.abandonAudioFocusRequest(req)
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "abandon focus failed: ${e.message}")
}
}
}
@@ -0,0 +1,194 @@
package com.utun.chat.ui.screens
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Headset
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.PhoneInTalk
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import com.utun.chat.data.AudioRoute
import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
private fun fmtDuration(sec: Int): String {
val m = sec / 60
val s = sec % 60
return "%02d:%02d".format(m, s)
}
private fun callStatusText(phase: CallPhase): String = when (phase) {
CallPhase.OUTGOING -> "Звоним…"
CallPhase.RINGING -> "Гудки…"
CallPhase.ACTIVE -> "Разговор"
CallPhase.ENDED -> "Завершён"
else -> ""
}
@Composable
private fun CallControlButton(
icon: ImageVector,
contentDescription: String,
label: String,
active: Boolean,
activeColor: Color = Color(0xFF1F6FEB),
onClick: () -> Unit
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(60.dp)
.background(if (active) activeColor else Color(0xFF24323D), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = onClick) {
Icon(icon, contentDescription, tint = Color.White, modifier = Modifier.size(28.dp))
}
}
Spacer(Modifier.height(6.dp))
Text(label, color = if (active) Color.White else Color(0xFF9BA6AD), fontSize = 11.sp)
}
}
@Composable
fun CallScreen(
viewModel: ChatViewModel,
onClose: () -> Unit
) {
val st = viewModel.callState.collectAsState().value
val muted by viewModel.muted.collectAsState()
val route by viewModel.audioRoute.collectAsState()
val headsetAvailable by viewModel.headsetAvailable.collectAsState()
val earpieceAvailable by viewModel.earpieceAvailable.collectAsState()
val phase = st?.phase ?: CallPhase.NONE
val duration = st?.durationMs ?: 0
val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""
val context = LocalContext.current
val hasBtPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
PackageManager.PERMISSION_GRANTED
val btPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) viewModel.setAudioRoute(AudioRoute.HEADSET)
}
Box(
modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape),
contentAlignment = Alignment.Center
) {
Text(
name.take(1).uppercase(),
fontSize = 40.sp,
color = Color.White,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(24.dp))
Text(name, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text(
if (phase == CallPhase.ACTIVE) fmtDuration(duration) else callStatusText(phase),
color = Color(0xFF9BA6AD),
fontSize = 16.sp
)
Spacer(Modifier.height(40.dp))
if (phase == CallPhase.ENDED) {
Button(onClick = { viewModel.dismissCall(); onClose() }) {
Text("Закрыть")
}
} else {
if (phase == CallPhase.ACTIVE) {
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
if (earpieceAvailable) {
CallControlButton(
icon = Icons.Filled.PhoneInTalk,
contentDescription = "Динамик",
label = "Динамик",
active = route == AudioRoute.EARPIECE,
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
)
}
CallControlButton(
icon = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Громкая связь",
label = "Громкая",
active = route == AudioRoute.SPEAKER,
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
)
if (headsetAvailable) {
CallControlButton(
icon = Icons.Filled.Headset,
contentDescription = "Гарнитура",
label = "Гарнитура",
active = route == AudioRoute.HEADSET,
onClick = {
if (hasBtPermission) {
viewModel.setAudioRoute(AudioRoute.HEADSET)
} else {
btPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
}
)
}
}
Spacer(Modifier.height(28.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(48.dp), verticalAlignment = Alignment.CenterVertically) {
if (phase == CallPhase.ACTIVE) {
CallControlButton(
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
contentDescription = if (muted) "Включить микрофон" else "Выключить микрофон",
label = if (muted) "Вкл. микрофон" else "Mute",
active = muted,
activeColor = Color(0xFFE53935),
onClick = { viewModel.toggleMute() }
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(72.dp).background(Color(0xFFE53935), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.hangupCall() }) {
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(36.dp))
}
}
Spacer(Modifier.height(6.dp))
Text("Завершить", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
}
}
}
}
}
@@ -0,0 +1,76 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.utun.chat.viewmodel.ChatViewModel
@Composable
fun IncomingCallDialog(
viewModel: ChatViewModel
) {
val st = viewModel.callState.collectAsState().value ?: return
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
Dialog(onDismissRequest = { }) {
Surface(
shape = MaterialTheme.shapes.large,
color = Color(0xFF1B2733)
) {
Column(
modifier = Modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.size(80.dp).background(Color(0xFF1F6FEB), CircleShape),
contentAlignment = Alignment.Center
) {
Text(name.take(1).uppercase(), fontSize = 34.sp, color = Color.White, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.height(20.dp))
Text(name, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(6.dp))
Text("Входящий звонок…", color = Color(0xFF9BA6AD), fontSize = 14.sp)
Spacer(Modifier.height(28.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(64.dp).background(Color(0xFFE53935), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.declineCall() }) {
Icon(Icons.Filled.CallEnd, "Отклонить", tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Отклонить", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier.size(64.dp).background(Color(0xFF43A047), CircleShape),
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.acceptCall() }) {
Icon(Icons.Filled.Call, "Принять", tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Принять", color = Color(0xFF9BA6AD), fontSize = 12.sp)
}
}
}
}
}
}
@@ -84,6 +84,7 @@ fun MemberListScreen(
isSelected = selectedMember?.nodeId == m.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onConnect = { viewModel.connectMemberNode(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onEdit = {
editMember = m
showEditDialog = true
@@ -135,6 +136,7 @@ private fun MemberListItem(
isSelected: Boolean = false,
onClick: () -> Unit,
onConnect: () -> Unit,
onCall: () -> Unit,
onEdit: () -> Unit
) {
val onlineColor = when {
@@ -227,6 +229,10 @@ private fun MemberListItem(
if (!member.isSelf) {
TextButton(onClick = onConnect) { Text("Connect") }
TextButton(
onClick = onCall,
enabled = member.online
) { Text("Call") }
}
TextButton(onClick = onEdit) { Text("Edit") }
}
@@ -18,12 +18,18 @@ import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.LogManager
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.headless.HeadlessService
import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -519,6 +525,43 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Входящие звонки поверх блокировки ── */
Text("Incoming calls", style = MaterialTheme.typography.titleMedium)
var fsiEnabled by remember { mutableStateOf(IncomingCallManager.canFullScreenIntent()) }
LifecycleResumeEffect(Unit) {
fsiEnabled = IncomingCallManager.canFullScreenIntent()
onPauseOrDispose { }
}
Row(
modifier = Modifier.fillMaxWidth().clickable(
enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && !fsiEnabled
) {
val ctx = ChatApplication.instance
try {
ctx.startActivity(Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT,
Uri.parse("package:${ctx.packageName}")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "SETTINGS", "open full-screen-intent settings failed: ${e.message}")
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text("Show over lock screen")
Text(
when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> "Always allowed"
fsiEnabled -> "Allowed"
else -> "Disabled — tap to enable"
},
style = MaterialTheme.typography.bodySmall,
color = if (fsiEnabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Microphone test ── */
Text("Microphone test", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -33,9 +33,40 @@ class ChatViewModel : ViewModel() {
val durMs: Int = 0
)
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
data class CallState(
val callId: Long = 0,
val channelId: String = "",
val peerNodeId: Long = 0,
val peerName: String = "",
val phase: CallPhase = CallPhase.NONE,
val reason: Int = -1,
val durationMs: Int = 0
)
private var repo: ChatRepository? = null
private var dbReady = false
private val _callState = MutableStateFlow<CallState?>(null)
val callState: StateFlow<CallState?> = _callState
val callAudio = CallAudioEngine()
private var callStartedMs = 0L
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted
private val _audioRoute = MutableStateFlow(AudioRoute.SPEAKER)
val audioRoute: StateFlow<AudioRoute> = _audioRoute
private val _headsetAvailable = MutableStateFlow(false)
val headsetAvailable: StateFlow<Boolean> = _headsetAvailable
private val _earpieceAvailable = MutableStateFlow(true)
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState
@@ -138,6 +169,16 @@ class ChatViewModel : ViewModel() {
refreshChannels()
}
}
viewModelScope.launch {
delay(300)
val p = IncomingCallManager.consumePendingIncoming() ?: return@launch
val cur = _callState.value
if (cur != null && cur.callId == p.callId) return@launch
LogManager.addLog("INFO", "VM", "restore pending incoming call 0x%016x ch=%s".format(p.callId, p.channelId))
_callState.value = CallState(callId = p.callId, channelId = p.channelId, peerNodeId = p.peerNodeId,
peerName = "0x%016x".format(p.peerNodeId), phase = CallPhase.INCOMING)
resolveCallerName(p.channelId, p.peerNodeId)
}
viewModelScope.launch {
_foreground.collectLatest { fg ->
while (fg && isActive) {
@@ -180,6 +221,16 @@ class ChatViewModel : ViewModel() {
}
}
}
viewModelScope.launch {
while (isActive) {
val st = _callState.value
if (st != null && st.phase == CallPhase.ACTIVE) {
val dur = ((System.currentTimeMillis() - callStartedMs) / 1000).toInt()
_callState.value = st.copy(durationMs = dur)
}
delay(500)
}
}
}
private fun handleEvent(type: Int, data: ByteArray?) {
@@ -417,9 +468,170 @@ class ChatViewModel : ViewModel() {
}
refreshChannels()
}
34 -> { /* CALL_INCOMING: [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 8 + 8) return
val chId = String(data, 1, cl)
val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 16).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val caller = buf.getLong(); val callId = buf.getLong()
LogManager.addLog("INFO", "VM", "CALL_INCOMING ch=$chId caller=0x%016x id=0x%016x".format(caller, callId))
val cur = _callState.value
if (cur != null && cur.callId == callId) return // уже обрабатываем этот звонок (replay/restore)
if (cur != null && cur.callId != callId && cur.phase != CallPhase.ENDED) {
// заняты другим звонком — авто-отклоняем
LogManager.addLog("WARN", "VM", "busy with another call, auto-decline id=0x%016x".format(callId))
NativeLib.callDecline(callId)
return
}
_callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller,
peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING)
resolveCallerName(chId, caller)
}
35 -> { /* CALL_RINGING: [call_id:8] */
if (data == null || data.size < 8) return
val callId = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val st = _callState.value
if (st != null && st.callId == callId) {
_callState.value = st.copy(phase = CallPhase.RINGING)
LogManager.addLog("INFO", "VM", "CALL_RINGING id=0x%016x".format(callId))
}
}
36 -> { /* CALL_ACCEPTED: [call_id:8] */
if (data == null || data.size < 8) return
val callId = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val st = _callState.value
if (st != null && st.callId == callId) {
LogManager.addLog("INFO", "VM", "CALL_ACCEPTED id=0x%016x".format(callId))
if (!callAudio.start(callId)) {
LogManager.addLog("ERROR", "VM", "call audio engine start failed")
NativeLib.callHangup(callId)
_callState.value = st.copy(phase = CallPhase.ENDED, reason = 7)
} else {
callStartedMs = System.currentTimeMillis()
_muted.value = false
callAudio.router.onChanged = { r, headset ->
_audioRoute.value = r
_headsetAvailable.value = headset
}
_audioRoute.value = callAudio.router.route
_headsetAvailable.value = callAudio.router.headsetAvailable
_earpieceAvailable.value = callAudio.router.hasEarpiece
_callState.value = st.copy(phase = CallPhase.ACTIVE, durationMs = 0)
}
}
}
37 -> { /* CALL_ENDED: [call_id:8][reason:1] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val reason = data[8].toInt() and 0xFF
finishCall(callId, reason, "ENDED")
}
38 -> { /* CALL_DECLINED: [call_id:8][reason:1] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val reason = data[8].toInt() and 0xFF
finishCall(callId, reason, "DECLINED")
}
40 -> { /* CALL_ERROR: [call_id:8][err:1][text:var] */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val err = data[8].toInt() and 0xFF
val text = if (data.size > 9) String(data, 9, data.size - 9) else ""
LogManager.addLog("ERROR", "VM", "CALL_ERROR id=0x%016x err=$err $text".format(callId))
finishCall(callId, 7, "ERROR")
}
}
}
private fun resolveCallerName(chId: String, caller: Long) {
val r = repo ?: return
viewModelScope.launch {
val name = withContext(Dispatchers.IO) { r.getMembers(chId) }
.firstOrNull { it.nodeId == caller }?.name?.takeIf { it.isNotEmpty() }
val st = _callState.value
if (name != null && st != null && st.peerNodeId == caller && st.phase == CallPhase.INCOMING) {
_callState.value = st.copy(peerName = name)
}
}
}
private fun finishCall(callId: Long, reason: Int, tag: String) {
val st = _callState.value
LogManager.addLog("INFO", "VM", "CALL_$tag id=0x%016x reason=$reason".format(callId))
if (st == null || st.callId != callId) return
callAudio.stop()
IncomingCallManager.stopRinging()
_muted.value = false
_audioRoute.value = AudioRoute.SPEAKER
_headsetAvailable.value = false
_earpieceAvailable.value = true
_callState.value = st.copy(phase = CallPhase.ENDED, reason = reason)
viewModelScope.launch {
delay(2500)
if (_callState.value?.callId == callId) _callState.value = null
}
}
fun startCall(channelId: String, peerNodeId: Long, peerName: String) {
val existing = _callState.value
if (existing != null && existing.phase != CallPhase.ENDED) {
LogManager.addLog("WARN", "VM", "startCall: call already active")
return
}
var callId = java.security.SecureRandom().nextLong()
if (callId == 0L) callId = 1
val name = peerName.ifEmpty { "0x%016x".format(peerNodeId) }
_callState.value = CallState(callId = callId, channelId = channelId, peerNodeId = peerNodeId,
peerName = name, phase = CallPhase.OUTGOING)
NativeLib.callStart(channelId, peerNodeId, callId)
LogManager.addLog("INFO", "VM", "startCall ch=$channelId peer=0x%016x id=0x%016x".format(peerNodeId, callId))
}
fun acceptCall() {
val st = _callState.value ?: return
if (st.phase != CallPhase.INCOMING) return
IncomingCallManager.stopRinging()
NativeLib.callAccept(st.callId)
LogManager.addLog("INFO", "VM", "acceptCall id=0x%016x".format(st.callId))
}
fun declineCall() {
val st = _callState.value ?: return
if (st.phase == CallPhase.INCOMING) {
NativeLib.callDecline(st.callId)
finishCall(st.callId, 2, "DECLINE")
} else {
NativeLib.callHangup(st.callId)
finishCall(st.callId, 0, "HANGUP")
}
}
fun hangupCall() {
val st = _callState.value ?: return
if (st.phase == CallPhase.OUTGOING || st.phase == CallPhase.RINGING || st.phase == CallPhase.ACTIVE) {
NativeLib.callHangup(st.callId)
finishCall(st.callId, 0, "HANGUP")
}
}
fun dismissCall() {
callAudio.stop()
_callState.value = null
}
fun toggleMute() {
val m = !callAudio.isMuted()
callAudio.setMuted(m)
_muted.value = m
LogManager.addLog("INFO", "VM", "toggleMute -> ${if (m) "on" else "off"}")
}
fun setAudioRoute(r: AudioRoute) {
callAudio.router.setRoute(r)
LogManager.addLog("INFO", "VM", "setAudioRoute -> $r")
}
fun requestRegenerateKeys() {
_generatingKeys.value = true
NativeLib.regenerateKeys()
@@ -811,6 +1023,8 @@ class ChatViewModel : ViewModel() {
}
fun clearState() {
callAudio.stop()
_callState.value = null
repo?.close()
repo = null
dbReady = false
@@ -16,6 +16,8 @@
#include "../libutun_lite/video_sender.h"
#include "../libutun_lite/photo_sender.h"
#include "../libutun_lite/standby.h"
#include "../libutun_lite/call_audio.h"
#include "../../../src/call/call.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/u_async.h"
#include "../../../lib/mem.h"
@@ -1320,6 +1322,69 @@ void utun_bridge_voice_decode_close(void* handle) {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "voice_decode_close");
}
/* ──────────────────────────────────────────────────────────────────
* P2P аудио-звонок (сигналинг)
* ────────────────────────────────────────────────────────────────── */
void utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id) {
if (!ch_id || !ch_id[0] || peer_node_id == 0 || call_id == 0) {
bridge_log(BLEV_ERROR, "call_start: bad args ch=%s peer=0x%llx id=0x%llx",
ch_id ? ch_id : "(null)",
(unsigned long long)peer_node_id, (unsigned long long)call_id);
return;
}
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "call_start: uasync not running"); return; }
struct call_start_arg* a = (struct call_start_arg*)u_calloc(1, sizeof(*a));
if (!a) { bridge_log(BLEV_ERROR, "call_start: OOM"); return; }
a->inst = instance_lite_get_instance();
snprintf(a->ch_id, sizeof(a->ch_id), "%s", ch_id);
a->peer_node_id = peer_node_id;
a->call_id = call_id;
bridge_log(BLEV_INFO, "call_start ch=%s peer=0x%llx id=0x%llx",
ch_id, (unsigned long long)peer_node_id, (unsigned long long)call_id);
uasync_post(ua, call_start_trampoline, a);
}
static void bridge_call_id_action(uint64_t call_id, const char* name,
void (*trampoline)(void*)) {
if (call_id == 0) { bridge_log(BLEV_ERROR, "call_%s: bad call_id=0", name); return; }
struct UASYNC* ua = instance_lite_get_uasync();
if (!uasync_is_running(ua)) { bridge_log(BLEV_ERROR, "call_%s: uasync not running", name); return; }
struct call_id_arg* a = (struct call_id_arg*)u_calloc(1, sizeof(*a));
if (!a) { bridge_log(BLEV_ERROR, "call_%s: OOM", name); return; }
a->inst = instance_lite_get_instance();
a->call_id = call_id;
bridge_log(BLEV_INFO, "call_%s id=0x%llx", name, (unsigned long long)call_id);
uasync_post(ua, trampoline, a);
}
void utun_bridge_call_accept(uint64_t call_id) { bridge_call_id_action(call_id, "accept", call_accept_trampoline); }
void utun_bridge_call_decline(uint64_t call_id) { bridge_call_id_action(call_id, "decline", call_decline_trampoline); }
void utun_bridge_call_hangup(uint64_t call_id) { bridge_call_id_action(call_id, "hangup", call_hangup_trampoline); }
int utun_bridge_call_is_online(const char* ch_id, uint64_t peer_node_id) {
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
if (!inst) return 0;
return call_is_online(inst, ch_id, peer_node_id);
}
/* ──────────────────────────────────────────────────────────────────
* P2P аудио-звонок (медиа: call_audio.c)
* ────────────────────────────────────────────────────────────────── */
int utun_bridge_call_audio_start(uint64_t call_id) {
return call_audio_start(call_id);
}
int utun_bridge_call_audio_feed(uint64_t call_id, const int16_t* pcm, int count) {
return call_audio_feed_pcm(call_id, pcm, count);
}
void utun_bridge_call_audio_stop(void) {
call_audio_stop();
}
/* ──────────────────────────────────────────────────────────────────
* JNI functions (compiled only for Android via NDK)
* ────────────────────────────────────────────────────────────────── */
@@ -1331,6 +1396,8 @@ static jobject g_config_callback = NULL;
static jobject g_log_callback = NULL;
static jobject g_event_callback = NULL;
static jmethodID g_event_mid = NULL;
static jobject g_call_audio_callback = NULL;
static jmethodID g_call_audio_mid = NULL;
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
(void)reserved;
@@ -1396,6 +1463,29 @@ void instance_event_handler(int type, const uint8_t* data, int len) {
jni_on_event(type, data, len);
}
/* ── JNI call-audio PCM callback → CallAudioEngine.onCallPcm() ──
* Вызывается из uasync-потока (call.c media_recv → call_audio_media_recv). */
static void jni_call_audio_pcm(uint64_t call_id, const int16_t* pcm, int count, void* arg) {
(void)arg;
if (!g_jvm || !g_call_audio_callback || !g_call_audio_mid || !pcm || count <= 0) return;
JNIEnv* env = NULL;
int attached = 0;
jint res = (*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6);
if (res == JNI_EDETACHED) {
if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) != JNI_OK) return;
attached = 1;
} else if (res != JNI_OK) {
return;
}
jshortArray jpcm = (*env)->NewShortArray(env, count);
if (jpcm) {
(*env)->SetShortArrayRegion(env, jpcm, 0, count, (const jshort*)pcm);
(*env)->CallVoidMethod(env, g_call_audio_callback, g_call_audio_mid, (jlong)call_id, jpcm);
(*env)->DeleteLocalRef(env, jpcm);
}
if (attached) (*g_jvm)->DetachCurrentThread(g_jvm);
}
static jstring jni_get_string_callback(JNIEnv* env, const char* key) {
if (!g_config_callback) return NULL;
jclass cls = (*env)->GetObjectClass(env, g_config_callback);
@@ -1445,6 +1535,7 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStart(
/* Set up event forwarder (bridge config/log are set by nativeInit) */
instance_lite_set_event_handler(instance_event_handler);
call_audio_set_pcm_recv_cb(jni_call_audio_pcm, NULL);
int rc = utun_bridge_start(cfg);
(*env)->ReleaseStringUTFChars(env, configText, cfg);
@@ -1579,6 +1670,8 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDestroy(
if (g_config_callback) { (*env)->DeleteGlobalRef(env, g_config_callback); g_config_callback = NULL; }
if (g_log_callback) { (*env)->DeleteGlobalRef(env, g_log_callback); g_log_callback = NULL; }
if (g_event_callback) { (*env)->DeleteGlobalRef(env, g_event_callback); g_event_callback = NULL; }
if (g_call_audio_callback) { (*env)->DeleteGlobalRef(env, g_call_audio_callback); g_call_audio_callback = NULL; }
g_call_audio_mid = NULL;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSendMessage(
@@ -2045,4 +2138,82 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCollectConnMetric
utun_bridge_collect_conn_metrics((uint64_t)peerNodeId);
}
/* ── P2P аудио-звонок (сигналинг) ── */
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallStart(
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId, jlong callId) {
(void)thiz;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
utun_bridge_call_start(ch, (uint64_t)peerNodeId, (uint64_t)callId);
if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAccept(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_accept((uint64_t)callId);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallDecline(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_decline((uint64_t)callId);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallHangup(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
utun_bridge_call_hangup((uint64_t)callId);
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallIsOnline(
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId) {
(void)thiz;
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
int online = utun_bridge_call_is_online(ch, (uint64_t)peerNodeId);
if (ch) (*env)->ReleaseStringUTFChars(env, channelId, ch);
return online ? JNI_TRUE : JNI_FALSE;
}
/* ── P2P аудио-звонок (медиа) ── */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioStart(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
return (utun_bridge_call_audio_start((uint64_t)callId) == 0) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioFeed(
JNIEnv* env, jobject thiz, jlong callId, jshortArray samples) {
(void)env; (void)thiz;
if (!samples) return JNI_FALSE;
jsize len = (*env)->GetArrayLength(env, samples);
jshort* data = (*env)->GetShortArrayElements(env, samples, NULL);
int rc = utun_bridge_call_audio_feed((uint64_t)callId, (const int16_t*)data, (int)len);
(*env)->ReleaseShortArrayElements(env, samples, data, JNI_ABORT);
return (rc == 0) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAudioStop(
JNIEnv* env, jobject thiz) {
(void)env; (void)thiz;
utun_bridge_call_audio_stop();
}
/* ── nativeSetCallAudioCallback(cb) ── */
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetCallAudioCallback(
JNIEnv* env, jobject thiz, jobject callback) {
(void)thiz;
if (g_call_audio_callback) (*env)->DeleteGlobalRef(env, g_call_audio_callback);
g_call_audio_callback = callback ? (*env)->NewGlobalRef(env, callback) : NULL;
if (g_call_audio_callback) {
jclass cls = (*env)->GetObjectClass(env, g_call_audio_callback);
g_call_audio_mid = (*env)->GetMethodID(env, cls, "onCallPcm", "(J[S)V");
if (!g_call_audio_mid) bridge_log(BLEV_ERROR, "onCallPcm method not found on callback");
} else {
g_call_audio_mid = NULL;
}
}
#endif /* __ANDROID__ */
@@ -118,6 +118,20 @@ void* utun_bridge_voice_decode_open(const char* file_path,
int utun_bridge_voice_decode_read(void* handle, int16_t* buf, int max_samples);
void utun_bridge_voice_decode_close(void* handle);
/* ── P2P аудио-звонок (сигналинг + медиа) ── */
void utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id);
void utun_bridge_call_accept(uint64_t call_id);
void utun_bridge_call_decline(uint64_t call_id);
void utun_bridge_call_hangup(uint64_t call_id);
int utun_bridge_call_is_online(const char* ch_id, uint64_t peer_node_id);
/* Аудио-движок звонка (call_audio.c): PCM-кадры из AudioRecord → Opus → peer;
* принятый Opus → PCM → коллбэк (настраивается через call_audio_set_pcm_recv_cb). */
int utun_bridge_call_audio_start(uint64_t call_id);
int utun_bridge_call_audio_feed(uint64_t call_id, const int16_t* pcm, int count);
void utun_bridge_call_audio_stop(void);
/* ── Invite link (for sharing) — асинхронно через uasync-поток.
Результат приходит событиями CHAT_EVT_INVITE_CANDIDATES / CHAT_EVT_INVITE_LINK_READY. ── */
void utun_bridge_request_invite_candidates(const char* channel_id);
@@ -0,0 +1,156 @@
#include "call_audio.h"
#include "instance_lite.h"
#include "../../../lib/opus_codec.h"
#include "../../../lib/debug_config.h"
#include "../../../lib/mem.h"
#include "../../../lib/u_async.h"
#include "../../../src/call/call.h"
#include <string.h>
#include <pthread.h>
#define CALL_AUDIO_BITRATE 32000
static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER;
static int g_active = 0;
static uint64_t g_call_id = 0;
static opus_codec_encoder_t* g_encoder = NULL;
static opus_codec_decoder_t* g_decoder = NULL;
static call_audio_pcm_recv_fn g_pcm_fn = NULL;
static void* g_pcm_arg = NULL;
void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg) {
pthread_mutex_lock(&g_mtx);
g_pcm_fn = fn;
g_pcm_arg = arg;
pthread_mutex_unlock(&g_mtx);
}
/* uasync-поток: принятый Opus-кадр пира (из call.c media_recv). */
static void call_audio_media_recv(struct UTUN_INSTANCE* inst, uint64_t call_id,
const uint8_t* opus, int len, void* arg) {
(void)inst; (void)arg;
int16_t pcm[CALL_AUDIO_FRAME_SAMPLES];
call_audio_pcm_recv_fn fn = NULL;
void* fn_arg = NULL;
int n = 0;
pthread_mutex_lock(&g_mtx);
if (g_active && call_id == g_call_id && g_decoder && opus && len > 0) {
n = opus_codec_decode(g_decoder, opus, len, pcm, CALL_AUDIO_FRAME_SAMPLES);
fn = g_pcm_fn;
fn_arg = g_pcm_arg;
}
pthread_mutex_unlock(&g_mtx);
if (n > 0 && fn)
fn(call_id, pcm, n, fn_arg);
}
int call_audio_start(uint64_t call_id) {
if (call_id == 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: bad call_id=0");
return -1;
}
pthread_mutex_lock(&g_mtx);
if (g_active && g_call_id == call_id) {
pthread_mutex_unlock(&g_mtx);
return 0; /* уже запущен для этого звонка */
}
if (g_active) {
/* другой звонок активен — сбрасываем его */
pthread_mutex_unlock(&g_mtx);
call_audio_stop();
pthread_mutex_lock(&g_mtx);
}
opus_codec_encoder_t* enc = opus_codec_encoder_create(CALL_AUDIO_SAMPLE_RATE, 1);
if (!enc) {
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: encoder create failed");
return -1;
}
opus_codec_encoder_bitrate_set(enc, CALL_AUDIO_BITRATE);
opus_codec_decoder_t* dec = opus_codec_decoder_create(CALL_AUDIO_SAMPLE_RATE, 1);
if (!dec) {
opus_codec_encoder_destroy(enc);
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_audio_start: decoder create failed");
return -1;
}
g_encoder = enc;
g_decoder = dec;
g_call_id = call_id;
g_active = 1;
pthread_mutex_unlock(&g_mtx);
call_set_media_recv_cb(instance_lite_get_instance(), call_audio_media_recv, NULL);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_start: ok call=0x%016llx",
(unsigned long long)call_id);
return 0;
}
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count) {
if (!pcm || count != CALL_AUDIO_FRAME_SAMPLES)
return -1;
uint8_t opus[256];
struct UASYNC* ua = NULL;
struct UTUN_INSTANCE* inst = NULL;
int len = -1;
pthread_mutex_lock(&g_mtx);
if (!g_active || call_id != g_call_id || !g_encoder) {
pthread_mutex_unlock(&g_mtx);
return -1;
}
len = opus_codec_encode(g_encoder, pcm, count, opus, (int)sizeof(opus));
ua = instance_lite_get_uasync();
inst = instance_lite_get_instance();
pthread_mutex_unlock(&g_mtx);
if (len <= 0 || !ua || !uasync_is_running(ua) || !inst)
return -1;
struct call_media_arg* a = (struct call_media_arg*)u_malloc(sizeof(*a));
if (!a) return -1;
a->inst = inst;
a->call_id = call_id;
a->len = (uint16_t)len;
memcpy(a->opus, opus, (size_t)len);
uasync_post(ua, call_send_media_trampoline, a);
return 0;
}
void call_audio_stop(void) {
opus_codec_encoder_t* enc = NULL;
opus_codec_decoder_t* dec = NULL;
int was_active = 0;
pthread_mutex_lock(&g_mtx);
if (g_active) {
was_active = 1;
g_active = 0;
g_call_id = 0;
enc = g_encoder; g_encoder = NULL;
dec = g_decoder; g_decoder = NULL;
}
pthread_mutex_unlock(&g_mtx);
if (!was_active)
return;
call_set_media_recv_cb(instance_lite_get_instance(), NULL, NULL);
if (enc) opus_codec_encoder_destroy(enc);
if (dec) opus_codec_decoder_destroy(dec);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_audio_stop");
}
int call_audio_is_active(uint64_t call_id) {
pthread_mutex_lock(&g_mtx);
int active = g_active && call_id == g_call_id;
pthread_mutex_unlock(&g_mtx);
return active;
}
@@ -0,0 +1,52 @@
#ifndef CALL_AUDIO_H
#define CALL_AUDIO_H
/* call_audio.h — аудио-движок P2P-звонка для Android (аналог десктопного CallAudioEngine)
*
* Держит один активный звонок: Opus encoder (PCM→Opus, отправка пиру) и
* decoder (Opus→PCM, приём от пира). Аудио I/O живёт в Kotlin (AudioRecord/
* AudioTrack), кодеки — здесь (Opus есть только в C).
*
* Потоки:
* - feed_pcm: из AudioRecord-потока Kotlin (кодирование + uasync_post)
* - media_recv: из uasync-потока (call.c) — декодирование + коллбэк PCM
* - start/stop: из GUI-потока Kotlin (по ACCEPTED/ENDED)
* Все общие поля защищены мьютексом; encoder/decoder используются строго
* одним потоком каждый, но освобождаются в stop под тем же мьютексом. */
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Параметры аудио: Opus 48kHz mono, кадр 20мс = 960 сэмплов. */
#define CALL_AUDIO_SAMPLE_RATE 48000
#define CALL_AUDIO_FRAME_MS 20
#define CALL_AUDIO_FRAME_SAMPLES (CALL_AUDIO_SAMPLE_RATE * CALL_AUDIO_FRAME_MS / 1000)
/* Запустить полнодуплексное аудио для активного звонка (после ACCEPTED).
* Создаёт encoder+decoder, регистрирует приём медиа в call.c.
* Повторный start с тем же call_id — no-op; с другим — сперва stop.
* Возвращает 0 при успехе, <0 при ошибке. */
int call_audio_start(uint64_t call_id);
/* Отправить один кадр PCM (ровно CALL_AUDIO_FRAME_SAMPLES) пиру.
* Кодируется в Opus и уходит через call_send_media (uasync-пост).
* Можно звать из любого потока. 0 — успех, <0 — звонок не активен/ошибка. */
int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count);
/* Остановить аудио: снять recv-cb, освободить encoder/decoder. */
void call_audio_stop(void);
/* Активен ли звонок с данным call_id (для логов/диагностики). */
int call_audio_is_active(uint64_t call_id);
/* Коллбэк приёма декодированного PCM пира (960 сэмплов/кадр).
* Вызывается в uasync-потоке на каждый принятый Opus-кадр. */
typedef void (*call_audio_pcm_recv_fn)(uint64_t call_id, const int16_t* pcm, int count, void* arg);
void call_audio_set_pcm_recv_cb(call_audio_pcm_recv_fn fn, void* arg);
#ifdef __cplusplus
}
#endif
#endif /* CALL_AUDIO_H */
@@ -29,6 +29,10 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
list(FILTER _src_src EXCLUDE REGEX "/lwip_orig/")
list(FILTER _src_src EXCLUDE REGEX "/uip/")
list(FILTER _src_src EXCLUDE REGEX "/transport_layer/BBR/")
# аудио-контур звонка (call_audio/call_tones/audio_jitter) — отдельная C++ задача,
# Android использует собственный libutun_lite/call_audio.c
list(FILTER _src_src EXCLUDE REGEX "/call/call_audio\\.c$")
list(FILTER _src_src EXCLUDE REGEX "/call/call_tones\\.c$")
list(REMOVE_ITEM _src_src "${SRC_DIR}/utun.c")
# ── Our added files ──
@@ -40,6 +44,7 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
"${CONFIG_DIR}/video_sender.c"
"${CONFIG_DIR}/photo_sender.c"
"${CONFIG_DIR}/standby.c"
"${CONFIG_DIR}/call_audio.c"
)
# ── Export sources ──