call: P2P аудиозвонки — сигналинг, релей медиа, аудио-контур, headless/desktop/android

- src/call/: сигналинг (call.c, call_proto.h, ETCP_RT_ID_CALL 0x36) + релей медиа
  + headless аудио-сокет (call_headless.c, call_audio_bind) + Opus/jitter/тоны
- chat: события CHAT_EVT_CALL_* (34–40), CHAT_EVT_DM_MESSAGES (41), DM-трамплины
- chatgui: call window, miniaudio I/O, audio_frame_ring, test_audio_jitter
- chatgui-android: CallScreen/IncomingCallDialog, аудио-роутинг, JNI, рингтон
- tests: test_call, test_call_headless
This commit is contained in:
evgeny committed 2026-09-16 10:17:57 +03:00
1 parent 0157f7f96b
commit b11e77e4c0
78 files changed
+7053 -70

No files matched your search

+14
View File
@@ -471,6 +471,10 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
QString name = m_model->data(index, Qt::DisplayRole).toString();
QMenu menu(m_listView);
QAction* pmAction = menu.addAction(QString::fromUtf8("Написать сообщение"));
if (nodeId == m_db->myNodeId()) pmAction->setEnabled(false);
QAction* callAction = menu.addAction(QString::fromUtf8("Позвонить"));
if (nodeId == m_db->myNodeId() || !m_model->isOnlineAt(index.row())) callAction->setEnabled(false);
QAction* connectAction = menu.addAction("Connect");
if (nodeId == m_db->myNodeId()) connectAction->setEnabled(false);
QAction* renameAction = menu.addAction("Rename");
@@ -478,6 +482,16 @@ void AccountList::onMemberContextMenu(const QPoint& pos) {
QAction* editAction = menu.addAction("Edit");
QAction* chosen = menu.exec(m_listView->viewport()->mapToGlobal(pos));
if (chosen == pmAction) {
emit pmRequested(m_channelId, nodeId, name);
return;
}
if (chosen == callAction) {
emit callRequested(m_channelId, nodeId, name);
return;
}
if (chosen == connectAction) {
struct chat_connect_node_req* r = (struct chat_connect_node_req*)u_malloc(sizeof(*r));
if (!r) return;
+2
View File
@@ -67,6 +67,8 @@ public:
signals:
void memberRenamed();
void callRequested(const QString& channelId, quint64 nodeId, const QString& name);
void pmRequested(const QString& channelId, quint64 nodeId, const QString& name);
private slots:
void onMemberClicked(const QModelIndex& index);
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <mutex>
#include <vector>
/* Кольцевой буфер кадров переменной длины (SPSC, mutex-protected).
*
* Producer (uasync-поток) и consumer (аудио-поток). Память предвыделена одним
* блоком — ноль аллокаций на кадр. При переполнении дропается старейший кадр
* (семантика джиттер-буфера).
*/
class AudioFrameRing {
public:
AudioFrameRing(int capacity, int slotSize)
: cap_(capacity), slotSize_(slotSize),
buf_((size_t)capacity * (size_t)slotSize, 0),
lens_((size_t)capacity, 0) {}
/* Producer: положить кадр. Возвращает false если len > slotSize.
* При переполнении дропает старейший кадр (успех). */
bool push(const uint8_t* data, int len) {
if (len <= 0 || len > slotSize_) return false;
std::lock_guard<std::mutex> lk(mtx_);
if (count_ == cap_) {
head_ = (head_ + 1) % cap_; /* drop oldest */
count_--;
}
std::memcpy(buf_.data() + (size_t)tail_ * slotSize_, data, (size_t)len);
lens_[(size_t)tail_] = (uint16_t)len;
tail_ = (tail_ + 1) % cap_;
count_++;
return true;
}
/* Consumer: взять старейший кадр. Возвращает длину (0 = пусто). */
int pop(uint8_t* out, int outCap) {
std::lock_guard<std::mutex> lk(mtx_);
if (count_ == 0) return 0;
int len = (int)lens_[(size_t)head_];
if (out && len > 0 && len <= outCap)
std::memcpy(out, buf_.data() + (size_t)head_ * slotSize_, (size_t)len);
head_ = (head_ + 1) % cap_;
count_--;
return len;
}
void clear() {
std::lock_guard<std::mutex> lk(mtx_);
head_ = tail_ = count_ = 0;
}
int size() const {
std::lock_guard<std::mutex> lk(mtx_);
return count_;
}
int capacity() const { return cap_; }
private:
int cap_, slotSize_;
int head_ = 0, tail_ = 0, count_ = 0;
std::vector<uint8_t> buf_;
std::vector<uint16_t> lens_;
mutable std::mutex mtx_;
};
+183
View File
@@ -0,0 +1,183 @@
#include "call_audio_engine.h"
#include "sound_manager.h"
#include "../transport/gui_bridge.h"
#include "miniaudio.h"
extern "C" {
#include "call/call_audio.h"
#include "../../lib/debug_config.h"
}
#include <QTimer>
#include <cstring>
static CallAudioEngine* g_callAudioEngine = nullptr;
static void callAudioDataCallback(ma_device* dev, void* out, const void* in, ma_uint32 frames) {
CallAudioEngine* e = (CallAudioEngine*)dev->pUserData;
if (e) e->dataCallback(in, out, frames);
}
CallAudioEngine* CallAudioEngine::instance() {
if (!g_callAudioEngine) g_callAudioEngine = new CallAudioEngine();
return g_callAudioEngine;
}
int CallAudioEngine::start(quint64 callId) {
if (m_active) {
if (m_callId == callId) return 0;
stop();
}
m_callId = callId;
struct UTUN_INSTANCE* inst = gui_bridge_get_inst();
if (!inst) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no instance, cannot start");
return -1;
}
if (call_audio_init(inst) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_init failed");
return -1;
}
if (call_audio_start(callId) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: call_audio_start failed call=%016llx", (unsigned long long)callId);
return -1;
}
m_captureBuf.assign(CALL_AUDIO_FRAME_SAMPLES, 0);
m_capturePos = 0;
m_muted = false;
if (initDevice() != 0) {
call_audio_release(callId);
return -1;
}
m_active = true;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: started call=%016llx", (unsigned long long)callId);
return 0;
}
int CallAudioEngine::initDevice() {
ma_context* ctx = SoundManager::instance()->context();
if (!ctx) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: no audio context");
return -1;
}
m_device = new ma_device;
std::memset(m_device, 0, sizeof(*m_device));
ma_device_config config = ma_device_config_init(ma_device_type_duplex);
config.capture.format = ma_format_s16;
config.capture.channels = 1;
config.playback.format = ma_format_s16;
config.playback.channels = 1;
config.sampleRate = CALL_AUDIO_SAMPLE_RATE;
config.periodSizeInFrames = 480;
config.dataCallback = callAudioDataCallback;
config.pUserData = this;
if (m_playbackDeviceIndex >= 0) {
ma_device_info* pPlaybackDevices = nullptr;
ma_uint32 playbackCount = 0;
if (ma_context_get_devices(ctx, &pPlaybackDevices, &playbackCount, nullptr, nullptr) == MA_SUCCESS &&
(ma_uint32)m_playbackDeviceIndex < playbackCount) {
config.playback.pDeviceID = &pPlaybackDevices[m_playbackDeviceIndex].id;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "CallAudio: using playback device[%d]: %s",
m_playbackDeviceIndex, pPlaybackDevices[m_playbackDeviceIndex].name);
} else {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: playback device[%d] not found, using default",
m_playbackDeviceIndex);
}
}
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device init failed, trying default");
config.playback.pDeviceID = nullptr;
if (ma_device_init(ctx, &config, m_device) != MA_SUCCESS) {
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: default device init also failed");
delete m_device; m_device = nullptr;
return -1;
}
}
if (ma_device_start(m_device) != MA_SUCCESS) {
DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "CallAudio: device start failed");
ma_device_uninit(m_device);
delete m_device; m_device = nullptr;
return -1;
}
m_deviceStarted = true;
m_capturePos = 0;
return 0;
}
void CallAudioEngine::setMuted(bool muted) {
if (m_muted.load() == muted) return;
m_muted = muted;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: mute %s", muted ? "on" : "off");
}
void CallAudioEngine::setPlaybackDeviceIndex(int index) {
if (index == m_playbackDeviceIndex) return;
int prev = m_playbackDeviceIndex;
m_playbackDeviceIndex = index;
if (m_active) {
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device %d -> %d", prev, index);
teardownDevice();
if (initDevice() != 0)
DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "CallAudio: switch playback device failed (kept index=%d)", index);
}
}
void CallAudioEngine::beginEnd(quint64 callId) {
if (!m_active || m_callId != callId) return;
call_audio_begin_end(callId); /* старт тона завершения */
QTimer::singleShot(500, this, [this, callId]() {
if (m_callId == callId) stop();
});
}
void CallAudioEngine::stop() {
if (!m_active && !m_device && !m_deviceStarted) return;
m_active = false;
m_muted = false;
quint64 cid = m_callId.load();
teardownDevice();
call_audio_release(cid);
m_callId = 0;
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CallAudio: stopped");
}
void CallAudioEngine::teardownDevice() {
if (m_deviceStarted) {
ma_device_stop(m_device);
ma_device_uninit(m_device);
m_deviceStarted = false;
}
if (m_device) { delete m_device; m_device = nullptr; }
}
void CallAudioEngine::dataCallback(const void* in, void* out, unsigned int frames) {
const int16_t* in16 = (const int16_t*)in;
int16_t* out16 = (int16_t*)out;
/* захват: копим 960 samples → encode+send (сервисный слой). mute → тишина. */
if (in16) {
bool muted = m_muted.load();
for (unsigned int i = 0; i < frames; i++) {
m_captureBuf[m_capturePos++] = muted ? 0 : in16[i];
if (m_capturePos >= (int)m_captureBuf.size()) {
call_audio_feed_pcm(m_callId.load(), m_captureBuf.data(), (int)m_captureBuf.size());
m_capturePos = 0;
}
}
}
/* воспроизведение: финальный PCM из сервисного слоя (jitter+stretch+тоны) */
int n = call_audio_pull_pcm(m_callId.load(), out16, (int)frames);
if (n < (int)frames)
std::memset(out16 + n, 0, (size_t)(frames - n) * sizeof(int16_t));
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <QObject>
#include <atomic>
#include <cstdint>
#include <vector>
struct ma_device;
/* Аудио-контур звонка (desktop): только miniaudio I/O + захват/воспроизведение.
* Весь аудиопроцессинг (Opus, джиттер-буфер, time-stretch, тоны) — в сервисном
* слое src/call/call_audio.c; сюда приходит/отсюда уходит финальный PCM.
*
* Потоки:
* - аудио-поток (miniaudio callback): capture → call_audio_feed_pcm,
* call_audio_pull_pcm → playback;
* - GUI-поток: start()/beginEnd()/stop() по ACCEPTED/ENDED. */
class CallAudioEngine : public QObject {
Q_OBJECT
public:
static CallAudioEngine* instance();
/* Запустить полнодуплекс для звонка (после ACCEPTED). 0 = успех. */
int start(quint64 callId);
/* По CALL_ENDED: тон завершения (~450мс), затем авто-teardown. */
void beginEnd(quint64 callId);
/* Немедленный teardown (device + освобождение call_audio). */
void stop();
bool isActive() const { return m_active.load(); }
quint64 callId() const { return m_callId.load(); }
/* Mute: пир слышит тишину (отправляем нулевые кадры вместо захваченного PCM). */
void setMuted(bool muted);
bool isMuted() const { return m_muted.load(); }
/* Устройство вывода звонка (-1 = default). Смена на лету пересоздаёт ma_device. */
void setPlaybackDeviceIndex(int index);
int playbackDeviceIndex() const { return m_playbackDeviceIndex; }
/* аудио-поток: miniaudio data callback (захват + воспроизведение) */
void dataCallback(const void* in, void* out, unsigned int frames);
private:
CallAudioEngine() = default;
void teardownDevice();
int initDevice();
std::atomic<bool> m_active{false};
std::atomic<uint64_t> m_callId{0};
std::atomic<bool> m_muted{false};
int m_playbackDeviceIndex = -1; /* только GUI-поток */
ma_device* m_device = nullptr;
bool m_deviceStarted = false;
std::vector<int16_t> m_captureBuf; /* 960 samples, аудио-поток */
int m_capturePos = 0;
};
+179
View File
@@ -0,0 +1,179 @@
#include "callwindow.h"
#include "../transport/gui_bridge.h"
#include "call_audio_engine.h"
#include "sound_manager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QComboBox>
#include <QTimer>
#include <QDateTime>
#include <QCloseEvent>
#include <cstring>
extern "C" {
#include "call/call.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
}
CallWindow::CallWindow(QWidget* parent)
: QWidget(parent, Qt::Window) {
setWindowTitle(QStringLiteral("Звонок"));
setFixedSize(320, 300);
setAttribute(Qt::WA_DeleteOnClose, false);
m_peerLabel = new QLabel(this);
m_peerLabel->setAlignment(Qt::AlignCenter);
m_statusLabel = new QLabel(this);
m_statusLabel->setAlignment(Qt::AlignCenter);
m_durationLabel = new QLabel(QStringLiteral("00:00"), this);
m_durationLabel->setAlignment(Qt::AlignCenter);
m_acceptBtn = new QPushButton(QStringLiteral("Принять"), this);
m_declineBtn = new QPushButton(QStringLiteral("Отклонить"), this);
m_hangupBtn = new QPushButton(QStringLiteral("Завершить"), this);
m_muteBtn = new QPushButton(QStringLiteral("Mute"), this);
m_muteBtn->setCheckable(true);
m_muteBtn->setStyleSheet(
"QPushButton { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 4px 12px; font-size: 12px; background: palette(button); }"
"QPushButton:checked { background: #c0392b; color: white; }");
m_deviceCombo = new QComboBox(this);
m_deviceCombo->setStyleSheet(
"QComboBox { border: 1px solid palette(mid); border-radius: 4px;"
" padding: 6px 10px; font-size: 13px; background: palette(base); }");
QVBoxLayout* lay = new QVBoxLayout(this);
lay->addWidget(m_peerLabel);
lay->addWidget(m_statusLabel);
lay->addWidget(m_durationLabel);
QHBoxLayout* btn = new QHBoxLayout();
btn->addWidget(m_acceptBtn);
btn->addWidget(m_declineBtn);
btn->addWidget(m_hangupBtn);
lay->addLayout(btn);
lay->addWidget(m_muteBtn);
lay->addWidget(m_deviceCombo);
connect(m_acceptBtn, &QPushButton::clicked, this, &CallWindow::onAccept);
connect(m_declineBtn, &QPushButton::clicked, this, &CallWindow::onDecline);
connect(m_hangupBtn, &QPushButton::clicked, this, &CallWindow::onHangup);
connect(m_muteBtn, &QPushButton::toggled, this, &CallWindow::onMuteToggled);
connect(m_deviceCombo, QOverload<int>::of(&QComboBox::activated), this, &CallWindow::onDeviceChanged);
m_durationTimer = new QTimer(this);
m_durationTimer->setInterval(1000);
connect(m_durationTimer, &QTimer::timeout, this, &CallWindow::updateDuration);
setState(Outgoing);
}
CallWindow::~CallWindow() = default;
void CallWindow::setPeerName(const QString& name) {
m_peerLabel->setText(name);
}
void CallWindow::setState(State s) {
m_state = s;
switch (s) {
case Outgoing:
m_statusLabel->setText(QStringLiteral("Звоним…"));
m_durationLabel->hide();
break;
case Incoming:
m_statusLabel->setText(QStringLiteral("Входящий звонок…"));
m_durationLabel->hide();
break;
case Active:
m_statusLabel->setText(QStringLiteral("Разговор"));
m_durationLabel->setText(QStringLiteral("00:00"));
m_durationLabel->show();
m_startMs = QDateTime::currentMSecsSinceEpoch();
m_durationTimer->start();
populateDeviceCombo();
m_muteBtn->setChecked(false);
break;
case Ended:
m_statusLabel->setText(QStringLiteral("Завершён"));
m_durationTimer->stop();
break;
}
updateButtons();
}
void CallWindow::populateDeviceCombo() {
m_deviceCombo->blockSignals(true);
m_deviceCombo->clear();
m_deviceCombo->addItem(QStringLiteral("Default (auto)"), -1);
const auto devs = SoundManager::enumeratePlaybackDevices();
for (int i = 0; i < devs.size(); i++) {
QString label = devs[i].name;
if (devs[i].isDefault) label += QStringLiteral(" (default)");
m_deviceCombo->addItem(label, i);
}
int cur = CallAudioEngine::instance()->playbackDeviceIndex();
for (int i = 0; i < m_deviceCombo->count(); i++) {
if (m_deviceCombo->itemData(i).toInt() == cur) { m_deviceCombo->setCurrentIndex(i); break; }
}
m_deviceCombo->blockSignals(false);
}
void CallWindow::updateButtons() {
m_acceptBtn->setVisible(m_state == Incoming);
m_declineBtn->setVisible(m_state == Incoming);
m_hangupBtn->setVisible(m_state == Outgoing || m_state == Active);
m_muteBtn->setVisible(m_state == Active);
m_deviceCombo->setVisible(m_state == Active);
}
void CallWindow::postCallFn(void (*fn)(void*), uint64_t callId) {
struct call_id_arg* a = (struct call_id_arg*)u_malloc(sizeof(*a));
if (!a) return;
a->inst = gui_bridge_get_inst();
a->call_id = callId;
gui_bridge_post_uasync_fn(fn, a);
}
void CallWindow::onAccept() {
setState(Active);
postCallFn(call_accept_trampoline, m_callId);
}
void CallWindow::onDecline() {
postCallFn(call_decline_trampoline, m_callId);
}
void CallWindow::onHangup() {
postCallFn(call_hangup_trampoline, m_callId);
}
void CallWindow::onMuteToggled(bool checked) {
CallAudioEngine::instance()->setMuted(checked);
m_muteBtn->setText(checked ? QStringLiteral("Вкл. микрофон") : QStringLiteral("Mute"));
}
void CallWindow::onDeviceChanged(int index) {
int devIdx = m_deviceCombo->itemData(index).toInt();
CallAudioEngine::instance()->setPlaybackDeviceIndex(devIdx);
}
void CallWindow::closeEvent(QCloseEvent* event) {
if (m_state == Active) postCallFn(call_hangup_trampoline, m_callId);
emit closed(m_callId);
event->accept();
}
void CallWindow::updateDuration() {
if (m_state != Active) return;
qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startMs;
qint64 s = ms / 1000;
m_durationLabel->setText(QStringLiteral("%1:%2")
.arg(s / 60, 2, 10, QChar('0'))
.arg(s % 60, 2, 10, QChar('0')));
}
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <QWidget>
#include <cstdint>
class QLabel;
class QPushButton;
class QComboBox;
class QTimer;
/* Окно звонка: состояния Outgoing (звоним), Incoming (принять/отклонить),
* Active (идёт разговор, таймер), Ended (завершён). */
class CallWindow : public QWidget {
Q_OBJECT
public:
enum State { Outgoing, Incoming, Active, Ended };
explicit CallWindow(QWidget* parent = nullptr);
~CallWindow() override;
void setCallId(quint64 id) { m_callId = id; }
quint64 callId() const { return m_callId; }
void setPeerName(const QString& name);
void setState(State s);
State state() const { return m_state; }
signals:
void closed(quint64 callId);
protected:
void closeEvent(QCloseEvent* event) override;
private slots:
void onAccept();
void onDecline();
void onHangup();
void updateDuration();
void onMuteToggled(bool checked);
void onDeviceChanged(int index);
private:
void updateButtons();
void populateDeviceCombo();
void postCallFn(void (*fn)(void*), uint64_t callId);
quint64 m_callId = 0;
State m_state = Outgoing;
qint64 m_startMs = 0;
QLabel* m_peerLabel;
QLabel* m_statusLabel;
QLabel* m_durationLabel;
QPushButton* m_acceptBtn;
QPushButton* m_declineBtn;
QPushButton* m_hangupBtn;
QPushButton* m_muteBtn;
QComboBox* m_deviceCombo;
QTimer* m_durationTimer;
};
+14
View File
@@ -30,6 +30,20 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
const QModelIndex &index) const {
painter->save();
/* ── заголовок секции (например, «Личные») ── */
if (index.data(ChannelIsSectionRole).toBool()) {
painter->fillRect(option.rect, option.palette.window());
painter->setPen(option.palette.mid().color());
QFont f = option.font;
f.setBold(true);
painter->setFont(f);
QRect tr = option.rect.adjusted(12, 0, -8, 0);
painter->drawText(tr, Qt::AlignLeft | Qt::AlignVCenter,
index.data(ChannelNameRole).toString());
painter->restore();
return;
}
const int barWidth = 4;
const int margin = 8;
const int iconSize = 32;
+2 -1
View File
@@ -13,7 +13,8 @@ enum ChannelDataRole {
ChannelInviteByLinkBtnHoveredRole = Qt::UserRole + 8, /* bool: hover for invite-by-link button */
ChannelSettingsBtnHoveredRole = Qt::UserRole + 9, /* bool: hover for per-channel settings gear */
ChannelUnreadCountRole = Qt::UserRole + 6, /* int: unread message count, 0 if none */
ChannelLastMsgAtRole = Qt::UserRole + 7 /* qint64: last message timestamp for sorting */
ChannelLastMsgAtRole = Qt::UserRole + 7, /* qint64: last message timestamp for sorting */
ChannelIsSectionRole = Qt::UserRole + 10 /* bool: 1 = заголовок секции (не выбирается) */
};
class ChannelDelegate : public QStyledItemDelegate {
+32 -1
View File
@@ -28,6 +28,7 @@ protected:
void mousePressEvent(QMouseEvent* e) override {
QModelIndex idx = indexAt(e->pos());
if (!idx.isValid()) return;
if (idx.data(ChannelIsSectionRole).toBool()) return;
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
if (gearRect.contains(e->pos())) {
emit settingsClicked(idx.data(ChannelChannelIdRole).toString());
@@ -53,7 +54,7 @@ protected:
m_hoveredInviteByLinkRow = -1;
m_hoveredSettingsRow = -1;
QModelIndex idx = indexAt(e->pos());
if (idx.isValid()) {
if (idx.isValid() && !idx.data(ChannelIsSectionRole).toBool()) {
QRect gearRect = ChannelDelegate::settingsButtonRect(visualRect(idx));
QRect invRect = ChannelDelegate::inviteByLinkButtonRect(visualRect(idx));
QRect plusRect = ChannelDelegate::inviteButtonRect(visualRect(idx));
@@ -81,6 +82,8 @@ protected:
QListView::mouseMoveEvent(e);
}
QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex& idx, const QEvent* e) const override {
if (idx.isValid() && idx.data(ChannelIsSectionRole).toBool())
return QItemSelectionModel::NoUpdate;
if (idx.isValid())
return QItemSelectionModel::ClearAndSelect;
return QItemSelectionModel::NoUpdate;
@@ -236,6 +239,34 @@ void ChannelList::loadChannels() {
(long long)ch.lastMsgAt);
ci++;
}
/* ── секция «Личные» (DM-беседы) ── */
auto dms = m_db->getDmConversations();
if (!dms.isEmpty()) {
auto *section = new QStandardItem();
section->setData(QStringLiteral("Личные"), ChannelNameRole);
section->setData(true, ChannelIsSectionRole);
section->setFlags(Qt::NoItemFlags);
m_model->appendRow(section);
static const QColor dmColor(0x8E, 0x44, 0xAD);
for (const auto& dm : dms) {
QString name = dm.peerName;
if (name.isEmpty()) name = QString("0x%1").arg(dm.peerNodeId, 4, 16, QChar('0')).toUpper();
auto letter = name.mid(0, 1).toUpper();
if (letter.isEmpty()) letter = "@";
auto *item = new QStandardItem();
item->setData(name, ChannelNameRole);
item->setData(QStringLiteral("dm:") + dm.convId, ChannelChannelIdRole);
item->setData(QString(), ChannelLastMessageRole);
item->setData(0, ChannelUnreadCountRole);
item->setData(dm.lastTs, ChannelLastMsgAtRole);
item->setData(makeChannelIcon(dmColor, letter), ChannelIconRole);
m_model->appendRow(item);
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "ChannelList::loadChannels dm row=%d conv='%s' peer='%s'",
m_model->rowCount()-1, qPrintable(dm.convId), qPrintable(name));
}
}
}
sel->blockSignals(false);
+5
View File
@@ -277,6 +277,11 @@ void InputBar::setRecordingContext(const QString& mediaDirBase, const QString& c
m_channelIdForRecord = channelId;
}
void InputBar::setMediaEnabled(bool enabled) {
if (m_pttBtn) m_pttBtn->setEnabled(enabled);
if (m_attachBtn) m_attachBtn->setEnabled(enabled);
}
void InputBar::onPttPressed() {
if (!m_recorder || !m_recorder->isInitialized()) return;
m_recorder->startRecording();
+3
View File
@@ -18,6 +18,9 @@ public:
void setRecordingContext(const QString& mediaDirBase, const QString& channelId);
/* Включить/выключить медиа-кнопки (PTT/attach). Для DM (text-only) — выключить. */
void setMediaEnabled(bool enabled);
signals:
void sendMessage(const QString &text);
void sendVoiceMessage(const QString& mediaSrc, const QString& mediaDest,
+146
View File
@@ -17,6 +17,8 @@
#include "voiceplayback.h"
#include "audiorecorder.h"
#include "voicemessageencoder.h"
#include "callwindow.h"
#include "call_audio_engine.h"
extern "C" {
#include "../../lib/audio_compressor.h"
@@ -39,10 +41,13 @@ extern "C" {
#include <cmath>
#include <QJsonDocument>
#include <QJsonObject>
#include <QRandomGenerator>
#include <csignal>
extern "C" {
#include "chat/chat_core.h"
#include "call/call.h"
#include "dm/dm_core.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
void chat_core_attachment_download_trampoline(void* arg);
@@ -417,6 +422,38 @@ void MainWindow::setupBridgeCallbacks() {
if (s_mainWindow->m_currentChannelId == chId)
s_mainWindow->m_accountList->setChannel(chId);
});
/* ── звонки ── */
gui_bridge_set_call_incoming_cb([](const char* ch_id, int ch_id_len, uint64_t caller, uint64_t call_id) {
if (!s_mainWindow) return;
s_mainWindow->onCallIncoming(QString::fromUtf8(ch_id, ch_id_len), caller, call_id);
});
gui_bridge_set_call_ringing_cb([](uint64_t call_id) {
if (s_mainWindow) s_mainWindow->onCallRinging(call_id);
});
gui_bridge_set_call_accepted_cb([](uint64_t call_id) {
if (s_mainWindow) s_mainWindow->onCallAccepted(call_id);
});
gui_bridge_set_call_ended_cb([](uint64_t call_id, uint8_t reason) {
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
});
gui_bridge_set_call_declined_cb([](uint64_t call_id, uint8_t reason) {
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
});
/* ── DM (p2p чат) ── */
gui_bridge_set_dm_conv_updated_cb([](const char* conv_id, int conv_id_len) {
if (s_mainWindow)
s_mainWindow->onDmConvUpdated(QString::fromUtf8(conv_id, conv_id_len));
});
gui_bridge_set_dm_msg_received_cb([](const char* conv_id, int conv_id_len, uint64_t author) {
if (s_mainWindow)
s_mainWindow->onDmMsgReceived(QString::fromUtf8(conv_id, conv_id_len), author);
});
gui_bridge_set_dm_messages_cb([](const uint8_t* data, int len) {
if (s_mainWindow)
s_mainWindow->m_messageList->onDmMessages(data, len);
});
}
void MainWindow::setupConnects() {
@@ -425,6 +462,12 @@ void MainWindow::setupConnects() {
saveLastChannelId(channelId);
m_currentChannelId = channelId;
m_messageList->loadChannel(channelId);
if (channelId.startsWith(QStringLiteral("dm:"))) {
/* DM: беседа уже создана/обслуживается dm_core */
m_accountList->setChannel(QString());
m_channelList->setChannelUnreadCount(channelId, 0);
return;
}
m_accountList->setChannel(channelId);
struct chat_create_auto_arg* arg = (struct chat_create_auto_arg*)u_calloc(1, sizeof(*arg));
if (arg) {
@@ -442,6 +485,8 @@ void MainWindow::setupConnects() {
connect(m_channelList, &ChannelList::channelSettingsRequested, this, &MainWindow::showChannelSettings);
connect(m_accountList, &AccountList::memberRenamed,
m_messageList, &MessageList::reloadLocalNicks);
connect(m_accountList, &AccountList::callRequested, this, &MainWindow::onCallRequested);
connect(m_accountList, &AccountList::pmRequested, this, &MainWindow::onPmRequested);
connect(m_messageList, &MessageList::readPositionChanged,
this, [this](const QString& channelId, qint64 lastReadMsgId) {
Q_UNUSED(lastReadMsgId);
@@ -453,6 +498,107 @@ void MainWindow::setupConnects() {
connect(m_messageList, &MessageList::openImageRequested, this, &MainWindow::openImage);
}
/* ── звонки ── */
void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QString& name) {
quint64 callId = QRandomGenerator::global()->generate64();
if (!callId) callId = 1;
struct call_start_arg* a = (struct call_start_arg*)u_malloc(sizeof(*a));
if (!a) return;
memset(a, 0, sizeof(*a));
a->inst = gui_bridge_get_inst();
strncpy(a->ch_id, chId.toUtf8().constData(), sizeof(a->ch_id) - 1);
a->peer_node_id = nodeId;
a->call_id = callId;
gui_bridge_post_uasync_fn(call_start_trampoline, a);
CallWindow* w = new CallWindow(this);
w->setCallId(callId);
w->setPeerName(name);
w->setState(CallWindow::Outgoing);
connect(w, &CallWindow::closed, this, &MainWindow::closeCallWindow);
m_activeCalls.insert(callId, w);
w->show();
}
void MainWindow::onCallIncoming(const QString& chId, quint64 caller, quint64 callId) {
Q_UNUSED(chId);
if (m_activeCalls.contains(callId)) return;
QString name = m_db->getDisplayName(caller);
if (name.isEmpty()) name = QStringLiteral("0x%1").arg(caller, 16, 16, QChar('0'));
CallWindow* w = new CallWindow(this);
w->setCallId(callId);
w->setPeerName(name);
w->setState(CallWindow::Incoming);
connect(w, &CallWindow::closed, this, &MainWindow::closeCallWindow);
m_activeCalls.insert(callId, w);
w->show();
w->raise();
w->activateWindow();
}
void MainWindow::onCallRinging(quint64 callId) {
/* caller слышит гудок — окно уже показано в Outgoing */
Q_UNUSED(callId);
}
void MainWindow::onCallAccepted(quint64 callId) {
CallWindow* w = m_activeCalls.value(callId, nullptr);
if (w) w->setState(CallWindow::Active);
if (m_db)
CallAudioEngine::instance()->setPlaybackDeviceIndex(m_db->getUiStateInt("audio_output_device", -1));
CallAudioEngine::instance()->start(callId);
}
void MainWindow::onCallEnded(quint64 callId, uint8_t reason) {
Q_UNUSED(reason);
/* тон завершения (~450мс) → авто-teardown аудио-движка */
if (CallAudioEngine::instance()->callId() == callId)
CallAudioEngine::instance()->beginEnd(callId);
closeCallWindow(callId);
}
void MainWindow::closeCallWindow(quint64 callId) {
CallWindow* w = m_activeCalls.take(callId);
if (!w) return;
w->disconnect(this);
w->deleteLater();
}
/* ── DM (p2p чат) ── */
void MainWindow::onPmRequested(const QString& chId, quint64 nodeId, const QString& name) {
struct dm_start_req* r = (struct dm_start_req*)u_malloc(sizeof(*r));
if (!r) return;
memset(r, 0, sizeof(*r));
r->inst = gui_bridge_get_inst();
r->peer_node_id = nodeId;
strncpy(r->source_ch_id, chId.toUtf8().constData(), sizeof(r->source_ch_id) - 1);
strncpy(r->peer_name, name.toUtf8().constData(), sizeof(r->peer_name) - 1);
gui_bridge_post_uasync_fn(dm_start_trampoline, r);
/* открытие беседы произойдёт по CHAT_EVT_DM_CONV_UPDATED (onDmConvUpdated) */
}
void MainWindow::onDmConvUpdated(const QString& convId) {
if (!m_channelList) return;
QString key = QStringLiteral("dm:") + convId;
m_channelList->loadChannels();
m_channelList->selectChannel(key);
}
void MainWindow::onDmMsgReceived(const QString& convId, quint64 author) {
QString key = QStringLiteral("dm:") + convId;
if (m_currentChannelId == key) {
m_messageList->refresh();
return;
}
if (author == m_db->myNodeId()) return;
if (m_channelList)
m_channelList->setChannelUnreadCount(key, m_channelList->getChannelUnreadCount(key) + 1);
}
void MainWindow::setupTray() {
m_trayIcon = new QSystemTrayIcon(this);
m_trayIcon->setIcon(style()->standardIcon(QStyle::SP_ComputerIcon));
+18
View File
@@ -5,6 +5,7 @@
#include <QMenu>
#include <QQueue>
#include <QSet>
#include <QHash>
#include <QTimer>
class ChannelList;
@@ -17,6 +18,8 @@ class AudioRecorder;
class ConnMonitorWindow;
class VideoPlayerWindow;
class ImageViewerWindow;
class CallWindow;
class CallAudioEngine;
struct AutoPlayItem {
QString channelId;
@@ -63,6 +66,19 @@ public:
void requestShutdown();
UtunNode* utunNode() const { return m_node; }
/* звонки */
void onCallRequested(const QString& chId, quint64 nodeId, const QString& name);
void onCallIncoming(const QString& chId, quint64 caller, quint64 callId);
void onCallRinging(quint64 callId);
void onCallAccepted(quint64 callId);
void onCallEnded(quint64 callId, uint8_t reason);
void closeCallWindow(quint64 callId);
/* DM (p2p чат) */
void onPmRequested(const QString& chId, quint64 nodeId, const QString& name);
void onDmConvUpdated(const QString& convId);
void onDmMsgReceived(const QString& convId, quint64 author);
private:
void setupUi();
void saveWindowGeometry();
@@ -101,4 +117,6 @@ private:
QTimer* m_autoPlayTimer = nullptr;
QTimer* m_shutdownTimer = nullptr;
QSet<int64_t> m_autoPlayDownloading; /* msgIds with download in progress */
QHash<quint64, CallWindow*> m_activeCalls; /* call_id → окно звонка */
};
+5
View File
@@ -92,6 +92,11 @@ quint64 MemberListModel::nodeIdAt(int row) const {
return it != m_cache.end() ? it->nodeId : 0;
}
bool MemberListModel::isOnlineAt(int row) const {
auto it = m_cache.find(row);
return it != m_cache.end() ? it->online : false;
}
static void deserialize_member(const uint8_t* src, MemberListItem& item) {
memcpy(&item.nodeId, src, 8);
uint8_t online = src[8];
+1
View File
@@ -34,6 +34,7 @@ public:
void refresh();
void refreshRtt();
quint64 nodeIdAt(int row) const;
bool isOnlineAt(int row) const;
/* Called from bridge callbacks (GUI thread) */
void onMemberList(const QString& chId, int count, const uint8_t* data, int dataLen);
+107 -1
View File
@@ -35,6 +35,7 @@ static std::vector<std::unique_ptr<LottieIcon>> s_icons;
extern "C" {
#include "../transport/gui_bridge.h"
#include "chat/chat_core.h"
#include "dm/dm_core.h"
#include "video/video.h"
#include "../../lib/u_async.h"
#include "../../lib/mem.h"
@@ -357,6 +358,21 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
return;
}
if (isDmChannel(m_currentChannelId)) {
QByteArray textData = text.toUtf8();
QString conv = dmConvId(m_currentChannelId);
struct dm_send_req* req = (struct dm_send_req*)u_malloc(sizeof(struct dm_send_req) + textData.size());
if (!req) return;
memset(req, 0, sizeof(*req));
req->inst = gui_bridge_get_inst();
strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1);
req->data = (uint8_t*)(req + 1);
req->data_len = (uint32_t)textData.size();
memcpy(req->data, textData.constData(), textData.size());
gui_bridge_post_uasync_fn(dm_send_trampoline, req);
return;
}
QByteArray textData = text.toUtf8();
auto ts = QDateTime::currentMSecsSinceEpoch();
@@ -545,6 +561,19 @@ void MessageList::loadChannel(const QString& channelId) {
m_currentChannelId = channelId;
m_inputBar->setRecordingContext(m_db ? m_db->dbPath() + "/media" : QString(), channelId);
m_readDebounce->stop();
/* DM: только текст; сообщения расшифровываются в dm_core (uasync-поток) */
if (isDmChannel(channelId)) {
m_inputBar->setMediaEnabled(false);
requestDmMessages(channelId);
if (prevFocus)
prevFocus->setFocus();
else
m_inputBar->setFocus();
return;
}
m_inputBar->setMediaEnabled(true);
if (!m_db || !m_db->isOpen()) return;
m_localNicks = m_db->getLocalNicknames(channelId);
@@ -606,7 +635,14 @@ void MessageList::loadChannel(const QString& channelId) {
}
void MessageList::refresh() {
if (m_currentChannelId.isEmpty() || !m_db || !m_db->isOpen()) return;
if (m_currentChannelId.isEmpty()) return;
if (isDmChannel(m_currentChannelId)) {
requestDmMessages(m_currentChannelId);
return;
}
if (!m_db || !m_db->isOpen()) return;
qint64 maxId = 0;
for (int r = 0; r < m_model->rowCount(); r++)
@@ -909,3 +945,73 @@ void MessageList::onMessageContextMenu(const QPoint& pos) {
gui_bridge_post_uasync_fn(chat_core_attachment_download_trampoline, req);
}
}
/* ── DM (p2p чат) ── */
bool MessageList::isDmChannel(const QString& channelId) {
return channelId.startsWith(QStringLiteral("dm:"));
}
QString MessageList::dmConvId(const QString& channelId) {
return channelId.mid(3);
}
void MessageList::requestDmMessages(const QString& channelId) {
QString conv = dmConvId(channelId);
struct dm_messages_req* req = (struct dm_messages_req*)u_malloc(sizeof(*req));
if (!req) return;
memset(req, 0, sizeof(*req));
req->inst = gui_bridge_get_inst();
strncpy(req->conv_id, conv.toUtf8().constData(), sizeof(req->conv_id) - 1);
gui_bridge_post_uasync_fn(dm_messages_trampoline, req);
}
void MessageList::onDmMessages(const uint8_t* data, int len) {
if (len < 3) return;
uint8_t cl = data[0];
if (len < 1 + (int)cl + 2) return;
QString convId = QString::fromUtf8((const char*)data + 1, cl);
if (m_currentChannelId != QStringLiteral("dm:") + convId) return; /* stale response */
uint16_t count; memcpy(&count, data + 1 + cl, 2);
const uint8_t* p = data + 1 + cl + 2;
const uint8_t* end = data + len;
m_model->clear();
m_avatarCache.clear();
for (int i = 0; i < count; i++) {
if (p + 1 + 8 + 8 + 8 + 1 > end) break;
int dir = p[0]; p += 1;
uint64_t seq; memcpy(&seq, p, 8); p += 8;
uint64_t ts; memcpy(&ts, p, 8); p += 8;
quint64 author; memcpy(&author, p, 8); p += 8;
uint8_t ctl = *p; p += 1;
if (p + ctl > end) break;
p += ctl; /* ct игнорируем: DM v1 — текст */
if (p + 2 > end) break;
uint16_t dlen; memcpy(&dlen, p, 2); p += 2;
if (p + dlen > end) break;
QString text = QString::fromUtf8((const char*)p, dlen);
p += dlen;
AccountRow acc = m_db ? m_db->getAccount(author) : AccountRow();
QString name = authorName(author);
QString letter = acc.avatarLetter.isEmpty() ? name.mid(0, 1) : acc.avatarLetter;
QColor color(acc.avatarColor);
if (!m_avatarCache.contains(author))
m_avatarCache[author] = color;
auto avatar = makeAvatar(color, letter);
auto time = QDateTime::fromMSecsSinceEpoch((qint64)ts).toString("HH:mm");
auto *item = new QStandardItem();
setMsg(item, name, text, time, avatar, false,
false, QString(), QString(), QVariantList{});
item->setData((qint64)seq, MsgIdRole);
item->setData(QVariant::fromValue(author), MsgAuthorNodeIdRole);
item->setData(dir == 1 ? 1 : 0, MsgIsOutgoingRole);
m_model->appendRow(item);
}
m_view->scrollToBottom();
}
+4
View File
@@ -26,6 +26,7 @@ public:
static void cleanupAnimations();
void updateMessageProgress(const QString& chId, int64_t msgId, int blocksDone, int numBlocks);
void onAttachmentDownloaded(const QString& chId, int64_t msgId);
void onDmMessages(const uint8_t* data, int len);
signals:
void readPositionChanged(const QString& channelId, qint64 lastReadMsgId);
@@ -52,4 +53,7 @@ private:
void restoreScrollPosition(const QString& channelId);
void updateReadPosition();
void onMessageContextMenu(const QPoint& pos);
void requestDmMessages(const QString& channelId);
static bool isDmChannel(const QString& channelId);
static QString dmConvId(const QString& channelId);
};