Browse Source

Keep Android calls active independently of screen and UI

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

15
doc/service_lifecycle.md

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

42
src/utun_instance.c

@ -113,7 +113,6 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u
instance->client_type = config->global.client_type; instance->client_type = config->global.client_type;
instance->keepalive_interval = (uint16_t)(config->global.keepalive_interval > 0 ? config->global.keepalive_interval : 200); 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 = (instance->client_type == CLIENT_TYPE_MOBILE) ? CLIENT_ACTIVITY_STANDBY : CLIENT_ACTIVITY_ACTIVE;
instance->client_activity_timer = NULL;
// Set my keys // 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) { 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"); if (instance->ua) uasync_print_resources(instance->ua, "INSTANCE_DESTROY_BEFORE");
instance->running = 0; 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"); DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] A done — diagnose complete");
/* Phase B: NTP */ /* 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) { /* Явная активность UI/звонка не истекает по таймеру: владелец сообщает завершение сам. */
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);
}
void utun_set_client_activity(struct UTUN_INSTANCE* instance, int active) { void utun_set_client_activity(struct UTUN_INSTANCE* instance, int active) {
if (!instance) return; if (!instance) {
if (instance->client_type == CLIENT_TYPE_SERVER) return; DEBUG_ERROR(DEBUG_CATEGORY_BGP, "client_activity: missing instance active=%d", active);
if (instance->client_activity_timer) { return;
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);
} }
utun_fire_activity_cbk(instance, active); if (instance->client_type == CLIENT_TYPE_SERVER) return;
int state = active ? CLIENT_ACTIVITY_ACTIVE : CLIENT_ACTIVITY_STANDBY;
if (instance->client_activity == state) return;
instance->client_activity = state;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "client_activity set %s until explicit change node=%016llx type=%u",
active ? "ACTIVE" : "STANDBY", (unsigned long long)instance->node_id, instance->client_type);
utun_fire_activity_cbk(instance, state);
} }

2
src/utun_instance.h

@ -241,7 +241,6 @@ struct UTUN_INSTANCE {
uint8_t client_type; // CLIENT_TYPE_SERVER/DESKTOP/MOBILE (from config) uint8_t client_type; // CLIENT_TYPE_SERVER/DESKTOP/MOBILE (from config)
uint16_t keepalive_interval; // желаемый keepalive (ms), из конфига. для handshake uint16_t keepalive_interval; // желаемый keepalive (ms), из конфига. для handshake
uint8_t client_activity; // CLIENT_ACTIVITY_STANDBY/ACTIVE 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 utun_activity_cbk_entry* activity_cbks; // подписки на смену client_activity
struct peer_sleep_cbk_entry* peer_sleep_cbks; // подписки на смену спячки пира (keepalive) struct peer_sleep_cbk_entry* peer_sleep_cbks; // подписки на смену спячки пира (keepalive)
uint8_t standby_enabled; // 1 = standby duty-cycle активен (chatgui-android) uint8_t standby_enabled; // 1 = standby duty-cycle активен (chatgui-android)
@ -295,6 +294,7 @@ void utun_instance_stop(struct UTUN_INSTANCE *instance);
/* Глобальные переключатели создания TUN/топологии для тестов и встраивания. */ /* Глобальные переключатели создания TUN/топологии для тестов и встраивания. */
void utun_instance_set_tun_init_enabled(int enabled); void utun_instance_set_tun_init_enabled(int enabled);
void utun_instance_set_topo_group_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_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_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); void utun_remove_activity_cbk(struct UTUN_INSTANCE* instance, utun_activity_cbk_fn fn, void* arg);

14
tests/test_services.c

@ -22,6 +22,19 @@ static void work(void* arg) { ((struct job*)arg)->worked = 1; }
static void done(void* arg, int err) { struct job* job = arg; job->done++; job->error = err; } static void 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)++; } 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. */ /* 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) { 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"); 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) { int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO);
struct UASYNC* ua = uasync_create(); assert(ua); struct UASYNC* ua = uasync_create(); assert(ua);
check_client_activity(ua);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua,
"[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" "[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
"my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n"

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -9,26 +9,10 @@ import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.utun.chat.AppEventHandler
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
import com.utun.chat.MainActivity import com.utun.chat.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers /** Уведомление, пробуждение экрана и мелодия входящего звонка. Жизненным циклом управляет CallController. */
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* Системные эффекты входящего звонка, независимые от жизненного цикла Activity:
* пробуждение устройства, полноэкранное уведомление (с кнопкой «Завершить») и мелодия
* через профиль звонка. Подписывается на AppEventHandler.events в app-скопе — работает
* и при убитом Activity, пока жив foreground-сервис с C-ядром.
*
* UI-состоянием (accept/decline/отрисовка) по-прежнему владеет ChatViewModel;
* этот объект лишь будит устройство и хранит отложенный входящий звонок,
* который VM забирает через consumePendingIncoming() при (пере)создании.
*/
object IncomingCallManager { object IncomingCallManager {
const val CHANNEL_CALL = "incoming_call" const val CHANNEL_CALL = "incoming_call"
@ -38,28 +22,19 @@ object IncomingCallManager {
const val EXTRA_CHANNEL_ID = "channel_id" const val EXTRA_CHANNEL_ID = "channel_id"
const val EXTRA_PEER_NODE_ID = "peer_node_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() private val ringtone = RingtonePlayer()
@Volatile private var initialized = false @Volatile private var initialized = false
@Volatile private var activeCallId = 0L @Volatile private var activeCallId = 0L
@Volatile private var pendingCall: PendingCall? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
fun init() { fun init() {
if (initialized) return if (initialized) return
initialized = true initialized = true
createChannel(ChatApplication.instance) createChannel(ChatApplication.instance)
scope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) }
}
LogManager.addLog("INFO", "InCall", "manager initialized") LogManager.addLog("INFO", "InCall", "manager initialized")
} }
fun currentIncomingCallId(): Long = activeCallId
/** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */ /** Разрешены ли полноэкранные уведомления (Android 14+ требует ручного включения). */
fun canFullScreenIntent(): Boolean { fun canFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
@ -67,14 +42,6 @@ object IncomingCallManager {
return nm.canUseFullScreenIntent() return nm.canUseFullScreenIntent()
} }
/** VM забирает отложенный входящий (сценарий «Activity убит, звонок пришёл»). */
@Synchronized
fun consumePendingIncoming(): PendingCall? {
val p = pendingCall
pendingCall = null
return p
}
/** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */ /** Немедленная остановка мелодии/уведомления (accept/decline/hangup из VM). Идемпотентно. */
fun stopRinging() { fun stopRinging() {
val id = activeCallId val id = activeCallId
@ -83,42 +50,16 @@ object IncomingCallManager {
ringtone.stop() ringtone.stop()
releaseWakeLock() releaseWakeLock()
cancelNotification() cancelNotification()
pendingCall = null
LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id)) LogManager.addLog("INFO", "InCall", "ringing stopped callId=0x%016x".format(id))
} }
private fun handleEvent(type: Int, data: ByteArray?) { fun show(callId: Long, channelId: String, peerNodeId: Long) {
when (type) { stopRinging()
34 -> onIncoming(data) /* CALL_INCOMING */
36, 37, 38, 40 -> onCallStop(data) /* ACCEPTED/ENDED/DECLINED/ERROR */
}
}
private fun onIncoming(data: ByteArray?) {
if (data == null || data.size < 1 + 8 + 8) return
val cl = data[0].toInt() and 0xFF
if (data.size < 1 + cl + 16) return
val chId = String(data, 1, cl)
val buf = ByteBuffer.wrap(data, 1 + cl, 16).order(ByteOrder.LITTLE_ENDIAN)
val caller = buf.long
val callId = buf.long
if (activeCallId != 0L) {
LogManager.addLog("WARN", "InCall", "busy (0x%016x), skip incoming 0x%016x".format(activeCallId, callId))
return
}
activeCallId = callId activeCallId = callId
pendingCall = PendingCall(callId, chId, caller) LogManager.addLog("INFO", "InCall", "incoming call id=$callId peer=$peerNodeId ch=$channelId")
LogManager.addLog("INFO", "InCall", "incoming call 0x%016x from 0x%016x ch=%s".format(callId, caller, chId))
acquireWakeLock() acquireWakeLock()
ringtone.play() ringtone.play()
showNotification(callId, chId, caller) showNotification(callId, channelId, peerNodeId)
}
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()
} }
/* ── пробуждение устройства ── */ /* ── пробуждение устройства ── */
@ -139,7 +80,9 @@ object IncomingCallManager {
private fun releaseWakeLock() { private fun releaseWakeLock() {
wakeLock?.let { 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 wakeLock = null
} }

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

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

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

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

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

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

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

@ -24,7 +24,7 @@ class UtunConnectionService : ConnectionService() {
} }
LogManager.addLog("INFO", "UtunConnSvc", LogManager.addLog("INFO", "UtunConnSvc",
"onCreateIncomingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId)) "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( override fun onCreateOutgoingConnection(
@ -37,7 +37,7 @@ class UtunConnectionService : ConnectionService() {
} }
LogManager.addLog("INFO", "UtunConnSvc", LogManager.addLog("INFO", "UtunConnSvc",
"onCreateOutgoingConnection id=0x%016x peer=0x%016x".format(callId, peerNodeId)) "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 { private fun createConnection(callId: Long, peerNodeId: Long, displayName: String): UtunConnection {

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

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

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

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

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

@ -22,8 +22,6 @@ import java.io.FileOutputStream
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
private const val CALL_INCOMING_TIMEOUT_MS = 50_000L
class ChatViewModel : ViewModel() { class ChatViewModel : ViewModel() {
data class PlaybackState( data class PlaybackState(
@ -35,40 +33,10 @@ class ChatViewModel : ViewModel() {
val durMs: Int = 0 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 repo: ChatRepository? = null
private var dbReady = false private var dbReady = false
private val _callState = MutableStateFlow<CallState?>(null) val callState = CallController.state
val callState: StateFlow<CallState?> = _callState
val callAudio = CallAudioEngine()
val proximity = ProximityManager(ChatApplication.instance)
val radioAudio = RadioAudioEngine.instance() val radioAudio = RadioAudioEngine.instance()
@ -102,23 +70,11 @@ class ChatViewModel : ViewModel() {
/* рация: активный канал (null = выключена). Одна рация — один канал. */ /* рация: активный канал (null = выключена). Одна рация — один канал. */
private var radioChannelId: String? = null private var radioChannelId: String? = null
private var callStartedMs = 0L val muted = CallController.muted
private var callIncomingAtMs = 0L val audioRoute = CallController.route
val headsetAvailable = CallController.headset
private val _muted = MutableStateFlow(false) val earpieceAvailable = CallController.earpiece
val muted: StateFlow<Boolean> = _muted val proximityNear = CallController.proximityNear
private val _audioRoute = MutableStateFlow(AudioRoute.SPEAKER)
val audioRoute: StateFlow<AudioRoute> = _audioRoute
private val _headsetAvailable = MutableStateFlow(false)
val headsetAvailable: StateFlow<Boolean> = _headsetAvailable
private val _earpieceAvailable = MutableStateFlow(true)
val earpieceAvailable: StateFlow<Boolean> = _earpieceAvailable
private val _proximityNear = MutableStateFlow(false)
val proximityNear: StateFlow<Boolean> = _proximityNear
private val _playbackState = MutableStateFlow(PlaybackState()) private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState val playbackState: StateFlow<PlaybackState> = _playbackState
@ -230,18 +186,6 @@ class ChatViewModel : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) } 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) { viewModelScope.launch(Dispatchers.IO) {
val key = ChatApplication.instance.configProvider.getString("node.public_key") val key = ChatApplication.instance.configProvider.getString("node.public_key")
if (key != null && key.length == 64) _pubKey.value = key if (key != null && key.length == 64) _pubKey.value = key
@ -255,17 +199,6 @@ class ChatViewModel : ViewModel() {
refreshChannels() 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 { viewModelScope.launch {
_foreground.collectLatest { fg -> _foreground.collectLatest { fg ->
while (fg && isActive) { 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?) { private fun handleEvent(type: Int, data: ByteArray?) {
@ -616,114 +511,6 @@ class ChatViewModel : ViewModel() {
if (cur != null && cur.convId == convId) refreshDmMessages() if (cur != null && cur.convId == convId) refreshDmMessages()
refreshDmConversations() 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] */ 43 -> { /* RADIO_TALK: [group_id:8][src:8][stream:2][on:1] */
if (data == null || data.size < 19) return if (data == null || data.size < 19) return
val bb = java.nio.ByteBuffer.wrap(data, 0, 19).order(java.nio.ByteOrder.LITTLE_ENDIAN) 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) { /* Звонком владеет CallController; ViewModel передаёт только пользовательские команды. */
val r = repo ?: return fun startCall(channelId: String, peerNodeId: Long, peerName: String) = CallController.start(channelId, peerNodeId, peerName)
viewModelScope.launch { fun acceptCall() = CallController.accept()
val name = withContext(Dispatchers.IO) { r.getMembers(chId) } fun declineCall() = CallController.end(reason = 2)
.firstOrNull { it.nodeId == caller }?.name?.takeIf { it.isNotEmpty() } fun hangupCall() = CallController.end()
val st = _callState.value fun dismissCall() = CallController.dismiss()
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
}
/* ── рация ── */ /* ── рация ── */
@ -985,19 +691,8 @@ class ChatViewModel : ViewModel() {
members.value.firstOrNull { it.nodeId == nodeId }?.name members.value.firstOrNull { it.nodeId == nodeId }?.name
?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId) ?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(nodeId)
fun toggleMute() { fun toggleMute() = CallController.toggleMute()
val m = !callAudio.isMuted() fun setAudioRoute(r: AudioRoute) = CallController.setRoute(r)
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 requestRegenerateKeys() { fun requestRegenerateKeys() {
_generatingKeys.value = true _generatingKeys.value = true
@ -1462,8 +1157,6 @@ class ChatViewModel : ViewModel() {
} }
fun clearState() { fun clearState() {
callAudio.stop()
_callState.value = null
repo?.close() repo?.close()
repo = null repo = null
dbReady = false dbReady = false

Loading…
Cancel
Save