From cc269e810c008f547d168123f0a40f053884d0ba Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 24 Sep 2026 10:15:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B2=D0=BE=D0=BD=D0=BA=D0=B8:=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B4=D1=91=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D0=BD=D1=8B=20(INVITE-=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B0=20+=20watchdog)=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B8=D0=B1=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4=D1=8F=D1=89=D0=B5=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=20ringer-=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/chat_call_arch.md | 11 +- doc/tasks.md | 12 +- src/call/call.c | 62 ++++++--- src/call/call_proto.h | 7 +- .../java/com/utun/chat/data/RingtonePlayer.kt | 123 +++++++++++------- 5 files changed, 144 insertions(+), 71 deletions(-) diff --git a/doc/chat_call_arch.md b/doc/chat_call_arch.md index f18ee8fe..eafa5c9b 100644 --- a/doc/chat_call_arch.md +++ b/doc/chat_call_arch.md @@ -63,10 +63,19 @@ Callee: IDLE → INCOMING(INVITE) → RINGING(sent RINGING) → ACCEPTING(accept NO_TRAFFIC / RING_TIMEOUT / CONNECT_FAIL`. Таймауты: -- ring-timeout 45с (нет ответа → CANCEL); +- **кодограмма INVITE 300мс** — вызывающий шлёт её весь период вызова + (`OUTGOING` + `RINGING`) как «сердцебиение»: покрывает потерю INVITE/RINGING и + держит маршрут живым; +- **watchdog 2с у вызываемого** — нет кодограммы 2с → вызов считается сорванным + (отмена потерялась), сессия закрывается локально (`CALL_REASON_CANCEL`); +- ring-timeout 45с (вызывающий, нет ответа → CANCEL); - connect: `conn_mgr` `CONN_EVENT_TIMEOUT` → fail; - **NO_TRAFFIC 20с** — нет входящих медиа-кадров → авто HANGUP. +Завершающие кодограммы (HANGUP/CANCEL/DECLINE) шлются без ретрансмиссий — потеря +компенсируется watchdog'ом 2с у вызываемого. Запоздалые кодограммы вызова при +идущем разговоре (`CONNECTING`/`ACTIVE`) игнорируются. + Онлайн-проверка: `topo_node_find_by_id(group, peer) == NULL` → «не в сети» (event). Соединение: на ACCEPT обе стороны `conn_mgr_open(inst, group_id, peer, cb, ...)`; diff --git a/doc/tasks.md b/doc/tasks.md index 442ba8d4..a68c39fe 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -77,10 +77,20 @@ Desktop: `CallWindow::Ringing` + `SoundManager` loop `call_connecting`/`call_ringing` (MP3 в qrc), переключение по `onCallRinging`, остановка по accepted/ended/error; добавлена обработка `GUI_EVT_CALL_ERROR` (40) — раньше при «пир офлайн» окно/гудок - зависали навсегда. Android: `CallRingback` (`ToneGenerator` DIAL/RINGTONE), + зависали навсегда. Android: `CallRingback` (MediaPlayer loop из res/raw), подписи `OUTGOING`/`RINGING` в CallScreen + ActiveCallBanner. Звуковые события добавлены в SoundSettingsPage (call_connecting/call_ringing). +[+] **Надёжность отмены вызова (кодограмма + watchdog)** — сделано. Отмена вызова + (HANGUP/CANCEL) шлётся один раз, без ретрансмита, и при флапе маршрута (RST/epoch, + прямой TCP к мобиле не поднимается — только релей) терялась: вызываемый висел в + RINGING вечно и отвечал BUSY на все новые INVITE. Исправлено моделью «сердцебиения»: + - вызывающий шлёт кодограмму INVITE каждые 300мс весь период вызова (OUTGOING+RINGING); + - у вызываемого watchdog 2с — нет кодограммы → вызов сорван (`CALL_REASON_CANCEL`); + - завершающие кодограммы без ретрансмиссий (потерю покрывает watchdog); + - запоздалые кодограммы вызова при разговоре (CONNECTING/ACTIVE) игнорируются. + `CALL_INVITE_RETRY_TB=3000`, `CALL_RING_DEAD_TB=20000`, `CALL_NO_ROUTE_MAX_STRIKES=20`. + ## Рефакторинг: per-instance chat/dm (однопоточный test_dm_e2e) Сделано (глобальные синглтоны переведены на `UTUN_INSTANCE`): diff --git a/src/call/call.c b/src/call/call.c index af6ea401..ada7839e 100644 --- a/src/call/call.c +++ b/src/call/call.c @@ -56,8 +56,8 @@ struct call_session { struct call_ctx* ctx; /* обратная ссылка (для коллбэков conn_mgr/таймеров) */ struct CONN_MGR_HANDLE* cm_handle; struct ETCP_CONN* data_conn; /* прямой data-коннект звонка (NULL для INDIRECT) */ - void* ring_timer; /* caller: 45с ожидания ответа */ - void* invite_timer; /* caller: периодический ретрансмит INVITE (до RINGING) */ + void* ring_timer; /* caller: 45с ожидания ответа; callee: 2с watchdog кодограмм */ + void* invite_timer; /* caller: кодограмма INVITE каждые 300мс (OUTGOING+RINGING) */ uint8_t no_route_strikes; /* caller: подряд ретрансмитов без маршрута (fail-fast) */ void* traffic_timer; /* ACTIVE: 20с без медиа (запускается по первому медиа) */ void* teardown_timer; /* отложенный разрыв conn_mgr (500мс после завершения) */ @@ -382,22 +382,24 @@ static void call_ring_timeout_cb(void* arg) { call_end(ctx, s, CALL_REASON_RING_TIMEOUT); } -/* Периодический ретрансмит INVITE (caller, до RINGING). Покрывает потерю INVITE/RINGING - * и появление маршрута позже. При стойком отсутствии маршрута — fail-fast CONNECT_FAIL. */ +/* Периодическая кодограмма INVITE (caller) каждые 300мс на всём протяжении вызова + * (OUTGOING + RINGING) — «сердцебиение»: покрывает потерю INVITE/RINGING, держит + * маршрут и кормит watchdog вызываемого (2с без кодограммы → вызов сорван). + * При стойком отсутствии маршрута — fail-fast CONNECT_FAIL. */ static void call_invite_retry_cb(void* arg) { struct call_session* s = (struct call_session*)arg; if (!s || !s->ctx) return; struct call_ctx* ctx = s->ctx; s->invite_timer = NULL; if (s->state == CALL_ST_ENDED) return; - if (s->state != CALL_ST_OUTGOING) return; /* RINGING — INVITE доставлен, ретрансмит не нужен */ + if (s->state != CALL_ST_OUTGOING && s->state != CALL_ST_RINGING) return; /* CONNECTING/ACTIVE — звонок идёт */ if (etcp_router_has_route(ctx->inst, s->group_id, s->peer_node_id)) { s->no_route_strikes = 0; - DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE retry id=%016llx (route ok)", CALL_ID, (unsigned long long)s->call_id); + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE codegram id=%016llx (route ok)", CALL_ID, (unsigned long long)s->call_id); call_send_invite(ctx, s); } else if (++s->no_route_strikes >= CALL_NO_ROUTE_MAX_STRIKES) { - DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: no route after %d retries id=%016llx -> CONNECT_FAIL", + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: no route after %d strikes id=%016llx -> CONNECT_FAIL", CALL_ID, s->no_route_strikes, (unsigned long long)s->call_id); uint8_t buf[9]; memcpy(buf, &s->call_id, 8); @@ -406,13 +408,26 @@ static void call_invite_retry_cb(void* arg) { call_end(ctx, s, CALL_REASON_CONNECT_FAIL); return; } else { - DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE retry id=%016llx no route (strike %d/%d)", + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE codegram id=%016llx no route (strike %d/%d)", CALL_ID, (unsigned long long)s->call_id, s->no_route_strikes, CALL_NO_ROUTE_MAX_STRIKES); } s->invite_timer = uasync_set_timeout(ctx->inst->ua, CALL_INVITE_RETRY_TB, s, call_invite_retry_cb, "call_invite_retry"); } +/* Вызываемый: 2с без кодограммы INVITE → вызывающий пропал (отмена потерялась) — закрываем. */ +static void call_ring_dead_cb(void* arg) { + struct call_session* s = (struct call_session*)arg; + if (!s || !s->ctx) return; + struct call_ctx* ctx = s->ctx; + s->ring_timer = NULL; + if (s->state != CALL_ST_RINGING) return; + + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: ring dead (2с без кодограммы) id=%016llx -> end", + CALL_ID, (unsigned long long)s->call_id); + call_end(ctx, s, CALL_REASON_CANCEL); +} + static void call_traffic_timeout_cb(void* arg) { struct call_session* s = (struct call_session*)arg; if (!s || !s->ctx) return; @@ -513,15 +528,23 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro memcpy(&call_id, b + 1, 8); memcpy(&inv_group, b + 9, 8); - /* повторная доставка INVITE (тот же call_id) — идемпотентно RINGING */ + /* кодограмма-дубликат (тот же call_id): пока звоним — пере-взводим watchdog и + * идемпотентно отвечаем RINGING; в разговоре (CONNECTING/ACTIVE) — игнорируем. */ struct call_session* s = call_session_find(ctx, call_id); - if (s && s->state != CALL_ST_ENDED) { - DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE retransmit id=%016llx -> re-RINGING", CALL_ID, (unsigned long long)call_id); - uint8_t pkt[sizeof(struct call_pkt_ringing)]; - struct call_pkt_ringing* r = (struct call_pkt_ringing*)pkt; - r->subcmd = CALL_SUBCMD_RINGING; - r->call_id = call_id; - call_send(ctx, s->group_id, from, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); + if (s) { + if (s->role == CALL_ROLE_CALLEE && s->state == CALL_ST_RINGING) { + if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; } + s->ring_timer = uasync_set_timeout(ctx->inst->ua, CALL_RING_DEAD_TB, s, call_ring_dead_cb, "call_ring_dead"); + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE codegram id=%016llx -> re-RINGING + re-arm dead", CALL_ID, (unsigned long long)call_id); + uint8_t pkt[sizeof(struct call_pkt_ringing)]; + struct call_pkt_ringing* r = (struct call_pkt_ringing*)pkt; + r->subcmd = CALL_SUBCMD_RINGING; + r->call_id = call_id; + call_send(ctx, s->group_id, from, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); + } else { + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: INVITE codegram id=%016llx ignored (state=%s)", + CALL_ID, (unsigned long long)call_id, call_state_name(s->state)); + } return; } @@ -555,6 +578,9 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro call_event_incoming(ctx, group, from, call_id); call_post_bgp_path(ctx, s); + /* watchdog: вызывающий шлёт кодограммы INVITE каждые 300мс; 2с тишины → вызов сорван */ + s->ring_timer = uasync_set_timeout(ctx->inst->ua, CALL_RING_DEAD_TB, s, call_ring_dead_cb, "call_ring_dead"); + uint8_t pkt[sizeof(struct call_pkt_ringing)]; struct call_pkt_ringing* r = (struct call_pkt_ringing*)pkt; r->subcmd = CALL_SUBCMD_RINGING; @@ -569,7 +595,7 @@ static void call_handle_ringing(struct call_ctx* ctx, uint64_t from, const uint8 if (!s || s->role != CALL_ROLE_CALLER || s->state == CALL_ST_ENDED) return; if (s->state == CALL_ST_OUTGOING) { s->state = CALL_ST_RINGING; - if (s->invite_timer) { uasync_cancel_timeout(ctx->inst->ua, s->invite_timer); s->invite_timer = NULL; } + /* invite_timer не трогаем — кодограмма INVITE продолжается и в RINGING */ DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: RINGING id=%016llx", CALL_ID, (unsigned long long)call_id); call_event_id(ctx, CHAT_EVT_CALL_RINGING, call_id, 0); } @@ -585,6 +611,7 @@ static void call_handle_accept(struct call_ctx* ctx, uint64_t from, const uint8_ DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: ACCEPT id=%016llx -> conn_mgr_open", CALL_ID, (unsigned long long)call_id); s->state = CALL_ST_CONNECTING; if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; } + if (s->invite_timer) { uasync_cancel_timeout(ctx->inst->ua, s->invite_timer); s->invite_timer = NULL; } int rc = conn_mgr_open(ctx->inst, s->group_id, s->peer_node_id, call_conn_cb, s, &s->cm_handle); if (rc != 0) { @@ -900,6 +927,7 @@ void call_accept(struct UTUN_INSTANCE* inst, uint64_t call_id) { DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_accept id=%016llx -> ACCEPT + conn_mgr_open", CALL_ID, (unsigned long long)call_id); s->state = CALL_ST_CONNECTING; + if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; } /* watchdog кодограмм */ uint8_t pkt[sizeof(struct call_pkt_accept)]; struct call_pkt_accept* a = (struct call_pkt_accept*)pkt; diff --git a/src/call/call_proto.h b/src/call/call_proto.h index 80521a56..8bdb5f8d 100644 --- a/src/call/call_proto.h +++ b/src/call/call_proto.h @@ -84,9 +84,10 @@ struct call_pkt_media { #define CALL_OPUS_FRAME_SAMPLES (CALL_OPUS_SAMPLE_RATE * CALL_OPUS_FRAME_MS / 1000) /* 960 */ /* ── таймауты и ёмкости (timebase 0.1ms) ── */ -#define CALL_RING_TIMEOUT_TB 450000 /* 45с — нет ответа → CANCEL */ -#define CALL_INVITE_RETRY_TB 20000 /* 2с — период ретрансмита INVITE (до RINGING) */ -#define CALL_NO_ROUTE_MAX_STRIKES 3 /* 3 × 2с ≈ 6с без маршрута → fail-fast CONNECT_FAIL */ +#define CALL_RING_TIMEOUT_TB 450000 /* 45с — вызывающий: нет ответа → CANCEL */ +#define CALL_INVITE_RETRY_TB 3000 /* 300мс — кодограмма INVITE (OUTGOING + RINGING) */ +#define CALL_RING_DEAD_TB 20000 /* 2с — вызываемый: нет кодограммы → вызов сорван */ +#define CALL_NO_ROUTE_MAX_STRIKES 20 /* 20 × 300мс ≈ 6с без маршрута → fail-fast CONNECT_FAIL */ #define CALL_NO_TRAFFIC_TB 200000 /* 20с — нет входящих медиа → HANGUP */ #define CALL_TEARDOWN_DELAY_TB 5000 /* 500мс — задержка разрыва conn_mgr после завершения */ #define CALL_MAX_TX_FRAMES 50 /* ~1с буфер отправки (50 кадров × 20мс) */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt index 2ae7e46a..862dc011 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt @@ -16,12 +16,18 @@ import com.utun.chat.ChatApplication import com.utun.chat.R /** - * Мелодия входящего звонка через профиль звонка (STREAM_RING): - * - громкость — настройка «громкость вызова»; - * - вибрация — по системной настройке «вибрировать при вызове» (VIBRATE_WHEN_RINGING). + * Мелодия входящего звонка через профиль звонка (STREAM_RING) + ручная вибрация. * - * API 28+ : Ringtone (RingtoneManager) — громкость и вибрация автоматически. - * API 26–27: MediaPlayer(STREAM_RING, loop) + ручная вибрация по системной настройке. + * Поведение по системному режиму звонка (AudioManager.getRingerMode): + * - NORMAL : звук + вибрация, если включена настройка «вибрировать при вызове» + * (Settings.System.VIBRATE_WHEN_RINGING); + * - VIBRATE: без звука, только вибрация; + * - SILENT : без звука и вибрации. + * + * Вибрация всегда запускается вручную через Vibrator (класс Ringtone сам не вибрирует). + * + * API 28+ : звук через Ringtone (RingtoneManager), громкость — «громкость вызова». + * API 26–27: звук через MediaPlayer(STREAM_RING, loop) + audio focus. */ class RingtonePlayer { @@ -33,50 +39,69 @@ class RingtonePlayer { fun play() { stop() val ctx = ChatApplication.instance - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val uri = Uri.parse("android.resource://${ctx.packageName}/${R.raw.ringtone}") - val r = try { - RingtoneManager.getRingtone(ctx, uri) - } catch (e: Exception) { - LogManager.addLog("ERROR", "Ringtone", "getRingtone failed: ${e.message}") - null - } ?: return - try { - r.isLooping = true - r.play() - } catch (e: Exception) { - LogManager.addLog("ERROR", "Ringtone", "play failed: ${e.message}") - return - } - ringtone = r - LogManager.addLog("INFO", "Ringtone", "started (Ringtone, STREAM_RING + system vibrate)") - } else { - val mp = try { - MediaPlayer.create(ctx, R.raw.ringtone) - } catch (e: Exception) { - LogManager.addLog("ERROR", "Ringtone", "MediaPlayer.create failed: ${e.message}") - null - } ?: return - try { - mp.setAudioAttributes( - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setLegacyStreamType(AudioManager.STREAM_RING) - .build() - ) - mp.isLooping = true - requestAudioFocus() - mp.start() - } catch (e: Exception) { - LogManager.addLog("ERROR", "Ringtone", "MediaPlayer start failed: ${e.message}") - try { mp.release() } catch (_: Exception) {} - abandonAudioFocus() - return + val am = try { + ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } catch (e: Exception) { + LogManager.addLog("WARN", "Ringtone", "audio service unavailable: ${e.message}") + null + } ?: return + + val ringerMode = am.ringerMode + val playSound = ringerMode == AudioManager.RINGER_MODE_NORMAL + val playVibrate = ringerMode == AudioManager.RINGER_MODE_VIBRATE + || (playSound && systemVibrateWhenRinging()) + + LogManager.addLog("INFO", "Ringtone", + "ringerMode=%d sound=%b vibrate=%b".format(ringerMode, playSound, playVibrate)) + + if (playSound) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val uri = Uri.parse("android.resource://${ctx.packageName}/${R.raw.ringtone}") + val r = try { + RingtoneManager.getRingtone(ctx, uri) + } catch (e: Exception) { + LogManager.addLog("ERROR", "Ringtone", "getRingtone failed: ${e.message}") + null + } ?: return + try { + r.isLooping = true + r.play() + } catch (e: Exception) { + LogManager.addLog("ERROR", "Ringtone", "play failed: ${e.message}") + return + } + ringtone = r + LogManager.addLog("INFO", "Ringtone", "sound started (Ringtone, STREAM_RING)") + } else { + val mp = try { + MediaPlayer.create(ctx, R.raw.ringtone) + } catch (e: Exception) { + LogManager.addLog("ERROR", "Ringtone", "MediaPlayer.create failed: ${e.message}") + null + } ?: return + try { + mp.setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setLegacyStreamType(AudioManager.STREAM_RING) + .build() + ) + mp.isLooping = true + requestAudioFocus() + mp.start() + } catch (e: Exception) { + LogManager.addLog("ERROR", "Ringtone", "MediaPlayer start failed: ${e.message}") + try { mp.release() } catch (_: Exception) {} + abandonAudioFocus() + return + } + mediaPlayer = mp + LogManager.addLog("INFO", "Ringtone", "sound started (MediaPlayer fallback, STREAM_RING)") } - mediaPlayer = mp + } + if (playVibrate) { startVibrate() - LogManager.addLog("INFO", "Ringtone", "started (MediaPlayer fallback, STREAM_RING)") } } @@ -95,7 +120,7 @@ class RingtonePlayer { LogManager.addLog("INFO", "Ringtone", "stopped") } - /* ── вибрация (fallback API 26–27) по системной настройке ── */ + /* ── вибрация (решение принято в play(): VIBRATE-режим или VIBRATE_WHEN_RINGING) ── */ private fun systemVibrateWhenRinging(): Boolean = try { Settings.System.getInt( @@ -105,7 +130,6 @@ class RingtonePlayer { } private fun startVibrate() { - if (!systemVibrateWhenRinging()) return val v = try { ChatApplication.instance.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } catch (e: Exception) { @@ -116,6 +140,7 @@ class RingtonePlayer { try { v.vibrate(VibrationEffect.createWaveform(longArrayOf(1000, 1000), 0)) vibrator = v + LogManager.addLog("INFO", "Ringtone", "vibration started") } catch (e: Exception) { LogManager.addLog("WARN", "Ringtone", "vibrate failed: ${e.message}") }