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