Compare commits
5 Commits
fb37ca747a
...
1c51df039f
| Author | SHA1 | Date |
|---|---|---|
|
|
1c51df039f | 17 hours ago |
|
|
9e3a417059 | 17 hours ago |
|
|
e914a1bdc1 | 18 hours ago |
|
|
b36c685cfa | 18 hours ago |
|
|
3c5c9604b3 | 19 hours ago |
32 changed files with 1406 additions and 714 deletions
@ -0,0 +1,180 @@
|
||||
/* Проверка режима и фаз standby на реальных keepalive-пакетах до шифрования.
|
||||
* Управляемая отправка исключает сеть; отказ таймера проверяет сохранение рабочего режима. */ |
||||
#include <assert.h> |
||||
#include <string.h> |
||||
#include "utun_instance.h" |
||||
#include "etcp.h" |
||||
#include "etcp_keepalive.h" |
||||
#include "standby.h" |
||||
#include "debug_config.h" |
||||
|
||||
static struct UASYNC* ua; |
||||
static struct UTUN_INSTANCE inst; |
||||
static struct ETCP_CONN udp, tcp; |
||||
static struct ETCP_LINK links[3]; |
||||
static struct ll_queue udp_queue, tcp_queue; |
||||
static struct ll_entry *udp_entry, *tcp_entry; |
||||
static struct { unsigned count; uint8_t flags; uint16_t active, sleep; } sent[3]; |
||||
static int fail_phase_timer, activity_events, wait_calls; |
||||
static unsigned wait_after_packets; |
||||
|
||||
/* Перехватывает сформированный keepalive, сохраняя его реальные флаги и тайминг. */ |
||||
int __wrap_etcp_encrypt_send(struct ETCP_DGRAM* pkt) { |
||||
assert(pkt && pkt->data[0] == ETCP_KEEPALIVE && pkt->data_len >= 4); |
||||
int i; |
||||
for (i = 0; i < 3 && pkt->link != &links[i]; i++); |
||||
assert(i < 3); |
||||
sent[i].count++; |
||||
sent[i].flags = pkt->data[1]; |
||||
sent[i].active = pkt->data_len >= 6 ? pkt->data[4] | ((uint16_t)pkt->data[5] << 8) : 0; |
||||
sent[i].sleep = pkt->data_len >= 8 ? pkt->data[6] | ((uint16_t)pkt->data[7] << 8) : 0; |
||||
return pkt->data_len; |
||||
} |
||||
|
||||
void* __real_uasync_set_timeout(struct UASYNC* ua, int tb, void* arg, timeout_callback_t cb, const char* name); |
||||
|
||||
/* Отказывает только фазовому таймеру, остальные таймеры выполняются настоящим uasync. */ |
||||
void* __wrap_uasync_set_timeout(struct UASYNC* loop, int tb, void* arg, timeout_callback_t cb, const char* name) { |
||||
if (fail_phase_timer && name && !strcmp(name, "standby_phase")) { |
||||
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "[TEST] injected standby phase timer failure"); |
||||
return NULL; |
||||
} |
||||
return __real_uasync_set_timeout(loop, tb, arg, cb, name); |
||||
} |
||||
|
||||
/* Подписчики активности видят уже согласованные режимы, без промежуточного состояния. */ |
||||
static void activity_changed(struct UTUN_INSTANCE* owner, int active, void* arg) { |
||||
(void)arg; |
||||
assert(owner == &inst && inst.client_activity == active); |
||||
assert(standby_is_enabled() == !active); |
||||
for (int i = 0; i < 3; i++) assert(links[i].ka_my_sleeping == !active); |
||||
activity_events++; |
||||
} |
||||
|
||||
/* Работа возобновляется после отправки новой фазы пирам или выхода из standby. */ |
||||
static void waited(void* arg) { |
||||
(void)arg; |
||||
assert(sent[0].count > wait_after_packets); |
||||
if (standby_is_enabled()) { |
||||
assert(standby_get_active_remaining_tb() > 0 && sent[0].flags == KA_FLAG_SLEEP && sent[0].active > 0); |
||||
} else { |
||||
assert(inst.client_activity == CLIENT_ACTIVITY_ACTIVE && !links[0].ka_my_sleeping && sent[0].flags == 0); |
||||
} |
||||
wait_calls++; |
||||
} |
||||
|
||||
/* Мобильный узел с исходящим UDP, входящим UDP и TCP линками. */ |
||||
static void setup(void) { |
||||
ua = uasync_create(); assert(ua); |
||||
memset(&inst, 0, sizeof(inst)); memset(&udp, 0, sizeof(udp)); memset(&tcp, 0, sizeof(tcp)); |
||||
memset(links, 0, sizeof(links)); memset(sent, 0, sizeof(sent)); |
||||
activity_events = wait_calls = fail_phase_timer = 0; |
||||
inst.ua = ua; inst.client_type = CLIENT_TYPE_MOBILE; inst.client_activity = CLIENT_ACTIVITY_STANDBY; |
||||
udp.instance = tcp.instance = &inst; |
||||
strcpy(udp.log_name, "standby-test-udp"); strcpy(tcp.log_name, "standby-test-tcp"); |
||||
for (int i = 0; i < 3; i++) { |
||||
links[i].etcp = i == 2 ? &tcp : &udp; |
||||
links[i].local_link_id = i + 1; links[i].initialized = links[i].link_status = 1; |
||||
links[i].keepalive_interval = links[i].ka_period_ms = 2000; |
||||
links[i].keepalive_timeout = 20000; links[i].last_recv_local_time = get_time_tb(); |
||||
} |
||||
links[0].next = &links[1]; links[1].is_server = 1; links[2].is_tcp = 1; |
||||
udp.links = &links[0]; tcp.links = &links[2]; |
||||
udp_entry = queue_entry_new(sizeof(struct conn_queue_entry)); assert(udp_entry); |
||||
tcp_entry = queue_entry_new(sizeof(struct tcp_conn_entry)); assert(tcp_entry); |
||||
((struct conn_queue_entry*)udp_entry->data)->conn = &udp; |
||||
((struct tcp_conn_entry*)tcp_entry->data)->etcp_conn = &tcp; |
||||
memset(&udp_queue, 0, sizeof(udp_queue)); memset(&tcp_queue, 0, sizeof(tcp_queue)); |
||||
udp_queue.head = udp_entry; tcp_queue.head = tcp_entry; |
||||
inst.connections = &udp_queue; inst.tcp_connections = &tcp_queue; |
||||
standby_set_intervals_ms(200, 300, 0); |
||||
standby_init(ua); etcp_keepalive_register(&inst); |
||||
utun_add_activity_cbk(&inst, activity_changed, NULL); |
||||
for (int i = 0; i < 3; i++) etcp_keepalive_link_ready(&links[i]); |
||||
} |
||||
|
||||
/* Отменяет таймеры и подписки перед повторной инициализацией ядра. */ |
||||
static void teardown(void) { |
||||
standby_deinit(); |
||||
for (int i = 0; i < 3; i++) { |
||||
if (links[i].keepalive_timer) uasync_cancel_timeout(ua, links[i].keepalive_timer); |
||||
if (links[i].ka_sleep_announce_timer) uasync_cancel_timeout(ua, links[i].ka_sleep_announce_timer); |
||||
} |
||||
while (inst.activity_cbks) utun_remove_activity_cbk(&inst, inst.activity_cbks->fn, inst.activity_cbks->arg); |
||||
queue_entry_free(udp_entry); queue_entry_free(tcp_entry); |
||||
uasync_poll(ua, 0); |
||||
assert(ua->timer_alloc_count == ua->timer_free_count); |
||||
uasync_destroy(ua, 0); |
||||
} |
||||
|
||||
/* Первый фон при начальном STANDBY, повторы, новые линки, обе причины wake и выход в ACTIVE. */ |
||||
static void check_transitions(void) { |
||||
setup(); |
||||
assert(!standby_is_enabled()); |
||||
for (int i = 0; i < 3; i++) assert(sent[i].count == 1 && sent[i].flags == 0 && links[i].keepalive_timer); |
||||
assert(utun_set_client_activity(&inst, 0) == 0 && standby_is_enabled() && activity_events == 0); |
||||
for (int i = 0; i < 3; i++) { |
||||
assert(links[i].ka_my_sleeping && !links[i].keepalive_timer); |
||||
assert(sent[i].count == 2 && sent[i].flags == KA_FLAG_SLEEP && sent[i].active > 0 && sent[i].sleep == 3); |
||||
} |
||||
uint64_t timers = ua->timer_alloc_count; |
||||
assert(utun_set_client_activity(&inst, 0) == 0 && ua->timer_alloc_count == timers); |
||||
assert(sent[0].count == 2 && sent[1].count == 2 && sent[2].count == 2); |
||||
uasync_poll(ua, -1); assert(standby_get_sleep_tb() > 0); |
||||
for (int i = 0; i < 3; i++) { |
||||
etcp_keepalive_link_ready(&links[i]); |
||||
assert(sent[i].flags == KA_FLAG_SLEEP && sent[i].active == 0 && sent[i].sleep > 0 && !links[i].keepalive_timer); |
||||
} |
||||
assert(links[0].ka_sleep_announce_timer && links[1].ka_sleep_announce_timer && !links[2].ka_sleep_announce_timer); |
||||
timers = ua->timer_alloc_count; wait_after_packets = sent[0].count; |
||||
assert(standby_wait(NULL, waited)); assert(ua->timer_alloc_count == timers); |
||||
fail_phase_timer = 1; |
||||
standby_notify_network_activity(); |
||||
assert(standby_get_sleep_tb() > 0 && wait_calls == 0 && sent[0].count == wait_after_packets); |
||||
fail_phase_timer = 0; |
||||
standby_notify_network_activity(); assert(wait_calls == 1); |
||||
uasync_poll(ua, -1); assert(standby_get_sleep_tb() > 0); |
||||
wait_after_packets = sent[0].count; |
||||
void* cancelled = standby_wait(NULL, waited); assert(cancelled); |
||||
standby_wait_cancel(cancelled); |
||||
assert(standby_wait(NULL, waited)); |
||||
uasync_poll(ua, -1); assert(wait_calls == 2 && standby_get_active_remaining_tb() > 0); |
||||
uasync_poll(ua, -1); assert(standby_get_sleep_tb() > 0); |
||||
wait_after_packets = sent[0].count; assert(standby_wait(NULL, waited)); |
||||
links[0].ka_peer_sleeping = 1; |
||||
assert(utun_set_client_activity(&inst, 1) == 0 && activity_events == 1 && wait_calls == 3); |
||||
for (int i = 0; i < 3; i++) { |
||||
assert(!links[i].ka_my_sleeping && sent[i].flags == 0 && !links[i].ka_sleep_announce_timer); |
||||
} |
||||
assert(!links[0].keepalive_timer && links[1].keepalive_timer && links[2].keepalive_timer); |
||||
timers = ua->timer_alloc_count; unsigned packets = sent[0].count; |
||||
assert(utun_set_client_activity(&inst, 1) == 0 && ua->timer_alloc_count == timers); |
||||
assert(activity_events == 1 && sent[0].count == packets); |
||||
assert(utun_set_client_activity(&inst, 0) == 0 && activity_events == 2); |
||||
teardown(); |
||||
DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "[PASS] first background, repeated commands, UDP/TCP ready, phase-before-work, peer sleep and cleanup"); |
||||
} |
||||
|
||||
/* Ошибка ENTER сохраняет normal; потеря фазового таймера возвращает normal, без зависания сна. */ |
||||
static void check_timer_failures(void) { |
||||
setup(); assert(utun_set_client_activity(&inst, 1) == 0); |
||||
fail_phase_timer = 1; |
||||
assert(utun_set_client_activity(&inst, 0) < 0); |
||||
assert(inst.client_activity == CLIENT_ACTIVITY_ACTIVE && !standby_is_enabled() && activity_events == 1); |
||||
for (int i = 0; i < 3; i++) assert(!links[i].ka_my_sleeping && links[i].keepalive_timer); |
||||
fail_phase_timer = 0; |
||||
assert(utun_set_client_activity(&inst, 0) == 0); |
||||
fail_phase_timer = 1; |
||||
uasync_poll(ua, -1); |
||||
assert(!standby_is_enabled() && inst.client_activity == CLIENT_ACTIVITY_ACTIVE && activity_events == 3); |
||||
for (int i = 0; i < 3; i++) assert(!links[i].ka_my_sleeping && links[i].keepalive_timer); |
||||
fail_phase_timer = 0; teardown(); |
||||
DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "[PASS] timer failure rollback and safe normal fallback"); |
||||
} |
||||
|
||||
int main(void) { |
||||
debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); |
||||
check_transitions(); check_transitions(); check_timer_failures(); |
||||
DEBUG_INFO(DEBUG_CATEGORY_KEEPALIVE, "PASS: standby transport regression tests"); |
||||
return 0; |
||||
} |
||||
@ -0,0 +1,358 @@
|
||||
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 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 - st.startedAtElapsedRealtime) / 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() |
||||
service?.updateNotification() |
||||
} |
||||
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 |
||||
} |
||||
accepting = false |
||||
CallRingback.stop() |
||||
IncomingCallManager.stopRinging() |
||||
TelecomCallManager.setActive(id) |
||||
_state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0, startedAtElapsedRealtime = SystemClock.elapsedRealtime()) |
||||
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" } |
||||
} |
||||
@ -0,0 +1,229 @@
|
||||
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 android.os.SystemClock |
||||
import androidx.core.app.NotificationCompat |
||||
import androidx.core.app.Person |
||||
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 { |
||||
// У старого low-канала Android не позволяет повысить importance после создания. |
||||
private const val CHANNEL = "ongoing_call_lockscreen" |
||||
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() |
||||
val manager = getSystemService(NotificationManager::class.java) |
||||
manager.createNotificationChannel( |
||||
NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_DEFAULT).apply { |
||||
description = "Собеседник, длительность и завершение звонка на экране блокировки" |
||||
setSound(null, null) |
||||
enableVibration(false) |
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC |
||||
}) |
||||
val channel = manager.getNotificationChannel(CHANNEL) |
||||
LogManager.addLog("INFO", "CallService", |
||||
"notification channel=$CHANNEL enabled=${manager.areNotificationsEnabled()} " + |
||||
"importance=${channel.importance} lockscreenVisibility=${channel.lockscreenVisibility}") |
||||
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) |
||||
LogManager.addLog("INFO", "CallService", "foreground notification id=$callId phase=${CallController.state.value?.phase}") |
||||
} |
||||
|
||||
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()) |
||||
LogManager.addLog("INFO", "CallService", "notification updated id=$callId phase=${CallController.state.value?.phase}") |
||||
} |
||||
|
||||
private fun notification(): Notification { |
||||
val st = CallController.state.value?.takeIf { it.callId == callId } |
||||
val active = st?.phase == CallPhase.ACTIVE |
||||
val person = Person.Builder().setName(st?.peerName?.takeIf { it.isNotBlank() } ?: "vibechat") |
||||
.setKey("node:${st?.peerNodeId}").build() |
||||
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) |
||||
val builder = NotificationCompat.Builder(this, CHANNEL) |
||||
.setSmallIcon(android.R.drawable.ic_menu_call) |
||||
.setContentTitle(person.name) |
||||
.setContentText(when (st?.phase) { |
||||
CallPhase.ACTIVE -> "Разговор" |
||||
CallPhase.RINGING -> "Звоним…" |
||||
else -> "Установка звонка" |
||||
}) |
||||
.setCategory(NotificationCompat.CATEGORY_CALL) |
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) |
||||
.setOngoing(true) |
||||
.setOnlyAlertOnce(true) |
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) |
||||
.setContentIntent(open) |
||||
.setStyle(NotificationCompat.CallStyle.forOngoingCall(person, end)) |
||||
.addPerson(person) |
||||
.setShowWhen(active) |
||||
.setUsesChronometer(active) |
||||
if (active && st != null) { |
||||
// Один источник начала разговора для UI и системного таймера; уведомление не обновляется каждую секунду. |
||||
builder.setWhen(System.currentTimeMillis() - (SystemClock.elapsedRealtime() - st.startedAtElapsedRealtime)) |
||||
} |
||||
return builder.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 |
||||
} |
||||
@ -0,0 +1,24 @@
|
||||
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 startedAtElapsedRealtime: Long = 0 |
||||
) { |
||||
val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED |
||||
} |
||||
Loading…
Reference in new issue