From 3c5c9604b3573b4bf8908744d69b5c2833f52a80 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 30 Sep 2026 19:12:04 +0300 Subject: [PATCH] Keep Android calls active independently of screen and UI --- doc/service_lifecycle.md | 15 + src/utun_instance.c | 42 +- src/utun_instance.h | 2 +- tests/test_services.c | 14 + .../app/src/main/AndroidManifest.xml | 6 + .../java/com/utun/chat/ChatApplication.kt | 8 + .../main/java/com/utun/chat/MainActivity.kt | 28 +- .../com/utun/chat/data/CallAudioEngine.kt | 330 ++++++++++------ .../java/com/utun/chat/data/CallController.kt | 359 ++++++++++++++++++ .../java/com/utun/chat/data/CallService.kt | 199 ++++++++++ .../main/java/com/utun/chat/data/CallState.kt | 23 ++ .../chat/data/IncomingCallActionReceiver.kt | 3 +- .../com/utun/chat/data/IncomingCallManager.kt | 75 +--- .../main/java/com/utun/chat/data/NativeLib.kt | 9 +- .../com/utun/chat/data/TelecomCallManager.kt | 27 +- .../java/com/utun/chat/data/UtunConnection.kt | 2 +- .../utun/chat/data/UtunConnectionService.kt | 4 +- .../chat/ui/components/ActiveCallBanner.kt | 4 +- .../com/utun/chat/ui/screens/CallScreen.kt | 4 +- .../com/utun/chat/viewmodel/ChatViewModel.kt | 335 +--------------- 20 files changed, 924 insertions(+), 565 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index 7408d890..50638375 100644 --- a/doc/service_lifecycle.md +++ b/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 сервиса. diff --git a/src/utun_instance.c b/src/utun_instance.c index 60ff7b72..83306231 100644 --- a/src/utun_instance.c +++ b/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); } diff --git a/src/utun_instance.h b/src/utun_instance.h index afa387fc..b058668a 100644 --- a/src/utun_instance.h +++ b/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); diff --git a/tests/test_services.c b/tests/test_services.c index e6431f9e..679b0f18 100644 --- a/tests/test_services.c +++ b/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" diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml index e2067036..a37e0eb7 100644 --- a/tools/chatgui-android/app/src/main/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + @@ -61,6 +62,11 @@ android:value="ptt_overlay_service" /> + + >(replay = 16, extraBufferCapacity = 64) val events: SharedFlow> = _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()) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 7161d1e2..3ec0811b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/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 рации, при активной рации и включённой опции. */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt index c98749c4..1bf67e3d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioEngine.kt +++ b/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,107 +96,154 @@ class CallAudioEngine { callId = id muted = false - - val ctx = ChatApplication.instance - val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager - prevMode = am.mode - prevSpeaker = am.isSpeakerphoneOn + failed.set(false) try { - am.mode = AudioManager.MODE_IN_COMMUNICATION - } catch (e: Exception) { - LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") - } + 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 + } catch (e: Exception) { + LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") + } - /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, - * вынули → сохранённый маршрут, по умолчанию тихая связь). */ - router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE - router.setAutoHeadset(true) - router.start() - - /* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном - * динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */ - playFramesWritten = 0L - capFramesRead = 0L - syncAecForRoute(router.route) - - // ── AudioRecord (захват) ── - wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null - wantedWired = wiredActive - audioRecord = createAudioRecord(wiredActive) - if (audioRecord == null) { - NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false - } + /* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, + * вынули → сохранённый маршрут, по умолчанию тихая связь). */ + router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE + router.setAutoHeadset(true) + router.start() + + /* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном + * динамике эхо незначимо. Задержка рендер→захват замеряется чуть позже. */ + playFramesWritten = 0L + capFramesRead = 0L + syncAecForRoute(router.route) + + // ── AudioRecord (захват) ── + wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null + wantedWired = wiredActive + audioRecord = createAudioRecord(wiredActive) + if (audioRecord == null) { + stop(); 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 - } + // ── 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( + 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}") + stop(); return false + } - /* Прямое роутирование трека на выбранное устройство вывода (обход глючной - глобальной маршрутизации на ряде устройств). */ - router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } - applyTrackOutputDevice(router.currentOutputDevice) + if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) { + LogManager.addLog("ERROR", "CallAudio", "AudioTrack not initialized id=$id") + stop(); return false + } - /* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */ - router.onInputDevice = { _ -> - wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null - } + /* Прямое роутирование трека на выбранное устройство вывода (обход глючной + глобальной маршрутизации на ряде устройств). */ + router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } + applyTrackOutputDevice(router.currentOutputDevice) - active = true - audioRecord?.startRecording() - audioTrack?.play() + /* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */ + router.onInputDevice = { _ -> + wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null + } - captureThread = Thread { captureLoop() }.apply { - priority = Thread.MAX_PRIORITY; start() - } - playThread = Thread { playLoop() }.apply { - priority = Thread.MAX_PRIORITY; start() + active = true + audioRecord?.startRecording() + audioTrack?.play() + 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 } + } - /* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */ - mainHandler.postDelayed(aecDelayRunnable, 300) + /** Перехватываем сбой 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") + } + } - LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf") - return true + 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() } - audioRecord = null - audioTrack?.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 + } + 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() } - wiredActive = wantedWired - rec = createAudioRecord(wiredActive) - if (rec == null) return - audioRecord = rec - rec.startRecording() + synchronized(recordLock) { + if (!active) return + rec?.apply { + stop() + release() + } + audioRecord = null + wiredActive = wantedWired + rec = createAudioRecord(wiredActive) + audioRecord = rec + 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,29 +408,31 @@ class CallAudioEngine { /** Замер реальной задержки рендер→захват и применение к AEC (один раз после старта). */ private fun measureAecDelay() { - if (!active) return - val track = audioTrack ?: return - val rec = audioRecord ?: return - try { - val tTs = AudioTimestamp() - val rTs = AudioTimestamp() - val trackOk = track.getTimestamp(tTs) - val recOk = rec.getTimestamp(rTs, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS - if (!trackOk || !recOk) { - LogManager.addLog("WARN", "CallAudio", "getTimestamp unavailable; AEC delay = $AEC_DEFAULT_DELAY_FRAMES frames") + synchronized(recordLock) { + if (!active) return + val track = audioTrack ?: return + val rec = audioRecord ?: return + try { + val tTs = AudioTimestamp() + val rTs = AudioTimestamp() + val trackOk = track.getTimestamp(tTs) + val recOk = rec.getTimestamp(rTs, AudioTimestamp.TIMEBASE_MONOTONIC) == AudioRecord.SUCCESS + if (!trackOk || !recOk) { + LogManager.addLog("WARN", "CallAudio", "getTimestamp unavailable; AEC delay = $AEC_DEFAULT_DELAY_FRAMES frames") + NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES) + return + } + val trackLatency = (playFramesWritten - tTs.framePosition).coerceAtLeast(0L) + val recLatency = (rTs.framePosition - capFramesRead).coerceAtLeast(0L) + val total = trackLatency + recLatency + AEC_ACOUSTIC_SAMPLES + val delayFrames = ((total + FRAME_SAMPLES - 1) / FRAME_SAMPLES).toInt().coerceIn(1, 20) + LogManager.addLog("INFO", "CallAudio", + "AEC delay measured: trackLat=$trackLatency recLat=$recLatency total=$total -> $delayFrames frames") + NativeLib.callAudioSetAecDelay(delayFrames) + } catch (e: Exception) { + LogManager.addLog("WARN", "CallAudio", "AEC delay measure failed: ${e.message}") NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES) - return } - val trackLatency = (playFramesWritten - tTs.framePosition).coerceAtLeast(0L) - val recLatency = (rTs.framePosition - capFramesRead).coerceAtLeast(0L) - val total = trackLatency + recLatency + AEC_ACOUSTIC_SAMPLES - val delayFrames = ((total + FRAME_SAMPLES - 1) / FRAME_SAMPLES).toInt().coerceIn(1, 20) - LogManager.addLog("INFO", "CallAudio", - "AEC delay measured: trackLat=$trackLatency recLat=$recLatency total=$total -> $delayFrames frames") - NativeLib.callAudioSetAecDelay(delayFrames) - } catch (e: Exception) { - LogManager.addLog("WARN", "CallAudio", "AEC delay measure failed: ${e.message}") - NativeLib.callAudioSetAecDelay(AEC_DEFAULT_DELAY_FRAMES) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt new file mode 100644 index 00000000..f48e725d --- /dev/null +++ b/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(null) + val state: StateFlow = _state + private val _muted = MutableStateFlow(false) + val muted: StateFlow = _muted + private val _route = MutableStateFlow(AudioRoute.SPEAKER) + val route: StateFlow = _route + private val _headset = MutableStateFlow(false) + val headset: StateFlow = _headset + private val _earpiece = MutableStateFlow(true) + val earpiece: StateFlow = _earpiece + private val proximity = ProximityManager(ChatApplication.instance) + val proximityNear: StateFlow = 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" } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt new file mode 100644 index 00000000..3ab4ebcd --- /dev/null +++ b/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 +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt new file mode 100644 index 00000000..9cd2b303 --- /dev/null +++ b/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 +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt index f35d41ef..eba62461 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallActionReceiver.kt +++ b/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) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt index b79b1066..440107b1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt +++ b/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 } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 8e43d670..c808ee3b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/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) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt index b192bf8f..9ad67b6f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/TelecomCallManager.kt +++ b/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)) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt index 83c3f0a2..7a0250b8 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnection.kt +++ b/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, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt index 10934eb1..12fbcae5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/UtunConnectionService.kt +++ b/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 { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt index 324b21ec..b793e92b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt +++ b/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 -> "Пытаемся связаться…" } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index 5a15f860..4b253b60 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/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) } ?: "" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 2e5ed7c5..9dcbbd96 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/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(null) - val callState: StateFlow = _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 = _muted - - private val _audioRoute = MutableStateFlow(AudioRoute.SPEAKER) - val audioRoute: StateFlow = _audioRoute - - private val _headsetAvailable = MutableStateFlow(false) - val headsetAvailable: StateFlow = _headsetAvailable - - private val _earpieceAvailable = MutableStateFlow(true) - val earpieceAvailable: StateFlow = _earpieceAvailable - - private val _proximityNear = MutableStateFlow(false) - val proximityNear: StateFlow = _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 @@ -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