Decline incoming calls when closing their window and verify teardown

This commit is contained in:
evgeny committed 2026-10-28 00:59:00 +01:00
1 parent 9ef188b0d3
commit fd4f092c9a
3 files changed
+188 -54

No files matched your search

+12 -4
View File
@@ -609,11 +609,19 @@ void CallWindow::onDeviceChanged(int index) {
CallAudioEngine::instance()->setPlaybackDevice(id);
}
/* Закрытие непринятого вызова равнозначно Decline; локальный Ended исключает повторную команду. */
void CallWindow::closeEvent(QCloseEvent* event) {
m_video->stop(); m_videoCall = 0;
if ((m_state == Outgoing || m_state == Ringing || m_state == Active || m_state == WaitDecision)
&& gui_bridge_call_action(GUI_CALL_HANGUP, m_callId) < 0)
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call window closed without queued hangup id=%llu", (unsigned long long)m_callId);
const State previous = m_state;
if (previous != Ended) {
setState(Ended);
const auto action = previous == Incoming ? GUI_CALL_DECLINE : GUI_CALL_HANGUP;
const char* name = action == GUI_CALL_DECLINE ? "decline" : "hangup";
if (gui_bridge_call_action(action, m_callId) < 0)
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call window closed without queued %s id=%llu previous_state=%d",
name, (unsigned long long)m_callId, int(previous));
else DEBUG_INFO(DEBUG_CATEGORY_CALL, "call window closed: queued %s id=%llu previous_state=%d",
name, (unsigned long long)m_callId, int(previous));
}
emit closed(m_callId);
event->accept();
}
+52 -45
View File
@@ -1,3 +1,6 @@
/* GUI-представление одного звонка. Владелец задаёт ID/имя/состояние и удаляет окно по closed (обычно deleteLater).
* Все методы/сигналы — GUI-поток. CallVideoEngine и отдельное окно видео принадлежат этому QWidget;
* аудио обслуживает общий CallAudioEngine, его native lifecycle координирует MainWindow. */
#pragma once
#include <QWidget>
@@ -16,7 +19,10 @@ class CallVideoWindow;
/* GUI-окно звонка: Outgoing (соединение), Ringing (доставлен вызов), Incoming (принять/отклонить),
* Active (разговор/таймер), Ended (завершён), WaitDecision (явное разрешение звонить занятому телефону).
* Команды передаются через typed API моста без заимствованного instance/heap payload в GUI.
* Отказ постановки сохраняет состояние и показывает ошибку; видео принадлежит окну, аудио — общему CallAudioEngine. */
* Кнопки при отказе постановки сохраняют состояние и показывают ошибку.
* Close переводит локальное состояние в Ended, останавливает видео/таймер и отправляет Decline для Incoming,
* Hangup для остальных живых состояний; повторный close/закрытие Ended не отправляет команду.
* Окно закрывается и при отказе очереди с WARN; завершение native-сессии подтверждают события ядра. */
class CallWindow : public QWidget {
Q_OBJECT
public:
@@ -25,18 +31,19 @@ public:
explicit CallWindow(QWidget* parent = nullptr);
~CallWindow() override;
void setCallId(quint64 id) { m_callId = id; }
quint64 callId() const { return m_callId; }
void setCallId(quint64 id) { m_callId = id; } // Ненулевой ID до действий пользователя; setter сам не запускает сессию.
quint64 callId() const { return m_callId; } // ID для команд моста и сигнала closed.
void setPeerName(const QString& name);
void setBgpPath(const QString& path);
void setConnectionType(uint8_t type, quint64 nextHop);
void setPeerName(const QString& name); // Имя, буквенная аватарка и заголовок отдельного видеоокна.
void setBgpPath(const QString& path); // Текст пути; показывается только при Active и непустой строке.
void setConnectionType(uint8_t type, quint64 nextHop); // 0/1/2/3 — нет/direct/reverse/indirect; nextHop в tooltip только для 3.
// rtt/buffer/minimum/maximum/reserve — мс; tempo=100 для 1x, dropped/underruns — счётчики; min/max за 10с.
void setAudioStats(int rtt, int buffer, int tempo, int dropped, int underruns, int minimum, int maximum, int reserve);
void setState(State s);
void setState(State s); // Локальное GUI-состояние; каждый Active сбрасывает длительность/mute, Ended останавливает видео/таймер.
State state() const { return m_state; }
signals:
void closed(quint64 callId);
void closed(quint64 callId); // После локальной очистки/постановки команды; удаления QWidget и ожидания native-ответа здесь нет.
protected:
void closeEvent(QCloseEvent* event) override;
@@ -50,45 +57,45 @@ private slots:
void onDeviceChanged(int index);
private:
void updateButtons();
void populateDeviceCombo();
void applyVideo();
void updateVideoPresentation();
CallVideoEngine* m_video;
QCheckBox* m_videoEnabled;
QComboBox *m_camera, *m_resolution, *m_codec;
QSpinBox *m_videoMin, *m_videoMax;
QLabel *m_localPreview, *m_videoStatus;
QWidget *m_videoControls, *m_panel;
CallVideoWindow* m_videoWindow;
QPushButton *m_settingsBtn, *m_showVideoBtn;
QImage m_remoteImage, m_localImage;
bool m_receivingVideo = false;
quint64 m_videoCall = 0;
bool m_videoRangeReady = false;
void updateButtons(); // Видимость действий по State, затем общая раскладка видео.
void populateDeviceCombo(); // Выходы SoundManager: пустой ID — default, текущий выбор из CallAudioEngine.
void applyVideo(); // Применяет выбранную камеру/режим; без доступного режима отправку видео отключает.
void updateVideoPresentation(); // Отдельное remote-видео, локальный preview, настройки и диагностические подписи.
CallVideoEngine* m_video; // Дочерний TX/RX/показ; start по таймеру 100мс только при Active и совпадающем активном аудиозвонке.
QCheckBox* m_videoEnabled; // Желание отправлять видео; наличие выбранного camera mode проверяется отдельно.
QComboBox *m_camera, *m_resolution, *m_codec; // ID камеры, размер/FPS и codec: 0=Auto, 2=H.265, 1=H.264.
QSpinBox *m_videoMin, *m_videoMax; // Диапазон 300..4000 кбит/с; native port получает бит/с.
QLabel *m_localPreview, *m_videoStatus; // Малый локальный preview и статус/ошибка видеодвижка.
QWidget *m_videoControls, *m_panel; // Сворачиваемые настройки и центральная панель шириной до 640.
CallVideoWindow* m_videoWindow; // Дочернее Qt::Window; закрытие скрывает картинку, звонок продолжает работать.
QPushButton *m_settingsBtn, *m_showVideoBtn; // Раскрытие настроек и повторное открытие remote-видео.
QImage m_remoteImage, m_localImage; // Последние shared-кадры; видеодвижок очищает их при stop.
bool m_receivingVideo = false; // Active + непустой remote-кадр; не признак видимости отдельного окна.
quint64 m_videoCall = 0; // ID, с которым запущен видеодвижок; 0 после Ended/close.
bool m_videoRangeReady = false; // Начальный native bitrate range уже прочитан после start видео.
quint64 m_callId = 0;
State m_state = Outgoing;
qint64 m_startMs = 0;
quint64 m_callId = 0; // ID UI-сессии, по нему MainWindow индексирует окна.
State m_state = Outgoing; // Локальное состояние окна, не подтверждение готовности native-транспорта.
qint64 m_startMs = 0; // Wall-clock QDateTime в мс на последнем переходе Active.
QLabel* m_avatarLabel;
QLabel* m_peerLabel;
QLabel* m_statusLabel;
QLabel* m_durationLabel;
QLabel* m_bgpPathLabel;
QLabel* m_connectionLabel;
QLabel* m_statsLabel;
QPushButton* m_acceptBtn;
QLabel* m_avatarLabel; // Буквенная аватарка 80×80.
QLabel* m_peerLabel; // Имя собеседника.
QLabel* m_statusLabel; // Состояние вызова либо ошибка capture/постановки команды.
QLabel* m_durationLabel; // Минуты:секунды с последнего Active.
QLabel* m_bgpPathLabel; // BGP-путь, скрыт вне Active.
QLabel* m_connectionLabel; // Тип соединения и relay tooltip.
QLabel* m_statsLabel; // Буфер/tempo/RTT/счётчики и доступная диагностика output latency.
QPushButton* m_acceptBtn; // Accept для Incoming; Active устанавливается после принятия команды мостом.
QPushButton* m_continueBtn; /* явное разрешение начать вызов занятого телефона */
QPushButton* m_declineBtn;
QPushButton* m_hangupBtn;
QPushButton* m_muteBtn;
QComboBox* m_deviceCombo;
QTimer* m_durationTimer;
QPushButton* m_declineBtn; // Decline для Incoming; результат закрывает окно через MainWindow.
QPushButton* m_hangupBtn; // Завершение остальных живых состояний.
QPushButton* m_muteBtn; // Checkable mute общего CallAudioEngine, виден при Active.
QComboBox* m_deviceCombo; // Выбор общего выходного устройства звонка; находится внутри настроек.
QTimer* m_durationTimer; // GUI 1с; запускается в Active, останавливается в Ended/close.
QWidget* m_acceptCell;
QWidget* m_declineCell;
QWidget* m_hangupCell;
QWidget* m_muteCell;
QLabel* m_muteCaption;
QWidget* m_acceptCell; // Кнопка Accept и её подпись, показываются вместе.
QWidget* m_declineCell; // Кнопка Decline и подпись.
QWidget* m_hangupCell; // Кнопка Hangup и подпись.
QWidget* m_muteCell; // Кнопка mute и подпись.
QLabel* m_muteCaption; // Меняется между Mute/Unmute.
};
+124 -5
View File
@@ -6,12 +6,19 @@
#include <QtTest>
#include <QLabel>
#include <QPushButton>
#include <QPointer>
#include <QScopeGuard>
#include <atomic>
#include <thread>
#include <cstring>
extern "C" {
#include "utun_instance.h"
#include "transport_layer/etcp.h"
#include "chat/chat_core.h"
#include "call/call.h"
#include "call/call_priv.h"
#include "call/call_proto.h"
#include "ntp_time.h"
#include "mem.h"
#include "debug_config.h"
}
@@ -75,6 +82,45 @@ static int pending(UASYNC* ua) {
return count;
}
struct IncomingEvents {
QPointer<CallWindow> window;
unsigned incoming = 0, ended = 0, declined = 0;
};
static void incomingEvent(UTUN_INSTANCE* instance, int type, const uint8_t*, int) {
auto* events = static_cast<IncomingEvents*>(instance->test_user_ptr);
if (type == CHAT_EVT_CALL_INCOMING) ++events->incoming;
if (type == CHAT_EVT_CALL_DECLINED) ++events->declined;
if (type == CHAT_EVT_CALL_ENDED) {
++events->ended;
if (events->window) events->window->deleteLater(); // Как MainWindow::closeCallWindow.
}
}
/* Граница сервиса после router: настоящий call_handle_invite создаёт сессию и её таймеры, внешнего пира нет. */
static bool deliverInvite(UTUN_INSTANCE* instance, uint64_t group, uint64_t id) {
auto callback = instance->router_bindings.callbacks[ETCP_RT_ID_CALL];
if (!callback) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "fixture: call service is not bound"); return false; }
auto* entry = queue_entry_new(0);
if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "fixture: invite entry allocation failed"); return false; }
entry->len = ROUTER_SVC_PAYLOAD_OFF + sizeof(call_pkt_invite);
entry->dgram = static_cast<uint8_t*>(u_calloc(1, entry->len));
if (!entry->dgram) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "fixture: invite payload allocation failed"); queue_entry_free(entry); return false;
}
entry->dgram[0] = ETCP_RT_ID_CALL;
entry->dgram[ROUTER_SVC_FLAGS_OFF] = ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED;
const uint64_t peer = 42;
memcpy(entry->dgram + ROUTER_SVC_SRC_OFF, &peer, sizeof(peer));
memcpy(entry->dgram + ROUTER_SVC_DST_OFF, &instance->node_id, sizeof(instance->node_id));
memcpy(entry->dgram + ROUTER_SVC_GROUP_OFF, &group, sizeof(group));
call_pkt_invite packet{}; packet.subcmd = CALL_SUBCMD_INVITE; packet.call_id = id; packet.group_id = group;
packet.num_tracks = 1; packet.expires_us = ntp_time_get_us(instance) + 2000000;
packet.tracks[0] = {CALL_TRACK_AUDIO, CALL_CODEC_OPUS, 48000, 1, 32000};
memcpy(entry->dgram + ROUTER_SVC_PAYLOAD_OFF, &packet, sizeof(packet));
ETCP_CONN connection{}; connection.instance = instance; callback(&connection, entry);
auto* session = call_session_find(instance->call, id);
return session && session->state == CALL_ST_RINGING && call_busy(instance);
}
class CallCommandsTest : public QObject {
Q_OBJECT
static void scenarios() {
@@ -114,7 +160,8 @@ private slots:
#ifdef UTUN_TEST_CALL_HOOKS
failures << "request-allocation" << "post-allocation";
#endif
for (const auto& failure : failures) for (const char* action : {"onAccept", "onDecline", "onHangup", "continue", "close"})
for (const auto& failure : failures)
for (const char* action : {"onAccept", "onDecline", "onHangup", "continue", "close", "closeIncoming"})
QTest::newRow(qPrintable(failure + '-' + action)) << failure << QString(action);
}
void windowAction() {
@@ -123,7 +170,8 @@ private slots:
{
CommandCore core; QVERIFY(core.ua);
CallWindow window; window.setCallId(0x123456789);
const auto state = action == "onAccept" || action == "onDecline" ? CallWindow::Incoming :
const bool closing = action.startsWith("close");
const auto state = action == "onAccept" || action == "onDecline" || action == "closeIncoming" ? CallWindow::Incoming :
action == "continue" ? CallWindow::WaitDecision : CallWindow::Outgoing;
window.setState(state); prepare(core, scenario);
const size_t ready = u_get_allocated_count();
@@ -132,11 +180,11 @@ private slots:
for (auto* candidate : window.findChildren<QPushButton*>())
if (candidate->text() == QCoreApplication::translate("ChatGui", "Call anyway")) button = candidate;
QVERIFY(button); button->click();
} else if (action == "close") { window.show(); window.close(); }
} else if (closing) { window.show(); window.close(); QCOMPARE(window.state(), CallWindow::Ended); }
else QVERIFY(QMetaObject::invokeMethod(&window, qPrintable(action), Qt::DirectConnection));
const bool accepted = scenario == "executed" || scenario == "cancelled";
QCOMPARE(pending(core.ua), accepted ? 1 : 0);
if (!accepted && action != "close") {
if (!accepted && !closing) {
QCOMPARE(window.state(), state);
bool message = false;
for (auto* label : window.findChildren<QLabel*>())
@@ -149,7 +197,8 @@ private slots:
#ifdef UTUN_TEST_CALL_HOOKS
QCOMPARE(failAllocation.load(), 0); QCOMPARE(observed.size(), scenario == "executed" ? 1 : 0);
if (!observed.isEmpty()) {
const int expected = action == "onAccept" ? GUI_CALL_ACCEPT : action == "onDecline" ? GUI_CALL_DECLINE :
const int expected = action == "onAccept" ? GUI_CALL_ACCEPT :
action == "onDecline" || action == "closeIncoming" ? GUI_CALL_DECLINE :
action == "continue" ? GUI_CALL_CONTINUE : GUI_CALL_HANGUP;
QCOMPARE(observed.front().action, expected); QCOMPARE(observed.front().id, uint64_t(0x123456789));
}
@@ -158,6 +207,76 @@ private slots:
}
QCOMPARE(u_get_allocated_count(), before);
}
void closeStates_data() {
QTest::addColumn<int>("state");
QTest::newRow("outgoing") << int(CallWindow::Outgoing); QTest::newRow("ringing") << int(CallWindow::Ringing);
QTest::newRow("incoming") << int(CallWindow::Incoming); QTest::newRow("active") << int(CallWindow::Active);
QTest::newRow("ended") << int(CallWindow::Ended); QTest::newRow("wait-decision") << int(CallWindow::WaitDecision);
}
void closeStates() {
QFETCH(int, state); const size_t before = u_get_allocated_count();
{
CommandCore core; QVERIFY(core.ua); core.attach();
CallWindow window; window.setCallId(77); window.setState(static_cast<CallWindow::State>(state));
QSignalSpy closed(&window, &CallWindow::closed); window.show(); window.close();
QCOMPARE(window.state(), CallWindow::Ended); QCOMPARE(closed.count(), 1);
QCOMPARE(closed.front().front().toULongLong(), quint64(77));
const int commands = state == CallWindow::Ended ? 0 : 1;
QCOMPARE(pending(core.ua), commands);
window.close(); QCOMPARE(pending(core.ua), commands); // До исполнения первой команды повтор не должен дублировать её.
core.poll(); QCOMPARE(pending(core.ua), 0); QVERIFY(bindingValid);
#ifdef UTUN_TEST_CALL_HOOKS
QCOMPARE(observed.size(), commands);
if (commands) QCOMPARE(observed.front().action, state == CallWindow::Incoming ? GUI_CALL_DECLINE : GUI_CALL_HANGUP);
#endif
}
QCOMPARE(u_get_allocated_count(), before);
}
void incomingEndsSession_data() {
QTest::addColumn<bool>("closeWindow");
QTest::newRow("close") << true; QTest::newRow("decline-control") << false;
}
void incomingEndsSession() {
QFETCH(bool, closeWindow); const size_t before = u_get_allocated_count();
{
IncomingEvents events; UASYNC* ua = uasync_create(); UTUN_INSTANCE* instance = nullptr;
const auto release = qScopeGuard([&] {
delete events.window.data(); gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr);
executingCore = nullptr;
if (ua) uasync_mark_stopped(ua);
if (instance) utun_instance_destroy(instance);
if (ua) uasync_destroy(ua, 0);
});
QVERIFY(ua);
instance = utun_instance_create_from_str(ua,
"[global]\nauto_sockets=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n"
"member_files_enabled=0\nlocal_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n");
QVERIFY(instance); QCOMPARE(utun_core_start(instance), 0); QCOMPARE(chat_service_start(instance), 0);
instance->test_user_ptr = &events; chat_event_set_handler(instance, incomingEvent);
uasync_mark_running(ua); gui_bridge_set_uasync(ua); gui_bridge_set_inst(instance);
const uint64_t group = chat_core_create_channel_auto(instance, "Incoming close test"); QVERIFY(group);
const uint64_t id = 101; QVERIFY(deliverInvite(instance, group, id)); QCOMPARE(events.incoming, 1u);
events.window = new CallWindow(); events.window->setCallId(id); events.window->setState(CallWindow::Incoming);
QObject::connect(events.window.data(), &CallWindow::closed, events.window.data(), [&events] {
events.window->deleteLater();
});
events.window->show(); const int queued = pending(ua);
if (closeWindow) events.window->close();
else QVERIFY(QMetaObject::invokeMethod(events.window.data(), "onDecline", Qt::DirectConnection));
QCOMPARE(pending(ua), queued + 1);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
if (closeWindow) QVERIFY(events.window.isNull()); // Команда должна пережить GUI-владельца.
executingCore = instance; uasync_poll(ua, 0); executingCore = nullptr;
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
auto* session = call_session_find(instance->call, id); QVERIFY(session);
QCOMPARE(int(session->state), int(CALL_ST_ENDED)); QCOMPARE(int(session->reason), int(CALL_REASON_DECLINE));
QCOMPARE(call_busy(instance), 0); QCOMPARE(events.ended, 1u); QCOMPARE(events.declined, 1u);
QVERIFY(events.window.isNull()); QVERIFY(bindingValid);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "incoming %s verified: native=ENDED reason=DECLINE busy=0 window=deleted",
closeWindow ? "close" : "decline");
}
QCOMPARE(u_get_allocated_count(), before);
}
void startLifecycle_data() { scenarios(); }
void startLifecycle() {
QFETCH(QString, scenario);