Browse Source

Keep Android calls active independently of screen and UI

master
evgeny 20 hours ago
parent
commit
3c5c9604b3
  1. 15
      doc/service_lifecycle.md
  2. 42
      src/utun_instance.c
  3. 2
      src/utun_instance.h
  4. 14
      tests/test_services.c
  5. 6
      tools/chatgui-android/app/src/main/AndroidManifest.xml
  6. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  7. 28
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  8. 168
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt
  9. 359
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
  10. 199
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt
  11. 23
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
  12. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt
  13. 75
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt
  14. 9
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  15. 27
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt
  16. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt
  17. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt
  18. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt
  19. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
  20. 335
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

15
doc/service_lifecycle.md

@ -57,3 +57,18 @@ SQLite открывается ядром один раз: `db_path/chats.db`, л
сохранение идентичности/сокетов/БД, повторную загрузку каналов, отмену workers,
заблокированного медиа-потока, транзита и ожиданий заменённой очереди. `test_chat_join_e2e`
проверяет join и распространение membership между узлами без UTUN-сервиса.
В Android звонковой сессией владеет `CallController` уровня приложения; события
ядра и команды Telecom обрабатываются один раз в main thread, без повторного
проигрывания событий при создании ViewModel. UI получает `StateFlow` и передаёт
пользовательские команды. `CallService` владеет `CallAudioEngine` и частичным
wake lock: перед исходящим вызовом или принятием входящего запускается foreground
режим `phoneCall|microphone`, затем сигналинг и аудио. Завершение сначала
останавливает I/O и дожидается аудиопотоков, затем освобождает native-аудио,
wake lock и сервис. Перед `NativeLib.stop/restart/destroy` выполняется тот же
порядок; новый звонок разрешается после `SERVICE_STARTED` нового ядра.
Активность ядра задаётся явно: видимая Activity или незавершённый звонок удерживают
ACTIVE и выключают Android standby. Состояние не истекает по таймеру. После
завершения звонка в фоне возвращается standby; экран может гаснуть во время
разговора независимо от частичного wake lock сервиса.

42
src/utun_instance.c

@ -113,7 +113,6 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u
instance->client_type = config->global.client_type;
instance->keepalive_interval = (uint16_t)(config->global.keepalive_interval > 0 ? config->global.keepalive_interval : 200);
instance->client_activity = (instance->client_type == CLIENT_TYPE_MOBILE) ? CLIENT_ACTIVITY_STANDBY : CLIENT_ACTIVITY_ACTIVE;
instance->client_activity_timer = NULL;
// Set my keys
if (sc_init_local_keys(&instance->my_keys, config->global.my_public_key_hex, config->global.my_private_key_hex) != SC_OK) {
@ -397,10 +396,6 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) {
if (instance->ua) uasync_print_resources(instance->ua, "INSTANCE_DESTROY_BEFORE");
instance->running = 0;
if (instance->client_activity_timer) {
uasync_cancel_timeout(instance->ua, instance->client_activity_timer);
instance->client_activity_timer = NULL;
}
DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] A done — diagnose complete");
/* Phase B: NTP */
@ -1133,32 +1128,17 @@ void utun_fire_peer_sleep_cbk(struct UTUN_INSTANCE* instance, uint64_t peer_node
}
}
static void client_activity_timeout_cb(void* arg) {
struct UTUN_INSTANCE* instance = (struct UTUN_INSTANCE*)arg;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity_timer fired: node=%016llx type=%u -> standby",
(unsigned long long)instance->node_id, instance->client_type);
instance->client_activity_timer = NULL;
if (instance->client_type == CLIENT_TYPE_SERVER) return;
instance->client_activity = CLIENT_ACTIVITY_STANDBY;
utun_fire_activity_cbk(instance, CLIENT_ACTIVITY_STANDBY);
}
/* Явная активность UI/звонка не истекает по таймеру: владелец сообщает завершение сам. */
void utun_set_client_activity(struct UTUN_INSTANCE* instance, int active) {
if (!instance) return;
if (instance->client_type == CLIENT_TYPE_SERVER) return;
if (instance->client_activity_timer) {
uasync_cancel_timeout(instance->ua, instance->client_activity_timer);
instance->client_activity_timer = NULL;
}
if (active) {
instance->client_activity = CLIENT_ACTIVITY_ACTIVE;
instance->client_activity_timer = uasync_set_timeout(instance->ua, 600000, instance, client_activity_timeout_cb, "activity_timeout");
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity set ACTIVE, timer restarted 60s node=%016llx type=%u",
(unsigned long long)instance->node_id, instance->client_type);
} else {
instance->client_activity = CLIENT_ACTIVITY_STANDBY;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity set STANDBY node=%016llx type=%u",
(unsigned long long)instance->node_id, instance->client_type);
if (!instance) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "client_activity: missing instance active=%d", active);
return;
}
utun_fire_activity_cbk(instance, active);
if (instance->client_type == CLIENT_TYPE_SERVER) return;
int state = active ? CLIENT_ACTIVITY_ACTIVE : CLIENT_ACTIVITY_STANDBY;
if (instance->client_activity == state) return;
instance->client_activity = state;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity set %s until explicit change node=%016llx type=%u",
active ? "ACTIVE" : "STANDBY", (unsigned long long)instance->node_id, instance->client_type);
utun_fire_activity_cbk(instance, state);
}

2
src/utun_instance.h

@ -241,7 +241,6 @@ struct UTUN_INSTANCE {
uint8_t client_type; // CLIENT_TYPE_SERVER/DESKTOP/MOBILE (from config)
uint16_t keepalive_interval; // желаемый keepalive (ms), из конфига. для handshake
uint8_t client_activity; // CLIENT_ACTIVITY_STANDBY/ACTIVE
void* client_activity_timer; // uasync timer handle for inactivity timeout
struct utun_activity_cbk_entry* activity_cbks; // подписки на смену client_activity
struct peer_sleep_cbk_entry* peer_sleep_cbks; // подписки на смену спячки пира (keepalive)
uint8_t standby_enabled; // 1 = standby duty-cycle активен (chatgui-android)
@ -295,6 +294,7 @@ void utun_instance_stop(struct UTUN_INSTANCE *instance);
/* Глобальные переключатели создания TUN/топологии для тестов и встраивания. */
void utun_instance_set_tun_init_enabled(int enabled);
void utun_instance_set_topo_group_enabled(int enabled);
/* Состояние активности клиента сохраняется до следующего явного вызова; выполнять в uasync-потоке. */
void utun_set_client_activity(struct UTUN_INSTANCE* instance, int active);
void utun_add_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg);
void utun_remove_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg);

14
tests/test_services.c

@ -22,6 +22,19 @@ static void work(void* arg) { ((struct job*)arg)->worked = 1; }
static void done(void* arg, int err) { struct job* job = arg; job->done++; job->error = err; }
static void ready(struct ll_queue* queue, void* arg) { (void)queue; (*(int*)arg)++; }
/* Активность звонка не создаёт таймер, который сам переведёт мобильный узел в standby. */
static void check_client_activity(struct UASYNC* ua) {
struct UTUN_INSTANCE mobile = { .ua = ua, .client_type = CLIENT_TYPE_MOBILE, .client_activity = CLIENT_ACTIVITY_STANDBY };
uint64_t timers = ua->timer_alloc_count;
utun_set_client_activity(&mobile, 1);
utun_set_client_activity(&mobile, 1);
uasync_poll(ua, 0);
assert(mobile.client_activity == CLIENT_ACTIVITY_ACTIVE && ua->timer_alloc_count == timers);
utun_set_client_activity(&mobile, 0);
assert(mobile.client_activity == CLIENT_ACTIVITY_STANDBY);
DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] explicit mobile activity persists without timeout and returns to standby");
}
/* Pending transit shares the physical connection but belongs to its group. */
static void pending_transit(struct ETCP_CONN* conn, uint64_t group_id, int* calls) {
if (!conn->transit_queues) conn->transit_queues = queue_new(conn->instance->ua, 16, 0, 24, "test_transit");
@ -47,6 +60,7 @@ static struct TOPO_GROUP* chat_group(struct UTUN_INSTANCE* inst) {
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO);
struct UASYNC* ua = uasync_create(); assert(ua);
check_client_activity(ua);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua,
"[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
"my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n"

6
tools/chatgui-android/app/src/main/AndroidManifest.xml

@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
@ -61,6 +62,11 @@
android:value="ptt_overlay_service" />
</service>
<service
android:name=".data.CallService"
android:exported="false"
android:foregroundServiceType="phoneCall|microphone" />
<service
android:name=".data.UtunConnectionService"
android:exported="true"

8
tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt

@ -2,6 +2,8 @@ package com.utun.chat
import android.app.Application
import android.util.Log
import android.os.Handler
import android.os.Looper
import com.utun.chat.data.LogManager
import com.utun.chat.data.ConfigProvider
import com.utun.chat.data.NativeLib
@ -9,14 +11,19 @@ import com.utun.chat.data.NotificationSound
import com.utun.chat.data.IncomingCallManager
import com.utun.chat.data.TelecomCallManager
import com.utun.chat.data.ChatEventCallback
import com.utun.chat.data.CallController
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
object AppEventHandler : ChatEventCallback {
private val mainHandler = Handler(Looper.getMainLooper())
private val _events = MutableSharedFlow<Pair<Int, ByteArray?>>(replay = 16, extraBufferCapacity = 64)
val events: SharedFlow<Pair<Int, ByteArray?>> = _events
override fun onEvent(type: Int, data: ByteArray?) {
if (type == 12 || type == 13 || type in 34..40 || type == 42) {
mainHandler.post { CallController.onEvent(type, data) }
}
Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}")
_events.tryEmit(type to data)
}
@ -44,6 +51,7 @@ class ChatApplication : Application() {
/* Telecom self-managed call integration (system audio routing for calls) */
TelecomCallManager.init()
CallController.init()
/* First launch config */
if (configProvider.isFirstLaunch()) {

28
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -7,6 +7,8 @@ import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import com.utun.chat.data.CallController
import com.utun.chat.data.CallPhase
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
@ -96,17 +98,15 @@ class MainActivity : ComponentActivity() {
val callState by vm.callState.collectAsState()
/* Пока идёт звонок — держим экран включённым; после завершения снимаем
и showWhenLocked (если он был выставлен для входящего поверх блокировки). */
/* После звонка снимаем showWhenLocked входящего. Экран может гаснуть:
аудио и CPU удерживает CallService независимо от Activity. */
LaunchedEffect(callState) {
val st = callState
val inCall = st != null && st.phase != ChatViewModel.CallPhase.NONE &&
st.phase != ChatViewModel.CallPhase.ENDED
val inCall = st != null && st.phase != CallPhase.NONE &&
st.phase != 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()
}
}
@ -116,8 +116,8 @@ class MainActivity : ComponentActivity() {
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
LaunchedEffect(callState?.phase) {
when (callState?.phase) {
ChatViewModel.CallPhase.OUTGOING, ChatViewModel.CallPhase.RINGING,
ChatViewModel.CallPhase.ACTIVE -> {
CallPhase.OUTGOING, CallPhase.RINGING,
CallPhase.ACTIVE -> {
if (screen != Screen.Call) screenBeforeCall = screen
screen = Screen.Call
}
@ -177,9 +177,9 @@ class MainActivity : ComponentActivity() {
Column(modifier = Modifier.fillMaxSize()) {
val callPhase = callState?.phase
val showCallBanner = screen != Screen.Call &&
(callPhase == ChatViewModel.CallPhase.OUTGOING ||
callPhase == ChatViewModel.CallPhase.RINGING ||
callPhase == ChatViewModel.CallPhase.ACTIVE)
(callPhase == CallPhase.OUTGOING ||
callPhase == CallPhase.RINGING ||
callPhase == CallPhase.ACTIVE)
if (showCallBanner) {
ActiveCallBanner(
viewModel = vm,
@ -313,7 +313,7 @@ class MainActivity : ComponentActivity() {
}
}
if (callState?.phase == ChatViewModel.CallPhase.INCOMING) {
if (callState?.phase == CallPhase.INCOMING) {
IncomingCallDialog(viewModel = vm)
}
}
@ -362,7 +362,7 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
if (NativeLib.isReady()) NativeLib.setActive(true)
CallController.setActivityVisible(true)
}
override fun onNewIntent(intent: Intent) {
@ -421,7 +421,7 @@ class MainActivity : ComponentActivity() {
override fun onStop() {
super.onStop()
if (NativeLib.isReady()) NativeLib.setActive(false)
CallController.setActivityVisible(false)
}
/** Volume-кнопки (удержание) → PTT рации, при активной рации и включённой опции. */

168
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt

@ -11,6 +11,8 @@ import android.media.AudioTrack
import android.media.MediaRecorder
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import java.util.concurrent.atomic.AtomicBoolean
import com.utun.chat.ChatApplication
/**
@ -37,6 +39,7 @@ class CallAudioEngine {
const val AEC_ACOUSTIC_SAMPLES = 150L
}
private val recordLock = Any()
private var audioRecord: AudioRecord? = null
private var captureThread: Thread? = null
private var audioTrack: AudioTrack? = null
@ -62,9 +65,17 @@ class CallAudioEngine {
@Volatile private var capFramesRead = 0L
private val mainHandler = Handler(Looper.getMainLooper())
private val aecDelayRunnable = Runnable { measureAecDelay() }
private val failed = AtomicBoolean(false)
var onFailure: ((Long, String) -> Unit)? = null
fun isActive(): Boolean = active
val diagnostics: String get() = synchronized(recordLock) {
"active=$active capture=$capFramesRead play=$playFramesWritten " +
"workers=${captureThread?.isAlive}/${playThread?.isAlive} " +
"record=${audioRecord?.recordingState} track=${audioTrack?.playState} route=${router.route}"
}
/** Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
fun setMuted(m: Boolean) {
if (muted == m) return
@ -85,7 +96,8 @@ class CallAudioEngine {
callId = id
muted = false
failed.set(false)
try {
val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode
@ -113,12 +125,16 @@ class CallAudioEngine {
wantedWired = wiredActive
audioRecord = createAudioRecord(wiredActive)
if (audioRecord == null) {
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false
stop(); return false
}
// ── AudioTrack (воспроизведение) ──
val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
if (trackMin <= 0) {
LogManager.addLog("ERROR", "CallAudio", "AudioTrack.getMinBufferSize=$trackMin id=$id")
stop(); return false
}
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
audioTrack = try {
AudioTrack(
@ -131,9 +147,12 @@ class CallAudioEngine {
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
stop(); return false
}
if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "CallAudio", "AudioTrack not initialized id=$id")
stop(); return false
}
/* Прямое роутирование трека на выбранное устройство вывода (обход глючной
@ -149,43 +168,82 @@ class CallAudioEngine {
active = true
audioRecord?.startRecording()
audioTrack?.play()
captureThread = Thread { captureLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
}
playThread = Thread { playLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
if (audioRecord?.recordingState != AudioRecord.RECORDSTATE_RECORDING ||
audioTrack?.playState != AudioTrack.PLAYSTATE_PLAYING) {
LogManager.addLog("ERROR", "CallAudio",
"I/O not running id=$id record=${audioRecord?.recordingState} track=${audioTrack?.playState}")
stop(); return false
}
captureThread = Thread({ runWorker(id, "capture") { captureLoop() } }, "utun-call-capture").apply { start() }
playThread = Thread({ runWorker(id, "play") { playLoop() } }, "utun-call-play").apply { start() }
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */
mainHandler.postDelayed(aecDelayRunnable, 300)
LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf")
return true
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudio", "start failed id=$id: ${e.message}")
stop()
return false
}
}
/** Перехватываем сбой worker и передаём владельцу на main; старый callback не завершит новый звонок. */
private fun runWorker(id: Long, name: String, work: () -> Unit) {
try {
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO)
work()
if (active) reportFailure(id, "$name worker stopped unexpectedly")
} catch (e: Exception) {
if (active) reportFailure(id, "$name worker failed: ${e.message}")
} finally {
LogManager.addLog("INFO", "CallAudio", "$name worker exited id=$id active=$active")
}
}
private fun reportFailure(id: Long, detail: String) {
if (!active || !failed.compareAndSet(false, true)) return
LogManager.addLog("ERROR", "CallAudio", "$detail id=$id route=${router.route} capture=$capFramesRead play=$playFramesWritten")
mainHandler.post { if (callId == id && active) onFailure?.invoke(id, detail) }
}
/** Сначала разблокируем I/O и дождёмся workers, затем освобождаем Android/native-объекты. */
fun stop() {
if (!active) return
if (callId == 0L) return
val id = callId
active = false
mainHandler.removeCallbacks(aecDelayRunnable)
captureThread?.join(1000); captureThread = null
playThread?.join(1000); playThread = null
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() }
synchronized(recordLock) {
audioRecord?.let { rec ->
try { if (rec.recordingState == AudioRecord.RECORDSTATE_RECORDING) rec.stop() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "record stop failed id=$id: ${e.message}") }
}
}
audioTrack?.let { track ->
try { if (track.playState == AudioTrack.PLAYSTATE_PLAYING) track.pause() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track pause failed id=$id: ${e.message}") }
}
playThread?.interrupt()
captureThread?.join(); captureThread = null
playThread?.join(); playThread = null
synchronized(recordLock) {
try { audioRecord?.release() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "record release failed id=$id: ${e.message}") }
audioRecord = null
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() }
}
try { audioTrack?.release() }
catch (e: Exception) { LogManager.addLog("WARN", "CallAudio", "track release failed id=$id: ${e.message}") }
audioTrack = null
NativeLib.callAudioStop()
router.onOutputDevice = null
router.onInputDevice = null
router.stop()
restoreAudioMode()
callId = 0L
muted = false
LogManager.addLog("INFO", "CallAudio", "stopped")
LogManager.addLog("INFO", "CallAudio", "stopped id=$id capture=$capFramesRead play=$playFramesWritten; workers joined")
}
private fun captureLoop() {
@ -197,27 +255,40 @@ class CallAudioEngine {
while (active) {
/* проводная гарнитура появилась/исчезла — меняем источник захвата */
if (wantedWired != wiredActive) {
audioRecord = null /* защита measureAecDelay от UAF на освобождаемой записи */
rec?.apply { try { stop() } catch (_: Exception) {}; release() }
synchronized(recordLock) {
if (!active) return
rec?.apply {
stop()
release()
}
audioRecord = null
wiredActive = wantedWired
rec = createAudioRecord(wiredActive)
if (rec == null) return
audioRecord = rec
rec.startRecording()
if (rec == null) return
rec?.startRecording()
}
sinceSwap = 0
}
val r = rec ?: return
if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) return
if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
reportFailure(callId, "capture not recording state=${r.recordingState}")
return
}
var read = 0
while (read < FRAME_SAMPLES) {
if (!active) return
val n = r.read(buf, read, FRAME_SAMPLES - read)
if (n <= 0) return
// stop() разблокирует read до join; обычный захват не требует polling.
val n = r.read(buf, read, FRAME_SAMPLES - read, AudioRecord.READ_BLOCKING)
if (n <= 0) {
reportFailure(callId, "AudioRecord.read=$n offset=$read state=${r.recordingState}")
return
}
read += n
}
/* диагностика: уровень захваченного сигнала (тишина vs звук) */
if (sinceSwap < 10 || frame % 100L == 0L) {
LogManager.addLog("WARN", "CallAudio",
LogManager.addLog("DEBUG", "CallAudio",
"cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}")
}
sinceSwap++
@ -228,7 +299,6 @@ class CallAudioEngine {
LogManager.addLog("WARN", "CallAudio", "callAudioFeed returned false")
}
}
LogManager.addLog("DEBUG", "CallAudio", "capture loop done")
}
/** RMS захваченного кадра в dBFS (0 = полная шкала, ниже = тише; тишина ~ -90). */
@ -245,6 +315,10 @@ class CallAudioEngine {
private fun createAudioRecord(wired: Boolean): AudioRecord? {
val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
if (recMin <= 0) {
LogManager.addLog("ERROR", "CallAudio", "AudioRecord.getMinBufferSize=$recMin wired=$wired")
return null
}
val rec = try {
AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
@ -283,9 +357,34 @@ class CallAudioEngine {
while (active) {
val track = audioTrack ?: return
val n = NativeLib.callAudioPull(callId, buf)
if (n > 0) track.write(buf, 0, n)
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n)
playFramesWritten += FRAME_SAMPLES
if (n !in 0..FRAME_SAMPLES) {
reportFailure(callId, "callAudioPull=$n expected=0..$FRAME_SAMPLES")
return
}
if (n > 0) buf.copyInto(silence, 0, 0, n)
if (n < FRAME_SAMPLES) silence.fill(0, n, FRAME_SAMPLES)
var offset = 0
var blockedAt = 0L
while (active && offset < FRAME_SAMPLES) {
val written = track.write(silence, offset, FRAME_SAMPLES - offset, AudioTrack.WRITE_NON_BLOCKING)
if (written < 0) {
reportFailure(callId, "AudioTrack.write=$written offset=$offset state=${track.playState}")
return
}
if (written == 0) {
val now = SystemClock.elapsedRealtime()
if (blockedAt == 0L) blockedAt = now
if (now - blockedAt > 2000) {
reportFailure(callId, "AudioTrack stalled for ${now - blockedAt}ms offset=$offset state=${track.playState}")
return
}
Thread.sleep(2)
} else {
blockedAt = 0L
offset += written
playFramesWritten += written
}
}
/* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф
* (важно для стабильного выравнивания рендер↔захват в AEC). */
@ -298,7 +397,6 @@ class CallAudioEngine {
nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */
}
}
LogManager.addLog("DEBUG", "CallAudio", "play loop done")
}
/** AEC включается только на громкой связи (спикерфон). */
@ -310,6 +408,7 @@ class CallAudioEngine {
/** Замер реальной задержки рендер→захват и применение к AEC (один раз после старта). */
private fun measureAecDelay() {
synchronized(recordLock) {
if (!active) return
val track = audioTrack ?: return
val rec = audioRecord ?: return
@ -335,6 +434,7 @@ class CallAudioEngine {
NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES)
}
}
}
private fun restoreAudioMode() {
try {

359
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt

@ -0,0 +1,359 @@
package com.utun.chat.data
import android.content.Intent
import android.os.Looper
import android.os.SystemClock
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.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread.
* Сервис владеет аудио и wake lock, ядро — сигналингом, ViewModel только читает StateFlow.
* События поступают напрямую из AppEventHandler без replay старых звонков новым подписчикам.
*/
object CallController : TelecomCallManager.Listener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow<CallState?>(null)
val state: StateFlow<CallState?> = _state
private val _muted = MutableStateFlow(false)
val muted: StateFlow<Boolean> = _muted
private val _route = MutableStateFlow(AudioRoute.SPEAKER)
val route: StateFlow<AudioRoute> = _route
private val _headset = MutableStateFlow(false)
val headset: StateFlow<Boolean> = _headset
private val _earpiece = MutableStateFlow(true)
val earpiece: StateFlow<Boolean> = _earpiece
private val proximity = ProximityManager(ChatApplication.instance)
val proximityNear: StateFlow<Boolean> = proximity.near
private var service: CallService? = null
private var serviceRequested = false
private var accepting = false
private var acceptSent = false
private var answerWhenVisible = 0L
private var activityVisible = false
private var coreReady = false
private var startedAt = 0L
private var incomingAt = 0L
private var initialized = false
fun init() {
checkMain()
if (initialized) return
initialized = true
TelecomCallManager.setListener(this)
scope.launch {
while (true) {
delay(500)
val st = _state.value ?: continue
val now = SystemClock.elapsedRealtime()
if (st.phase == CallPhase.ACTIVE) {
_state.value = st.copy(durationSeconds = ((now - startedAt) / 1000).toInt())
} else if (st.phase == CallPhase.INCOMING && now - incomingAt > 50_000) {
LogManager.addLog("WARN", "Call", "incoming timeout id=${st.callId}; declining")
end(st.callId)
}
}
}
LogManager.addLog("INFO", "Call", "controller initialized; session independent of UI")
}
fun setActivityVisible(visible: Boolean) {
checkMain()
activityVisible = visible
syncCoreActivity()
if (visible && answerWhenVisible != 0L) {
val id = answerWhenVisible
answerWhenVisible = 0L
if (_state.value?.callId == id && _state.value?.phase == CallPhase.INCOMING) accept()
}
}
private fun syncCoreActivity() {
val ongoing = _state.value?.ongoing == true
val active = activityVisible || ongoing
LogManager.addLog("INFO", "Call", "core activity=$active visible=$activityVisible call=$ongoing")
if (coreReady && NativeLib.isReady()) NativeLib.setActive(active)
}
fun start(channelId: String, peerNodeId: Long, peerName: String) {
checkMain()
if (channelId.isEmpty() || peerNodeId == 0L) {
LogManager.addLog("ERROR", "Call", "invalid outgoing call channel=$channelId peer=$peerNodeId")
return
}
if (_state.value?.ongoing == true || !coreReady || !NativeLib.isReady()) {
LogManager.addLog("WARN", "Call", "start rejected: busy=${_state.value?.ongoing} core=$coreReady native=${NativeLib.isReady()}")
return
}
var id = java.security.SecureRandom().nextLong()
if (id == 0L) id = 1
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) }, CallPhase.OUTGOING)
syncCoreActivity()
requestService(id)
}
fun accept() {
checkMain()
val st = _state.value ?: return
if (st.phase != CallPhase.INCOMING || accepting) {
LogManager.addLog("WARN", "Call", "accept ignored id=${st.callId} phase=${st.phase} accepting=$accepting")
return
}
accepting = true
requestService(st.callId)
}
private fun requestService(id: Long) {
serviceRequested = true
try {
CallService.start(ChatApplication.instance, id)
} catch (e: Exception) {
LogManager.addLog("ERROR", "Call", "service request failed id=$id: ${e.message}")
end(id, 7)
}
}
/** Вызывается сервисом после startForeground и приобретения wake lock, до запуска сигналинга/микрофона. */
fun serviceReady(id: Long, owner: CallService): Boolean {
checkMain()
val st = _state.value
if (st == null || st.callId != id || !st.ongoing || !serviceRequested) {
LogManager.addLog("WARN", "Call", "discard stale service start id=$id current=${st?.callId}")
return false
}
if (service != null) {
if (service !== owner) LogManager.addLog("ERROR", "Call", "service owner conflict id=$id")
return service === owner
}
service = owner
LogManager.addLog("INFO", "Call", "service ready id=$id phase=${st.phase}")
if (st.phase == CallPhase.OUTGOING) {
NativeLib.callStart(st.channelId, st.peerNodeId, id)
TelecomCallManager.placeCall(id, st.peerNodeId, st.peerName)
CallRingback.startConnecting()
} else if (st.phase == CallPhase.INCOMING && accepting) {
IncomingCallManager.stopRinging()
acceptSent = true
NativeLib.callAccept(id)
}
return true
}
fun wantsService(id: Long): Boolean = _state.value?.let { it.callId == id && it.ongoing && serviceRequested } == true
fun serviceLost(id: Long, owner: CallService) {
checkMain()
if (service !== owner) return
service = null
LogManager.addLog("ERROR", "Call", "service destroyed during call id=$id")
end(id, 7)
}
fun audioFailed(id: Long, detail: String) {
checkMain()
if (_state.value?.callId != id || _state.value?.ongoing != true) return
LogManager.addLog("ERROR", "Call", "audio failed id=$id: $detail")
end(id, 7)
}
/** Завершение из UI, Telecom и уведомления; идентификатор защищает от действий старого звонка. */
fun end(id: Long = _state.value?.callId ?: 0L, reason: Int = 0) {
checkMain()
val st = _state.value ?: return
if (st.callId != id || !st.ongoing) {
LogManager.addLog("WARN", "Call", "end ignored id=$id current=${st.callId} phase=${st.phase}")
return
}
if (NativeLib.isReady()) {
if (st.phase == CallPhase.INCOMING && !acceptSent) NativeLib.callDecline(id) else NativeLib.callHangup(id)
}
finish(id, reason)
}
private fun finish(id: Long, reason: Int) {
val st = _state.value ?: return
if (st.callId != id || !st.ongoing) return
val owner = service
service = null
serviceRequested = false
accepting = false
acceptSent = false
answerWhenVisible = 0L
owner?.shutdown()
proximity.stop()
TelecomCallManager.setDisconnected(id, reason)
TelecomCallManager.removeConnection(id)
CallRingback.stop()
IncomingCallManager.stopRinging()
_muted.value = false
_route.value = AudioRoute.SPEAKER
_headset.value = false
_earpiece.value = true
_state.value = st.copy(phase = CallPhase.ENDED, reason = reason)
syncCoreActivity()
LogManager.addLog("INFO", "Call", "finished id=$id reason=$reason; audio/service/proximity released")
scope.launch {
delay(2500)
if (_state.value?.callId == id && _state.value?.phase == CallPhase.ENDED) _state.value = null
}
}
fun dismiss() {
checkMain()
if (_state.value?.ongoing == true) end()
_state.value = null
}
/** Перед stop/restart ядра освобождаем аудио, чтобы workers больше не обращались к native-ресурсам. */
fun reset() {
checkMain()
coreReady = false
end()
_state.value = null
}
fun toggleMute() {
checkMain()
val audio = service?.audio ?: return
if (!audio.isActive()) return
audio.setMuted(!audio.isMuted())
_muted.value = audio.isMuted()
}
fun setRoute(route: AudioRoute) {
checkMain()
service?.audio?.router?.setRoute(route)
_state.value?.let { TelecomCallManager.setRoute(it.callId, route) }
scope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(route) }
}
private fun routeChanged(route: AudioRoute, headset: Boolean) {
_route.value = route
_headset.value = headset
if (route == AudioRoute.EARPIECE && _state.value?.phase == CallPhase.ACTIVE) proximity.start() else proximity.stop()
service?.audio?.syncAecForRoute(route)
}
/** Только живые события ядра, сериализованные AppEventHandler на main thread. */
fun onEvent(type: Int, data: ByteArray?) {
checkMain()
if (type == 12) {
coreReady = NativeLib.isReady()
syncCoreActivity()
LogManager.addLog("INFO", "Call", "core started ready=$coreReady")
return
}
if (type == 13) { reset(); return }
if (type !in 34..40 && type != 42) return
if (!coreReady || !NativeLib.isReady()) {
LogManager.addLog("WARN", "Call", "ignore event=$type while core stopped/restarting")
return
}
if (type == 34) {
if (data == null || data.size < 17) { badEvent(type, data); return }
val len = data[0].toInt() and 0xFF
if (len == 0 || data.size < 17 + len) { badEvent(type, data); return }
val channel = String(data, 1, len)
val buf = ByteBuffer.wrap(data, 1 + len, 16).order(ByteOrder.LITTLE_ENDIAN)
val peer = buf.long
val id = buf.long
if (peer == 0L || id == 0L) { badEvent(type, data); return }
val current = _state.value
if (current?.callId == id) return
if (current?.ongoing == true) {
LogManager.addLog("WARN", "Call", "busy; decline incoming id=$id current=${current.callId}")
NativeLib.callDecline(id)
return
}
incomingAt = SystemClock.elapsedRealtime()
// Однократное чтение на main завершается до reset; фоновый JNI-запрос не переживает ядро.
val name = ChatRepository().getMembers(channel).firstOrNull { it.nodeId == peer }?.name
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer)
_state.value = CallState(id, channel, peer, name, CallPhase.INCOMING)
syncCoreActivity()
IncomingCallManager.show(id, channel, peer)
TelecomCallManager.addIncomingCall(id, peer, name)
return
}
if (data == null || data.size < 8 || ((type == 37 || type == 38 || type == 40 || type == 42) && data.size < 9) ||
(type == 39 && data.size != 24)) { badEvent(type, data); return }
val id = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
val st = _state.value
if (st == null || st.callId != id || !st.ongoing) {
LogManager.addLog("DEBUG", "Call", "ignore event=$type id=$id current=${st?.callId} phase=${st?.phase}")
return
}
when (type) {
35 -> if (st.phase == CallPhase.OUTGOING) {
_state.value = st.copy(phase = CallPhase.RINGING)
CallRingback.startRinging()
}
36 -> {
if (st.phase == CallPhase.ACTIVE) return
val owner = service
if (owner == null || !owner.startAudio(id)) {
LogManager.addLog("ERROR", "Call", "accepted without working audio/service id=$id")
end(id, 7)
return
}
startedAt = SystemClock.elapsedRealtime()
accepting = false
CallRingback.stop()
IncomingCallManager.stopRinging()
TelecomCallManager.setActive(id)
_state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0)
owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) }
_earpiece.value = owner.audio.router.hasEarpiece
routeChanged(owner.audio.router.route, owner.audio.router.headsetAvailable)
owner.updateNotification()
LogManager.addLog("INFO", "Call", "active id=$id; service owns audio and CPU wake lock")
}
37, 38 -> finish(id, data[8].toInt() and 0xFF)
40 -> {
LogManager.addLog("ERROR", "Call", "native error id=$id code=${data[8]} ${String(data, 9, data.size - 9)}")
finish(id, 7)
}
39 -> if (st.phase == CallPhase.ACTIVE) {
val buf = ByteBuffer.wrap(data, 8, 16).order(ByteOrder.BIG_ENDIAN)
fun metric() = buf.short.toInt() and 0xFFFF
_state.value = st.copy(stats = CallStats(metric(), metric(), metric(), metric(), metric(), metric(), metric(), metric()))
}
42 -> _state.value = st.copy(bgpPath = String(data, 8, data.size - 8))
}
}
private fun badEvent(type: Int, data: ByteArray?) {
LogManager.addLog("WARN", "Call", "invalid event=$type size=${data?.size ?: 0}")
}
override fun onAnswer(callId: Long) {
if (_state.value?.callId != callId || _state.value?.phase != CallPhase.INCOMING) return
if (activityVisible) { accept(); return }
// Ответ гарнитурой в фоне: сначала видимый UI, затем разрешённый запуск microphone FGS.
answerWhenVisible = callId
try {
ChatApplication.instance.startActivity(Intent(ChatApplication.instance, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
putExtra(IncomingCallManager.EXTRA_CALL_ID, callId)
})
LogManager.addLog("INFO", "Call", "Telecom answer id=$callId; waiting for visible Activity before microphone service")
} catch (e: Exception) {
answerWhenVisible = 0L
LogManager.addLog("ERROR", "Call", "cannot show incoming UI for Telecom answer id=$callId: ${e.message}")
}
}
override fun onReject(callId: Long) { end(callId, 2) }
override fun onDisconnect(callId: Long) { end(callId) }
private fun checkMain() = check(Looper.myLooper() == Looper.getMainLooper()) { "CallController requires main thread" }
}

199
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt

@ -0,0 +1,199 @@
package com.utun.chat.data
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity
/**
* Владелец аудиодвижка и PARTIAL_WAKE_LOCK на время исходящего/принятого звонка.
* Сервис запускается действием пользователя до сигналинга и захвата микрофона.
* Не восстанавливает звонок после смерти процесса: native-сессия тогда тоже утрачена.
*/
class CallService : Service() {
companion object {
private const val CHANNEL = "ongoing_call"
private const val NOTIFICATION_ID = 1002
private const val EXTRA_ID = "call_id"
private const val ACTION_END = "com.utun.chat.action.END_CALL"
fun start(context: Context, callId: Long) {
ContextCompat.startForegroundService(context,
Intent(context, CallService::class.java).putExtra(EXTRA_ID, callId))
}
}
val audio = CallAudioEngine()
private var callId = 0L
private var wakeLock: PowerManager.WakeLock? = null
private var powerEventsRegistered = false
private val powerEvents = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
LogManager.addLog("INFO", "CallService",
"power event=${intent.action} id=$callId interactive=${pm.isInteractive} " +
"idle=${pm.isDeviceIdleMode} save=${pm.isPowerSaveMode} lock=${wakeLock?.isHeld} ${audio.diagnostics}")
}
}
override fun onCreate() {
super.onCreate()
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_LOW).apply {
setSound(null, null)
})
audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) }
LogManager.addLog("INFO", "CallService", "created")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val id = intent?.getLongExtra(EXTRA_ID, 0L) ?: 0L
if (intent?.action == ACTION_END) {
CallController.end(id)
return START_NOT_STICKY
}
if (!CallController.wantsService(id)) {
LogManager.addLog("WARN", "CallService", "stale start id=$id owned=$callId")
if (callId == 0L) {
// Даже отменённый startForegroundService должен завершить foreground-handshake.
try {
promote(false)
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallService", "cancelled foreground handshake failed id=$id: ${e.message}")
} finally {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf(startId)
}
}
return START_NOT_STICKY
}
if (callId == id) return START_NOT_STICKY
if (callId != 0L) {
LogManager.addLog("ERROR", "CallService", "start conflicts id=$id owned=$callId")
CallController.audioFailed(id, "service already owns another call")
return START_NOT_STICKY
}
callId = id
try {
promote(true)
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "utun:ongoing_call").apply {
setReferenceCounted(false)
}
wakeLock!!.acquire()
LogManager.addLog("INFO", "CallService",
"foreground phoneCall|microphone; CPU lock acquired id=$id held=${wakeLock?.isHeld}")
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_SCREEN_ON)
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
}
ContextCompat.registerReceiver(this, powerEvents, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
powerEventsRegistered = true
if (!CallController.serviceReady(id, this)) shutdown()
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallService", "start failed id=$id: ${e.message}")
CallController.audioFailed(id, "foreground/wake lock failed: ${e.message}")
shutdown()
}
return START_NOT_STICKY
}
private fun promote(microphone: Boolean) {
val micType = if (microphone && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0
val types = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or micType
} else 0
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types)
}
fun startAudio(id: Long): Boolean {
if (id != callId || wakeLock?.isHeld != true) {
LogManager.addLog("ERROR", "CallService", "audio start rejected id=$id owned=$callId lock=${wakeLock?.isHeld}")
return false
}
audio.router.setPreferredRoute(ChatApplication.instance.configProvider.getCallAudioRoute())
return audio.start(id)
}
fun updateNotification() {
getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification())
}
private fun notification(): Notification {
val st = CallController.state.value
val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val end = PendingIntent.getService(this, 0,
Intent(this, CallService::class.java).setAction(ACTION_END)
.setData(Uri.parse("utun-call:$callId")).putExtra(EXTRA_ID, callId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_call)
.setContentTitle(st?.peerName ?: "vibechat")
.setContentText(if (st?.phase == CallPhase.ACTIVE) "Разговор" else "Установка звонка")
.setCategory(NotificationCompat.CATEGORY_CALL)
.setOngoing(true)
.setContentIntent(open)
.addAction(0, "Завершить", end)
.build()
}
/** Идемпотентное освобождение до остановки сервиса; выполняется в main thread. */
fun shutdown() {
releaseResources()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun releaseResources() {
val id = callId
if (powerEventsRegistered) {
try { unregisterReceiver(powerEvents) }
catch (e: Exception) { LogManager.addLog("WARN", "CallService", "power receiver cleanup failed id=$id: ${e.message}") }
powerEventsRegistered = false
}
audio.router.onChanged = null
try {
audio.stop()
} finally {
wakeLock?.let { lock ->
try {
if (lock.isHeld) lock.release()
} catch (e: Exception) {
LogManager.addLog("ERROR", "CallService", "wake lock release failed id=$id: ${e.message}")
}
}
wakeLock = null
callId = 0L
if (id != 0L) LogManager.addLog("INFO", "CallService", "resources released id=$id; CPU lock cleared")
}
}
override fun onDestroy() {
val id = callId
releaseResources()
CallController.serviceLost(id, this)
LogManager.addLog("INFO", "CallService", "destroyed id=$id")
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}

23
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt

@ -0,0 +1,23 @@
package com.utun.chat.data
/** Состояние единственной звонковой сессии; не зависит от Activity или ViewModel. */
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
data class CallStats(
val rttMs: Int, val bufferMs: Int, val tempoX100: Int, val dropped: Int,
val underruns: Int, val minMs: Int, val maxMs: Int, val reserveMs: Int
)
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 durationSeconds: Int = 0,
val bgpPath: String = "",
val stats: CallStats? = null
) {
val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED
}

3
tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt

@ -19,7 +19,6 @@ class IncomingCallActionReceiver : BroadcastReceiver() {
return
}
LogManager.addLog("INFO", "InCall", "DECLINE action callId=0x%016x".format(callId))
NativeLib.callDecline(callId)
IncomingCallManager.stopRinging()
CallController.end(callId, 2)
}
}

75
tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt

@ -9,26 +9,10 @@ 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() при (пере)создании.
*/
/** Уведомление, пробуждение экрана и мелодия входящего звонка. Жизненным циклом управляет CallController. */
object IncomingCallManager {
const val CHANNEL_CALL = "incoming_call"
@ -38,28 +22,19 @@ object IncomingCallManager {
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
@ -67,14 +42,6 @@ object IncomingCallManager {
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
@ -83,42 +50,16 @@ object IncomingCallManager {
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
}
fun show(callId: Long, channelId: String, peerNodeId: Long) {
stopRinging()
activeCallId = callId
pendingCall = PendingCall(callId, chId, caller)
LogManager.addLog("INFO", "InCall", "incoming call 0x%016x from 0x%016x ch=%s".format(callId, caller, chId))
LogManager.addLog("INFO", "InCall", "incoming call id=$callId peer=$peerNodeId ch=$channelId")
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()
showNotification(callId, channelId, peerNodeId)
}
/* ── пробуждение устройства ── */
@ -139,7 +80,9 @@ object IncomingCallManager {
private fun releaseWakeLock() {
wakeLock?.let {
try { if (it.isHeld) it.release() } catch (_: Exception) {}
try { if (it.isHeld) it.release() } catch (e: Exception) {
LogManager.addLog("WARN", "InCall", "wake lock release failed: ${e.message}")
}
}
wakeLock = null
}

9
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -1,6 +1,8 @@
package com.utun.chat.data
import com.utun.chat.ChatApplication
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
interface ChatEventCallback {
fun onEvent(type: Int, data: ByteArray?)
@ -28,6 +30,7 @@ object NativeLib {
}
fun stop() {
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
if (!isReady()) return
nativeStop()
}
@ -38,9 +41,13 @@ object NativeLib {
})
}
fun destroy() { nativeDestroy() }
fun destroy() {
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
nativeDestroy()
}
fun restart(dbPath: String, controlPort: Int, configText: String): Boolean {
runBlocking(Dispatchers.Main.immediate) { CallController.reset() }
return nativeRestart(configText)
}

27
tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt

@ -13,15 +13,15 @@ import com.utun.chat.ChatApplication
import java.util.concurrent.ConcurrentHashMap
/**
* Мост между жизненным циклом P2P-звонка (ChatViewModel) и Telecom.
* Мост между жизненным циклом P2P-звонка (CallController) и Telecom.
*
* - Регистрирует self-managed PhoneAccount (делает вызовы uTun «настоящими» для системы).
* - placeCall/addIncomingCall создают Connection через UtunConnectionService.
* - Хранит Connection по callId и прокидывает state/route из VM в Connection.
* - Обратные события (answer/reject/disconnect из системного ввода) — через Listener в VM.
* - Хранит Connection по callId и прокидывает state/route из контроллера в Connection.
* - Обратные события (answer/reject/disconnect из системного ввода) — через Listener в контроллер.
*
* Всё «best effort»: если Telecom недоступен (нет прав/стека), методы возвращают false,
* и VM продолжает звонок прежним путём без системной маршрутизации.
* и контроллер продолжает звонок без системной маршрутизации.
*/
object TelecomCallManager {
@ -120,12 +120,25 @@ object TelecomCallManager {
/* ── реестр Connection (вызывается из UtunConnectionService) ── */
fun registerConnection(callId: Long, conn: UtunConnection) {
val st = CallController.state.value
if (st == null || st.callId != callId || !st.ongoing) {
LogManager.addLog("WARN", TAG, "late connection for ended/stale call id=$callId current=${st?.callId}")
conn.markDisconnected(0)
conn.destroy()
return
}
connections[callId] = conn
when (st.phase) {
CallPhase.ACTIVE -> conn.markActive()
CallPhase.INCOMING -> conn.markRinging()
else -> conn.setDialing()
}
LogManager.addLog("INFO", TAG, "connection registered id=$callId phase=${st.phase}")
}
fun connectionFor(callId: Long): UtunConnection? = connections[callId]
/* ── события из Connection (системный ввод) → VM ── */
/* ── события из Connection (системный ввод) → контроллер ── */
fun notifyAnswer(callId: Long) = post { listener?.onAnswer(callId) }
@ -135,7 +148,7 @@ object TelecomCallManager {
fun notifyAbort(callId: Long) = post { listener?.onDisconnect(callId) }
/* ── управление состоянием из VM ── */
/* ── управление состоянием из контроллера ── */
fun setRinging(callId: Long) = connections[callId]?.markRinging()
@ -146,7 +159,7 @@ object TelecomCallManager {
fun setRoute(callId: Long, route: AudioRoute) = connections[callId]?.routeTo(route)
fun removeConnection(callId: Long) {
connections.remove(callId)
connections.remove(callId)?.destroy()
LogManager.addLog("DEBUG", TAG, "connection removed id=0x%016x".format(callId))
}

2
tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt

@ -23,7 +23,7 @@ import java.util.concurrent.CopyOnWriteArrayList
*
* Владелец — TelecomCallManager (создаётся в UtunConnectionService, хранится в
* реестре по callId). Ответ/отбой/разрыв приходят сюда из системного ввода
* (например, кнопка BT-гарнитуры) и делегируются в VM через TelecomCallManager.Listener.
* (например, кнопка BT-гарнитуры) и делегируются в CallController через TelecomCallManager.Listener.
*/
class UtunConnection(
private val callId: Long,

4
tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt

@ -24,7 +24,7 @@ class UtunConnectionService : ConnectionService() {
}
LogManager.addLog("INFO", "UtunConnSvc",
"onCreateIncomingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId))
return createConnection(callId, peerNodeId, name).also { it.markRinging() }
return createConnection(callId, peerNodeId, name)
}
override fun onCreateOutgoingConnection(
@ -37,7 +37,7 @@ class UtunConnectionService : ConnectionService() {
}
LogManager.addLog("INFO", "UtunConnSvc",
"onCreateOutgoingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId))
return createConnection(callId, peerNodeId, name).also { it.setDialing() }
return createConnection(callId, peerNodeId, name)
}
private fun createConnection(callId: Long, peerNodeId: Long, displayName: String): UtunConnection {

4
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt

@ -21,7 +21,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
import com.utun.chat.data.CallPhase
private fun fmtDuration(sec: Int): String {
val m = sec / 60
@ -43,7 +43,7 @@ fun ActiveCallBanner(
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
val status = when (phase) {
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationMs)
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationSeconds)
CallPhase.RINGING -> "Звоним…"
else -> "Пытаемся связаться…"
}

4
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt

@ -45,7 +45,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import com.utun.chat.data.AudioRoute
import com.utun.chat.data.LogManager
import com.utun.chat.viewmodel.ChatViewModel
import com.utun.chat.viewmodel.ChatViewModel.CallPhase
import com.utun.chat.data.CallPhase
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
@ -105,7 +105,7 @@ fun CallScreen(
val proximityNear by viewModel.proximityNear.collectAsState()
val phase = st?.phase ?: CallPhase.NONE
val duration = st?.durationMs ?: 0
val duration = st?.durationSeconds ?: 0
val peerNodeId = st?.peerNodeId ?: 0L
val name = st?.peerName?.ifEmpty { "0x%016x".format(peerNodeId) } ?: ""

335
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -22,8 +22,6 @@ import java.io.FileOutputStream
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private const val CALL_INCOMING_TIMEOUT_MS = 50_000L
class ChatViewModel : ViewModel() {
data class PlaybackState(
@ -35,40 +33,10 @@ class ChatViewModel : ViewModel() {
val durMs: Int = 0
)
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
data class CallStats(
val rttMs: Int,
val bufferMs: Int,
val tempoX100: Int,
val dropped: Int,
val underruns: Int,
val minMs: Int,
val maxMs: Int,
val reserveMs: Int
)
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,
val bgpPath: String = "",
val stats: CallStats? = null
)
private var repo: ChatRepository? = null
private var dbReady = false
private val _callState = MutableStateFlow<CallState?>(null)
val callState: StateFlow<CallState?> = _callState
val callAudio = CallAudioEngine()
val proximity = ProximityManager(ChatApplication.instance)
val callState = CallController.state
val radioAudio = RadioAudioEngine.instance()
@ -102,23 +70,11 @@ class ChatViewModel : ViewModel() {
/* рация: активный канал (null = выключена). Одна рация — один канал. */
private var radioChannelId: String? = null
private var callStartedMs = 0L
private var callIncomingAtMs = 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 _proximityNear = MutableStateFlow(false)
val proximityNear: StateFlow<Boolean> = _proximityNear
val muted = CallController.muted
val audioRoute = CallController.route
val headsetAvailable = CallController.headset
val earpieceAvailable = CallController.earpiece
val proximityNear = CallController.proximityNear
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState
@ -230,18 +186,6 @@ class ChatViewModel : ViewModel() {
viewModelScope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
}
/* Системный ввод по вызову (кнопка BT-гарнитуры, разрыв из Telecom) → те же действия, что и UI. */
TelecomCallManager.setListener(object : TelecomCallManager.Listener {
override fun onAnswer(callId: Long) { acceptCall() }
override fun onReject(callId: Long) { declineCall() }
override fun onDisconnect(callId: Long) {
val st = _callState.value
if (st != null && st.callId == callId && st.phase != CallPhase.ENDED) {
LogManager.addLog("INFO", "VM", "telecom disconnect -> hangup 0x%016x".format(callId))
hangupCall()
}
}
})
viewModelScope.launch(Dispatchers.IO) {
val key = ChatApplication.instance.configProvider.getString("node.public_key")
if (key != null && key.length == 64) _pubKey.value = key
@ -255,17 +199,6 @@ 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))
callIncomingAtMs = System.currentTimeMillis()
_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) {
@ -308,44 +241,6 @@ 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)
}
}
viewModelScope.launch {
while (isActive) {
val st = _callState.value
if (st != null && st.phase == CallPhase.INCOMING &&
callIncomingAtMs > 0 &&
System.currentTimeMillis() - callIncomingAtMs > CALL_INCOMING_TIMEOUT_MS) {
LogManager.addLog("WARN", "VM", "INCOMING watchdog: no resolution, clearing call 0x%016x".format(st.callId))
IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_callState.value = null
}
delay(1000)
}
}
viewModelScope.launch {
combine(_callState, _audioRoute) { st, route -> st to route }.collect { (st, route) ->
val monitor = st != null && st.phase == CallPhase.ACTIVE && route == AudioRoute.EARPIECE
if (monitor) {
proximity.start()
} else {
proximity.stop()
_proximityNear.value = false
}
}
}
viewModelScope.launch {
proximity.near.collect { _proximityNear.value = it }
}
}
private fun handleEvent(type: Int, data: ByteArray?) {
@ -616,114 +511,6 @@ class ChatViewModel : ViewModel() {
if (cur != null && cur.convId == convId) refreshDmMessages()
refreshDmConversations()
}
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
}
callIncomingAtMs = System.currentTimeMillis()
_callState.value = CallState(callId = callId, channelId = chId, peerNodeId = caller,
peerName = "0x%016x".format(caller), phase = CallPhase.INCOMING)
TelecomCallManager.addIncomingCall(callId, caller, "0x%016x".format(caller))
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)
CallRingback.startRinging()
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))
CallRingback.stop()
TelecomCallManager.setActive(callId)
callAudio.router.setPreferredRoute(
ChatApplication.instance.configProvider.getCallAudioRoute())
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
callAudio.syncAecForRoute(r)
}
_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")
}
39 -> { /* CALL_STATS: call_id LE64, восемь BE16 метрик */
if (data == null || data.size != 24) {
LogManager.addLog("WARN", "call", "CALL_STATS invalid size=${data?.size ?: 0}")
return
}
val callId = java.nio.ByteBuffer.wrap(data, 0, 8)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
val st = _callState.value
if (st == null || st.callId != callId || st.phase != CallPhase.ACTIVE) return
val buf = java.nio.ByteBuffer.wrap(data, 8, 16).order(java.nio.ByteOrder.BIG_ENDIAN)
fun readMetric() = buf.short.toInt() and 0xFFFF
val stats = CallStats(readMetric(), readMetric(), readMetric(), readMetric(),
readMetric(), readMetric(), readMetric(), readMetric())
if (st.stats == null)
LogManager.addLog("DEBUG", "call", "CALL_STATS started id=0x%016x reserve=${stats.reserveMs}ms".format(callId))
_callState.value = st.copy(stats = stats)
}
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")
}
42 -> { /* CALL_PATH: [call_id:8][text:var] — BGP-маршрут до пира */
if (data == null || data.size < 9) return
val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong()
val text = String(data, 8, data.size - 8)
val st = _callState.value
if (st != null && st.callId == callId) {
_callState.value = st.copy(bgpPath = text)
LogManager.addLog("INFO", "VM", "CALL_PATH id=0x%016x %s".format(callId, text))
}
}
43 -> { /* RADIO_TALK: [group_id:8][src:8][stream:2][on:1] */
if (data == null || data.size < 19) return
val bb = java.nio.ByteBuffer.wrap(data, 0, 19).order(java.nio.ByteOrder.LITTLE_ENDIAN)
@ -740,93 +527,12 @@ class ChatViewModel : ViewModel() {
}
}
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()
TelecomCallManager.setDisconnected(callId, reason)
TelecomCallManager.removeConnection(callId)
CallRingback.stop()
IncomingCallManager.stopRinging()
callIncomingAtMs = 0L
_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)
TelecomCallManager.placeCall(callId, peerNodeId, name)
CallRingback.startConnecting()
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) {
LogManager.addLog("WARN", "VM", "acceptCall ignored: phase=${st.phase} id=0x%016x".format(st.callId))
return
}
callIncomingAtMs = 0L
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()
CallRingback.stop()
callIncomingAtMs = 0L
_callState.value = null
}
/* Звонком владеет CallController; ViewModel передаёт только пользовательские команды. */
fun startCall(channelId: String, peerNodeId: Long, peerName: String) = CallController.start(channelId, peerNodeId, peerName)
fun acceptCall() = CallController.accept()
fun declineCall() = CallController.end(reason = 2)
fun hangupCall() = CallController.end()
fun dismissCall() = CallController.dismiss()
/* ── рация ── */
@ -985,19 +691,8 @@ class ChatViewModel : ViewModel() {
members.value.firstOrNull { it.nodeId == nodeId }?.name
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId)
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)
_callState.value?.let { TelecomCallManager.setRoute(it.callId, r) }
viewModelScope.launch { ChatApplication.instance.configProvider.setCallAudioRoute(r) }
LogManager.addLog("INFO", "VM", "setAudioRoute -> $r")
}
fun toggleMute() = CallController.toggleMute()
fun setAudioRoute(r: AudioRoute) = CallController.setRoute(r)
fun requestRegenerateKeys() {
_generatingKeys.value = true
@ -1462,8 +1157,6 @@ class ChatViewModel : ViewModel() {
}
fun clearState() {
callAudio.stop()
_callState.value = null
repo?.close()
repo = null
dbReady = false

Loading…
Cancel
Save