81 changed files with 3624 additions and 527 deletions

No files matched your search

+59 -48
View File
@@ -1,48 +1,59 @@
Внедрение группы устройств в чат (devgroup).
Первый этап: объединение устройств одного пользователя и группировка в списке участников.
1. Идентичность и хранение.
Устройство состоит максимум в одной devgroup. При создании группы генерируются две отдельные общие пары ключей:
X25519 для шифрования и Ed25519 для подписей. Публичный ключ Ed25519 идентифицирует группу.
Обе пары одинаковы на всех устройствах devgroup; при добавлении устройств передаются существующие ключи.
Принадлежность подтверждает подпись devgroup над идентичностью конкретного устройства (node_id и его публичные ключи).
Одного совпадения публичного ключа devgroup недостаточно.
Секреты хранятся отдельно от публичного профиля и не теряются при перезапуске; текущий local_identity перезаписывается при старте.
2. Приглашение.
A выбирает известное устройство B и показывает PIN из 4 цифр. B получает приглашение и вводит PIN с экрана A.
Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут.
Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки.
После проверки A передаёт ключи и профиль группы. B проверяет и сохраняет их, затем подтверждает успех.
Повторы идемпотентны; потеря подтверждения означает неизвестный результат, а не отказ. Переданные секреты отменой не отзываются.
Устройство из другой devgroup должно сначала выйти из неё; неявного слияния групп нет.
3. Профиль и версии.
Ник, ver, timestamp и публичный ключ X25519 входят в расширяемый JSON, целиком подписанный ключом Ed25519 devgroup
с отдельным назначением подписи. Подпись связывает ключ шифрования с идентичностью группы.
Хранятся и пересылаются исходные подписанные байты. Победитель определяется по (ver, timestamp, SHA256(JSON)).
Локальное изменение увеличивает максимальную известную ver на 1; при репликации версия не увеличивается.
Формат должен учитывать текущие ограничения userinfo: строковые значения и общий размер 1024 байта вместе с остальными свойствами.
4. Репликация первого этапа.
Оба публичных ключа, удостоверение устройства и подписанный профиль передаются в подписанном userinfo участника.
Устройство публикует их во всех своих каналах. Более свежий проверенный профиль своей devgroup принимает
и переносит в собственные записи остальных каналов, не меняя чужие записи и не перепубликуя неизменившиеся данные.
Это работает по связанной цепочке общих каналов; независимая от каналов сеть устройств в первый этап не входит.
При создании канала и вступлении в новый канал сразу публикуется актуальная принадлежность.
5. Выход.
Добровольный выход удаляет локальные ключи и devgroup-поля из всех собственных member-записей с повышением их версий.
Локальное состояние и публикации меняются атомарно; уведомления и распространение выполняются после commit.
Старые сетевые записи не возвращают устройство в группу. Принудительный отзыв устройства со сменой ключей — отдельный этап.
6. Список участников и границы этапа.
В Qt и Android устройства с проверенной принадлежностью объединяются под раскрывающимся [+] с актуальным ником devgroup.
Внутри показываются устройства текущего канала. Права и действия остаются у конкретных устройств;
заголовок группы не подменяется node_id одного из них. Группа online, если доступно хотя бы одно активное устройство.
Звонки на все устройства и объединение DM в первый этап не входят.
Порядок реализации: состояние и проверка devgroup → профиль и репликация → приглашения → API/headless → Qt и Android.
Проверить подделки, конфликты версий, offline-сходимость, повторы, потери ответов, перезапуск, ошибки сохранения и выход.
Логи показывают состояния и причины отказов, но не PIN и секреты.
Внедрение группы устройств в чат (devgroup).
Первый этап: объединение устройств одного пользователя и группировка в списке участников.
1. Идентичность и хранение.
Устройство состоит максимум в одной devgroup. При создании группы генерируются две отдельные общие пары ключей:
X25519 для шифрования и Ed25519 для подписей. Публичный ключ Ed25519 идентифицирует группу.
Обе пары одинаковы на всех устройствах devgroup; при добавлении устройств передаются существующие ключи.
Принадлежность подтверждает подпись devgroup над идентичностью конкретного устройства (node_id и его публичные ключи).
Одного совпадения публичного ключа devgroup недостаточно.
Публичные данные devgroup хранятся в отдельных полях nodes: оба публичных ключа, удостоверение устройства, JSON профиля и его подпись.
Эти же данные входят в TOPO_NODE и подписанный снимок node_snapshots; nodes содержит их согласованную проекцию.
Принадлежность общая для узла, независимо от каналов; в peers_<channel_id> и userinfo поля devgroup не добавляются.
Секреты хранятся отдельно от публичного профиля и не теряются при перезапуске; текущий local_identity перезаписывается при старте.
2. Приглашение.
A нажимает «Добавить устройство» в свойствах B: приглашение отправляется B, на A показывается PIN из 4 цифр.
На B автоматически появляется диалог ввода PIN с экрана A; введённый PIN возвращается A для проверки.
Обмен идёт через ETCP-router с обязательными сквозным шифрованием и подписью; BGP обеспечивает маршрут.
Приглашение одноразовое, привязано к ключам B и случайному ID, действует 2 минуты, допускает 3 попытки.
Только после успешной проверки PIN A создаёт обе пары ключей, если группы ещё нет; иначе использует существующие.
A передаёт ключи и профиль группы. B проверяет и сохраняет их, затем подтверждает успех.
Повторы идемпотентны; потеря подтверждения означает неизвестный результат, а не отказ. Переданные секреты отменой не отзываются.
Устройство из другой devgroup должно сначала выйти из неё; неявного слияния групп нет.
3. Профиль и версии.
Ник, ver, timestamp и публичный ключ X25519 входят в расширяемый JSON, целиком подписанный ключом Ed25519 devgroup
с отдельным назначением подписи. Подпись связывает ключ шифрования с идентичностью группы.
Хранятся и пересылаются исходные подписанные байты. Победитель определяется по (ver, timestamp, SHA256(JSON)).
Локальное изменение увеличивает максимальную известную ver на 1; при репликации версия не увеличивается.
Для профиля devgroup задаётся отдельный ограниченный размер с учётом полного NODEINFO; лимит userinfo менять не требуется.
Версия профиля devgroup и timestamp подписанного снимка узла независимы.
4. Репликация первого этапа.
Данные devgroup распространяются через текущую синхронизацию nodes (NODEINFO/BGP) в составе общей подписанной записи узла.
Текущих гарантий доставки достаточно для первого этапа; отдельный протокол синхронизации и offline-доставки не добавляется.
Проверяются подпись узла, удостоверение принадлежности и подпись профиля devgroup. Обновление снимка заменяет блок целиком.
Более свежий профиль своей devgroup устройство принимает и публикует в новом собственном снимке во всех своих топологических группах.
Чужие снимки не меняются; неизменившийся профиль не вызывает новой публикации.
Синхронизация зависит от распространения NODEINFO через существующие группы; отдельная сеть устройств в первый этап не входит.
После восстановления связи и при вступлении в новую группу используется штатный обмен NODEINFO.
5. Выход.
Кнопка «Отключить от моих устройств» доступна в свойствах self. Выход удаляет локальные ключи и публикует новый собственный снимок с пустым блоком devgroup и увеличенным timestamp.
Локальное состояние, снимок node_snapshots и поля nodes меняются атомарно; уведомления и распространение выполняются после commit.
Старые сетевые записи не возвращают устройство в группу. Принудительный отзыв устройства со сменой ключей — отдельный этап.
6. Список участников и границы этапа.
В Qt и Android устройства с проверенной принадлежностью объединяются под раскрывающимся [+] с актуальным ником devgroup.
Принадлежность берётся из nodes по node_id; профиль выбирается простым поиском наиболее свежего проверенного объявления
среди текущих записей известных узлов devgroup по порядку из п. 3. Отдельный постоянный кеш профилей не нужен.
Изменение общей записи обновляет отображение участника во всех каналах, где он присутствует.
Внутри показываются устройства текущего канала. Права и действия остаются у конкретных устройств;
заголовок группы не подменяется node_id одного из них. Группа online, если доступно хотя бы одно активное устройство.
Звонки на все устройства и объединение DM в первый этап не входят.
Порядок реализации: nodes/TOPO_NODE/снимок и проверка devgroup → NODEINFO/BGP и профиль → приглашения → API/headless → Qt и Android.
Проверить подделки, конфликты версий, обновление через штатный обмен NODEINFO, повторы, потери ответов, перезапуск, ошибки сохранения и выход.
Логи показывают состояния и причины отказов, но не PIN и секреты.
+54
View File
@@ -0,0 +1,54 @@
Вызов подгруппы устройств: звонок и приглашение в рацию
Цель задаётся устройством и явным флагом devices. В потоке ядра выбираются все
проверенные участники его devgroup в выбранном CHAT-канале, с живым BGP-маршрутом,
кроме собственного узла. Имя группы не является идентификатором. Снимок адресатов
фиксируется при старте; максимум 64, превышение отклоняется без частичной рассылки.
Одиночный вызов использует тот же протокол выбора с одним адресатом. DM остаётся адресным.
Первый ACCEPT, обработанный инициатором, фиксирует победителя. Ручной DECLINE,
полученный раньше выбора, завершает всю попытку. BUSY, POLICY, PHONE_BUSY и потеря
маршрута относятся только к отдельному устройству. PHONE_BUSY требует решения
инициатора только когда не осталось других потенциальных адресатов.
После выбора поздние ответы проигравших не меняют победителя; повторного выбора нет.
Отмена инициатором завершает также подключение выбранного устройства.
Обычный звонок: INVITE -> RINGING -> ACCEPT -> SELECT -> SELECTED -> ACTIVE.
ACCEPT выражает решение пользователя, SELECT разрешает подключение только победителю.
До SELECT вызываемый остаётся ANSWERING без медиа. Инициатор переходит из SELECTING
в ACTIVE после SELECTED. ACCEPT и SELECT повторяются каждые 300мс; повтор SELECT
в ACTIVE повторяет SELECTED. Общий deadline приглашения 60с не продлевается.
Финальные команды и подтверждения обслуживаются отдельно для каждого адресата.
Ранний CANCEL сохраняет закрытый tuple (id, group, peer), чтобы поздний INVITE
не создал новый рингтон. Медиа принимает только выбранный peer.
CALL_ACCEPTED: call_id:8, reason:1=0, actual_peer:8 (native endian).
Рация: START -> INCOMING/READY -> ANSWER -> ANSWERING -> SELECTED -> ACCEPT/FAIL.
ANSWER — кнопка пользователя (wire RC_ACCEPT), ACCEPT — фактическая готовность
рации после SELECTED. На проводе RC_SELECT повторяется до RESULT/ACK.
Рация, уже включённая в канале, тоже получает уведомление и сигнал вызова.
Автоматического ответа ALREADY_ACTIVE больше нет. Приглашение не делает рацию
приватной: это по-прежнему общий PTT-канал. Финальный отбой не выключает ранее
работавшую рацию. Отменённая незавершённая активация освобождает только ресурсы
своего ID; ручной/новый запуск меняет владельца и защищён от старого callback.
После успешного приглашения рация продолжает обычную работу независимо от него.
UI: заголовок подгруппы запускает devices, строка устройства — адресный вызов.
Один исходящий вызов и один итог для пользователя. После выбора показывается
фактический peer. Линия занята всей попыткой, не отдельными адресатами.
Ядро/сеть сериализованы в uasync; GUI передаёт только значения команд.
Headless control:
call_start: прежние ch/node_id/call_id, дополнительно devices:1.
radio_call: action, call_id (hex); START также ch (decimal), node_id (hex), devices:0/1.
action: 1 START, 2 READY, 3 ACCEPT, 4 DECLINE, 5 CANCEL, 6 FAIL, 7 REJECT_BUSY,
8 CONTINUE, 9 ANSWER. START без call_id генерирует его. Событие radio_call содержит
call_id/ch/peer/state/reason/incoming. state: 1 OUTGOING, 2 INCOMING, 3 RINGING,
4 FINISHED, 5 WAIT_DECISION, 6 ANSWERING, 7 SELECTED. После SELECTED клиент включает
рацию через radio_on и отправляет ACCEPT либо FAIL. Финальный reason=9 означает POLICY.
Диагностика: категории call/radio. INFO — адресаты, выбор, результат и отбой;
DEBUG — подтверждения отдельных адресатов. Ошибки отправки/таймеров/активации
логируются. Для сетевых проверок: test_call_admission и test_radio (три узла,
подписанная devgroup, два ответа, потеря SELECT/SELECTED, отказ всей подгруппе,
уже включённая рация и отбой до завершения активации).
+29
View File
@@ -60,6 +60,11 @@ NCD пропускает нулевые порты. Непубличный ло
snapshot загрузка использует его. Локальная публикация сохраняется до рассылки;
ошибка сохранения не разрешает публикацию новой версии.
`node_snapshots.format=1` отмечает текущий формат NODEINFO. При обновлении старой
БД несовместимые BLOB очищаются, timestamp и адреса сохраняются для bootstrap.
Пустые снимки не участвуют в классификации пиров; новый проверенный снимок может
заполнить такой кеш с тем же timestamp. Повторный запуск сохраняет новые снимки.
В проекции адресов сохраняются `nat_mapping`, `nat_inbound`, `nat_source`, `address_kind`.
Уникальность определяется endpoint и видом адреса,
а не отображаемым классом NAT. Старый производный формат пересоздаётся;
@@ -167,3 +172,27 @@ member-записи не должны восстанавливать намер
REALITY использует полное SNI до 255 символов; размер поля в записи узла
согласован с транспортом. Если локального TCP listen-сокета нет, NCD может
создать исходящий TCP-линк с выбором локального адреса операционной системой.
Devgroup входит в общую подписанную запись: Ed25519/X25519 public keys,
удостоверение данного устройства, подпись профиля и исходные байты JSON.
В `nodes` хранятся `devgroup_ed`, `devgroup_x`, `devgroup_member_sig`,
`devgroup_json`, `devgroup_profile_sig`; отсутствие группы — NULL во всех пяти полях.
Проекция меняется вместе с `node_snapshots` в одной транзакции. Локальные секреты
находятся в `devgroup_local`, квитанции завершённого импорта — в `devgroup_receipts`.
Квитанции сохраняются после выхода: повтор передачи не восстанавливает членство.
Выбор имени: максимум (ver,timestamp,SHA256(JSON)) среди проверенных объявлений `nodes`.
NODEINFO упакован без выравнивания структур. Заголовок содержит uint16
`reality_size` и `devgroup_size`, затем прежние поля. После заголовка идут
`node_name_len` байт имени (без NUL), `devgroup_size` байт devgroup, metadata и адреса,
REALITY, опциональные подсети и список переходов. Пустой devgroup занимает 0 байт тела.
Непустой блок: Ed32, X32, удостоверение64, подпись JSON64, JSON без NUL (до768 байт).
REALITY: socket_id1, short_id8, server_pubkey32, version3, server_name_len1,
затем ровно server_name_len байт SNI без NUL. Прежний char[256] на проводе удалён.
Для SNI длиной N экономия составляет 255−N байт на сокет; `reality_size` добавляет
2 байта один раз в заголовке. Например, для example.com экономия244 байта на сокет.
Имя самого узла уже передавалось переменной длиной; его упаковка сохранена.
Парсер проверяет длину секции, число записей и отсутствие NUL внутри переданного имени.
Локальный GUI/JNI-снимок участника имеет свой формат, описанный в `chat_member.h`;
он не передаётся в BGP и не является NODEINFO.
+9
View File
@@ -1,9 +1,18 @@
# Задачи по проекту
Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ.
## Текущие задачи
[ ] **Проверить зависание test_group_connect / test_group_connect_standby** — 02.11.2026.
Обнаружено при проверке вызовов подгруппы: полный check.sh — таймаут90с в обоих тестах.
В логах повторяются group exchange stalled (group=42, sent=0, received=0) и
conn_mgr initial connection timeout. Отдельные диагностические запуски сохранены ниже.
Логи: /tmp/device-calls-check-full.log, /tmp/device-group-connect.log,
/tmp/device-group-standby.log. Требуется диагностика стенда/ожиданий standby;
код планировщика групп в задаче вызовов не изменялся.
[+] **BLE: единый GATT discovery и полное имя узла** — 31.10.2026.
Linux/WinRT/Android публикуют один node UUID v5 без manufacturer-ника/LocalName и без чередования.
Read-only NODE_NAME (полный UTF-8 до63 байт) и NODE_ID (BE64) имеют постоянные UUID и описания0x2901.
+10
View File
@@ -53,6 +53,8 @@ utun_CORE_SOURCES = \
routing_layer/route_ping.c \
routing_layer/nat_detection.c \
routing_layer/topo_node.c \
routing_layer/devgroup.c \
routing_layer/devgroup.h \
routing_layer/topo_node_sqlite.c \
routing_layer/route_connectivity.c \
routing_layer/sock_match.c \
@@ -183,6 +185,9 @@ utun_CORE_SOURCES = \
chat/chat_whisper.c \
chat/chat_channel.c \
chat/chat_profile.c \
chat/chat_devgroup.c \
chat/chat_devgroup_invite.c \
chat/chat_devgroup.h \
chat/group_avatar.c \
chat/chat_status.c \
chat/chat_sync.c \
@@ -250,6 +255,8 @@ libutun_a_SOURCES = \
routing_layer/route_ping.c \
routing_layer/nat_detection.c \
routing_layer/topo_node.c \
routing_layer/devgroup.c \
routing_layer/devgroup.h \
routing_layer/topo_node_sqlite.c \
routing_layer/route_connectivity.c \
routing_layer/sock_match.c \
@@ -378,6 +385,9 @@ libutun_a_SOURCES = \
chat/chat_poll.h \
chat/chat_channel.c \
chat/chat_profile.c \
chat/chat_devgroup.c \
chat/chat_devgroup_invite.c \
chat/chat_devgroup.h \
chat/group_avatar.c \
chat/chat_status.c \
chat/chat_sync.c \
+261 -57
View File
@@ -1,6 +1,6 @@
// call.c — P2P аудио-звонок: сигналинг + медиа (per-instance)
//
// Полный цикл: INVITE → RINGING → ACCEPT → conn_mgr (прямой линк) → ACTIVE →
// Полный цикл: INVITE → RINGING → ACCEPT → SELECT/SELECTED → ACTIVE →
// медиа (CALL_MEDIA) → HANGUP/ENDED. Звонок из CHAT-группы или DM — модуль
// оперирует (group_id, peer_node_id), маршрутизация через etcp_router.
//
@@ -20,6 +20,7 @@
#include "../utun_instance.h"
#include "../chat/chat_event.h"
#include "../chat/chat_setting.h"
#include "../chat/member_sync.h"
#include "../ntp_time.h"
#include "../routing_layer/etcp_router.h"
#include "../routing_layer/route_crypto.h"
@@ -36,6 +37,7 @@
#include "../../lib/u_async.h"
#include "../../lib/platform_compat.h"
#include <sqlite3.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
@@ -67,6 +69,8 @@ static const char* call_state_name(uint8_t st) {
case CALL_ST_ACTIVE: return "ACTIVE";
case CALL_ST_ENDED: return "ENDED";
case CALL_ST_WAIT_DECISION: return "WAIT_DECISION";
case CALL_ST_ANSWERING: return "ANSWERING";
case CALL_ST_SELECTING: return "SELECTING";
default: return "?";
}
}
@@ -76,6 +80,8 @@ static const char* call_subcmd_name(uint8_t sc) {
case CALL_SUBCMD_INVITE: return "INVITE";
case CALL_SUBCMD_RINGING: return "RINGING";
case CALL_SUBCMD_ACCEPT: return "ACCEPT";
case CALL_SUBCMD_SELECT: return "SELECT";
case CALL_SUBCMD_SELECTED: return "SELECTED";
case CALL_SUBCMD_DECLINE: return "DECLINE";
case CALL_SUBCMD_BUSY: return "BUSY";
case CALL_SUBCMD_CANCEL: return "CANCEL";
@@ -87,6 +93,19 @@ static const char* call_subcmd_name(uint8_t sc) {
}
}
/* Возврат 1 — tuple закрыт, 0 — нет, -1 — кэш заполнен. Без вытеснения свежих решений. */
static int call_closed(struct call_ctx* ctx, uint64_t id, uint64_t group, uint64_t peer, int save) {
uint64_t now = get_time_tb(); struct call_closed* empty = NULL;
for (unsigned i = 0; i < 256; ++i) {
struct call_closed* c = &ctx->closed[i];
if (c->until <= now) { if (!empty) empty = c; continue; }
if (c->id == id && c->group == group && c->peer == peer) return 1;
}
if (!save) return 0;
if (!empty) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call closed cache full; refusing end acknowledgement"); return -1; }
*empty = (struct call_closed){id, group, peer, now + 700000}; return 1;
}
/* ─────────────────── отправка ─────────────────── */
static void call_stats_timer_cb(void* arg);
@@ -128,7 +147,65 @@ static int call_send_invite(struct call_ctx* ctx, struct call_session* s) {
inv->tracks[0].sample_rate = CALL_OPUS_SAMPLE_RATE;
inv->tracks[0].channels = 1;
inv->tracks[0].bitrate = 32000;
return call_send(ctx, s->group_id, s->peer_node_id, buf, sizeof(buf), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
int result = -1;
for (unsigned i = 0; i < s->target_count; ++i) {
struct call_target* t = &s->targets[i];
if (t->state == CALL_ST_ENDED) continue;
inv->force = s->force;
if (!call_send(ctx, s->group_id, t->peer, buf, sizeof(buf), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)) result = 0;
}
return result;
}
/* Снимок devgroup формируется по подписанным записям и текущим маршрутам канала. */
int call_resolve_targets(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, int devices,
uint64_t* out, unsigned capacity) {
struct TOPO_GROUP* g = inst && inst->topo_groups ? topo_groups_find(inst->topo_groups, group) : NULL;
if (!g || g->group_type != TOPO_GROUP_TYPE_CHAT || !peer || !out || !capacity) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call target invalid group=%llx peer=%llx", (unsigned long long)group, (unsigned long long)peer);
return -1;
}
uint8_t id[32], key[32]; char name[64];
if (!devices) {
if (peer == inst->node_id || member_sync_verified_ed25519(inst, g->channel_id, peer, key) < 0 ||
!topo_group_find_conn_for_node(g, peer)) return 0;
out[0] = peer; return 1;
}
if (devgroup_member(inst->topo_sqlite_db, peer, id, name) != 1) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call target has no verified devgroup peer=%llx", (unsigned long long)peer); return -1;
}
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(inst->topo_sqlite_db, "SELECT node_id FROM nodes WHERE devgroup_ed=? ORDER BY node_id", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, id, 32, SQLITE_TRANSIENT);
unsigned count = 0;
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
uint64_t node = (uint64_t)sqlite3_column_int64(st, 0);
if (node == inst->node_id || !topo_group_find_conn_for_node(g, node) ||
member_sync_verified_ed25519(inst, g->channel_id, node, key) < 0) continue;
uint8_t found[32]; char title[64];
if (devgroup_member(inst->topo_sqlite_db, node, found, title) != 1 || memcmp(id, found, 32)) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call target rejected unverified device=%llx", (unsigned long long)node); continue;
}
if (count == capacity) { rc = SQLITE_TOOBIG; break; }
out[count++] = node;
}
int final = sqlite3_finalize(st);
if (rc != SQLITE_DONE || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call target query failed rc=%d finalize=%d capacity=%u", rc, final, capacity); return -1;
}
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call devices resolved group=%llx anchor=%llx count=%u",
(unsigned long long)group, (unsigned long long)peer, count);
return (int)count;
}
static struct call_target* call_target_find(struct call_session* s, uint64_t peer) {
for (unsigned i = 0; i < s->target_count; ++i) if (s->targets[i].peer == peer) return &s->targets[i];
return NULL;
}
static void call_send_simple(struct call_session* s, uint64_t peer, uint8_t command) {
struct call_pkt_accept packet = {command, s->call_id};
call_send(s->ctx, s->group_id, peer, (const uint8_t*)&packet, sizeof(packet), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
}
/* ─────────────────── реестр сессий ─────────────────── */
@@ -191,6 +268,8 @@ static void call_session_cleanup(struct call_ctx* ctx, struct call_session* s) {
/* Убрать сессию из реестра и освободить отложенно (не из коллбэков conn_mgr/таймеров). */
static void call_session_remove_and_free(struct call_ctx* ctx, struct call_session* s) {
if (s->target_timer) uasync_cancel_timeout(ctx->inst->ua, s->target_timer);
s->target_timer = NULL;
call_session_cleanup(ctx, s);
struct ll_entry* e = queue_find_data_by_index(ctx->sessions, &s->call_id);
if (e) {
@@ -338,6 +417,38 @@ static void call_watch_connection(struct call_session* s) {
/* ─────────────────── завершение ─────────────────── */
/* Финальная команда восстанавливается слоем звонка после смены эпохи роутера. */
static void call_target_tick(void* arg) {
struct call_session* s = arg;
s->target_timer = NULL;
int pending = 0; uint64_t now = get_time_tb();
for (unsigned i = 0; i < s->target_count; ++i) {
struct call_target* t = &s->targets[i];
if (!t->end_subcmd || t->ack) continue;
if (now - t->ended_tb >= CALL_END_TIMEOUT_TB) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call target end timeout id=%llx peer=%llx",
(unsigned long long)s->call_id, (unsigned long long)t->peer);
t->ack = 1; continue;
}
struct call_pkt_hangup packet = {t->end_subcmd, s->call_id, t->reason};
size_t size = t->end_subcmd == CALL_SUBCMD_CANCEL ? sizeof(struct call_pkt_cancel) : sizeof(packet);
call_send(s->ctx, s->group_id, t->peer, (const uint8_t*)&packet, size, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
pending = 1;
}
if (pending) {
s->target_timer = uasync_set_timeout(s->ctx->inst->ua, CALL_END_RETRY_TB, s, call_target_tick, "call_target_end");
if (!s->target_timer) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call target end timer allocation failed");
}
}
static void call_target_end(struct call_session* s, struct call_target* t, uint8_t reason) {
if (t->state == CALL_ST_ENDED) return;
t->state = CALL_ST_ENDED; t->reason = reason; t->end_subcmd = CALL_SUBCMD_CANCEL; t->ended_tb = get_time_tb();
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call target cancelled id=%llx peer=%llx selected=%llx reason=%u",
(unsigned long long)s->call_id, (unsigned long long)t->peer, (unsigned long long)s->selected, reason);
if (s->target_timer) uasync_cancel_timeout(s->ctx->inst->ua, s->target_timer);
call_target_tick(s);
}
static void call_send_end(struct call_session* s) {
struct call_pkt_hangup packet = {s->end_subcmd, s->call_id, s->reason};
size_t len = s->end_subcmd == CALL_SUBCMD_CANCEL ? sizeof(struct call_pkt_cancel) : sizeof(packet);
@@ -353,8 +464,8 @@ static void call_teardown_timer_cb(void* arg) {
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->teardown_timer = NULL;
if (s->end_subcmd && get_time_tb() - s->end_started_tb < CALL_END_TIMEOUT_TB) {
call_send_end(s);
if ((s->end_subcmd || s->target_timer) && get_time_tb() - s->end_started_tb < CALL_END_TIMEOUT_TB) {
if (s->end_subcmd) call_send_end(s);
s->teardown_timer = uasync_set_timeout(ctx->inst->ua, CALL_END_RETRY_TB, s, call_teardown_timer_cb, "call_end_retry");
if (s->teardown_timer) return;
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call end retry timer allocation failed id=%016llx", (unsigned long long)s->call_id);
@@ -370,11 +481,14 @@ static void call_teardown_timer_cb(void* arg) {
static void call_end(struct call_ctx* ctx, struct call_session* s, uint8_t reason, uint8_t end_subcmd) {
if (!ctx || !s || s->state == CALL_ST_ENDED) return;
if (s->role == CALL_ROLE_CALLEE) call_closed(ctx, s->call_id, s->group_id, s->peer_node_id, 1);
s->state = CALL_ST_ENDED;
for (unsigned i = 0; i < s->target_count; ++i)
if (!s->selected || s->targets[i].peer != s->selected) call_target_end(s, &s->targets[i], reason);
call_publish_phone_status(ctx->inst);
call_session_cleanup(ctx, s);
s->reason = reason;
s->end_subcmd = end_subcmd;
s->end_subcmd = s->target_count && !s->selected ? 0 : end_subcmd;
s->end_started_tb = get_time_tb();
ctx->c_calls_ended++;
@@ -392,7 +506,7 @@ static void call_end(struct call_ctx* ctx, struct call_session* s, uint8_t reaso
call_event_id(ctx, CHAT_EVT_CALL_ENDED, s->call_id, reason);
if (s->end_subcmd) call_send_end(s);
if (s->cm_handle || s->end_subcmd || s->role == CALL_ROLE_CALLEE) {
if (s->target_count || s->cm_handle || s->end_subcmd || s->role == CALL_ROLE_CALLEE) {
unsigned delay = s->end_subcmd ? CALL_END_RETRY_TB : CALL_RING_DEAD_TB + 1000;
s->teardown_timer = uasync_set_timeout(ctx->inst->ua, delay, s, call_teardown_timer_cb, "call_teardown");
if (s->teardown_timer) return;
@@ -438,7 +552,9 @@ static void call_activate(struct call_session* s) {
(unsigned long long)s->call_id, s->audio_batch.target, CALL_MEDIA_MAX_BYTES);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: accepted id=%016llx -> ACTIVE (router delivery)", CALL_ID,
(unsigned long long)s->call_id);
call_event_id(ctx, CHAT_EVT_CALL_ACCEPTED, s->call_id, 0);
uint8_t event[17] = {0};
memcpy(event, &s->call_id, 8); memcpy(event + 9, &s->peer_node_id, 8);
chat_event_post(ctx->inst, CHAT_EVT_CALL_ACCEPTED, event, sizeof(event));
call_watch_connection(s);
s->traffic_timer = uasync_set_timeout(ctx->inst->ua, CALL_NO_TRAFFIC_TB, s, call_traffic_timeout_cb, "call_traffic");
}
@@ -467,25 +583,22 @@ static void call_invite_retry_cb(void* arg) {
struct call_ctx* ctx = s->ctx;
s->invite_timer = NULL;
if (s->state == CALL_ST_ENDED) return;
if (s->state != CALL_ST_OUTGOING && s->state != CALL_ST_RINGING && s->state != CALL_ST_WAIT_DECISION) return;
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 codegram id=%016llx (route ok)", CALL_ID, (unsigned long long)s->call_id);
if (s->state == CALL_ST_ANSWERING) call_send_simple(s, s->peer_node_id, CALL_SUBCMD_ACCEPT);
else if (s->state == CALL_ST_SELECTING) call_send_simple(s, s->peer_node_id, CALL_SUBCMD_SELECT);
else if (s->role == CALL_ROLE_CALLER && !s->selected) {
unsigned live = 0;
for (unsigned i = 0; i < s->target_count; ++i) {
struct call_target* t = &s->targets[i];
if (t->state == CALL_ST_ENDED) continue;
if (etcp_router_has_route(ctx->inst, s->group_id, t->peer)) t->no_route = 0;
else if (++t->no_route >= CALL_NO_ROUTE_MAX_STRIKES) {
call_target_end(s, t, CALL_REASON_CONNECT_FAIL); continue;
}
live++;
}
if (!live) { call_end(ctx, s, CALL_REASON_CONNECT_FAIL, 0); return; }
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 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);
buf[8] = 2; /* err: peer unreachable (no route) */
chat_event_post(ctx->inst, CHAT_EVT_CALL_ERROR, buf, sizeof(buf));
call_end(ctx, s, CALL_REASON_CONNECT_FAIL, 0);
return;
} else {
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);
}
} else return;
s->invite_timer = uasync_set_timeout(ctx->inst->ua, CALL_INVITE_RETRY_TB, s, call_invite_retry_cb, "call_invite_retry");
}
@@ -496,7 +609,7 @@ static void call_ring_dead_cb(void* arg) {
if (!s || !s->ctx) return;
struct call_ctx* ctx = s->ctx;
s->ring_timer = NULL;
if (s->state != CALL_ST_RINGING && s->state != CALL_ST_WAIT_DECISION) return;
if (s->state != CALL_ST_RINGING && s->state != CALL_ST_WAIT_DECISION && s->state != CALL_ST_ANSWERING) return;
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: ring dead (2с без кодограммы) id=%016llx -> end",
CALL_ID, (unsigned long long)s->call_id);
@@ -594,6 +707,11 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro
(unsigned long long)call_id, invite.force, n); return;
}
if (call_closed(ctx, call_id, group, from, 0)) {
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call invite ignored after terminal id=%llx peer=%llx",
(unsigned long long)call_id, (unsigned long long)from); return;
}
/* кодограмма-дубликат (тот же call_id): пока звоним — пере-взводим watchdog и
* идемпотентно отвечаем RINGING; в разговоре (ACTIVE) — игнорируем. */
struct call_session* s = call_session_find(ctx, call_id);
@@ -601,10 +719,10 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro
if (s->peer_node_id != from || s->group_id != group || s->role != CALL_ROLE_CALLEE) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call invite tuple/role mismatch id=%016llx", (unsigned long long)call_id); return;
}
if (s->state == CALL_ST_RINGING || s->state == CALL_ST_WAIT_DECISION) {
if (s->state == CALL_ST_RINGING || s->state == CALL_ST_WAIT_DECISION || s->state == CALL_ST_ANSWERING) {
if (s->state == CALL_ST_WAIT_DECISION && ((ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_CALLS) ||
(ctx->inst->phone_state == 2 && (ctx->inst->incoming_call_policy & CALL_POLICY_REJECT_PHONE)))) {
call_end(ctx, s, (ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_CALLS) ? CALL_REASON_DECLINE : CALL_REASON_PHONE_BUSY,
call_end(ctx, s, (ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_CALLS) ? CALL_REASON_POLICY : CALL_REASON_PHONE_BUSY,
CALL_SUBCMD_DECLINE); return;
}
if (get_time_tb() - s->started_tb >= CALL_RING_TIMEOUT_TB) {
@@ -622,7 +740,8 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro
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 = s->state == CALL_ST_WAIT_DECISION ? CALL_SUBCMD_PHONE_BUSY : CALL_SUBCMD_RINGING;
r->subcmd = s->state == CALL_ST_ANSWERING ? CALL_SUBCMD_ACCEPT :
s->state == CALL_ST_WAIT_DECISION ? CALL_SUBCMD_PHONE_BUSY : 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 {
@@ -666,7 +785,7 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro
}
if (ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_CALLS) {
DEBUG_INFO(DEBUG_CATEGORY_CALL, "incoming call blocked by policy id=%016llx", (unsigned long long)call_id);
call_end(ctx, s, CALL_REASON_DECLINE, CALL_SUBCMD_DECLINE); return;
call_end(ctx, s, CALL_REASON_POLICY, CALL_SUBCMD_DECLINE); return;
}
if (ctx->inst->phone_state == 2 && (ctx->inst->incoming_call_policy & CALL_POLICY_REJECT_PHONE)) {
DEBUG_INFO(DEBUG_CATEGORY_CALL, "phone busy; incoming call rejected id=%016llx", (unsigned long long)call_id);
@@ -722,14 +841,19 @@ static void call_handle_accept(struct call_ctx* ctx, uint64_t from, const uint8_
if (n < 9) return;
uint64_t call_id; memcpy(&call_id, b + 1, 8);
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->role != CALL_ROLE_CALLER || (s->state != CALL_ST_OUTGOING && s->state != CALL_ST_RINGING)) return;
if (!s || s->role != CALL_ROLE_CALLER || (s->state != CALL_ST_OUTGOING && s->state != CALL_ST_RINGING && s->state != CALL_ST_WAIT_DECISION)) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: ACCEPT id=%016llx", CALL_ID, (unsigned long long)call_id);
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; }
call_open_cm(s);
call_activate(s);
(void)from;
struct call_target* t = call_target_find(s, from);
if (!t || t->state == CALL_ST_ENDED || s->selected) return;
if (get_time_tb() - s->started_tb >= CALL_RING_TIMEOUT_TB) {
call_end(ctx, s, CALL_REASON_RING_TIMEOUT, CALL_SUBCMD_CANCEL); return;
}
s->selected = from; s->peer_node_id = from; s->state = CALL_ST_SELECTING;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call selected id=%llx peer=%llx targets=%u",
(unsigned long long)call_id, (unsigned long long)from, s->target_count);
for (unsigned i = 0; i < s->target_count; ++i)
if (s->targets[i].peer != from) call_target_end(s, &s->targets[i], CALL_REASON_CANCEL);
call_send_simple(s, from, CALL_SUBCMD_SELECT);
}
static void call_handle_decline(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) {
@@ -833,11 +957,75 @@ static void call_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
queue_dgram_free(entry); queue_entry_free(entry); return;
}
if (subcmd != CALL_SUBCMD_INVITE && blen >= 9) {
if (subcmd != CALL_SUBCMD_MEDIA) {
size_t expected = subcmd == CALL_SUBCMD_DECLINE || subcmd == CALL_SUBCMD_HANGUP ? 10 : 9;
if (blen != expected) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "call signal size invalid command=%u bytes=%zu", subcmd, blen); goto packet_done; }
}
uint64_t id; memcpy(&id, body + 1, 8);
struct call_session *s = call_session_find(ctx, id);
if (s && (s->peer_node_id != from || s->group_id != group)) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call packet peer/group mismatch id=%llu", (unsigned long long)id);
queue_dgram_free(entry); queue_entry_free(entry); return;
struct call_session* s = call_session_find(ctx, id);
struct call_target* t = s ? call_target_find(s, from) : NULL;
if (s && (s->group_id != group || (s->peer_node_id != from && !t))) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "call packet peer/group mismatch id=%llx", (unsigned long long)id);
goto packet_done;
}
if (s && t) {
if (subcmd == CALL_SUBCMD_END_ACK && blen == sizeof(struct call_pkt_end_ack)) {
t->ack = 1;
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call target end acknowledged id=%llx peer=%llx",
(unsigned long long)id, (unsigned long long)from);
if (s->selected != from) goto packet_done;
}
if (s->selected && s->selected != from) {
if (subcmd == CALL_SUBCMD_DECLINE || subcmd == CALL_SUBCMD_CANCEL || subcmd == CALL_SUBCMD_HANGUP)
call_send_simple(s, from, CALL_SUBCMD_END_ACK);
else if (subcmd != CALL_SUBCMD_END_ACK) call_send_simple(s, from, CALL_SUBCMD_CANCEL);
goto packet_done;
}
if (!s->selected && t->state == CALL_ST_ENDED && subcmd != CALL_SUBCMD_END_ACK) {
if (subcmd == CALL_SUBCMD_DECLINE || subcmd == CALL_SUBCMD_CANCEL || subcmd == CALL_SUBCMD_HANGUP)
call_send_simple(s, from, CALL_SUBCMD_END_ACK);
call_send_simple(s, from, CALL_SUBCMD_CANCEL); goto packet_done;
}
if (!s->selected && (subcmd == CALL_SUBCMD_BUSY || subcmd == CALL_SUBCMD_PHONE_BUSY ||
subcmd == CALL_SUBCMD_RINGING || subcmd == CALL_SUBCMD_DECLINE || subcmd == CALL_SUBCMD_CANCEL)) {
if (subcmd == CALL_SUBCMD_DECLINE && blen != sizeof(struct call_pkt_decline)) goto packet_done;
if (subcmd == CALL_SUBCMD_DECLINE || subcmd == CALL_SUBCMD_CANCEL) call_send_simple(s, from, CALL_SUBCMD_END_ACK);
if (subcmd == CALL_SUBCMD_DECLINE && body[9] == CALL_REASON_DECLINE) {
call_event_id(ctx, CHAT_EVT_CALL_DECLINED, id, CALL_REASON_DECLINE);
call_end(ctx, s, CALL_REASON_DECLINE, 0); goto packet_done;
}
if (subcmd == CALL_SUBCMD_PHONE_BUSY && s->force) goto packet_done;
if (subcmd == CALL_SUBCMD_RINGING) t->state = CALL_ST_RINGING;
else if (subcmd == CALL_SUBCMD_PHONE_BUSY) t->state = CALL_ST_WAIT_DECISION;
else call_target_end(s, t, subcmd == CALL_SUBCMD_BUSY ? CALL_REASON_BUSY : CALL_REASON_CONNECT_FAIL);
unsigned live = 0, waiting = 0, ringing = 0;
for (unsigned i = 0; i < s->target_count; ++i) {
live += s->targets[i].state != CALL_ST_ENDED;
waiting += s->targets[i].state == CALL_ST_WAIT_DECISION;
ringing += s->targets[i].state == CALL_ST_RINGING;
}
if (!live) call_end(ctx, s, subcmd == CALL_SUBCMD_BUSY ? CALL_REASON_BUSY :
subcmd == CALL_SUBCMD_DECLINE ? body[9] : CALL_REASON_CONNECT_FAIL, 0);
else if (waiting == live && s->state != CALL_ST_WAIT_DECISION) {
s->state = CALL_ST_WAIT_DECISION; call_event_id(ctx, CHAT_EVT_CALL_PHONE_BUSY, id, 0);
} else if (ringing && s->state != CALL_ST_RINGING) {
s->state = CALL_ST_RINGING; call_event_id(ctx, CHAT_EVT_CALL_RINGING, id, 0);
}
goto packet_done;
}
}
if (s && blen == sizeof(struct call_pkt_accept) &&
((subcmd == CALL_SUBCMD_SELECT && s->role == CALL_ROLE_CALLEE &&
(s->state == CALL_ST_ANSWERING || s->state == CALL_ST_ACTIVE)) ||
(subcmd == CALL_SUBCMD_SELECTED && s->role == CALL_ROLE_CALLER && s->state == CALL_ST_SELECTING))) {
if (s->state != CALL_ST_ACTIVE && get_time_tb() - s->started_tb >= CALL_RING_TIMEOUT_TB) {
call_end(ctx, s, CALL_REASON_RING_TIMEOUT, CALL_SUBCMD_CANCEL); goto packet_done;
}
if (subcmd == CALL_SUBCMD_SELECT) call_send_simple(s, from, CALL_SUBCMD_SELECTED);
if (s->ring_timer) uasync_cancel_timeout(ctx->inst->ua, s->ring_timer);
if (s->invite_timer) uasync_cancel_timeout(ctx->inst->ua, s->invite_timer);
s->ring_timer = s->invite_timer = NULL;
call_open_cm(s); call_activate(s); goto packet_done;
}
}
@@ -858,6 +1046,7 @@ static void call_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
uint64_t id; memcpy(&id, body + 1, 8);
struct call_session* s = call_session_find(ctx, id);
if (terminal) {
if (call_closed(ctx, id, group, from, 1) < 0) goto packet_done;
// Подтверждаем также повторы после удаления сессии; повторного ENDED обработчики не создают.
struct call_pkt_end_ack ack = {CALL_SUBCMD_END_ACK, id};
DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call end ack id=%016llx command=%s state=%s",
@@ -892,6 +1081,7 @@ static void call_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
CALL_ID, subcmd, (unsigned long long)from, blen);
break;
}
packet_done:
queue_dgram_free(entry);
queue_entry_free(entry);
}
@@ -948,6 +1138,7 @@ void call_destroy(struct UTUN_INSTANCE* inst) {
if (s->invite_timer) { uasync_cancel_timeout(inst->ua, s->invite_timer); s->invite_timer = NULL; }
if (s->traffic_timer) { uasync_cancel_timeout(inst->ua, s->traffic_timer); s->traffic_timer = NULL; }
if (s->stats_timer) { uasync_cancel_timeout(inst->ua, s->stats_timer); s->stats_timer = NULL; }
if (s->target_timer) { uasync_cancel_timeout(inst->ua, s->target_timer); s->target_timer = NULL; }
if (s->teardown_timer) { uasync_cancel_timeout(inst->ua, s->teardown_timer); s->teardown_timer = NULL; }
if (s->cm_handle) { conn_mgr_close(s->cm_handle); s->cm_handle = NULL; }
call_session_cleanup(ctx, s);
@@ -1154,7 +1345,7 @@ int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t*
return 0;
}
void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id) {
static void call_start_target(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id, int devices) {
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized || !ch_id || !ch_id[0] || !peer_node_id || !out_call_id) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: call_start bad args ctx=%p ch=%s peer=%016llx id=%016llx", CALL_ID,
@@ -1168,7 +1359,7 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod
(unsigned long long)peer_node_id, ch_id);
return;
}
if (peer_node_id == inst->node_id) {
if (peer_node_id == inst->node_id && !devices) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: call_start self-call rejected", CALL_ID);
return;
}
@@ -1190,6 +1381,10 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod
uint64_t group_id = 0;
char route_ch[64];
if (devices && !strncmp(ch_id, "dm:", 3)) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "device group call requires CHAT channel");
call_event_id(ctx, CHAT_EVT_CALL_ERROR, out_call_id, 1); return;
}
if (!strncmp(ch_id, "dm:", 3)) {
uint64_t peer = 0;
int available = dm_call_route(inst, ch_id + 3, &peer, &group_id);
@@ -1214,11 +1409,17 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod
return;
}
uint64_t targets[CALL_MAX_TARGETS];
int count = devices ? call_resolve_targets(inst, group_id, peer_node_id, 1, targets, CALL_MAX_TARGETS) : 1;
if (!devices) targets[0] = peer_node_id;
if (count <= 0) { call_event_id(ctx, CHAT_EVT_CALL_ERROR, out_call_id, 1); return; }
struct call_session* s = call_session_new(ctx, out_call_id, group_id, peer_node_id, CALL_ROLE_CALLER);
if (!s) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: session alloc failed id=%016llx", CALL_ID, (unsigned long long)out_call_id);
return;
}
s->target_count = (unsigned)count;
for (int i = 0; i < count; ++i) { s->targets[i].peer = targets[i]; s->targets[i].state = CALL_ST_OUTGOING; }
s->state = CALL_ST_OUTGOING;
call_publish_phone_status(inst);
ctx->c_calls_started++;
@@ -1226,7 +1427,7 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_start id=%016llx peer=0x%016llx group=0x%016llx",
CALL_ID, (unsigned long long)out_call_id, (unsigned long long)peer_node_id, (unsigned long long)group_id);
call_open_cm(s);
if (!devices) call_open_cm(s);
if (call_send_invite(ctx, s) != 0) {
call_end(ctx, s, CALL_REASON_CONNECT_FAIL, 0);
return;
@@ -1236,24 +1437,27 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod
s->invite_timer = uasync_set_timeout(inst->ua, CALL_INVITE_RETRY_TB, s, call_invite_retry_cb, "call_invite_retry");
}
void call_start(struct UTUN_INSTANCE* inst, const char* ch, uint64_t peer, uint64_t id) {
call_start_target(inst, ch, peer, id, 0);
}
void call_start_devices(struct UTUN_INSTANCE* inst, const char* ch, uint64_t peer, uint64_t id) {
call_start_target(inst, ch, peer, id, 1);
}
void call_accept(struct UTUN_INSTANCE* inst, uint64_t call_id) {
struct call_ctx* ctx = call_of(inst);
if (!ctx || !ctx->initialized) return;
struct call_session* s = call_session_find(ctx, call_id);
if (!s || s->role != CALL_ROLE_CALLEE || s->state != CALL_ST_RINGING) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_accept id=%016llx -> ACCEPT + conn_mgr_open",
CALL_ID, (unsigned long long)call_id);
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;
a->subcmd = CALL_SUBCMD_ACCEPT;
a->call_id = call_id;
call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
call_open_cm(s);
call_activate(s);
if (get_time_tb() - s->started_tb >= CALL_RING_TIMEOUT_TB) {
call_end(ctx, s, CALL_REASON_RING_TIMEOUT, CALL_SUBCMD_CANCEL); return;
}
s->state = CALL_ST_ANSWERING;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "call answer waiting for selection id=%llx", (unsigned long long)call_id);
call_send_simple(s, s->peer_node_id, CALL_SUBCMD_ACCEPT);
s->invite_timer = uasync_set_timeout(inst->ua, CALL_INVITE_RETRY_TB, s, call_invite_retry_cb, "call_answer_retry");
if (!s->invite_timer) call_end(ctx, s, CALL_REASON_CONNECT_FAIL, CALL_SUBCMD_CANCEL);
}
void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id) {
@@ -1300,7 +1504,7 @@ void call_continue_trampoline(void* arg) {
void call_start_trampoline(void* arg) {
struct call_start_arg* a = (struct call_start_arg*)arg;
call_start(a->inst, a->ch_id, a->peer_node_id, a->call_id);
call_start_target(a->inst, a->ch_id, a->peer_node_id, a->call_id, a->devices);
u_free(a);
}
+10 -1
View File
@@ -43,7 +43,15 @@ void call_destroy(struct UTUN_INSTANCE* inst);
* После ENDED новый уникальный ID разрешён; результат запуска приходит через CHAT_EVT_CALL_*. */
void call_start (struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_node_id, uint64_t out_call_id);
// Callee: принять только RINGING; неизвестный ID или неподходящее состояние — no-op.
/* Вызвать все доступные устройства той же проверенной devgroup, что peer, в CHAT-канале.
* Список фиксируется ядром; DM здесь не допускается. Остальные условия как у call_start. */
void call_start_devices(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer, uint64_t call_id);
/* Снимок адресатов: devices=0 — один peer; иначе его devgroup. Только проверенные участники
* CHAT с живым маршрутом, без self. Возврат count>=0 либо -1, capacity не обрезает список молча. */
int call_resolve_targets(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, int devices,
uint64_t* out, unsigned capacity);
// Callee: ответить только RINGING и ждать SELECT инициатора; медиа разрешает последующее CALL_ACCEPTED.
void call_accept (struct UTUN_INSTANCE* inst, uint64_t call_id);
// Callee: отклонить существующую незавершённую сессию, события DECLINED и ENDED с причиной DECLINE.
@@ -133,6 +141,7 @@ struct call_start_arg {
struct UTUN_INSTANCE* inst; // Заимствованное ядро, живёт до callback.
char ch_id[64]; // Десятичный ID группы либо dm:<conv_id>, с завершающим NUL.
uint64_t peer_node_id; // Ненулевой ID собеседника, не собственного узла.
uint8_t devices; // 1 — вызвать devgroup указанного устройства, 0 — только устройство.
uint64_t call_id; // Новый уникальный ненулевой ID, генерирует вызывающий.
};
+15 -2
View File
@@ -16,13 +16,24 @@
/* IDLE — только создание; RINGING бывает у обеих ролей, WAIT_DECISION — ожидание force
* при телефонной занятости; ENDED в реестре уже не занимает линию и не принимает медиа. */
enum call_state { CALL_ST_IDLE, CALL_ST_OUTGOING, CALL_ST_RINGING, CALL_ST_ACTIVE, CALL_ST_ENDED, CALL_ST_WAIT_DECISION };
enum call_state { CALL_ST_IDLE, CALL_ST_OUTGOING, CALL_ST_RINGING, CALL_ST_ACTIVE, CALL_ST_ENDED,
CALL_ST_WAIT_DECISION, CALL_ST_ANSWERING, CALL_ST_SELECTING };
enum call_role { CALL_ROLE_CALLER, CALL_ROLE_CALLEE };
#define CALL_MAX_TARGETS 64
/* Состояние отдельного адресата: terminal/ack обслуживаются независимо от выбранного медиа-пира. */
struct call_target {
uint64_t peer, ended_tb;
uint8_t state, end_subcmd, reason, ack, no_route;
};
struct call_video_transport;
struct call_session {
struct ll_entry ll; /* Не используется; реальный queue entry находится перед s == entry->data. */
uint64_t call_id, group_id, peer_node_id; /* Неизменные ID попытки, группы маршрутизации и собеседника. */
uint64_t call_id, group_id, peer_node_id; /* ID попытки/группы; peer до выбора — anchor, после выбора — единственный медиа-пир. */
uint8_t role, state, reason; /* call_role/call_state; reason — CALL_REASON_* после перехода в ENDED. */
unsigned target_count; /* Ноль у callee; caller владеет снимком адресатов до удаления сессии. */
struct call_target targets[CALL_MAX_TARGETS];
uint64_t selected; /* Окончательный выбор caller; 0 до первого ACCEPT. */
void *target_timer; /* Независимые повторы отбоя проигравшим, также во время ACTIVE. */
uint8_t force; /* вызывающий подтвердил обход телефонной занятости */
uint64_t started_tb; /* Начало попытки get_time_tb (100мкс); 60с отсчитываются отсюда, включая WAIT_DECISION. */
uint8_t end_subcmd; /* Финальная команда для повтора; 0 — нет своей команды либо уже принят END_ACK. */
@@ -46,7 +57,9 @@ struct call_session {
uint32_t tx_wait_frames, tx_wait_max_ms; /* Число drained кадров/максимальное ожидание за тот же отчётный интервал. */
struct call_video_transport *video; /* Собственное ленивое состояние видеотрека; cleanup обнуляет и освобождает его. */
};
struct call_closed { uint64_t id, group, peer, until; }; /* Короткий запрет оживления после раннего отбоя. */
struct call_ctx {
struct call_closed closed[256]; /* Только завершённые tuple; 70с, без вытеснения свежих записей. */
struct UTUN_INSTANCE *inst; /* Заимствованное ядро, владеющее ctx в inst->call; audio_ops хранится отдельно у ядра. */
int initialized; /* Сигналинг привязан; 1 также во время регистрации media services/отката call_init. */
struct ll_queue *sessions; /* Собственные queue entries с call_session в data и 8-байтовым индексом call_id. */
+3
View File
@@ -27,6 +27,8 @@ enum {
CALL_SUBCMD_CANCEL = 0x06, /* {call_id:8} */
CALL_SUBCMD_HANGUP = 0x07, /* {call_id:8, reason:1} */
CALL_SUBCMD_END_ACK = 0x08, /* {call_id:8}: подтверждение HANGUP/CANCEL/DECLINE */
CALL_SUBCMD_SELECT = 0x0a, /* {call_id:8}: инициатор выбрал адресата */
CALL_SUBCMD_SELECTED = 0x0b, /* {call_id:8}: выбранный адресат подтвердил подключение */
CALL_SUBCMD_PHONE_BUSY = 0x09, /* {call_id:8}: ожидание решения, не завершение */
CALL_SUBCMD_MEDIA = 0x10, /* call_pkt_media + payload[] */
};
@@ -45,6 +47,7 @@ enum {
CALL_REASON_NO_TRAFFIC = 5,
CALL_REASON_RING_TIMEOUT = 6,
CALL_REASON_CONNECT_FAIL = 7,
CALL_REASON_POLICY = 9, /* запрет настройкой устройства, не ручной отказ всей группы */
CALL_REASON_PHONE_BUSY = 8, /* автоматический отказ: абонент разговаривает по телефону */
};
+8
View File
@@ -11,6 +11,7 @@
*/
#include "chat_core_priv.h"
#include "chat_devgroup.h"
#include "chat_whisper.h"
#include "chat_attachment.h"
#include "group_avatar.h"
@@ -240,6 +241,7 @@ void chat_core_destroy(struct UTUN_INSTANCE* inst) {
struct chat_core_ctx* cc = inst ? CC(inst) : NULL;
if (!cc || !cc->initialized) return;
cc->initialized = 0;
chat_devgroup_destroy(inst);
chat_whisper_destroy(inst);
chat_member_destroy(inst);
chat_attachment_destroy(inst);
@@ -523,12 +525,18 @@ int chat_core_get_members_json(struct UTUN_INSTANCE* inst, const char* ch_id, ch
const uint8_t* key = sqlite3_column_blob(st, i + 2); if (!key) { rc = SQLITE_NOMEM; goto error; }
for (int b = 0; b < 32; b++) snprintf(keys[i] + b * 2, 3, "%02x", key[b]);
}
uint8_t dg[32]; char dg_name[64], dg_hex[65] = "";
int dg_found = devgroup_member(cc->db, node, dg, dg_name);
if (dg_found < 0) { stage = "devgroup"; rc = SQLITE_CORRUPT; goto error; }
if (dg_found) for (int i = 0; i < 32; i++) snprintf(dg_hex + 2*i, 3, "%02x", dg[i]);
int online = !sqlite3_column_int(st, 5) && chat_core_member_online(inst, ch_id, node);
int connected = inst->connections && queue_find_data_by_index(inst->connections, (const uint8_t*)&node);
stage = "JSON"; rc = SQLITE_NOMEM;
if (strbuf_addf(&json, "%s{\"node_id\":\"0x%016llx\",\"name\":", json.len > 1 ? "," : "", (unsigned long long)node) < 0 ||
strbuf_add_json_string(&json, name) < 0 || strbuf_addf(&json, ",\"online\":%d,\"connected\":%d,\"adm_tags\":", online, connected) < 0 ||
strbuf_add_json_string(&json, tags) < 0 ||
strbuf_addf(&json, ",\"devgroup\":\"%s\",\"devgroup_name\":", dg_hex) < 0 ||
strbuf_add_json_string(&json, dg_name) < 0 ||
strbuf_addf(&json, ",\"x25519\":\"%s\",\"ed25519\":\"%s\",\"addrs\":[", keys[0], keys[1]) < 0) goto error;
stage = "addresses prepare";
rc = sqlite3_prepare_v2(cc->db, "SELECT family,protocol,address,port,rtt FROM node_addresses WHERE node_id=? ORDER BY family,protocol",
+2
View File
@@ -93,7 +93,9 @@ struct ak_file;
struct chat_whisper;
struct member_route_event;
struct chat_devgroup;
struct chat_core_ctx {
struct chat_devgroup* devgroup; /* Приглашения, retry и периодическое принятие профиля из nodes. */
struct UTUN_INSTANCE* inst; /* Заимствованный владелец;не уничтожается из callbacks чата. */
sqlite3* db; /* Заимствована у ядра;chat_core её не открывает/не закрывает. */
char db_path[512]; /* Копия пути init для идентичности запуска/default media base;не отдельное соединение БД. */
+203
View File
@@ -0,0 +1,203 @@
#include "chat_devgroup.h"
#include "chat_core_priv.h"
#include "chat_member.h"
#include "../utun_instance.h"
#include "../routing_layer/topo_group.h"
#include "../routing_layer/topo_node_sqlite.h"
#include "../transport_layer/secure_channel.h"
#include "../ntp_time.h"
#include "../../lib/mem.h"
#include "../../lib/strbuf.h"
#include <openssl/crypto.h>
#include <openssl/rand.h>
static int cd_sql(sqlite3* db, const char* sql) {
int rc = sqlite3_exec(db, sql, NULL, NULL, NULL);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: SQL rc=%d: %s", rc, sqlite3_errmsg(db));
return rc == SQLITE_OK ? 0 : -1;
}
static int cd_finish(sqlite3* db, sqlite3_stmt* st, int rc, int expected) {
int final = sqlite3_finalize(st);
if (rc == expected && final == SQLITE_OK) return 0;
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: SQL rc=%d expected=%d finalize=%d: %s", rc, expected, final, sqlite3_errmsg(db));
return -1;
}
/* Отложенную доставку снимков выполняет штатная BGP-очередь. */
static void cd_publish(struct UTUN_INSTANCE* inst) {
for (struct ll_entry* e = inst->topo_groups->group_list->head; e; e = e->next) {
struct TOPO_GROUP* g = (struct TOPO_GROUP*)e;
if (!g->local_node || !g->senders_list) continue;
for (struct ll_entry* s = g->senders_list->head; s; s = s->next) {
struct TOPO_GROUP_CONN_ITEM* peer = (struct TOPO_GROUP_CONN_ITEM*)s->data;
if (peer && peer->conn) topo_group_send_nodeinfo(g, g->local_node, peer->conn);
}
}
/* Снимок self мог изменить заголовок всей devgroup, поэтому обновляем полные списки. */
chat_event_post(inst, CHAT_EVT_DEVGROUP_CHANGED, NULL, 0);
}
int chat_devgroup_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[16], uint64_t peer) {
sqlite3* db = inst ? inst->topo_sqlite_db : NULL; sqlite3_stmt* st = NULL;
if (!db || !receipt || !peer) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid receipt query"); return -1; }
int rc = sqlite3_prepare_v2(db, "SELECT peer FROM devgroup_receipts WHERE node_id=? AND id=?", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, receipt, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int found = rc == SQLITE_ROW;
if (found) rc = (uint64_t)sqlite3_column_int64(st, 0) == peer ? SQLITE_DONE : SQLITE_CONSTRAINT;
if (cd_finish(db, st, rc, SQLITE_DONE)) return -1;
return found;
}
static int cd_commit(struct UTUN_INSTANCE* inst, const struct devgroup_record* r,
const uint8_t ep[32], const uint8_t xp[32], const uint8_t receipt[16], uint64_t peer) {
sqlite3* db = inst ? inst->topo_sqlite_db : NULL;
struct TOPO_NODE* current = inst ? topo_node_registry_find(inst->topo_groups, inst->node_id) : NULL;
if (!db || !current || !sqlite3_get_autocommit(db) ||
devgroup_verify(r, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid commit context/record"); return -1;
}
struct TOPO_NODE next = *current; next.devgroup = *r; next.ver = (current->ver % 255) + 1;
if (cd_sql(db, "BEGIN IMMEDIATE")) return -1;
sqlite3_stmt* st = NULL; int rc;
if (r->json[0]) {
uint8_t wire[DEVGROUP_WIRE_MAX]; int n = devgroup_encode(r, wire, sizeof(wire));
if (n <= 0) goto rollback;
rc = sqlite3_prepare_v2(db, "INSERT INTO devgroup_local(node_id,ed_priv,x_priv,record) VALUES(?,?,?,?)"
" ON CONFLICT(node_id) DO UPDATE SET ed_priv=excluded.ed_priv,x_priv=excluded.x_priv,record=excluded.record", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, ep, 32, SQLITE_TRANSIENT);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, xp, 32, SQLITE_TRANSIENT);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 4, wire, n, SQLITE_TRANSIENT);
} else {
rc = sqlite3_prepare_v2(db, "DELETE FROM devgroup_local WHERE node_id=?", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
}
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int failed = cd_finish(db, st, rc, SQLITE_DONE); st = NULL;
if (failed || topo_node_sign_self(inst, &next)) goto rollback;
if (receipt) {
rc = sqlite3_prepare_v2(db, "INSERT INTO devgroup_receipts(node_id,id,peer) VALUES(?,?,?)", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, receipt, 16, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 3, (sqlite3_int64)peer);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
failed = cd_finish(db, st, rc, SQLITE_DONE); st = NULL; if (failed) goto rollback;
}
if (cd_sql(db, "COMMIT")) goto rollback;
*current = next;
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: committed node=%016llx member=%d timestamp=%llu",
(unsigned long long)inst->node_id, !!r->json[0], (unsigned long long)next.timestamp);
cd_publish(inst); return 0;
rollback:
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: operation rolled back");
if (!sqlite3_get_autocommit(db) && cd_sql(db, "ROLLBACK")) utun_instance_stop(inst);
return -1;
}
/* Подписать удостоверение данного устройства, не меняя подписанный JSON профиля. */
static int cd_rebind(struct UTUN_INSTANCE* inst, struct devgroup_record* record, const uint8_t ep[32]) {
return devgroup_bind(record, ep, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey);
}
int chat_devgroup_refresh(struct UTUN_INSTANCE* inst) {
struct devgroup_record local, best; struct devgroup_profile lp, bp; uint8_t ep[32], xp[32];
int rc = devgroup_load_local(inst, &local, ep, xp);
if (rc <= 0) goto done;
rc = devgroup_latest(inst->topo_sqlite_db, local.ed, &best, &bp);
if (rc <= 0) goto done;
if (devgroup_profile_read(&local, &lp)) { rc = -1; goto done; }
if (devgroup_profile_compare(&bp, &lp) <= 0) { rc = 0; goto done; }
if (memcmp(local.x, best.x, 32)) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: encryption key change rejected"); rc = -1; goto done; }
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: adopt newer profile version=%llu->%llu timestamp=%llu",
(unsigned long long)lp.version, (unsigned long long)bp.version, (unsigned long long)bp.timestamp);
rc = cd_rebind(inst, &best, ep);
if (!rc) rc = cd_commit(inst, &best, ep, xp, NULL, 0);
done:
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); return rc < 0 ? -1 : 0;
}
int chat_devgroup_rename(struct UTUN_INSTANCE* inst, const char* name) {
struct devgroup_record r; struct devgroup_profile p = {0}; uint8_t ep[32], xp[32];
if (chat_devgroup_refresh(inst)) return -1;
int rc = devgroup_load_local(inst, &r, ep, xp); if (rc < 0) goto done;
if (rc) {
if (devgroup_profile_read(&r, &p)) { rc = -1; goto done; }
if (name && !strcmp(name, p.name)) { rc = 0; goto done; }
} else if (RAND_bytes(ep, 32) != 1 || RAND_bytes(xp, 32) != 1 || devgroup_public_keys(ep, xp, r.ed, r.x)) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: key generation failed"); rc = -1; goto done;
}
time_t now = ntp_time_get_seconds(inst);
rc = devgroup_sign(&r, ep, name, p.version + 1, now > 0 ? (uint64_t)now : 1,
inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey);
if (!rc) rc = cd_commit(inst, &r, ep, xp, NULL, 0);
done:
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); return rc;
}
int chat_devgroup_leave(struct UTUN_INSTANCE* inst) {
struct devgroup_record r = {0}; uint8_t ep[32], xp[32];
int found = devgroup_load_local(inst, &r, ep, xp);
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32);
if (found < 0) return -1;
int rc = 0;
if (found) { memset(&r, 0, sizeof(r)); rc = cd_commit(inst, &r, NULL, NULL, NULL, 0); }
if (!rc) chat_devgroup_cancel_pending(inst);
return rc;
}
int chat_devgroup_import(struct UTUN_INSTANCE* inst, const struct devgroup_record* input,
const uint8_t ep[32], const uint8_t xp[32], const uint8_t receipt[16], uint64_t peer) {
if (!input || !ep || !xp || !receipt) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid import arguments"); return -1; }
int done = chat_devgroup_receipt(inst, receipt, peer); if (done) return done > 0 ? 0 : -1;
struct devgroup_record local, r = *input; uint8_t old_e[32], old_x[32], ed[32], x[32]; struct devgroup_profile p;
int found = devgroup_load_local(inst, &local, old_e, old_x);
OPENSSL_cleanse(old_e, 32); OPENSSL_cleanse(old_x, 32);
if (found < 0 || (found && (memcmp(local.ed, r.ed, 32) || memcmp(local.x, r.x, 32)))) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: import into another group rejected"); return -1;
}
if (devgroup_public_keys(ep, xp, ed, x) || memcmp(ed, r.ed, 32) || memcmp(x, r.x, 32) || devgroup_profile_read(&r, &p)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: imported keys/profile mismatch"); return -1;
}
if (found) { struct devgroup_profile own; if (devgroup_profile_read(&local, &own)) return -1;
if (devgroup_profile_compare(&own, &p) > 0) r = local;
}
if (cd_rebind(inst, &r, ep)) return -1;
return cd_commit(inst, &r, ep, xp, receipt, peer);
}
int chat_devgroup_execute(const struct chat_devgroup_request* req, char** json) {
if (!json) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: missing command result"); return -1; }
*json = NULL;
if (!req || !req->inst || !memchr(req->text, 0, sizeof(req->text)) || !memchr(req->pin, 0, sizeof(req->pin))) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invalid command"); return -1;
}
int rc = -1;
switch (req->action) {
case CHAT_DEVGROUP_STATUS: break;
case CHAT_DEVGROUP_INVITE: {
char id[33], pin[5]; struct strbuf result = {0};
rc = chat_devgroup_invite(req->inst, req->group, req->peer, id, pin);
if (!rc && strbuf_addf(&result, "{\"id\":\"%s\",\"pin\":\"%s\"}", id, pin) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: invite result allocation failed"); rc = -1;
}
OPENSSL_cleanse(pin, sizeof(pin));
if (!rc) *json = strbuf_detach(&result);
strbuf_free(&result); return rc;
}
case CHAT_DEVGROUP_ACCEPT: rc = chat_devgroup_accept(req->inst, req->text, req->pin); break;
case CHAT_DEVGROUP_RENAME: rc = chat_devgroup_rename(req->inst, req->text); break;
case CHAT_DEVGROUP_LEAVE: rc = chat_devgroup_leave(req->inst); break;
default: DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: unknown action=%d", req->action); return -1;
}
if (req->action != CHAT_DEVGROUP_STATUS && rc) return -1;
*json = chat_devgroup_status(req->inst);
return *json ? 0 : -1;
}
void chat_devgroup_trampoline(void* arg) {
struct chat_devgroup_request* req = arg;
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: missing request"); return; }
char* json = NULL; int rc = chat_devgroup_execute(req, &json);
size_t n = json ? strlen(json) : 0; uint8_t* event = u_malloc(9 + n);
if (event) {
for (unsigned i = 0; i < 8; i++) event[i] = (uint8_t)(req->request_id >> (8 * i));
event[8] = !rc; if (n) memcpy(event + 9, json, n);
chat_event_post(req->inst, CHAT_EVT_DEVGROUP_RESULT, event, 9 + n);
OPENSSL_cleanse(event, 9 + n); u_free(event);
} else DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup: result event allocation failed");
if (json) { OPENSSL_cleanse(json, n); u_free(json); }
OPENSSL_cleanse(req, sizeof(*req)); u_free(req);
}
+49
View File
@@ -0,0 +1,49 @@
/* chat_devgroup — управление общей группой устройств в потоке uasync.
* Публичная принадлежность живёт в ядре; чат управляет приглашениями и локальными изменениями.
* Все результаты операций 0/-1, события после commit. Аргументы заимствованы до возврата. */
#ifndef CHAT_DEVGROUP_H
#define CHAT_DEVGROUP_H
#include "../routing_layer/devgroup.h"
#ifdef __cplusplus
extern "C" {
#endif
enum chat_devgroup_action { CHAT_DEVGROUP_STATUS, CHAT_DEVGROUP_INVITE, CHAT_DEVGROUP_ACCEPT, CHAT_DEVGROUP_RENAME, CHAT_DEVGROUP_LEAVE };
/* Запрос UI: text=имя/ID приглашения, pin только для ACCEPT; group/peer только для INVITE.
* Все массивы NUL-terminated. Трамплин забирает u_malloc-блок; RESULT=request_id LE8,success1,JSON UTF-8.
* JSON результата INVITE содержит PIN: показывать только локально, не логировать. */
struct chat_devgroup_request {
struct UTUN_INSTANCE* inst;
uint64_t request_id, group, peer;
enum chat_devgroup_action action;
char text[128], pin[5];
};
/* Синхронно в uasync:0/-1, *json caller освобождает u_free; при успехе всегда JSON. */
int chat_devgroup_execute(const struct chat_devgroup_request* req, char** json);
void chat_devgroup_trampoline(void* arg);
/* Сервис приглашений: init0/-1, destroy отменяет ожидания; локальная принадлежность остаётся в ядре. */
int chat_devgroup_init(struct UTUN_INSTANCE* inst);
/* После локального выхода отменить ожидания; уже переданные ключи имеют неизвестный результат. */
void chat_devgroup_cancel_pending(struct UTUN_INSTANCE* inst);
void chat_devgroup_destroy(struct UTUN_INSTANCE* inst);
/* Пригласить известный узел через CHAT-группу; out_id[33] hex, out_pin[5]. 0/-1, PIN только для локального UI. */
int chat_devgroup_invite(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, char out_id[33], char out_pin[5]);
/* Ввести PIN для входящего приглашения; id — 32 hex, pin — ровно четыре цифры. 0/-1. */
int chat_devgroup_accept(struct UTUN_INSTANCE* inst, const char* id, const char* pin);
/* Снимок группы/приглашений в JSON; caller освобождает через u_free. PIN/секретов в снимке нет. */
char* chat_devgroup_status(struct UTUN_INSTANCE* inst);
/* Создать группу либо переименовать свою. Имя UTF-8,1..63 байта; no-op не повышает версию. */
int chat_devgroup_rename(struct UTUN_INSTANCE* inst, const char* name);
/* Выйти: удалить секреты и опубликовать пустой блок в одной транзакции. Повтор допустим. */
int chat_devgroup_leave(struct UTUN_INSTANCE* inst);
/* Принять профиль из nodes и опубликовать только при увеличении версии; секреты не устанавливает. */
int chat_devgroup_refresh(struct UTUN_INSTANCE* inst);
/* Импорт от протокола приглашения: ключи проверяются, смешение разных групп запрещено.
* receipt[16] и peer связывают результат с приглашением; повтор никогда не восстанавливает удалённые ключи. */
int chat_devgroup_import(struct UTUN_INSTANCE* inst, const struct devgroup_record* record,
const uint8_t ed_priv[32], const uint8_t x_priv[32], const uint8_t receipt[16], uint64_t peer);
/* Прочитать сохранённый результат приглашения:1=импорт уже выполнен,0=нет,-1=ошибка/другой peer. */
int chat_devgroup_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[16], uint64_t peer);
#ifdef __cplusplus
}
#endif
#endif
+288
View File
@@ -0,0 +1,288 @@
#include "chat_devgroup.h"
#include "chat_core_priv.h"
#include "chat_event.h"
#include "member_sync.h"
#include "../utun_instance.h"
#include "../routing_layer/route_crypto.h"
#include "../transport_layer/etcp.h"
#include "../../lib/mem.h"
#include "../../lib/u_async.h"
#include "../../lib/strbuf.h"
#include <openssl/rand.h>
#include <openssl/crypto.h>
#define DG_SLOTS 16
#define DG_TTL 1200000ULL
#define DG_RETRY 30000ULL
#define DG_OFFER 1
#define DG_PIN 2
#define DG_KEYS 3
#define DG_RESULT 4
/* state: 1=ожидается PIN,2=отправлен PIN,3=переданы ключи,4=успех,5=отказ,6=истёк,7=результат неизвестен. */
struct dg_invite {
uint8_t id[16], x[32], ed[32], group_ed[32];
uint64_t group, peer, expires, sent;
unsigned state, outgoing, attempts;
uint8_t last_pin[4], last_result; /* Повторы одной попытки получают прежний результат. */
char pin[5]; /* Только локальная память; никогда не выводится в журнал. */
};
struct chat_devgroup {
struct UTUN_INSTANCE* inst;
struct dg_invite slots[DG_SLOTS];
void* timer;
unsigned ticks;
};
static struct chat_devgroup* dg_ctx(struct UTUN_INSTANCE* inst) { return inst && CC(inst) ? CC(inst)->devgroup : NULL; }
static void dg_hex(const uint8_t* in, size_t n, char* out) { for (size_t i = 0; i<n; i++) snprintf(out + 2*i, 3,"%02x", in[i]); }
static int dg_id(const char* text, uint8_t out[16]) {
return !text || strlen(text) != 32 || strspn(text,"0123456789abcdef") != 32 || sc_hex_to_binary(text, out, 16) ? -1 : 0;
}
static int dg_peer(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, uint8_t x[32], uint8_t ed[32]) {
char channel[32]; snprintf(channel, sizeof(channel),"%llu", (unsigned long long)group);
struct TOPO_NODE* node = topo_node_registry_find(inst->topo_groups, peer);
if (!node || peer == inst->node_id || member_sync_verified_ed25519(inst, channel, peer, ed) || memcmp(ed, node->ed25519_public_key, 32)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: peer identity unavailable group=%llu peer=%016llx", (unsigned long long)group, (unsigned long long)peer);
return -1;
}
memcpy(x, node->public_key, 32); return 0;
}
static struct dg_invite* dg_find(struct chat_devgroup* ctx, const uint8_t id[16]) {
for (unsigned i = 0; i<DG_SLOTS; i++) if (ctx->slots[i].state && !memcmp(ctx->slots[i].id, id, 16)) return &ctx->slots[i];
return NULL;
}
static struct dg_invite* dg_slot(struct chat_devgroup* ctx, uint64_t peer) {
struct dg_invite* free_slot = NULL;
for (unsigned i = 0; i<DG_SLOTS; i++) {
struct dg_invite* p = &ctx->slots[i];
if (p->state && p->state<4 && p->peer == peer) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: peer already has pending invitation"); return NULL; }
if (!p->state || (p->state >= 4 && get_time_tb()>p->expires)) free_slot = p;
}
if (!free_slot) DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation capacity reached");
if (free_slot) OPENSSL_cleanse(free_slot, sizeof(*free_slot));
return free_slot;
}
static int dg_send(struct chat_devgroup* ctx, struct dg_invite* p, uint8_t kind, const uint8_t* body, size_t n) {
uint8_t x[32], ed[32];
p->sent = get_time_tb();
if (dg_peer(ctx->inst, p->group, p->peer, x, ed) || memcmp(x, p->x, 32) || memcmp(ed, p->ed, 32)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation peer keys changed"); return -1;
}
struct ll_entry* entry = ll_alloc_lldgram(18 + n);
if (!entry) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: packet allocation failed"); return -1; }
entry->dgram[0] = ETCP_RT_ID_DEVGROUP; entry->dgram[1] = kind; memcpy(entry->dgram + 2, p->id, 16);
if (n) memcpy(entry->dgram + 18, body, n);
entry->len = 18 + n;
char id[33]; dg_hex(p->id, 16, id);
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "devgroup TX: invite=%s peer=%016llx kind=%u state=%u bytes=%zu",
id, (unsigned long long)p->peer, kind, p->state, n);
int rc = etcp_route_send(ctx->inst, p->group, p->peer, entry, 0, ROUTE_CRYPTO_SIGN|ROUTE_CRYPTO_ENCRYPT);
if (rc) DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: send failed peer=%016llx kind=%u", (unsigned long long)p->peer, kind);
return rc;
}
static void dg_changed(struct chat_devgroup* ctx) { chat_event_post(ctx->inst, CHAT_EVT_DEVGROUP_CHANGED, NULL, 0); }
static int dg_keys(struct chat_devgroup* ctx, struct dg_invite* p) {
struct devgroup_record r; uint8_t ep[32], xp[32], data[64 + DEVGROUP_WIRE_MAX];
int rc = devgroup_load_local(ctx->inst, &r, ep, xp);
if (rc != 1 || memcmp(r.ed, p->group_ed, 32)) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: pending transfer cancelled after local membership changed"); p->state = 7; rc = -1;
} else {
int n = devgroup_encode(&r, data + 64, DEVGROUP_WIRE_MAX);
if (n<0) rc = -1;
else { memcpy(data, ep, 32); memcpy(data + 32, xp, 32); rc = dg_send(ctx, p, DG_KEYS, data, 64 + n); }
}
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); OPENSSL_cleanse(data, sizeof(data)); return rc;
}
static void dg_result(struct chat_devgroup* ctx, struct dg_invite* p, uint8_t code) {
uint8_t result[2] = {(uint8_t)p->attempts, code};
dg_send(ctx, p, DG_RESULT, result, sizeof(result));
}
static void dg_send_pin(struct chat_devgroup* ctx, struct dg_invite* p) {
uint8_t body[5] = {(uint8_t)p->attempts}; memcpy(body + 1, p->pin, 4);
dg_send(ctx, p, DG_PIN, body, sizeof(body)); OPENSSL_cleanse(body, sizeof(body));
}
static void dg_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
struct chat_devgroup* ctx = conn ? dg_ctx(conn->instance) : NULL;
if (!ctx || !entry || !entry->dgram) goto out;
if (entry->len == ROUTER_SVC_HDR_SIZE) goto out; /* reset маршрутизатора: retry остаётся у операции */
if (entry->len<ROUTER_SVC_HDR_SIZE + 17 || (entry->dgram[ROUTER_SVC_FLAGS_OFF]&(ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) != (ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) goto bad;
uint64_t peer, group; memcpy(&peer, entry->dgram + ROUTER_SVC_SRC_OFF, 8); memcpy(&group, entry->dgram + ROUTER_SVC_GROUP_OFF, 8);
uint8_t x[32], ed[32]; if (dg_peer(ctx->inst, group, peer, x, ed)) goto out;
const uint8_t* data = entry->dgram + ROUTER_SVC_HDR_SIZE; uint8_t kind = *data++;
const uint8_t* id = data; data+=16; size_t n = entry->len-ROUTER_SVC_HDR_SIZE-17;
struct dg_invite* p = dg_find(ctx, id);
char id_hex[33]; dg_hex(id, 16, id_hex);
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "devgroup RX: invite=%s peer=%016llx kind=%u state=%u bytes=%zu",
id_hex, (unsigned long long)peer, kind, p ? p->state : 0, n);
if (p && (p->peer != peer || p->group != group || memcmp(x, p->x, 32) || memcmp(ed, p->ed, 32))) goto bad;
if (kind == DG_KEYS) {
int receipt = chat_devgroup_receipt(ctx->inst, id, peer);
if (receipt<0) goto out;
if (receipt) {
struct dg_invite reply = {.group = group, .peer = peer}; memcpy(reply.id, id, 16); memcpy(reply.x, x, 32); memcpy(reply.ed, ed, 32);
dg_result(ctx, &reply, 0); goto out;
}
}
if (kind == DG_OFFER) {
if (n) goto bad;
if (!p) {
p = dg_slot(ctx, peer); if (!p) goto out;
p->state = 1; p->group = group; p->peer = peer; p->expires = get_time_tb() + DG_TTL;
memcpy(p->id, id, 16); memcpy(p->x, x, 32); memcpy(p->ed, ed, 32);
DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation received peer=%016llx", (unsigned long long)peer);
dg_changed(ctx);
}
goto out;
}
if (!p || get_time_tb()>p->expires) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: expired/unknown invitation packet kind=%u", kind); goto out; }
if (kind == DG_PIN && p->outgoing) {
if (n != 5 || data[0] < 1 || data[0] > 3) goto bad;
if (p->state == 1 || p->state == 5) {
if (data[0] == p->attempts) {
if (CRYPTO_memcmp(data + 1, p->last_pin, 4)) goto bad;
dg_result(ctx, p, p->last_result); goto out;
}
if (data[0] != p->attempts + 1) goto bad;
}
if (p->state == 3) { dg_keys(ctx, p); goto out; }
if (p->state == 4) { dg_result(ctx, p, 0); goto out; }
if (p->state != 1) { dg_result(ctx, p, 2); goto out; }
p->attempts = data[0]; memcpy(p->last_pin, data + 1, 4);
if (CRYPTO_memcmp(data + 1, p->pin, 4)) {
if (p->attempts >= 3) p->state = 5;
p->last_result = p->state == 5 ? 2 : 1;
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: PIN rejected peer=%016llx attempt=%u", (unsigned long long)peer, p->attempts);
dg_result(ctx, p, p->state == 5 ? 2 : 1); dg_changed(ctx); goto out;
}
struct devgroup_record own; uint8_t ep[32], xp[32]; int has = devgroup_load_local(ctx->inst, &own, ep, xp);
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32);
const uint8_t empty[32] = {0};
int pinned = memcmp(p->group_ed, empty, 32) != 0;
if (!has && !pinned) {
if (chat_devgroup_rename(ctx->inst, ctx->inst->name[0] ? ctx->inst->name : "My devices")) has = -1;
else has = devgroup_load_local(ctx->inst, &own, ep, xp);
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32);
}
if (has != 1 || (pinned && memcmp(p->group_ed, own.ed, 32))) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: invitation cancelled after membership changed");
p->state = 5; dg_result(ctx, p, 2); dg_changed(ctx); goto out;
}
memcpy(p->group_ed, own.ed, 32);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: PIN verified, transferring existing group keys peer=%016llx",
(unsigned long long)peer);
p->state = 3; OPENSSL_cleanse(p->pin, sizeof(p->pin)); OPENSSL_cleanse(p->last_pin, sizeof(p->last_pin));
dg_keys(ctx, p); dg_changed(ctx);
} else if (kind == DG_KEYS && !p->outgoing && p->state == 2) {
if (n <= 64) goto bad;
struct devgroup_record r;
if (devgroup_decode(&r, data + 64, n-64)) goto bad;
int rc = chat_devgroup_import(ctx->inst, &r, data, data + 32, p->id, p->peer);
p->state = rc ? 5 : 4; OPENSSL_cleanse(p->pin, sizeof(p->pin)); dg_result(ctx, p, rc ? 2 : 0); dg_changed(ctx);
} else if (kind == DG_RESULT) {
if (n != 2 || data[1] > 2) goto bad;
if (p->outgoing && p->state == 3) p->state = data[1] ? 7 : 4;
else if (!p->outgoing && p->state == 2 && data[0] == p->attempts && data[1]) {
p->state = data[1] == 1 ? 1 : 5; OPENSSL_cleanse(p->pin, sizeof(p->pin));
}
else goto out;
DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation result peer=%016llx state=%u", (unsigned long long)peer, p->state); dg_changed(ctx);
} else goto bad;
goto out;
bad:
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: malformed/unexpected invitation packet bytes=%u", entry ? entry->len : 0);
out:
if (entry) { if (entry->dgram) OPENSSL_cleanse(entry->dgram, entry->len); queue_dgram_free(entry); queue_entry_free(entry); }
}
static void dg_tick(void* arg) {
struct chat_devgroup* ctx = arg; ctx->timer = NULL; uint64_t now = get_time_tb();
for (unsigned i = 0; i<DG_SLOTS; i++) {
struct dg_invite* p = &ctx->slots[i]; if (!p->state || p->state >= 4) continue;
if (now>p->expires) {
p->state = p->state == 3 ? 7 : 6; OPENSSL_cleanse(p->pin, sizeof(p->pin));
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invitation timeout peer=%016llx state=%u", (unsigned long long)p->peer, p->state);
dg_changed(ctx); continue;
}
if (now-p->sent<DG_RETRY) continue;
if (p->outgoing && p->state == 1) dg_send(ctx, p, DG_OFFER, NULL, 0);
else if (p->outgoing && p->state == 3) dg_keys(ctx, p);
else if (!p->outgoing && p->state == 2) dg_send_pin(ctx, p);
}
if (++ctx->ticks%5 == 0) chat_devgroup_refresh(ctx->inst);
ctx->timer = uasync_set_timeout(ctx->inst->ua, 10000, ctx, dg_tick,"devgroup");
if (!ctx->timer) DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: timer allocation failed");
}
int chat_devgroup_init(struct UTUN_INSTANCE* inst) {
if (!inst || !CC(inst) || CC(inst)->devgroup) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: invalid init"); return -1; }
struct chat_devgroup* ctx = u_calloc(1, sizeof(*ctx));
if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: context allocation failed"); return -1; }
ctx->inst = inst; CC(inst)->devgroup = ctx;
if (etcp_router_bind(inst, ETCP_RT_ID_DEVGROUP, dg_receive) || etcp_router_require_crypto(inst, ETCP_RT_ID_DEVGROUP, ROUTER_FLAG_ENCRYPTED|ROUTER_FLAG_SIGNED)) goto fail;
ctx->timer = uasync_set_timeout(inst->ua, 10000, ctx, dg_tick,"devgroup"); if (!ctx->timer) goto fail;
DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation service started"); return 0;
fail:
DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: service initialization failed"); chat_devgroup_destroy(inst); return -1;
}
void chat_devgroup_cancel_pending(struct UTUN_INSTANCE* inst) {
struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) return;
for (unsigned i = 0; i < DG_SLOTS; i++) {
struct dg_invite* p = &ctx->slots[i]; if (!p->state || p->state >= 4) continue;
p->state = p->state == 3 ? 7 : 5;
OPENSSL_cleanse(p->pin, sizeof(p->pin)); OPENSSL_cleanse(p->last_pin, sizeof(p->last_pin));
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup: invitation cancelled peer=%016llx state=%u", (unsigned long long)p->peer, p->state);
}
dg_changed(ctx);
}
void chat_devgroup_destroy(struct UTUN_INSTANCE* inst) {
struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) return;
chat_devgroup_cancel_pending(inst);
if (ctx->timer) uasync_cancel_timeout(inst->ua, ctx->timer);
etcp_router_unbind(inst, ETCP_RT_ID_DEVGROUP); CC(inst)->devgroup = NULL;
OPENSSL_cleanse(ctx, sizeof(*ctx)); u_free(ctx); DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation service stopped");
}
int chat_devgroup_invite(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, char out_id[33], char out_pin[5]) {
struct chat_devgroup* ctx = dg_ctx(inst); uint8_t x[32], ed[32];
if (!ctx || !out_id || !out_pin || dg_peer(inst, group, peer, x, ed)) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invite rejected"); return -1; }
struct dg_invite* p = dg_slot(ctx, peer); if (!p) return -1;
struct devgroup_record own; uint8_t ep[32], xp[32];
int has = devgroup_load_local(inst, &own, ep, xp);
OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32);
if (has < 0) return -1;
if (has) memcpy(p->group_ed, own.ed, 32); // Нулевая привязка: группа создаётся после проверки PIN.
uint32_t random;
do { if (RAND_bytes((uint8_t*)&random, sizeof(random)) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: PIN randomness failed"); return -1; } }
while (random >= UINT32_MAX-UINT32_MAX%10000);
if (RAND_bytes(p->id, 16) != 1) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: invitation randomness failed"); return -1; }
snprintf(p->pin, sizeof(p->pin),"%04u", random%10000); p->group = group; p->peer = peer; p->outgoing = 1; p->state = 1; p->expires = get_time_tb() + DG_TTL;
memcpy(p->x, x, 32); memcpy(p->ed, ed, 32); dg_hex(p->id, 16, out_id); memcpy(out_pin, p->pin, 5);
dg_send(ctx, p, DG_OFFER, NULL, 0); DEBUG_INFO(DEBUG_CATEGORY_CHAT,"devgroup: invitation created peer=%016llx", (unsigned long long)peer);
dg_changed(ctx); return 0;
}
int chat_devgroup_accept(struct UTUN_INSTANCE* inst, const char* id, const char* pin) {
struct chat_devgroup* ctx = dg_ctx(inst); uint8_t binary[16];
if (!ctx || dg_id(id, binary) || !pin || strlen(pin) != 4 || strspn(pin,"0123456789") != 4) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: invalid acceptance arguments"); return -1;
}
struct dg_invite* p = dg_find(ctx, binary);
if (!p || p->outgoing || p->state != 1 || get_time_tb()>p->expires) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: acceptance unavailable"); return -1; }
if (p->attempts >= 3) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: PIN attempts exhausted"); return -1; }
memcpy(p->pin, pin, 5); p->attempts++; p->state = 2;
dg_send_pin(ctx, p); dg_changed(ctx); return 0;
}
char* chat_devgroup_status(struct UTUN_INSTANCE* inst) {
struct chat_devgroup* ctx = dg_ctx(inst); if (!ctx) { DEBUG_WARN(DEBUG_CATEGORY_CHAT,"devgroup: status unavailable"); return NULL; }
struct devgroup_record r; struct devgroup_profile profile; uint8_t ep[32], xp[32]; char id[65] = "";
int found = devgroup_load_local(inst, &r, ep, xp); OPENSSL_cleanse(ep, 32); OPENSSL_cleanse(xp, 32); if (found<0) return NULL;
struct strbuf json = {0};
if (found) { if (devgroup_profile_read(&r, &profile)) return NULL; dg_hex(r.ed, 32, id); }
if (strbuf_addf(&json,"{\"group\":\"%s\",\"name\":", id)<0 || strbuf_add_json_string(&json, found ? profile.name : "") || strbuf_addf(&json,",\"invitations\":[")<0) goto fail;
unsigned count = 0;
for (unsigned i = 0; i<DG_SLOTS; i++) {
struct dg_invite* p = &ctx->slots[i]; if (!p->state) continue; dg_hex(p->id, 16, id);
if (strbuf_addf(&json,"%s{\"id\":\"%s\",\"peer\":\"%016llx\",\"channel\":\"%llu\",\"outgoing\":%u,\"state\":%u}",
count++ ? "," : "", id, (unsigned long long)p->peer, (unsigned long long)p->group, p->outgoing, p->state)<0) goto fail;
}
if (strbuf_addf(&json,"]}")<0) goto fail;
return strbuf_detach(&json);
fail:
DEBUG_ERROR(DEBUG_CATEGORY_CHAT,"devgroup: status allocation failed"); strbuf_free(&json); return NULL;
}
+2 -2
View File
@@ -64,12 +64,12 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data,
[60]="ATTACHMENT_KEYS_CHANGED",
[61]="LOCAL_DEVICES", [62]="LOCAL_INVITE", [63]="LOCAL_DELIVERY", [64]="LOCAL_DISCOVERY_ACTIVE",
[65]="RADIO_CALL", [66]="CALL_PHONE_BUSY", [67]="MSG_UPDATED",
[69]="NETWORK_STATS",
[69]="NETWORK_STATS", [70]="DEVGROUP_CHANGED", [71]="DEVGROUP_RESULT",
};
const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : NULL;
if (!n) n = "?";
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "chat_event: %s(%d) data=%d bytes", n, type, len);
if (data && len > 0 && type != CHAT_EVT_LOCAL_INVITE && type != CHAT_EVT_INVITE_LINK_READY)
if (data && len > 0 && type != CHAT_EVT_DEVGROUP_RESULT && type != CHAT_EVT_LOCAL_INVITE && type != CHAT_EVT_INVITE_LINK_READY)
log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_CHAT, " ", data, len);
}
+4 -2
View File
@@ -57,8 +57,8 @@ extern "C" {
#define CHAT_EVT_DM_MSG_RECEIVED 32 /* [conv_id_len:1][conv_id:var][author_node_id:8] */
#define CHAT_EVT_DM_CONV_UPDATED 33 /* [conv_id_len:1][conv_id:var][generation:8 native] — беседа создана/изменена */
#define CHAT_EVT_CALL_INCOMING 34 /* [ch_id_len:1][ch_id:var][caller_node_id:8][call_id:8] */
#define CHAT_EVT_CALL_RINGING 35 /* [call_id:8][reason:1=0], native */
#define CHAT_EVT_CALL_ACCEPTED 36 /* [call_id:8][reason:1=0], native */
#define CHAT_EVT_CALL_RINGING 35 /* [call_id:8][reason:1=0][peer:8], окончательно выбранный собеседник */
#define CHAT_EVT_CALL_ACCEPTED 36 /* [call_id:8][reason:1=0][peer:8], окончательно выбранный собеседник */
#define CHAT_EVT_CALL_ENDED 37 /* [call_id:8][reason:1] */
#define CHAT_EVT_CALL_DECLINED 38 /* [call_id:8][reason:1] */
#define CHAT_EVT_CALL_STATS 39 /* [call_id:8][rtt:2][buffer:2][tempo:2][dropped:2][underruns:2][min:2][max:2][reserve:2] */
@@ -93,6 +93,8 @@ extern "C" {
#define CHAT_EVT_CALL_PHONE_BUSY 66 /* [id:8LE][reserved:1]; вызывающий выбирает продолжить либо завершить */
#define CHAT_EVT_MSG_UPDATED 67 /* [channel_len:1][channel][message_id:32] — правка после commit, без unread/звука */
#define CHAT_EVT_GROUP_AVATAR_SNAPSHOT 68 /* Только Android JNI: id/group/version:8LE, hash:32, can_edit/failed:1, size:2LE, image:size. */
#define CHAT_EVT_DEVGROUP_RESULT 71 /* request_id LE8 + success1 + JSON; может содержать PIN, не логировать */
#define CHAT_EVT_DEVGROUP_CHANGED 70 /* Обновление nodes/devgroup: перезагрузить снимки участников. */
#define CHAT_EVT_NETWORK_STATS 69 /* Native POD снимок ETCP-подключений и линков, chat_network_stats.h. */
// ── Подключение получателя событий ──
+67 -5
View File
@@ -1,3 +1,6 @@
#include "../radio/radio_call.h"
#include "chat_devgroup.h"
#include <openssl/crypto.h>
/*
* chat_headless_control.c — TCP control socket for headless chat CLI
*
@@ -341,12 +344,20 @@ static void hc_emit_chat_event(struct headless_control* hc, int type, const uint
"{\"event\":\"call_incoming\",\"ch\":\"%.*s\",\"caller_id\":\"0x%016llx\",\"call_id\":\"0x%016llx\"}",
(int)cl, data + 1, (unsigned long long)caller, (unsigned long long)cid);
}
} else if (type == CHAT_EVT_RADIO_CALL && data && len == 27) {
uint64_t cid, group, peer;
memcpy(&cid, data, 8); memcpy(&group, data + 8, 8); memcpy(&peer, data + 16, 8);
snprintf(json, sizeof(json), "{\"event\":\"radio_call\",\"call_id\":\"0x%016llx\",\"ch\":\"%llu\","
"\"peer\":\"0x%016llx\",\"state\":%u,\"reason\":%u,\"incoming\":%u}",
(unsigned long long)cid, (unsigned long long)group, (unsigned long long)peer, data[24], data[25], data[26]);
} else if (type == CHAT_EVT_CALL_RINGING && data && len >= 8) { /* CALL_RINGING */
uint64_t cid; memcpy(&cid, data, 8);
snprintf(json, sizeof(json), "{\"event\":\"call_ringing\",\"call_id\":\"0x%016llx\"}", (unsigned long long)cid);
} else if (type == CHAT_EVT_CALL_ACCEPTED && data && len >= 8) { /* CALL_ACCEPTED */
} else if (type == CHAT_EVT_CALL_ACCEPTED && data && len == 17) { /* CALL_ACCEPTED */
uint64_t cid; memcpy(&cid, data, 8);
snprintf(json, sizeof(json), "{\"event\":\"call_accepted\",\"call_id\":\"0x%016llx\"}", (unsigned long long)cid);
uint64_t peer; memcpy(&peer, data + 9, 8);
snprintf(json, sizeof(json), "{\"event\":\"call_accepted\",\"call_id\":\"0x%016llx\",\"peer\":\"0x%016llx\"}",
(unsigned long long)cid, (unsigned long long)peer);
} else if (type == CHAT_EVT_CALL_ENDED && data && len >= 9) { /* CALL_ENDED */
uint64_t cid; memcpy(&cid, data, 8);
snprintf(json, sizeof(json), "{\"event\":\"call_ended\",\"call_id\":\"0x%016llx\",\"reason\":%d}", (unsigned long long)cid, data[8]);
@@ -913,7 +924,8 @@ static void hc_handle_call_start(struct headless_client* cli, int id, const char
if (!call_id) call_id = hc_gen_call_id();
if (!call_id) { send_response(cli, id, NULL, "call ID unavailable"); return; }
call_start(cli->hc->inst, ch, peer, call_id);
if (json_get_int(cli, "devices", 0)) call_start_devices(cli->hc->inst, ch, peer, call_id);
else call_start(cli->hc->inst, ch, peer, call_id);
char resp[256]; snprintf(resp, sizeof(resp), "{\"call_id\":\"0x%016llx\",\"calling\":true}", (unsigned long long)call_id);
send_response(cli, id, resp, NULL);
}
@@ -990,8 +1002,57 @@ static void hc_handle_feedback(struct headless_client* cli, int id, const char*
u_free(result);
}
static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "attach", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "dm_start", "dm_list", "dm_messages", "dm_send", "call_start", "call_accept", "call_decline", "call_hangup", "radio_on", "radio_off", "radio_subscribers", "server_link", "server_add", "quit", NULL };
static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_attach, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_dm_start, hc_handle_dm_list, hc_handle_dm_messages, hc_handle_dm_send, hc_handle_call_start, hc_handle_call_accept, hc_handle_call_decline, hc_handle_call_hangup, hc_handle_radio_on, hc_handle_radio_off, hc_handle_radio_subscribers, hc_handle_server_link, hc_handle_server_add, hc_handle_quit };
static void hc_handle_devgroup(struct headless_client* cli, int id, const char* cmd) {
struct chat_devgroup_request req = {.inst = cli->hc->inst};
if (!strcmp(cmd, "devgroup_status")) req.action = CHAT_DEVGROUP_STATUS;
else if (!strcmp(cmd, "devgroup_leave")) req.action = CHAT_DEVGROUP_LEAVE;
else if (!strcmp(cmd, "devgroup_rename")) {
req.action = CHAT_DEVGROUP_RENAME;
if (json_get_str(cli, "name", req.text, sizeof(req.text))) goto invalid;
} else if (!strcmp(cmd, "devgroup_accept")) {
req.action = CHAT_DEVGROUP_ACCEPT;
if (json_get_str(cli, "invite", req.text, sizeof(req.text)) || json_get_str(cli, "pin", req.pin, sizeof(req.pin))) goto invalid;
} else if (!strcmp(cmd, "devgroup_invite")) {
char channel[32], peer[32]; req.action = CHAT_DEVGROUP_INVITE;
if (json_get_str(cli, "ch", channel, sizeof(channel)) || json_get_str(cli, "peer", peer, sizeof(peer)) ||
!chat_channel_id(channel) || strlen(peer) != 16 || strspn(peer, "0123456789abcdef") != 16) goto invalid;
req.group = chat_channel_id(channel); req.peer = strtoull(peer, NULL, 16);
} else goto invalid;
char* result = NULL; int rc = chat_devgroup_execute(&req, &result);
send_response(cli, id, result, rc ? "devgroup operation failed" : NULL);
if (result) { OPENSSL_cleanse(result, strlen(result)); u_free(result); }
OPENSSL_cleanse(&req, sizeof(req)); return;
invalid:
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup: invalid headless command");
OPENSSL_cleanse(&req, sizeof(req)); send_response(cli, id, NULL, "invalid devgroup command");
}
/* Headless тоже подтверждает включение рации только после события SELECTED. */
static void hc_handle_radio_call(struct headless_client* cli, int id, const char* json) {
(void)json;
char text[64]; struct radio_call_request req = {.inst = cli->hc->inst};
req.action = json_get_int(cli, "action", 0); req.devices = json_get_int(cli, "devices", 0);
if (req.action < RADIO_CALL_START || req.action > RADIO_CALL_ANSWER || !req.inst) goto invalid;
if (json_get_str(cli, "call_id", text, sizeof(text)) == 0) req.id = strtoull(text, NULL, 16);
if (req.action == RADIO_CALL_START) {
if (!req.id) req.id = hc_gen_call_id();
if (json_get_str(cli, "ch", text, sizeof(text)) < 0 || !chat_channel_id(text)) goto invalid;
req.group = strtoull(text, NULL, 10);
if (json_get_str(cli, "node_id", text, sizeof(text)) < 0) goto invalid;
req.peer = strtoull(text, NULL, 16);
if (!req.peer) goto invalid;
}
if (!req.id) goto invalid;
radio_call_execute(&req);
snprintf(text, sizeof(text), "{\"call_id\":\"0x%016llx\"}", (unsigned long long)req.id);
send_response(cli, id, text, NULL); return;
invalid:
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "headless invalid radio call action=%d", req.action);
send_response(cli, id, NULL, "invalid radio_call parameters");
}
static const char* cmd_names[] = { "ping", "status", "channels", "members", "messages", "send", "attach", "invite", "invite_nodes", "connect", "create_channel", "invite_to", "subscribe", "dm_start", "dm_list", "dm_messages", "dm_send", "radio_call", "call_start", "call_accept", "call_decline", "call_hangup", "radio_on", "radio_off", "radio_subscribers", "server_link", "server_add", "quit", NULL };
static hc_cmd_fn cmd_handlers[] = { hc_handle_ping, hc_handle_status, hc_handle_channels, hc_handle_members, hc_handle_messages, hc_handle_send, hc_handle_attach, hc_handle_invite, hc_handle_invite_nodes, hc_handle_connect, hc_handle_create_channel, hc_handle_invite_to, hc_handle_subscribe, hc_handle_dm_start, hc_handle_dm_list, hc_handle_dm_messages, hc_handle_dm_send, hc_handle_radio_call, hc_handle_call_start, hc_handle_call_accept, hc_handle_call_decline, hc_handle_call_hangup, hc_handle_radio_on, hc_handle_radio_off, hc_handle_radio_subscribers, hc_handle_server_link, hc_handle_server_add, hc_handle_quit };
static void hc_handle_command(struct headless_client* cli, const char* json) {
if (!cli || cli->closing || !json) return;
@@ -1005,6 +1066,7 @@ static void hc_handle_command(struct headless_client* cli, const char* json) {
|| !strcmp(cmd, "feedback") || !strcmp(cmd, "reaction_catalog")) {
hc_handle_feedback(cli, id, json); goto done;
}
if (!strncmp(cmd, "devgroup_", 9)) { hc_handle_devgroup(cli, id, cmd); goto done; }
for (int i = 0; cmd_names[i]; i++) {
if (strcmp(cmd, cmd_names[i]) == 0) { cmd_handlers[i](cli, id, json); goto done; }
}
+9 -1
View File
@@ -107,12 +107,16 @@ void chat_core_serialize_member(const struct chat_member_display* src, uint8_t*
dst[158] = src->verified; dst[159] = src->node_type;
dst[160] = src->conn_presence; dst[161] = src->conn_up;
dst[162] = (uint8_t)src->intermediaries; dst[163] = (uint8_t)(src->intermediaries >> 8);
memcpy(dst + 164, src->devgroup, 32);
strncpy((char*)dst + 196, src->devgroup_name, 63); dst[259] = 0;
DEBUG_TRACE(DEBUG_CATEGORY_CHAT_SYNC, "member snapshot: serialized node=%016llx name_bytes=%zu zero_padded=1",
(unsigned long long)src->node_id, strlen((const char*)dst + 15));
}
int chat_core_deserialize_member(const uint8_t* src, struct chat_member_display* dst) {
if (!src || !dst) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member deserialization: missing argument"); return -1; }
memcpy(dst->devgroup, src + 164, 32);
memcpy(dst->devgroup_name, src + 196, 64); dst->devgroup_name[63] = 0;
dst->node_id = mf_get64(src);
dst->online = src[8];
dst->is_self = src[9];
@@ -234,7 +238,11 @@ static int member_query(struct UTUN_INSTANCE* inst, const char* ch_id, int singl
}
members = tmp;
}
member_from_row(inst, ch_id, st, &members[cnt++]);
member_from_row(inst, ch_id, st, &members[cnt]);
if (devgroup_member(cc->db, members[cnt].node_id, members[cnt].devgroup, members[cnt].devgroup_name) < 0) {
rc = SQLITE_CORRUPT; break;
}
cnt++;
}
done:
{
+9 -5
View File
@@ -8,7 +8,7 @@
* Все вызовы — в uasync-потоке. ch_id — канонический десятичный ID1..INT64_MAX.
* В список входят self,placeholders и deleted;deleted показывается offline,self обычно online.
*
* Формат на проводе (ручная сериализация, фиксированный размер 164 байта):
* Формат на проводе (ручная сериализация, фиксированный размер 260 байта):
* [0-7] node_id uint64_t LE
* [8] online uint8_t
* [9] is_self uint8_t
@@ -25,6 +25,8 @@
* [159] node_type uint8_t, вычисленный тип узла из peers
* [160] conn_presence uint8_t, известные виды подключения NCONN_*
* [161] conn_up uint8_t, работающие виды подключения NCONN_*
* [164-195] devgroup Ed25519 public key, нули — без группы
* [196-259] devgroup_name UTF-8 char[64], NUL-padded
* [162-163] intermediaries uint16_t LE, число посредников выбранного пути
*/
@@ -49,7 +51,7 @@ extern "C" {
#define CHAT_MEMBER_FLAG_PHONE_KNOWN 0x40 /* опубликовано состояние телефонной занятости */
#define CHAT_MEMBER_FLAG_PHONE_BUSY 0x80 /* занят звонком; цвет трубки в интерфейсе */
#define CHAT_MEMBER_DISPLAY_SIZE 164
#define CHAT_MEMBER_DISPLAY_SIZE 260
/* Снять полный список:0 — успех,-1 — ошибка/более65535 записей. Обязательные out/count сбрасываются в NULL/0.
* out — u_malloc-массив count*CHAT_MEMBER_DISPLAY_SIZE байт,освобождает caller через u_free;пустой список — NULL/0.
@@ -66,6 +68,8 @@ int chat_core_member_online(struct UTUN_INSTANCE* inst, const char* ch_id, uint6
/* Внутренний снимок:поля далее кодируются вручную,struct-layout в wire не передаётся. */
struct chat_member_display {
uint8_t devgroup[32]; /* Проверенный Ed25519 ID группы устройств; нули — нет группы. */
char devgroup_name[64]; /* Имя наиболее свежего профиля из nodes. */
uint64_t node_id; /* ID участника. */
uint8_t online; /* Живой путь/self,сброшен для deleted. */
uint8_t is_self; /* Совпадение с node_id instance. */
@@ -84,14 +88,14 @@ struct chat_member_display {
uint16_t intermediaries; /* число посредников выбранного живого пути */
};
/* Копирование фиксированного wire164:src/dst обязательны,caller обеспечивает размеры.
/* Копирование фиксированного wire260:src/dst обязательны,caller обеспечивает размеры.
* Serialize завершает/дополняет NUL поля name/avatar_file;NULL только логируется. */
void chat_core_serialize_member(const struct chat_member_display* src, uint8_t* dst);
/* Deserialize читает ровно164 доступных байта;0/-1 при NULL,длину/семантику полей проверяет caller. */
/* Deserialize читает ровно260 доступных байта;0/-1 при NULL,длину/семантику полей проверяет caller. */
int chat_core_deserialize_member(const uint8_t* src, struct chat_member_display* dst);
/* Трамплин для gui_bridge, только uasync: принимает собственный u_malloc-блок arg и освобождает его.
* ch_id — канонический ID+NUL. Успех: CHAT_EVT_MEMBER_LIST с uint16 native count (0..65535), записи wire164 LE.
* ch_id — канонический ID+NUL. Успех: CHAT_EVT_MEMBER_LIST с uint16 native count (0..65535), записи wire260 LE.
* Непредставимый count/ошибка чата/памяти логируются, события нет; ложный/усечённый снимок не отправляется. */
struct chat_member_list_req { struct UTUN_INSTANCE* inst; char ch_id[64]; };
void chat_core_request_member_list_trampoline(void* arg);
+234 -193
View File
@@ -19,18 +19,21 @@
#include "../../lib/u_async.h"
#define RC_SLOTS 64
enum { RC_INVITE = 1, RC_RINGING, RC_RESULT, RC_ACK, RC_PHONE_BUSY, RC_FORCE };
/* wire: command:1, id:8LE, expires_us:8LE, reason:1. Результат повторяется до ACK.
* INVITE действителен 2с: застрявшая в роутере попытка не запускает поздний рингтон.
* Часы общие с NTP ядра; deadline самой попытки всегда монотонный. */
#define RC_TARGETS 64
#define RC_PACKET_SIZE 18
/* ACCEPT — решение пользователя, SELECT — необратимый выбор инициатора.
* RESULT/ACK завершают отдельную ветку; ACCEPTED допустим только от выбранного адресата. */
enum { RC_INVITE = 1, RC_RINGING, RC_RESULT, RC_ACK, RC_PHONE_BUSY, RC_FORCE, RC_ACCEPT, RC_SELECT };
struct rc_target {
uint64_t peer, ended;
uint8_t state, reason, awaiting_ack, force;
};
struct radio_call_session {
uint64_t id, group, peer; /* ключ — вся тройка, чужой пакет не меняет попытку */
uint64_t started, last_peer, last_send, finished;
uint64_t response_started; /* 2с для первоначального запроса и подтверждённого продолжения */
uint8_t state, reason, incoming, ready, awaiting_ack, blocked;
uint8_t force; /* подтверждение действительно только внутри данной попытки */
uint64_t id, group, peer, selected;
uint64_t started, last_peer, last_send, finished, response_started;
uint8_t state, reason, incoming, ready, awaiting_ack, blocked, force;
unsigned count;
struct rc_target targets[RC_TARGETS];
};
struct radio_call_context {
struct UTUN_INSTANCE* inst;
@@ -38,60 +41,58 @@ struct radio_call_context {
int stopping;
struct radio_call_session sessions[RC_SLOTS];
};
static void rc_tick(void* arg);
/* Только ближайший deadline/повтор: у idle-контекста вообще нет таймера.
* Кэш подтверждённых результатов очищается лениво при следующем поиске. */
static struct rc_target* rc_target(struct radio_call_session* s, uint64_t peer) {
for (unsigned i = 0; i < s->count; ++i) if (s->targets[i].peer == peer) return &s->targets[i];
return NULL;
}
/* 300мс только пока есть приглашение/неподтверждённый отбой; idle не просыпается. */
static void rc_schedule(struct radio_call_context* ctx) {
if (ctx->timer) { uasync_cancel_timeout(ctx->inst->ua, ctx->timer); ctx->timer = NULL; }
if (ctx->stopping) return;
uint64_t now = get_time_tb(), next = 0;
uint64_t now = get_time_tb(), due = 0;
for (int i = 0; i < RC_SLOTS; ++i) {
struct radio_call_session* s = &ctx->sessions[i];
if (!s->id) continue;
uint64_t due;
if (s->state == RADIO_CALL_FINISHED) {
if (!s->awaiting_ack || now - s->finished >= RADIO_CALL_RESPONSE_TB * 10) continue;
due = s->last_send + RADIO_CALL_RETRY_TB;
} else {
due = s->started + RADIO_CALL_RING_TB;
if (!s->incoming && s->state == RADIO_CALL_OUTGOING && s->response_started + RADIO_CALL_RESPONSE_TB < due)
due = s->response_started + RADIO_CALL_RESPONSE_TB;
uint64_t heartbeat = s->incoming ? s->last_peer + RADIO_CALL_RESPONSE_TB : s->last_send + RADIO_CALL_RETRY_TB;
if (heartbeat < due) due = heartbeat;
int active = s->state != RADIO_CALL_FINISHED ||
(s->awaiting_ack && now - s->finished < RADIO_CALL_RESPONSE_TB * 10);
for (unsigned j = 0; j < s->count; ++j)
active |= s->targets[j].awaiting_ack && now - s->targets[j].ended < RADIO_CALL_RESPONSE_TB * 10;
if (!active) continue;
uint64_t next = s->last_send + RADIO_CALL_RETRY_TB;
if (s->state != RADIO_CALL_FINISHED) {
uint64_t deadline = s->started + RADIO_CALL_RING_TB;
if (s->incoming && s->last_peer + RADIO_CALL_RESPONSE_TB < deadline) deadline = s->last_peer + RADIO_CALL_RESPONSE_TB;
if (!s->incoming && s->state == RADIO_CALL_OUTGOING && s->response_started + RADIO_CALL_RESPONSE_TB < deadline)
deadline = s->response_started + RADIO_CALL_RESPONSE_TB;
if (deadline < next) next = deadline;
}
if (!next || due < next) next = due;
if (!due || next < due) due = next;
}
if (!next) return;
int delay = next > now ? (int)(next - now) : 10;
if (delay < 10) delay = 10;
ctx->timer = uasync_set_timeout(ctx->inst->ua, delay, ctx, rc_tick, "radio_call");
if (!ctx->timer) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call deadline timer allocation failed delay_tb=%d", delay);
if (!due) return;
ctx->timer = uasync_set_timeout(ctx->inst->ua, due > now ? (int)(due - now) : 10, ctx, rc_tick, "radio_call");
if (!ctx->timer) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call timer allocation failed");
}
/* Формат события: id/group/peer:8LE, state/reason/incoming:1 (27 байт). */
static void rc_event(struct radio_call_context* ctx, const struct radio_call_session* s) {
static const char* states[] = {"invalid", "outgoing", "incoming", "ringing", "finished", "wait_decision"};
static const char* reasons[] = {
"none", "accepted", "declined", "busy", "cancelled", "unavailable", "no_answer", "audio_error", "phone_busy", "already_active"
};
uint8_t data[27];
memcpy(data, &s->id, 8); memcpy(data + 8, &s->group, 8); memcpy(data + 16, &s->peer, 8);
uint64_t peer = !s->incoming && s->selected ? s->selected : s->peer;
memcpy(data, &s->id, 8); memcpy(data + 8, &s->group, 8); memcpy(data + 16, &peer, 8);
data[24] = s->state; data[25] = s->reason; data[26] = s->incoming;
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio call id=%016llx group=%016llx peer=%016llx incoming=%u state=%s reason=%s elapsed_ms=%llu",
(unsigned long long)s->id, (unsigned long long)s->group, (unsigned long long)s->peer,
s->incoming, states[s->state], reasons[s->reason], (unsigned long long)((get_time_tb() - s->started) / 10));
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio call id=%llx group=%llx peer=%llx selected=%llx incoming=%u state=%u reason=%u targets=%u",
(unsigned long long)s->id, (unsigned long long)s->group, (unsigned long long)peer,
(unsigned long long)s->selected, s->incoming, s->state, s->reason, s->count);
chat_event_post(ctx->inst, CHAT_EVT_RADIO_CALL, data, sizeof(data));
}
/* Никакого отдельного соединения: используем живой BGP-путь канала и backpressure роутера. */
static int rc_send(struct radio_call_context* ctx, struct radio_call_session* s, uint8_t command, uint8_t reason) {
static int rc_send(struct radio_call_context* ctx, struct radio_call_session* s, uint64_t peer, uint8_t command, uint8_t reason) {
s->last_send = get_time_tb();
if (!etcp_router_conn_get(ctx->inst, s->group, s->peer, ETCP_RT_ID_RADIO_CALL)) {
if (!etcp_router_conn_get(ctx->inst, s->group, peer, ETCP_RT_ID_RADIO_CALL)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call router allocation failed id=%016llx", (unsigned long long)s->id); return -1;
}
if (!etcp_router_send_q_has_room(ctx->inst, s->group, s->peer, ETCP_RT_ID_RADIO_CALL)) {
if (!etcp_router_send_q_has_room(ctx->inst, s->group, peer, ETCP_RT_ID_RADIO_CALL)) {
if (!s->blocked) DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio call waiting for send queue id=%016llx command=%u",
(unsigned long long)s->id, command);
s->blocked = 1; return 0;
@@ -104,25 +105,36 @@ static int rc_send(struct radio_call_context* ctx, struct radio_call_session* s,
if (!e->dgram) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call payload allocation failed"); queue_entry_free(e); return -1; }
e->len = 1 + RC_PACKET_SIZE;
DEBUG_TRACE(DEBUG_CATEGORY_RADIO, "radio call TX id=%016llx group=%016llx peer=%016llx command=%u reason=%u",
(unsigned long long)s->id, (unsigned long long)s->group, (unsigned long long)s->peer, command, reason);
(unsigned long long)s->id, (unsigned long long)s->group, (unsigned long long)peer, command, reason);
e->dgram[0] = ETCP_RT_ID_RADIO_CALL; e->dgram[1] = command;
memcpy(e->dgram + 2, &s->id, 8);
int64_t expires = ntp_time_get_us(ctx->inst) + 2000000;
memcpy(e->dgram + 10, &expires, 8); e->dgram[18] = reason;
int rc = etcp_route_send(ctx->inst, s->group, s->peer, e, 1, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
int rc = etcp_route_send(ctx->inst, s->group, peer, e, 1, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT);
if (rc) DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call send failed id=%016llx command=%u rc=%d",
(unsigned long long)s->id, command, rc);
return rc;
}
/* Кэш не вытесняет живые записи: даже при потоке новых запросов старый INVITE не оживает. */
static struct TOPO_GROUP* rc_group(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer) {
struct TOPO_GROUP* g = inst->topo_groups ? topo_groups_find(inst->topo_groups, group) : NULL;
uint8_t key[32];
if (!g || g->group_type != TOPO_GROUP_TYPE_CHAT || peer == inst->node_id || !peer ||
member_sync_verified_ed25519(inst, g->channel_id, peer, key) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call invalid channel/member group=%016llx peer=%016llx",
(unsigned long long)group, (unsigned long long)peer); return NULL;
}
return g;
}
static struct radio_call_session* rc_slot(struct radio_call_context* ctx, uint64_t id, uint64_t group, uint64_t peer, int create) {
struct radio_call_session* empty = NULL;
uint64_t now = get_time_tb();
for (int i = 0; i < RC_SLOTS; ++i) {
struct radio_call_session* s = &ctx->sessions[i];
if (s->id && s->state == RADIO_CALL_FINISHED && now - s->finished >= RADIO_CALL_CACHE_TB) memset(s, 0, sizeof(*s));
if (s->id == id && s->group == group && s->peer == peer) return s;
if (s->id == id && s->group == group && (s->peer == peer || rc_target(s, peer))) return s;
if (!s->id && !empty) empty = s;
}
if (!create || !empty) return NULL;
@@ -137,220 +149,249 @@ int radio_call_busy(struct UTUN_INSTANCE* inst) {
return 0;
}
/* Один результат на попытку; последующие пакеты только подтверждают сохранённый итог. */
static void rc_end_target(struct radio_call_context* ctx, struct radio_call_session* s, struct rc_target* t, uint8_t reason) {
if (t->state == RADIO_CALL_FINISHED) return;
t->state = RADIO_CALL_FINISHED; t->reason = reason; t->ended = get_time_tb(); t->awaiting_ack = 1;
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio call target cancel id=%llx peer=%llx reason=%u",
(unsigned long long)s->id, (unsigned long long)t->peer, reason);
rc_send(ctx, s, t->peer, RC_RESULT, reason);
}
static void rc_finish(struct radio_call_context* ctx, struct radio_call_session* s, uint8_t reason, int send) {
if (s->state == RADIO_CALL_FINISHED) return;
s->state = RADIO_CALL_FINISHED; s->reason = reason; s->finished = get_time_tb(); s->awaiting_ack = send;
if (send) rc_send(ctx, s, RC_RESULT, reason);
rc_schedule(ctx);
rc_event(ctx, s);
s->state = RADIO_CALL_FINISHED; s->reason = reason; s->finished = get_time_tb();
if (s->incoming) {
s->awaiting_ack = send;
if (send) rc_send(ctx, s, s->peer, RC_RESULT, reason);
} else for (unsigned i = 0; i < s->count; ++i) {
struct rc_target* t = &s->targets[i];
if (reason == RADIO_CALL_ACCEPTED && t->peer == s->selected) {
t->state = RADIO_CALL_FINISHED; t->awaiting_ack = 0;
} else rc_end_target(ctx, s, t, RADIO_CALL_CANCELLED);
}
rc_event(ctx, s); rc_schedule(ctx);
}
/* Новый запрос допустим только в существующей CHAT-группе между её проверенными участниками. */
static struct TOPO_GROUP* rc_group(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer) {
struct TOPO_GROUP* g = inst->topo_groups ? topo_groups_find(inst->topo_groups, group) : NULL;
uint8_t key[32];
if (!g || g->group_type != TOPO_GROUP_TYPE_CHAT || peer == inst->node_id || !peer ||
member_sync_verified_ed25519(inst, g->channel_id, peer, key) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call invalid channel/member group=%016llx peer=%016llx",
(unsigned long long)group, (unsigned long long)peer); return NULL;
/* PHONE_BUSY не заглушает устройства, которые ещё могут ответить без force. */
static void rc_aggregate(struct radio_call_context* ctx, struct radio_call_session* s, uint8_t failure) {
if (s->state == RADIO_CALL_FINISHED || s->selected) return;
unsigned live = 0, waiting = 0, ringing = 0;
for (unsigned i = 0; i < s->count; ++i) {
live += s->targets[i].state != RADIO_CALL_FINISHED;
waiting += s->targets[i].state == RADIO_CALL_WAIT_DECISION;
ringing += s->targets[i].state == RADIO_CALL_RINGING;
}
if (!live) { rc_finish(ctx, s, failure, 0); return; }
uint8_t state = waiting == live ? RADIO_CALL_WAIT_DECISION : ringing ? RADIO_CALL_RINGING : RADIO_CALL_OUTGOING;
if (s->state != state) { s->state = state; rc_event(ctx, s); }
}
static void rc_transmit(struct radio_call_context* ctx, struct radio_call_session* s) {
uint64_t now = get_time_tb();
s->last_send = now;
if (s->incoming) {
if (s->state == RADIO_CALL_FINISHED) {
if (s->awaiting_ack && now - s->finished < RADIO_CALL_RESPONSE_TB * 10) rc_send(ctx, s, s->peer, RC_RESULT, s->reason);
} else if (s->state == RADIO_CALL_ANSWERING) rc_send(ctx, s, s->peer, RC_ACCEPT, 0);
return;
}
for (unsigned i = 0; i < s->count; ++i) {
struct rc_target* t = &s->targets[i];
if (t->state == RADIO_CALL_FINISHED) {
if (t->awaiting_ack && now - t->ended < RADIO_CALL_RESPONSE_TB * 10) rc_send(ctx, s, t->peer, RC_RESULT, t->reason);
} else if (s->selected == t->peer) rc_send(ctx, s, t->peer, RC_SELECT, 0);
else rc_send(ctx, s, t->peer, t->force ? RC_FORCE : RC_INVITE, 0);
}
return g;
}
/* Единый монотонный deadline: OUTGOING=2с, RINGING=60с от начала, callee lease=2с. */
static void rc_tick(void* arg) {
struct radio_call_context* ctx = arg;
ctx->timer = NULL;
struct radio_call_context* ctx = arg; ctx->timer = NULL;
uint64_t now = get_time_tb();
for (int i = 0; i < RC_SLOTS; ++i) {
struct radio_call_session* s = &ctx->sessions[i];
if (!s->id) continue;
if (s->state == RADIO_CALL_FINISHED) {
if (now - s->finished >= RADIO_CALL_CACHE_TB) { memset(s, 0, sizeof(*s)); continue; }
if (s->awaiting_ack && now - s->finished < RADIO_CALL_RESPONSE_TB * 10 && now - s->last_send >= RADIO_CALL_RETRY_TB)
rc_send(ctx, s, RC_RESULT, s->reason);
continue;
if (s->state != RADIO_CALL_FINISHED) {
if (!topo_groups_find(ctx->inst->topo_groups, s->group)) rc_finish(ctx, s, RADIO_CALL_UNAVAILABLE, 1);
else if (now - s->started >= RADIO_CALL_RING_TB) rc_finish(ctx, s, RADIO_CALL_NO_ANSWER, 1);
else if (s->incoming && now - s->last_peer >= RADIO_CALL_RESPONSE_TB) rc_finish(ctx, s, RADIO_CALL_CANCELLED, 1);
else if (!s->incoming && !s->selected) {
for (unsigned j = 0; j < s->count; ++j)
if (s->targets[j].state == RADIO_CALL_OUTGOING && now - s->response_started >= RADIO_CALL_RESPONSE_TB)
rc_end_target(ctx, s, &s->targets[j], RADIO_CALL_UNAVAILABLE);
rc_aggregate(ctx, s, RADIO_CALL_UNAVAILABLE);
}
}
struct TOPO_GROUP* g = topo_groups_find(ctx->inst->topo_groups, s->group);
if (!g) { rc_finish(ctx, s, RADIO_CALL_UNAVAILABLE, 1); continue; }
if (!s->incoming && s->state == RADIO_CALL_OUTGOING && now - s->response_started >= RADIO_CALL_RESPONSE_TB)
rc_finish(ctx, s, RADIO_CALL_UNAVAILABLE, 1);
else if (now - s->started >= RADIO_CALL_RING_TB) rc_finish(ctx, s, RADIO_CALL_NO_ANSWER, 1);
else if (s->incoming && now - s->last_peer >= RADIO_CALL_RESPONSE_TB) rc_finish(ctx, s, RADIO_CALL_CANCELLED, 1);
else if (!s->incoming && now - s->last_send >= RADIO_CALL_RETRY_TB) rc_send(ctx, s, s->force ? RC_FORCE : RC_INVITE, 0);
if (now - s->last_send >= RADIO_CALL_RETRY_TB) rc_transmit(ctx, s);
}
rc_schedule(ctx);
}
/* Получатель доверяет identity из подписанного router envelope, а не физическому соседу. */
static void rc_receive(struct ETCP_CONN* conn, struct ll_entry* entry) {
struct radio_call_context* ctx = conn && conn->instance ? conn->instance->radio_call : NULL;
if (ctx && entry && entry->len == ROUTER_SVC_HDR_SIZE) {
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio call router epoch closed/restarted; pending deadline unchanged"); goto done;
}
if (ctx && entry && entry->len == ROUTER_SVC_HDR_SIZE) goto done;
if (!ctx || !entry || !entry->dgram || entry->len != ROUTER_SVC_PAYLOAD_OFF + RC_PACKET_SIZE) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call invalid packet bytes=%u", entry ? entry->len : 0); goto done;
}
const uint8_t* d = entry->dgram;
uint8_t flags = ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED;
if ((d[ROUTER_SVC_FLAGS_OFF] & flags) != flags) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call unsigned/unencrypted packet flags=%02x", d[ROUTER_SVC_FLAGS_OFF]); goto done;
const uint8_t* d = entry->dgram; const uint8_t* b = d + ROUTER_SVC_PAYLOAD_OFF;
if ((d[ROUTER_SVC_FLAGS_OFF] & (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) != (ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call unauthenticated packet"); goto done;
}
uint64_t group, peer, id;
uint64_t group, peer, id; int64_t expires;
memcpy(&group, d + ROUTER_SVC_GROUP_OFF, 8); memcpy(&peer, d + ROUTER_SVC_SRC_OFF, 8);
const uint8_t* b = d + ROUTER_SVC_PAYLOAD_OFF;
memcpy(&id, b + 1, 8);
memcpy(&id, b + 1, 8); memcpy(&expires, b + 9, 8);
uint8_t command = b[0], reason = b[17];
DEBUG_TRACE(DEBUG_CATEGORY_RADIO, "radio call RX id=%016llx group=%016llx peer=%016llx command=%u reason=%u",
(unsigned long long)id, (unsigned long long)group, (unsigned long long)peer, command, reason);
if (!id || command < RC_INVITE || command > RC_FORCE || reason > RADIO_CALL_ALREADY_ACTIVE ||
if (!id || command < RC_INVITE || command > RC_SELECT || reason > RADIO_CALL_POLICY ||
((command == RC_RESULT || command == RC_ACK) ? !reason : reason != 0)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call invalid command=%u reason=%u id=%llu", command, reason, (unsigned long long)id); goto done;
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call invalid command=%u reason=%u", command, reason); goto done;
}
if (command == RC_INVITE || command == RC_FORCE || command == RC_SELECT) {
int64_t now = ntp_time_get_us(ctx->inst);
if (expires <= now || expires > now + 4000000 || !rc_group(ctx->inst, group, peer)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call stale/invalid command=%u id=%llx", command, (unsigned long long)id); goto done;
}
}
struct radio_call_session* s = rc_slot(ctx, id, group, peer, 0);
if (command == RC_INVITE || command == RC_FORCE) {
int64_t expires; memcpy(&expires, b + 9, 8);
int64_t now_us = ntp_time_get_us(ctx->inst);
if (expires <= now_us || expires > now_us + 4000000 || !rc_group(ctx->inst, group, peer)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call stale/invalid invite id=%016llx expires_us=%lld now_us=%lld",
(unsigned long long)id, (long long)expires, (long long)now_us); goto done;
}
}
if (command == RC_INVITE && !s) {
int busy = radio_call_busy(ctx->inst) || call_busy(ctx->inst);
if (!s && command == RC_INVITE) {
int busy = call_busy(ctx->inst) || radio_call_busy(ctx->inst);
s = rc_slot(ctx, id, group, peer, 1);
if (!s) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call result cache full; reply BUSY");
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call cache full; reply BUSY");
struct radio_call_session rejected = {.id = id, .group = group, .peer = peer};
rc_send(ctx, &rejected, RC_RESULT, RADIO_CALL_BUSY); goto done;
rc_send(ctx, &rejected, peer, RC_RESULT, RADIO_CALL_BUSY); goto done;
}
s->incoming = 1;
s->state = (ctx->inst->incoming_call_policy & CALL_POLICY_WARN_PHONE) && ctx->inst->phone_state == 2
? RADIO_CALL_WAIT_DECISION : RADIO_CALL_INCOMING;
if (radio_is_active(ctx->inst, group)) rc_finish(ctx, s, RADIO_CALL_ALREADY_ACTIVE, 1);
s->incoming = 1; s->state = RADIO_CALL_INCOMING;
if (ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_RADIO) rc_finish(ctx, s, RADIO_CALL_POLICY, 1);
else if (busy) rc_finish(ctx, s, RADIO_CALL_BUSY, 1);
else if (ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_RADIO) rc_finish(ctx, s, RADIO_CALL_DECLINED, 1);
else if (ctx->inst->phone_state == 2 && (ctx->inst->incoming_call_policy & CALL_POLICY_REJECT_PHONE))
rc_finish(ctx, s, RADIO_CALL_PHONE_BUSY, 1);
else if (s->state == RADIO_CALL_WAIT_DECISION) {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "phone busy; incoming radio call suppressed id=%016llx", (unsigned long long)id);
rc_send(ctx, s, RC_PHONE_BUSY, 0);
} else rc_event(ctx, s); /* приложение подтверждает показ через READY */
else if (ctx->inst->phone_state == 2 && (ctx->inst->incoming_call_policy & CALL_POLICY_WARN_PHONE)) {
s->state = RADIO_CALL_WAIT_DECISION; rc_send(ctx, s, peer, RC_PHONE_BUSY, 0);
} else rc_event(ctx, s);
goto done;
}
if (!s) {
if (command == RC_RESULT) {
/* CANCEL раньше INVITE также оставляет tombstone, не позволяя открыть старое окно. */
if (command == RC_RESULT && reason != RADIO_CALL_ACCEPTED) {
s = rc_slot(ctx, id, group, peer, 1);
if (s) { s->state = RADIO_CALL_FINISHED; s->reason = reason; s->finished = get_time_tb(); }
struct radio_call_session ack = {.id = id, .group = group, .peer = peer};
rc_send(ctx, &ack, RC_ACK, reason);
} else DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio call late command ignored id=%016llx command=%u", (unsigned long long)id, command);
if (s) { s->incoming = 1; s->state = RADIO_CALL_FINISHED; s->reason = reason; s->finished = get_time_tb();
rc_send(ctx, s, peer, RC_ACK, reason); }
}
goto done;
}
struct rc_target* t = rc_target(s, peer);
if (command == RC_ACK) {
if (s->state == RADIO_CALL_FINISHED && s->reason == reason && s->awaiting_ack) {
s->awaiting_ack = 0;
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio call result acknowledged id=%016llx reason=%u", (unsigned long long)id, reason);
if (t && t->state == RADIO_CALL_FINISHED && t->reason == reason) t->awaiting_ack = 0;
else if (s->incoming && s->state == RADIO_CALL_FINISHED && s->reason == reason) s->awaiting_ack = 0;
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio call end acknowledged id=%llx peer=%llx reason=%u",
(unsigned long long)id, (unsigned long long)peer, reason); goto done;
}
if (s->state != RADIO_CALL_FINISHED && get_time_tb() - s->started >= RADIO_CALL_RING_TB)
rc_finish(ctx, s, RADIO_CALL_NO_ANSWER, 1);
if (command == RC_RESULT) {
if (s->incoming) {
if (reason != RADIO_CALL_CANCELLED && reason != RADIO_CALL_UNAVAILABLE && reason != RADIO_CALL_NO_ANSWER &&
reason != RADIO_CALL_AUDIO_ERROR) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call invalid caller result=%u", reason); goto done; }
rc_send(ctx, s, peer, RC_ACK, reason); rc_finish(ctx, s, reason, 0);
} else if (t) {
rc_send(ctx, s, peer, RC_ACK, reason);
if (s->state == RADIO_CALL_FINISHED || (s->selected && s->selected != peer) || t->state == RADIO_CALL_FINISHED) goto done;
if (reason == RADIO_CALL_ACCEPTED && s->selected != peer) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call acceptance before selection id=%llx", (unsigned long long)id); goto done;
}
if (s->selected || reason == RADIO_CALL_DECLINED) rc_finish(ctx, s, reason, 0);
else { rc_end_target(ctx, s, t, RADIO_CALL_CANCELLED); rc_aggregate(ctx, s, reason); }
}
} else if (command == RC_RESULT) {
if (s->incoming && (reason == RADIO_CALL_ACCEPTED || reason == RADIO_CALL_DECLINED || reason == RADIO_CALL_BUSY ||
reason == RADIO_CALL_PHONE_BUSY || reason == RADIO_CALL_ALREADY_ACTIVE)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call result invalid for callee id=%016llx reason=%u", (unsigned long long)id, reason); goto done;
goto done;
}
if (s->state == RADIO_CALL_FINISHED || (t && t->state == RADIO_CALL_FINISHED)) {
rc_send(ctx, s, peer, RC_RESULT, t ? t->reason : s->reason); goto done;
}
if (!s->incoming && t) {
if (s->selected) { if (peer == s->selected) rc_send(ctx, s, peer, RC_SELECT, 0); goto done; }
if (t->state == RADIO_CALL_OUTGOING && get_time_tb() - s->response_started >= RADIO_CALL_RESPONSE_TB) {
rc_end_target(ctx, s, t, RADIO_CALL_UNAVAILABLE); rc_aggregate(ctx, s, RADIO_CALL_UNAVAILABLE); goto done;
}
if (!s->incoming && s->state == RADIO_CALL_OUTGOING && get_time_tb() - s->response_started >= RADIO_CALL_RESPONSE_TB)
rc_finish(ctx, s, RADIO_CALL_UNAVAILABLE, 1);
else if (s->state != RADIO_CALL_FINISHED && get_time_tb() - s->started >= RADIO_CALL_RING_TB)
rc_finish(ctx, s, RADIO_CALL_NO_ANSWER, 1);
rc_send(ctx, s, RC_ACK, reason);
rc_finish(ctx, s, reason, 0);
} else if (s->state == RADIO_CALL_FINISHED) rc_send(ctx, s, RC_RESULT, s->reason);
else if ((command == RC_INVITE || command == RC_FORCE) && s->incoming) {
if (s->state == RADIO_CALL_WAIT_DECISION && radio_is_active(ctx->inst, group)) {
rc_finish(ctx, s, RADIO_CALL_ALREADY_ACTIVE, 1); goto done;
}
if (s->state == RADIO_CALL_WAIT_DECISION && ((ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_RADIO) ||
(ctx->inst->phone_state == 2 && (ctx->inst->incoming_call_policy & CALL_POLICY_REJECT_PHONE)))) {
rc_finish(ctx, s, (ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_RADIO) ? RADIO_CALL_DECLINED : RADIO_CALL_PHONE_BUSY, 1);
goto done;
}
if (get_time_tb() - s->started >= RADIO_CALL_RING_TB) { rc_finish(ctx, s, RADIO_CALL_NO_ANSWER, 1); goto done; }
if (command == RC_ACCEPT) {
s->selected = peer;
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio call selected id=%llx peer=%llx", (unsigned long long)id, (unsigned long long)peer);
for (unsigned i = 0; i < s->count; ++i) if (s->targets[i].peer != peer)
rc_end_target(ctx, s, &s->targets[i], RADIO_CALL_CANCELLED);
rc_send(ctx, s, peer, RC_SELECT, 0);
} else if (command == RC_RINGING) { t->state = RADIO_CALL_RINGING; rc_aggregate(ctx, s, RADIO_CALL_UNAVAILABLE); }
else if (command == RC_PHONE_BUSY && !t->force) { t->state = RADIO_CALL_WAIT_DECISION; rc_aggregate(ctx, s, RADIO_CALL_PHONE_BUSY); }
} else if (s->incoming && (command == RC_INVITE || command == RC_FORCE || command == RC_SELECT)) {
s->last_peer = get_time_tb();
if (s->state == RADIO_CALL_WAIT_DECISION && command == RC_FORCE) {
s->force = 1; s->state = RADIO_CALL_INCOMING;
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "phone busy overridden for radio id=%016llx", (unsigned long long)id);
rc_event(ctx, s);
if (command == RC_SELECT) {
if (s->state == RADIO_CALL_ANSWERING) { s->state = RADIO_CALL_SELECTED; rc_event(ctx, s); }
} else {
if (s->state == RADIO_CALL_WAIT_DECISION && ((ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_RADIO) ||
(ctx->inst->phone_state == 2 && (ctx->inst->incoming_call_policy & CALL_POLICY_REJECT_PHONE)))) {
rc_finish(ctx, s, (ctx->inst->incoming_call_policy & CALL_POLICY_BLOCK_RADIO) ? RADIO_CALL_POLICY : RADIO_CALL_PHONE_BUSY, 1);
goto done;
}
if (s->state == RADIO_CALL_WAIT_DECISION && command == RC_FORCE) {
s->state = RADIO_CALL_INCOMING; s->force = 1; rc_event(ctx, s);
}
if (s->state == RADIO_CALL_WAIT_DECISION) rc_send(ctx, s, peer, RC_PHONE_BUSY, 0);
else if (s->state == RADIO_CALL_ANSWERING) rc_send(ctx, s, peer, RC_ACCEPT, 0);
else if (s->ready) rc_send(ctx, s, peer, RC_RINGING, 0);
}
if (s->state == RADIO_CALL_WAIT_DECISION) rc_send(ctx, s, RC_PHONE_BUSY, 0);
if (s->ready) rc_send(ctx, s, RC_RINGING, 0);
} else if (command == RC_PHONE_BUSY && !s->incoming && !s->force && s->state == RADIO_CALL_OUTGOING) {
if (get_time_tb() - s->response_started >= RADIO_CALL_RESPONSE_TB) rc_finish(ctx, s, RADIO_CALL_UNAVAILABLE, 1);
else { s->state = RADIO_CALL_WAIT_DECISION; rc_event(ctx, s); }
} else if (command == RC_RINGING && !s->incoming && s->state == RADIO_CALL_OUTGOING) {
if (get_time_tb() - s->response_started >= RADIO_CALL_RESPONSE_TB) rc_finish(ctx, s, RADIO_CALL_UNAVAILABLE, 1);
else { s->state = RADIO_CALL_RINGING; rc_event(ctx, s); }
}
done:
if (entry) { queue_dgram_free(entry); queue_entry_free(entry); }
if (ctx) rc_schedule(ctx);
}
/* UI-команды сериализованы с сетью; запоздалое нажатие не меняет завершённый вызов. */
void radio_call_execute(const struct radio_call_request* req) {
struct radio_call_context* ctx = req && req->inst ? req->inst->radio_call : NULL;
if (!ctx || ctx->stopping || !req->id) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call command without live context/id"); return; }
if (!ctx || ctx->stopping || !req->id) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call command without context/id"); return; }
if (req->action == RADIO_CALL_START) {
struct radio_call_session* s = rc_slot(ctx, req->id, req->group, req->peer, 0);
if (s) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call duplicate local start id=%016llx", (unsigned long long)req->id); return; }
if (rc_slot(ctx, req->id, req->group, req->peer, 0)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call duplicate start id=%llx", (unsigned long long)req->id); return;
}
int busy = radio_call_busy(req->inst) || call_busy(req->inst);
s = rc_slot(ctx, req->id, req->group, req->peer, 1);
struct radio_call_session* s = rc_slot(ctx, req->id, req->group, req->peer, 1);
if (!s) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call local result cache full");
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call cache full at start");
struct radio_call_session failed = {.id = req->id, .group = req->group, .peer = req->peer,
.started = get_time_tb(), .state = RADIO_CALL_FINISHED, .reason = RADIO_CALL_BUSY};
.state = RADIO_CALL_FINISHED, .reason = RADIO_CALL_BUSY};
rc_event(ctx, &failed); return;
}
s->state = RADIO_CALL_OUTGOING;
s->response_started = s->started;
struct TOPO_GROUP* g = rc_group(req->inst, req->group, req->peer);
s->state = RADIO_CALL_OUTGOING; s->response_started = s->started;
if (busy) { rc_finish(ctx, s, RADIO_CALL_BUSY, 0); return; }
if (!g || !topo_group_find_conn_for_node(g, req->peer)) { rc_finish(ctx, s, RADIO_CALL_UNAVAILABLE, 0); return; }
rc_event(ctx, s);
rc_send(ctx, s, RC_INVITE, 0);
rc_schedule(ctx);
return;
uint64_t peers[RC_TARGETS];
int count = call_resolve_targets(req->inst, req->group, req->peer, req->devices, peers, RC_TARGETS);
if (count <= 0) { rc_finish(ctx, s, RADIO_CALL_UNAVAILABLE, 0); return; }
s->count = (unsigned)count;
for (int i = 0; i < count; ++i) { s->targets[i].peer = peers[i]; s->targets[i].state = RADIO_CALL_OUTGOING; }
rc_event(ctx, s); rc_transmit(ctx, s); rc_schedule(ctx); return;
}
struct radio_call_session* s = NULL;
for (int i = 0; i < RC_SLOTS; ++i) if (ctx->sessions[i].id == req->id && ctx->sessions[i].state != RADIO_CALL_FINISHED)
s = &ctx->sessions[i];
if (!s) { DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio call stale UI command id=%016llx action=%d", (unsigned long long)req->id, req->action); return; }
if (!s) { DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "radio call stale action id=%llx action=%d", (unsigned long long)req->id, req->action); return; }
if (get_time_tb() - s->started >= RADIO_CALL_RING_TB) { rc_finish(ctx, s, RADIO_CALL_NO_ANSWER, 1); return; }
if (req->action == RADIO_CALL_CONTINUE && !s->incoming && s->state == RADIO_CALL_WAIT_DECISION) {
s->force = 1; s->state = RADIO_CALL_OUTGOING; s->response_started = get_time_tb();
rc_event(ctx, s); rc_send(ctx, s, RC_FORCE, 0); rc_schedule(ctx); return;
}
if (req->action == RADIO_CALL_READY && s->incoming && s->state == RADIO_CALL_INCOMING) {
s->ready = 1; rc_send(ctx, s, RC_RINGING, 0); rc_schedule(ctx); return;
}
uint8_t reason = 0;
if (req->action == RADIO_CALL_ACCEPT && s->incoming && s->state == RADIO_CALL_INCOMING) {
reason = radio_is_active(req->inst, s->group) ? RADIO_CALL_ACCEPTED : RADIO_CALL_AUDIO_ERROR;
if (reason == RADIO_CALL_AUDIO_ERROR) DEBUG_ERROR(DEBUG_CATEGORY_RADIO,
"radio call accept without active channel subscription id=%016llx group=%016llx",
(unsigned long long)s->id, (unsigned long long)s->group);
}
else if (req->action == RADIO_CALL_DECLINE && s->incoming) reason = RADIO_CALL_DECLINED;
else if (req->action == RADIO_CALL_CANCEL) reason = RADIO_CALL_CANCELLED;
else if (req->action == RADIO_CALL_FAIL) reason = RADIO_CALL_AUDIO_ERROR;
else if (req->action == RADIO_CALL_REJECT_BUSY) reason = RADIO_CALL_BUSY;
if (reason) rc_finish(ctx, s, reason, 1);
else DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call invalid UI action=%d incoming=%u", req->action, s->incoming);
if (req->action == RADIO_CALL_CONTINUE && !s->incoming && !s->selected && s->state == RADIO_CALL_WAIT_DECISION) {
for (unsigned i = 0; i < s->count; ++i) if (s->targets[i].state == RADIO_CALL_WAIT_DECISION) {
s->targets[i].force = 1; s->targets[i].state = RADIO_CALL_OUTGOING;
}
s->state = RADIO_CALL_OUTGOING; s->response_started = get_time_tb(); rc_event(ctx, s); rc_transmit(ctx, s);
} else if (req->action == RADIO_CALL_READY && s->incoming && s->state == RADIO_CALL_INCOMING) {
s->ready = 1; rc_send(ctx, s, s->peer, RC_RINGING, 0);
} else if (req->action == RADIO_CALL_ANSWER && s->incoming && s->state == RADIO_CALL_INCOMING) {
s->state = RADIO_CALL_ANSWERING; rc_event(ctx, s); rc_send(ctx, s, s->peer, RC_ACCEPT, 0);
} else if (req->action == RADIO_CALL_ACCEPT && s->incoming && s->state == RADIO_CALL_SELECTED) {
int ready = radio_is_active(req->inst, s->group);
if (!ready) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio selected device failed to activate id=%llx", (unsigned long long)s->id);
rc_finish(ctx, s, ready ? RADIO_CALL_ACCEPTED : RADIO_CALL_AUDIO_ERROR, 1);
} else if (req->action == RADIO_CALL_DECLINE && s->incoming) rc_finish(ctx, s, RADIO_CALL_DECLINED, 1);
else if (req->action == RADIO_CALL_CANCEL) rc_finish(ctx, s, RADIO_CALL_CANCELLED, 1);
else if (req->action == RADIO_CALL_FAIL) rc_finish(ctx, s, RADIO_CALL_AUDIO_ERROR, 1);
else if (req->action == RADIO_CALL_REJECT_BUSY) rc_finish(ctx, s, RADIO_CALL_BUSY, 1);
else DEBUG_WARN(DEBUG_CATEGORY_RADIO, "radio call invalid action=%d state=%u incoming=%u", req->action, s->state, s->incoming);
rc_schedule(ctx);
}
void radio_call_trampoline(void* arg) {
radio_call_execute(arg); u_free(arg);
}
void radio_call_trampoline(void* arg) { radio_call_execute(arg); u_free(arg); }
int radio_call_init(struct UTUN_INSTANCE* inst) {
if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call init without instance"); return -1; }
+7 -4
View File
@@ -2,7 +2,8 @@
* Ядро владеет сигналингом, GUI — рингтоном и включением обычной рации.
* После INCOMING приложение вызывает READY; первый ответ ожидается строго 2с.
* WAIT_DECISION не запускает UI получателя; CONTINUE разрешает начало входящего.
* ALREADY_ACTIVE — уведомление, что адресат уже слушает канал; рацию вызывающего не включает.
* Уже слушающее устройство тоже получает INCOMING и рингтон.
* ANSWER сообщает решение пользователя; SELECTED разрешает включение рации.
* ACCEPT отправляется после локального включения рации. FINISHED завершает только
* приглашение, а не рацию. Все функции и события выполняются в uasync-потоке. */
#ifndef RADIO_CALL_H
@@ -17,16 +18,17 @@ extern "C" {
#endif
enum radio_call_state {
RADIO_CALL_OUTGOING = 1, RADIO_CALL_INCOMING, RADIO_CALL_RINGING, RADIO_CALL_FINISHED, RADIO_CALL_WAIT_DECISION
RADIO_CALL_OUTGOING = 1, RADIO_CALL_INCOMING, RADIO_CALL_RINGING, RADIO_CALL_FINISHED, RADIO_CALL_WAIT_DECISION,
RADIO_CALL_ANSWERING, RADIO_CALL_SELECTED
};
enum radio_call_reason {
RADIO_CALL_NONE, RADIO_CALL_ACCEPTED, RADIO_CALL_DECLINED, RADIO_CALL_BUSY,
RADIO_CALL_CANCELLED, RADIO_CALL_UNAVAILABLE, RADIO_CALL_NO_ANSWER, RADIO_CALL_AUDIO_ERROR, RADIO_CALL_PHONE_BUSY,
RADIO_CALL_ALREADY_ACTIVE
RADIO_CALL_POLICY
};
enum radio_call_action {
RADIO_CALL_START = 1, RADIO_CALL_READY, RADIO_CALL_ACCEPT, RADIO_CALL_DECLINE,
RADIO_CALL_CANCEL, RADIO_CALL_FAIL, RADIO_CALL_REJECT_BUSY, RADIO_CALL_CONTINUE
RADIO_CALL_CANCEL, RADIO_CALL_FAIL, RADIO_CALL_REJECT_BUSY, RADIO_CALL_CONTINUE, RADIO_CALL_ANSWER
};
/* Таймауты в timebase 0.1мс; повторы не сдвигают первоначальный deadline. */
@@ -48,6 +50,7 @@ struct radio_call_request {
struct UTUN_INSTANCE* inst; /* заимствован, запрос не должен пережить остановку ядра */
uint64_t id, group, peer; /* идентификатор попытки, CHAT-канал, удалённый участник */
int action; /* radio_call_action */
int devices; /* START: вызвать devgroup peer, иначе только peer. */
};
void radio_call_execute(const struct radio_call_request* req);
/* Выполняет запрос и освобождает выделенный через u_malloc аргумент. */
+293
View File
@@ -0,0 +1,293 @@
#include "devgroup.h"
#include "topo_node.h"
#include "../utun_instance.h"
#include "../transport_layer/secure_channel.h"
#include "../../lib/json_flat.h"
#include "../../lib/strbuf.h"
#include "../../lib/debug_config.h"
#include <sqlite3.h>
#include <openssl/evp.h>
#include <openssl/sha.h>
#include <openssl/crypto.h>
#include <string.h>
#include <stdlib.h>
#include <limits.h>
static int dg_error(const char* stage) {
DEBUG_WARN(DEBUG_CATEGORY_BGP, "devgroup: rejected stage=%s", stage); return -1;
}
static int dg_sql(sqlite3* db, const char* sql) {
int rc = sqlite3_exec(db, sql, NULL, NULL, NULL);
if (rc != SQLITE_OK) DEBUG_ERROR(DEBUG_CATEGORY_BGP, "devgroup: SQL failed rc=%d: %s", rc, sqlite3_errmsg(db));
return rc == SQLITE_OK ? 0 : -1;
}
static int dg_finish(sqlite3* db, sqlite3_stmt* st, int rc, int expected) {
int final = sqlite3_finalize(st);
if (rc == expected && final == SQLITE_OK) return 0;
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "devgroup: SQL rc=%d expected=%d finalize=%d: %s", rc, expected, final, sqlite3_errmsg(db));
return -1;
}
static int dg_utf8(const char* text) {
const unsigned char* p = (const unsigned char*)text;
while (*p) {
uint32_t c = *p++; unsigned n; uint32_t min;
if (c < 128) { if (c < 32) return 0; continue; }
if (c >= 0xc2 && c <= 0xdf) { n = 1; min = 0x80; c &= 31; }
else if (c >= 0xe0 && c <= 0xef) { n = 2; min = 0x800; c &= 15; }
else if (c >= 0xf0 && c <= 0xf4) { n = 3; min = 0x10000; c &= 7; }
else return 0;
while (n--) { if ((*p & 0xc0) != 0x80) return 0; c = (c << 6) | (*p++ & 63); }
if (c < min || c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff)) return 0;
}
return 1;
}
/* Строгий каркас плоского JSON; escapes и Unicode проверяет json_flat_parse. */
static void dg_ws(const char** p) { while (**p && strchr(" \t\r\n", **p)) ++*p; }
static int dg_string(const char** p) {
if (*(*p)++ != '"') return -1;
while (**p && **p != '"') {
if (**p == '\\') { ++*p; if (!**p) return -1; }
++*p;
}
if (!**p) return -1;
++*p; return 0;
}
static int dg_syntax(const char* p) {
dg_ws(&p); if (*p++ != '{') return -1;
dg_ws(&p);
if (*p != '}') while (1) {
if (dg_string(&p)) return -1;
dg_ws(&p); if (*p++ != ':') return -1;
dg_ws(&p); if (dg_string(&p)) return -1;
dg_ws(&p); if (*p == '}') break;
if (*p++ != ',') return -1;
dg_ws(&p);
}
p++; dg_ws(&p); return *p ? -1 : 0;
}
static int dg_number(const char* text, uint64_t* out) {
if (*text < '1' || *text > '9') return -1;
uint64_t value = 0;
for (const char* p = text; *p; p++) {
if (*p < '0' || *p > '9' || value > ((uint64_t)INT64_MAX - (*p - '0')) / 10) return -1;
value = value * 10 + (*p - '0');
}
*out = value; return 0;
}
struct dg_parser { struct devgroup_profile p; uint8_t x[32]; unsigned fields, count; char keys[32][64]; };
static int dg_field(const char* key, const char* value, void* arg) {
struct dg_parser* p = arg;
if (!*key || strlen(key) > 63 || p->count == 32) return -1;
for (unsigned i = 0; i < p->count; i++) if (!strcmp(p->keys[i], key)) return -1;
strcpy(p->keys[p->count++], key);
if (!strcmp(key, "name")) {
if (!*value || strlen(value) > 63 || !dg_utf8(value)) return -1;
strcpy(p->p.name, value); p->fields |= 1;
} else if (!strcmp(key, "ver")) { if (dg_number(value, &p->p.version)) return -1; p->fields |= 2;
} else if (!strcmp(key, "timestamp")) { if (dg_number(value, &p->p.timestamp)) return -1; p->fields |= 4;
} else if (!strcmp(key, "x25519")) {
if (strlen(value) != 64 || strspn(value, "0123456789abcdef") != 64 || sc_hex_to_binary(value, p->x, 32)) return -1;
p->fields |= 8;
}
return 0;
}
static size_t dg_profile_message(const char* json, uint8_t* out) {
const char domain[] = "utun-devgroup-profile-v1";
memcpy(out, domain, sizeof(domain)); size_t n = strlen(json);
memcpy(out + sizeof(domain), json, n); return sizeof(domain) + n;
}
static size_t dg_member_message(const struct devgroup_record* r, uint64_t node, const uint8_t x[32], const uint8_t ed[32], uint8_t* out) {
const char domain[] = "utun-devgroup-member-v1"; size_t n = sizeof(domain);
memcpy(out, domain, n); memcpy(out + n, r->ed, 32); n += 32; memcpy(out + n, r->x, 32); n += 32;
for (unsigned i = 0; i < 8; i++) out[n++] = (uint8_t)(node >> (8 * i));
memcpy(out + n, x, 32); n += 32; memcpy(out + n, ed, 32); return n + 32;
}
int devgroup_profile_read(const struct devgroup_record* r, struct devgroup_profile* out) {
if (!r || !out || !r->json[0] || strnlen(r->json, sizeof(r->json)) > DEVGROUP_PROFILE_MAX) return dg_error("profile length");
struct dg_parser p = {0}; uint8_t body[DEVGROUP_PROFILE_MAX + 32];
if (dg_syntax(r->json) || json_flat_parse(r->json, dg_field, &p) || p.fields != 15 || memcmp(p.x, r->x, 32))
return dg_error("profile JSON");
size_t n = dg_profile_message(r->json, body);
if (sc_ed25519_verify(r->ed, body, n, r->profile_sig) != SC_OK) return dg_error("profile signature");
SHA256((const uint8_t*)r->json, strlen(r->json), p.p.hash); *out = p.p; return 0;
}
int devgroup_verify(const struct devgroup_record* r, uint64_t node, const uint8_t x[32], const uint8_t ed[32]) {
if (!r || !x || !ed) return dg_error("membership arguments");
if (!r->json[0]) {
const struct devgroup_record zero = {0};
return memcmp(r, &zero, sizeof(zero)) ? dg_error("partial membership") : 0;
}
struct devgroup_profile p; uint8_t body[192];
if (devgroup_profile_read(r, &p)) return -1;
size_t n = dg_member_message(r, node, x, ed, body);
if (sc_ed25519_verify(r->ed, body, n, r->member_sig) != SC_OK) return dg_error("membership signature");
return 0;
}
int devgroup_encode(const struct devgroup_record* r, uint8_t* out, size_t capacity) {
if (!r || !out) return dg_error("encode arguments");
size_t n = strnlen(r->json, sizeof(r->json)); if (!n) return 0;
if (n > DEVGROUP_PROFILE_MAX || capacity < 192 + n) return dg_error("encode capacity");
memcpy(out, r->ed, 32); memcpy(out + 32, r->x, 32); memcpy(out + 64, r->member_sig, 64);
memcpy(out + 128, r->profile_sig, 64); memcpy(out + 192, r->json, n); return (int)(192 + n);
}
int devgroup_decode(struct devgroup_record* out, const uint8_t* data, size_t size) {
if (!out || (size && (!data || size <= 192 || size > DEVGROUP_WIRE_MAX || memchr(data + 192, 0, size - 192))))
return dg_error("decode length");
struct devgroup_record r = {0};
if (size) {
memcpy(r.ed, data, 32); memcpy(r.x, data + 32, 32); memcpy(r.member_sig, data + 64, 64);
memcpy(r.profile_sig, data + 128, 64); memcpy(r.json, data + 192, size - 192);
}
*out = r; return 0;
}
int devgroup_profile_compare(const struct devgroup_profile* a, const struct devgroup_profile* b) {
if (a->version != b->version) return a->version > b->version ? 1 : -1;
if (a->timestamp != b->timestamp) return a->timestamp > b->timestamp ? 1 : -1;
return memcmp(a->hash, b->hash, 32);
}
int devgroup_bind(struct devgroup_record* r, const uint8_t priv[32], uint64_t node, const uint8_t x[32], const uint8_t ed[32]) {
if (!r || !priv || !node || !x || !ed) return dg_error("bind arguments");
uint8_t body[192]; size_t n = dg_member_message(r, node, x, ed, body);
if (sc_ed25519_sign(priv, body, n, r->member_sig) != SC_OK) return dg_error("bind signature");
return devgroup_verify(r, node, x, ed);
}
/* При локальном переименовании сохраняем неизвестные строковые поля расширений. */
static int dg_copy_extension(const char* key, const char* value, void* arg) {
if (!strcmp(key, "name") || !strcmp(key, "ver") || !strcmp(key, "timestamp") || !strcmp(key, "x25519")) return 0;
struct strbuf* json = arg;
return strbuf_addf(json, ",") < 0 || strbuf_add_json_string(json, key) || strbuf_addf(json, ":") < 0 ||
strbuf_add_json_string(json, value) ? -1 : 0;
}
int devgroup_sign(struct devgroup_record* r, const uint8_t priv[32], const char* name,
uint64_t version, uint64_t timestamp, uint64_t node, const uint8_t x[32], const uint8_t ed[32]) {
if (!r || !priv || !name || !*name || strlen(name) > 63 || !dg_utf8(name) || !version || version > INT64_MAX ||
!timestamp || timestamp > INT64_MAX || !node || !x || !ed) return dg_error("sign arguments");
struct devgroup_record next = *r; struct strbuf json = {0}; char hex[65];
for (unsigned i = 0; i < 32; i++) snprintf(hex + i * 2, 3, "%02x", r->x[i]);
int rc = strbuf_addf(&json, "{\"ver\":\"%llu\",\"timestamp\":\"%llu\",\"x25519\":\"%s\",\"name\":",
(unsigned long long)version, (unsigned long long)timestamp, hex);
if (rc < 0 || strbuf_add_json_string(&json, name) ||
(r->json[0] && json_flat_parse(r->json, dg_copy_extension, &json)) || strbuf_addf(&json, "}") < 0 || json.len > DEVGROUP_PROFILE_MAX) {
strbuf_free(&json); return dg_error("JSON allocation/size");
}
memset(next.json, 0, sizeof(next.json)); memcpy(next.json, json.buf, json.len); strbuf_free(&json);
uint8_t body[DEVGROUP_PROFILE_MAX + 32]; size_t n = dg_profile_message(next.json, body);
if (sc_ed25519_sign(priv, body, n, next.profile_sig) != SC_OK) return dg_error("profile signing");
n = dg_member_message(&next, node, x, ed, body);
if (sc_ed25519_sign(priv, body, n, next.member_sig) != SC_OK || devgroup_verify(&next, node, x, ed))
return dg_error("membership signing");
*r = next; return 0;
}
int devgroup_public_keys(const uint8_t ep[32], const uint8_t xp[32], uint8_t ed[32], uint8_t x[32]) {
EVP_PKEY* key = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, ep, 32); size_t n = 32;
int ok = key && EVP_PKEY_get_raw_public_key(key, ed, &n) == 1 && n == 32;
EVP_PKEY_free(key);
if (!ok || sc_compute_public_key_from_private(xp, x) != SC_OK) return dg_error("key derivation");
return 0;
}
int devgroup_schema(sqlite3* db) {
if (!db) return dg_error("schema database");
const char* names[] = {"devgroup_ed", "devgroup_x", "devgroup_member_sig", "devgroup_json", "devgroup_profile_sig"};
for (unsigned i = 0; i < 5; i++) {
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM pragma_table_info('nodes') WHERE name=?", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_text(st, 1, names[i], -1, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int exists = rc == SQLITE_ROW && sqlite3_column_int(st, 0);
if (dg_finish(db, st, rc, SQLITE_ROW)) return -1;
if (!exists) {
char sql[128]; snprintf(sql, sizeof(sql), "ALTER TABLE nodes ADD COLUMN %s %s", names[i], i == 3 ? "TEXT" : "BLOB");
if (dg_sql(db, sql)) return -1;
}
}
return dg_sql(db, "CREATE INDEX IF NOT EXISTS nodes_devgroup ON nodes(devgroup_ed);"
"CREATE TABLE IF NOT EXISTS devgroup_local(node_id INTEGER PRIMARY KEY,ed_priv BLOB NOT NULL,x_priv BLOB NOT NULL,record BLOB NOT NULL);"
"CREATE TABLE IF NOT EXISTS devgroup_receipts(node_id INTEGER NOT NULL,id BLOB NOT NULL,peer INTEGER NOT NULL,PRIMARY KEY(node_id,id));");
}
int devgroup_project(sqlite3* db, const struct TOPO_NODE* node) {
const struct devgroup_record* r = &node->devgroup; sqlite3_stmt* st = NULL;
const char* sql = "INSERT INTO nodes(node_id,x25519_pubkey,ed25519_pubkey,devgroup_ed,devgroup_x,devgroup_member_sig,devgroup_json,devgroup_profile_sig)"
" VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(node_id) DO UPDATE SET devgroup_ed=excluded.devgroup_ed,devgroup_x=excluded.devgroup_x,"
"devgroup_member_sig=excluded.devgroup_member_sig,devgroup_json=excluded.devgroup_json,devgroup_profile_sig=excluded.devgroup_profile_sig";
int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node->node_id);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 2, node->public_key, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 3, node->ed25519_public_key, 32, SQLITE_STATIC);
const void* blobs[] = {r->ed, r->x, r->member_sig, r->profile_sig}; const int sizes[] = {32,32,64,64};
for (unsigned i = 0; i < 4 && rc == SQLITE_OK; i++) {
int col = i == 3 ? 8 : 4 + (int)i;
rc = r->json[0] ? sqlite3_bind_blob(st, col, blobs[i], sizes[i], SQLITE_STATIC) : sqlite3_bind_null(st, col);
}
if (rc == SQLITE_OK) rc = r->json[0] ? sqlite3_bind_text(st, 7, r->json, -1, SQLITE_STATIC) : sqlite3_bind_null(st, 7);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_DONE && sqlite3_changes(db) != 1) rc = SQLITE_CONSTRAINT;
return dg_finish(db, st, rc, SQLITE_DONE);
}
int devgroup_load_local(struct UTUN_INSTANCE* inst, struct devgroup_record* out, uint8_t ep[32], uint8_t xp[32]) {
sqlite3* db = inst ? inst->topo_sqlite_db : NULL;
if (!db || !out || !ep || !xp) return dg_error("load arguments");
sqlite3_stmt* st = NULL; struct devgroup_record r = {0}; uint8_t e[32] = {0}, x[32] = {0}; int found = 0;
int rc = sqlite3_prepare_v2(db, "SELECT ed_priv,x_priv,record FROM devgroup_local WHERE node_id=?", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)inst->node_id);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
if (rc == SQLITE_ROW) {
if (sqlite3_column_bytes(st, 0) != 32 || sqlite3_column_bytes(st, 1) != 32 ||
devgroup_decode(&r, sqlite3_column_blob(st, 2), sqlite3_column_bytes(st, 2)) || !r.json[0]) rc = SQLITE_CORRUPT;
else { memcpy(e, sqlite3_column_blob(st, 0), 32); memcpy(x, sqlite3_column_blob(st, 1), 32); found = 1; rc = SQLITE_DONE; }
}
int error = dg_finish(db, st, rc, SQLITE_DONE);
if (!error && found) {
uint8_t ed[32], pub[32];
if (devgroup_public_keys(e, x, ed, pub) || memcmp(ed, r.ed, 32) || memcmp(pub, r.x, 32) ||
devgroup_verify(&r, inst->node_id, inst->my_keys.public_key, inst->my_ed25519_pubkey)) error = dg_error("stored identity");
}
if (!error) { *out = r; memcpy(ep, e, 32); memcpy(xp, x, 32); }
OPENSSL_cleanse(e, 32); OPENSSL_cleanse(x, 32); return error ? -1 : found;
}
int devgroup_latest(sqlite3* db, const uint8_t id[32], struct devgroup_record* out, struct devgroup_profile* profile) {
if (!db || !id || !out || !profile) return dg_error("latest arguments");
sqlite3_stmt* st = NULL; struct devgroup_record best = {0}; struct devgroup_profile bp = {0}; int found = 0;
int rc = sqlite3_prepare_v2(db, "SELECT devgroup_x,devgroup_member_sig,devgroup_json,devgroup_profile_sig,"
"node_id,x25519_pubkey,ed25519_pubkey FROM nodes WHERE devgroup_ed=?", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(st, 1, id, 32, SQLITE_STATIC);
if (rc == SQLITE_OK) while ((rc = sqlite3_step(st)) == SQLITE_ROW) {
struct devgroup_record r = {0}; struct devgroup_profile p;
int n = sqlite3_column_bytes(st, 2); const char* json = (const char*)sqlite3_column_text(st, 2);
if (sqlite3_column_bytes(st, 0) != 32 || sqlite3_column_bytes(st, 1) != 64 || sqlite3_column_bytes(st, 3) != 64 ||
sqlite3_column_bytes(st, 5) != 32 || sqlite3_column_bytes(st, 6) != 32 || !json || n <= 0 || n > DEVGROUP_PROFILE_MAX ||
memchr(json, 0, n)) { rc = SQLITE_CORRUPT; break; }
memcpy(r.ed, id, 32); memcpy(r.x, sqlite3_column_blob(st, 0), 32); memcpy(r.member_sig, sqlite3_column_blob(st, 1), 64);
memcpy(r.json, json, n); memcpy(r.profile_sig, sqlite3_column_blob(st, 3), 64);
if (devgroup_verify(&r, (uint64_t)sqlite3_column_int64(st, 4), sqlite3_column_blob(st, 5), sqlite3_column_blob(st, 6)) ||
devgroup_profile_read(&r, &p)) { rc = SQLITE_CORRUPT; break; }
if (!found || devgroup_profile_compare(&p, &bp) > 0) { best = r; bp = p; found = 1; }
}
if (dg_finish(db, st, rc, SQLITE_DONE)) return -1;
*out = best; *profile = bp; return found;
}
int devgroup_member(sqlite3* db, uint64_t node, uint8_t id[32], char name[64]) {
sqlite3_stmt* st = NULL; struct devgroup_record r = {0}, best; struct devgroup_profile profile;
int rc = sqlite3_prepare_v2(db, "SELECT devgroup_ed,devgroup_x,devgroup_member_sig,devgroup_json,devgroup_profile_sig,"
"x25519_pubkey,ed25519_pubkey FROM nodes WHERE node_id=? AND devgroup_ed IS NOT NULL", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(st, 1, (sqlite3_int64)node);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int found = rc == SQLITE_ROW;
if (found) {
int n = sqlite3_column_bytes(st, 3); const char* json = (const char*)sqlite3_column_text(st, 3);
if (sqlite3_column_bytes(st, 0) != 32 || sqlite3_column_bytes(st, 1) != 32 || sqlite3_column_bytes(st, 2) != 64 ||
sqlite3_column_bytes(st, 4) != 64 || sqlite3_column_bytes(st, 5) != 32 || sqlite3_column_bytes(st, 6) != 32 ||
!json || n <= 0 || n > DEVGROUP_PROFILE_MAX || memchr(json, 0, n)) rc = SQLITE_CORRUPT;
else {
memcpy(r.ed, sqlite3_column_blob(st, 0), 32); memcpy(r.x, sqlite3_column_blob(st, 1), 32);
memcpy(r.member_sig, sqlite3_column_blob(st, 2), 64); memcpy(r.json, json, n);
memcpy(r.profile_sig, sqlite3_column_blob(st, 4), 64);
rc = devgroup_verify(&r, node, sqlite3_column_blob(st, 5), sqlite3_column_blob(st, 6)) ? SQLITE_CORRUPT : SQLITE_DONE;
}
}
if (dg_finish(db, st, rc, SQLITE_DONE)) return -1;
memset(id, 0, 32); memset(name, 0, 64);
if (!found) return 0;
if (devgroup_latest(db, r.ed, &best, &profile) != 1) return dg_error("member profile unavailable");
memcpy(id, r.ed, 32); memcpy(name, profile.name, 64); return 1;
}
+57
View File
@@ -0,0 +1,57 @@
/* devgroup — общая подписанная принадлежность устройства и профиль группы.
* Публичная запись хранится по значению в TOPO_NODE; нулевая запись означает отсутствие группы.
* JSON пересылается без пересериализации, ключи группы независимы от ключей устройства.
* Все операции БД/instance вызываются в uasync-потоке; codec не обращается к instance. */
#ifndef UTUN_DEVGROUP_H
#define UTUN_DEVGROUP_H
#include <stdint.h>
#include <stddef.h>
struct sqlite3;
struct UTUN_INSTANCE;
struct TOPO_NODE;
#ifdef __cplusplus
extern "C" {
#endif
#define DEVGROUP_PROFILE_MAX 768
#define DEVGROUP_WIRE_MAX (192 + DEVGROUP_PROFILE_MAX)
struct devgroup_record {
uint8_t ed[32], x[32]; /* Общие публичные ключи подписи и шифрования. */
uint8_t member_sig[64]; /* Подпись идентичности конкретного устройства ключом группы. */
uint8_t profile_sig[64]; /* Подпись исходных байтов JSON ключом группы. */
char json[DEVGROUP_PROFILE_MAX + 1]; /* Полный строковый JSON; пустой — группы нет. */
};
struct devgroup_profile {
uint64_t version, timestamp; /* Порядок выбора профиля, независимо от timestamp ноды. */
char name[64]; /* UTF-8 ник, максимум 63 байта. */
uint8_t hash[32]; /* Последний критерий при равенстве version/timestamp. */
};
/* 0/-1: проверить весь JSON и подпись; out меняется только при успехе. Запись обязательна и непустая. */
int devgroup_profile_read(const struct devgroup_record* record, struct devgroup_profile* out);
/* 0/-1: проверить пустую запись либо обе подписи и привязку к ключам устройства. */
int devgroup_verify(const struct devgroup_record* record, uint64_t node, const uint8_t x[32], const uint8_t ed[32]);
/* Codec: encode возвращает число байтов (0 для пустой записи), decode0/-1. Буферы caller, без struct-padding на проводе. */
int devgroup_encode(const struct devgroup_record* record, uint8_t* out, size_t capacity);
int devgroup_decode(struct devgroup_record* out, const uint8_t* data, size_t size);
/* Сравнение проверенных профилей: отрицательное/0/положительное, a относительно b. */
int devgroup_profile_compare(const struct devgroup_profile* a, const struct devgroup_profile* b);
/* Подписать только удостоверение устройства, сохраняя исходные JSON/подпись профиля; 0/-1. */
int devgroup_bind(struct devgroup_record* record, const uint8_t priv[32], uint64_t node, const uint8_t x[32], const uint8_t ed[32]);
/* Создать/переименовать профиль и удостоверение. priv — Ed25519 seed; версии положительные <=INT64_MAX; 0/-1. */
int devgroup_sign(struct devgroup_record* record, const uint8_t priv[32], const char* name,
uint64_t version, uint64_t timestamp, uint64_t node, const uint8_t x[32], const uint8_t ed[32]);
/* Схема постоянного локального состояния и полей nodes; повторный вызов допустим. 0/-1. */
int devgroup_schema(struct sqlite3* db);
/* Внутри транзакции snapshot: записать публичную проекцию узла, 0/-1. */
int devgroup_project(struct sqlite3* db, const struct TOPO_NODE* node);
/* Загрузить собственную запись и проверить обе пары секретов. 0=нет группы,1=загружена,-1=ошибка. */
int devgroup_load_local(struct UTUN_INSTANCE* inst, struct devgroup_record* out, uint8_t ed_priv[32], uint8_t x_priv[32]);
/* Поиск максимального проверенного объявления среди nodes, 0/1/-1. */
int devgroup_latest(struct sqlite3* db, const uint8_t id[32], struct devgroup_record* out, struct devgroup_profile* profile);
/* Проверенная принадлежность узла из nodes и самое свежее имя;0=нет,1=найдено,-1=ошибка. Буферы32/64 байта. */
int devgroup_member(struct sqlite3* db, uint64_t node, uint8_t id[32], char name[64]);
/* Независимые публичные ключи из seed, 0/-1. */
int devgroup_public_keys(const uint8_t ed_priv[32], const uint8_t x_priv[32], uint8_t ed[32], uint8_t x[32]);
#ifdef __cplusplus
}
#endif
#endif
+1 -1
View File
@@ -1455,7 +1455,7 @@ int topo_group_process_nodeinfo(struct TOPO_GROUP* group, struct ETCP_CONN* from
if (sdb) {
if (topo_node_sqlite_snapshot_put(sdb, new_ni) < 0)
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "cannot persist received NODEINFO node=%016llx", (unsigned long long)node_id);
/* узлы в nodes пишет только merkle-путь; BGP лишь дополняет IP для мемберов */
/* Snapshot сохраняет общую принадлежность devgroup; адреса дополняем для известных мемберов. */
if (topo_node_sqlite_member_exists(sdb, node_id))
topo_node_sqlite_addrs_put(sdb, node_id, new_ni);
if (group->group_type == TOPO_GROUP_TYPE_CHAT && group->channel_id[0]) {
+42 -6
View File
@@ -11,6 +11,7 @@
#include "etcp.h"
#include "config_parser.h"
#include "topo_node.h"
#include <openssl/crypto.h>
#include "topo_group.h"
#include "route_connectivity.h"
#include "route_lib.h"
@@ -426,6 +427,13 @@ int topo_node_build_sig_msg(struct TOPO_NODE* ni, uint8_t* buf, size_t buf_size)
e = e->next;
}}
uint8_t dg[DEVGROUP_WIRE_MAX];
int bytes = devgroup_encode(&ni->devgroup, dg, sizeof(dg));
if (bytes < 0 || off + 2 + (size_t)bytes > buf_size) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "devgroup: signature buffer too small"); return -1;
}
buf[off++] = (uint8_t)bytes; buf[off++] = (uint8_t)(bytes >> 8);
memcpy(buf + off, dg, (size_t)bytes); off += (size_t)bytes;
return (int)off;
}
@@ -467,6 +475,7 @@ int topo_node_verify(const struct TOPO_NODE* ni) {
if (!ni || !ni->timestamp || ni->timestamp > INT64_MAX || ni->node_id != sc_derive_node_id_from_pubkey(ni->public_key)) {
DEBUG_WARN(DEBUG_CATEGORY_BGP, "invalid signed node identity or timestamp"); return -1;
}
if (devgroup_verify(&ni->devgroup, ni->node_id, ni->public_key, ni->ed25519_public_key)) return -1;
int len = topo_node_build_sig_msg((struct TOPO_NODE*)ni, msg, sizeof(msg));
if (len <= 0 || sc_ed25519_verify(ni->ed25519_public_key, msg, (size_t)len, ni->x25519_self_sig) != SC_OK) {
DEBUG_WARN(DEBUG_CATEGORY_BGP, "node signature rejected: node=%016llx timestamp=%llu",
@@ -483,12 +492,15 @@ int topo_node_dyn_size(const struct TOPOMSG_NODE* msg) {
(unsigned long long)msg->node_id, msg->flags, msg->local_v4_subnets, msg->local_v6_subnets);
return -1;
}
return msg->node_name_len
if (msg->devgroup_size && (msg->devgroup_size <= 192 || msg->devgroup_size > DEVGROUP_WIRE_MAX)) {
DEBUG_WARN(DEBUG_CATEGORY_BGP, "devgroup: invalid NODEINFO block size=%u", msg->devgroup_size); return -1;
}
return msg->devgroup_size + msg->node_name_len
+ msg->local_v4_sockets * sizeof(struct TOPOMSG_SOCKMETA4)
+ msg->local_v4_addrs * sizeof(struct TOPOMSG_ADDR4)
+ msg->local_v6_sockets * sizeof(struct TOPOMSG_SOCKMETA6)
+ msg->local_v6_addrs * sizeof(struct TOPOMSG_ADDR6)
+ msg->local_reality_socks * sizeof(struct TOPOMSG_REALITY_SOCK)
+ msg->reality_size
+ msg->local_v4_subnets * sizeof(struct TOPOMSG_SUBNET4)
+ msg->local_v6_subnets * sizeof(struct TOPOMSG_SUBNET6)
+ msg->hop_count * 8;
@@ -500,8 +512,12 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq,
uint8_t* out, size_t out_max, uint16_t cumulative_rtt) {
if (!ni || !nq || !out) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "NODEINFO encode: missing node/group/output"); return -1; }
uint8_t dg[DEVGROUP_WIRE_MAX];
int dg_size = devgroup_encode(&ni->devgroup, dg, sizeof(dg));
if (dg_size < 0) return -1;
struct TOPOMSG_NODE msg;
memset(&msg, 0, sizeof(msg));
msg.devgroup_size = (uint16_t)dg_size;
msg.flags = flags;
msg.group_id = group_id;
msg.node_id = ni->node_id;
@@ -527,6 +543,14 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq,
msg.local_v6_sockets = topo_list_count((struct _topo_head*)ni->v6_sock_meta);
msg.local_v6_addrs = topo_list_count((struct _topo_head*)ni->v6_addrs);
msg.local_reality_socks = topo_list_count((struct _topo_head*)ni->reality_socks);
size_t reality_size = 0;
for (struct TOPO_REALITY_SOCK* e = ni->reality_socks; e; e = e->next) {
size_t n = strnlen(e->server_name, sizeof(e->server_name));
if (n >= sizeof(e->server_name)) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "REALITY name unterminated"); return -1; }
reality_size += sizeof(struct TOPOMSG_REALITY_SOCK) + n;
}
if (reality_size > UINT16_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "REALITY section too large"); return -1; }
msg.reality_size = (uint16_t)reality_size;
if ((flags & TOPO_FLAG_SEND_SUBNETS) && nq->subnets) {
msg.local_v4_subnets = topo_list_count((struct _topo_head*)nq->subnets->v4_subnets);
msg.local_v6_subnets = topo_list_count((struct _topo_head*)nq->subnets->v6_subnets);
@@ -551,6 +575,7 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq,
uint8_t* dp = out + TOPOMSG_NODE_HDR_SIZE;
if (msg.node_name_len) { memcpy(dp, ni->node_name, msg.node_name_len); dp += msg.node_name_len; }
memcpy(dp, dg, (size_t)dg_size); dp += dg_size;
{ struct TOPO_SOCKMETA4* e = ni->v4_sock_meta; while (e) {
sockmeta_wire_write(dp, e->id, e->config_type, &e->address_profile);
@@ -574,8 +599,9 @@ int topo_node_serialize(struct TOPO_NODE* ni, struct TOPO_GROUP_NODE* nq,
memcpy(w.short_id, e->short_id, 8);
memcpy(w.server_pubkey, e->server_pubkey, 32);
memcpy(w.version, e->version, 3);
memcpy(w.server_name, e->server_name, TOPO_REALITY_SNAME_MAX);
memcpy(dp, &w, sizeof(w)); dp += sizeof(w); e = e->next;
w.server_name_len = (uint8_t)strlen(e->server_name);
memcpy(dp, &w, sizeof(w)); dp += sizeof(w);
memcpy(dp, e->server_name, w.server_name_len); dp += w.server_name_len; e = e->next;
}}
if (msg.flags & TOPO_FLAG_SEND_SUBNETS) {
if (nq->subnets) {
@@ -646,6 +672,9 @@ int topo_node_deserialize(struct TOPO_GROUP* group, const uint8_t* data, size_t
memcpy(ni->node_name, dp, msg->node_name_len); ni->node_name[msg->node_name_len] = 0;
}
dp += msg->node_name_len;
section = "devgroup";
if (devgroup_decode(&ni->devgroup, dp, msg->devgroup_size)) goto invalid_profile;
dp += msg->devgroup_size;
/* Списки сразу принадлежат ni: общий выход ошибки видит и незавершённую секцию. */
section = "v4 socket metadata";
@@ -694,16 +723,19 @@ int topo_node_deserialize(struct TOPO_GROUP* group, const uint8_t* data, size_t
}
section = "REALITY sockets";
{ struct TOPO_REALITY_SOCK** tail = &ni->reality_socks;
const uint8_t* end = dp + msg->reality_size;
for (index = 0; index < msg->local_reality_socks; index++) {
if ((size_t)(end - dp) < sizeof(struct TOPOMSG_REALITY_SOCK)) goto invalid_profile;
const struct TOPOMSG_REALITY_SOCK* w = (const struct TOPOMSG_REALITY_SOCK*)dp; dp += sizeof(*w);
if ((size_t)(end - dp) < w->server_name_len || memchr(dp, 0, w->server_name_len)) goto invalid_profile;
struct TOPO_REALITY_SOCK* e = u_calloc(1, sizeof(*e));
if (!e) goto no_memory;
*tail = e; tail = &e->next;
e->socket_id = w->socket_id;
memcpy(e->short_id, w->short_id, 8); memcpy(e->server_pubkey, w->server_pubkey, 32);
memcpy(e->version, w->version, 3); memcpy(e->server_name, w->server_name, TOPO_REALITY_SNAME_MAX);
e->server_name[TOPO_REALITY_SNAME_MAX - 1] = 0;
memcpy(e->version, w->version, 3); memcpy(e->server_name, dp, w->server_name_len); dp += w->server_name_len;
}
if (dp != end) goto invalid_profile;
}
section = "subnets"; index = 0;
@@ -1098,6 +1130,10 @@ int topo_node_init_self(struct UTUN_INSTANCE* instance) {
memcpy(ni->public_key, instance->my_keys.public_key, SC_PUBKEY_SIZE);
memcpy(ni->ed25519_public_key, instance->my_ed25519_pubkey, SC_PUBKEY_SIZE);
ni->node_name = u_strdup(instance->name);
uint8_t ed_private[32], x_private[32];
int dg = instance->topo_sqlite_db ? devgroup_load_local(instance, &ni->devgroup, ed_private, x_private) : 0;
OPENSSL_cleanse(ed_private, sizeof(ed_private)); OPENSSL_cleanse(x_private, sizeof(x_private));
if (dg < 0) { topo_node_destroy(instance->topo_groups, ni); return -1; }
if (!ni->node_name || topo_node_sign_self(instance, ni) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "cannot initialize local identity");
topo_node_destroy(instance->topo_groups, ni); return -1;
+8 -4
View File
@@ -1,4 +1,4 @@
/**
/**
* @file topo_node.h
* @brief Модель данных узла сети — структуры, сериализация, wire-формат.
*
@@ -20,6 +20,7 @@ extern "C" {
#include "../lib/ll_queue.h"
#include "secure_channel.h"
#include "address_profile.h"
#include "devgroup.h"
struct TOPO_GROUP;
struct TOPO_GROUPS;
@@ -120,20 +121,22 @@ struct TOPOMSG_SUBNET4 { uint8_t addr[4]; uint8_t prefix_length; } __attribute
struct TOPOMSG_SUBNET6 { uint8_t addr[16]; uint8_t prefix_length; } __attribute__((packed));
/* Reality-камуфляж сокета: что нужно клиенту для подключения к reality TCP-сокету.
* short_id(8) + server_pubkey(32) + version(3) + server_name(фиксированный 256). */
* short_id(8) + server_pubkey(32) + version(3) + server_name_len(1), затем server_name без NUL (до 255 байт). */
#define TOPO_REALITY_SNAME_MAX 256
struct TOPOMSG_REALITY_SOCK {
uint8_t socket_id;
uint8_t short_id[8];
uint8_t server_pubkey[32];
uint8_t version[3];
uint8_t server_name[TOPO_REALITY_SNAME_MAX];
uint8_t server_name_len;
} __attribute__((packed));
/* NODEINFO: этот заголовок, имя без NUL, SOCKMETA4/ADDR4/SOCKMETA6/ADDR6/REALITY,
/* NODEINFO: этот заголовок, имя без NUL, devgroup[devgroup_size], SOCKMETA4/ADDR4/SOCKMETA6/ADDR6/REALITY,
* опциональные SUBNET4/SUBNET6, затем hop_count ID. Счётчики — число элементов, имя до 63 байт.
* Многобайтные числа wire копируются в нативном порядке; IP — сетевые байты, port в памяти — host order. */
struct TOPOMSG_NODE {
uint16_t reality_size; /* Суммарные байты переменных REALITY-записей. */
uint16_t devgroup_size; /* Блок devgroup после имени, 0 — отсутствует. */
uint8_t flags; uint64_t group_id, node_id; uint8_t ver;
uint64_t timestamp; // версия всей подписанной записи, Unix microseconds
uint8_t public_key[SC_PUBKEY_SIZE], ed25519_public_key[SC_PUBKEY_SIZE];
@@ -181,6 +184,7 @@ static inline int topo_list_count(struct _topo_head* head) { int n = 0; while (h
/** Общая запись узла; node_registry хранит указатель на неё в отдельном ll_entry. */
struct TOPO_NODE {
struct ll_entry ll;
struct devgroup_record devgroup; /* Общая подписанная принадлежность, владеет всеми байтами. */
uint32_t group_ref_count; // ссылки групп, ядра и временных пользователей
uint64_t node_id; uint8_t ver;
uint64_t timestamp; // 0 = неподписанные bootstrap-сведения
+37 -6
View File
@@ -264,6 +264,32 @@ int topo_node_sqlite_get_member_channels(sqlite3* db, uint64_t node_id,
return 0;
}
/* Формат BLOB меняется вместе с NODEINFO. Старые байты не декодируем: адресная проекция
* обеспечивает bootstrap, timestamp сохраняет нижнюю границу новой подписи владельца. */
static int snapshot_schema(sqlite3* db) {
sqlite3_stmt* st = NULL;
int rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM pragma_table_info('node_snapshots') WHERE name='format'", -1, &st, NULL);
if (rc == SQLITE_OK) rc = sqlite3_step(st);
int present = rc == SQLITE_ROW && sqlite3_column_int(st, 0);
int final = sqlite3_finalize(st);
if (rc != SQLITE_ROW || final != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot format inspection failed rc=%d finalize=%d: %s", rc, final, sqlite3_errmsg(db)); return -1;
}
if (present) return 0;
const char* migration = "SAVEPOINT snapshot_format;"
"ALTER TABLE node_snapshots ADD COLUMN format INTEGER NOT NULL DEFAULT 0;"
"UPDATE node_snapshots SET record=X'';"
"RELEASE snapshot_format;";
if (sqlite3_exec(db, migration, NULL, NULL, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot format migration failed: %s", sqlite3_errmsg(db));
if (sqlite3_exec(db, "ROLLBACK TO snapshot_format; RELEASE snapshot_format", NULL, NULL, NULL) != SQLITE_OK)
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot format migration rollback failed: %s", sqlite3_errmsg(db));
return -1;
}
DEBUG_INFO(DEBUG_CATEGORY_BGP, "NODEINFO cache upgraded: obsolete blobs invalidated; timestamps, addresses, members and keys preserved");
return 0;
}
int topo_node_sqlite_init(sqlite3* db) {
if (!db) return -1;
@@ -330,7 +356,7 @@ int topo_node_sqlite_init(sqlite3* db) {
const char* sql =
"CREATE TABLE IF NOT EXISTS node_snapshots ("
"node_id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, record BLOB NOT NULL);"
"node_id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL, record BLOB NOT NULL, format INTEGER NOT NULL DEFAULT 1);"
"CREATE TABLE IF NOT EXISTS nodes ("
" node_id INTEGER PRIMARY KEY,"
" name TEXT,"
@@ -392,6 +418,8 @@ int topo_node_sqlite_init(sqlite3* db) {
return -1;
}
if (snapshot_schema(db) || devgroup_schema(db)) return -1;
/* Старые подписи непригодны; адреса и максимальные версии остаются для bootstrap и новой публикации. */
if (legacy_profile) {
const char *migration = "SAVEPOINT compact_nat_profile;"
@@ -1311,15 +1339,18 @@ int topo_node_sqlite_snapshot_put(sqlite3* db, struct TOPO_NODE* ni) {
int rc = sqlite3_exec(db, "SAVEPOINT node_snapshot", NULL, NULL, NULL);
if (rc != SQLITE_OK) { DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot begin rc=%d: %s", rc, sqlite3_errmsg(db)); return -1; }
sqlite3_stmt* stmt = NULL;
const char* sql = "INSERT INTO node_snapshots(node_id,timestamp,record) VALUES(?,?,?) "
"ON CONFLICT(node_id) DO UPDATE SET timestamp=excluded.timestamp,record=excluded.record "
"WHERE excluded.timestamp > node_snapshots.timestamp";
const char* sql = "INSERT INTO node_snapshots(node_id,timestamp,record,format) VALUES(?,?,?,1) "
"ON CONFLICT(node_id) DO UPDATE SET timestamp=excluded.timestamp,record=excluded.record,format=excluded.format "
"WHERE excluded.timestamp > node_snapshots.timestamp OR "
"(excluded.timestamp = node_snapshots.timestamp AND length(node_snapshots.record)=0)";
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(stmt, 1, (sqlite3_int64)ni->node_id);
if (rc == SQLITE_OK) rc = sqlite3_bind_int64(stmt, 2, (sqlite3_int64)ni->timestamp);
if (rc == SQLITE_OK) rc = sqlite3_bind_blob(stmt, 3, record, len, SQLITE_STATIC);
if (rc == SQLITE_OK) rc = sqlite3_step(stmt);
int changed = rc == SQLITE_DONE ? sqlite3_changes(db) : 0;
int final = sqlite3_finalize(stmt);
if (rc == SQLITE_DONE && final == SQLITE_OK && changed && devgroup_project(db, ni)) rc = SQLITE_ERROR;
if (rc == SQLITE_DONE && final == SQLITE_OK) {
rc = sqlite3_exec(db, "RELEASE node_snapshot", NULL, NULL, NULL);
if (rc == SQLITE_OK) return 0;
@@ -1341,7 +1372,7 @@ int topo_node_sqlite_snapshot_put(sqlite3* db, struct TOPO_NODE* ni) {
struct TOPO_NODE* topo_node_sqlite_snapshot_load(sqlite3* db, struct TOPO_GROUPS* groups, uint64_t node_id) {
sqlite3_stmt* stmt = NULL;
struct TOPO_NODE* ni = NULL;
if (sqlite3_prepare_v2(db, "SELECT record FROM node_snapshots WHERE node_id=?", -1, &stmt, NULL) != SQLITE_OK) {
if (sqlite3_prepare_v2(db, "SELECT record FROM node_snapshots WHERE node_id=? AND format=1", -1, &stmt, NULL) != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_BGP, "snapshot load: %s", sqlite3_errmsg(db)); return NULL;
}
sqlite3_bind_int64(stmt, 1, (sqlite3_int64)node_id);
@@ -1603,7 +1634,7 @@ int topo_node_sqlite_get_connect_peers(sqlite3* db, const char* channel_id,
char sql[512];
snprintf(sql, sizeof(sql),
"SELECT p.node_id,p.adm_tags,s.timestamp,s.record FROM \"%s\" p"
" LEFT JOIN node_snapshots s ON s.node_id=p.node_id WHERE p.deleted=0 AND p.source=0"
" LEFT JOIN node_snapshots s ON s.node_id=p.node_id AND s.format=1 AND length(s.record)>0 WHERE p.deleted=0 AND p.source=0"
" ORDER BY p.connected DESC,p.node_RTT IS NULL,p.node_RTT,p.node_id", table);
sqlite3_stmt* stmt = NULL;
int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
+1
View File
@@ -78,6 +78,7 @@ extern "C" {
#define ETCP_RT_ID_CALL_AUDIO 0x3F // аудио, независимо от сигналинга/видео
#define ETCP_RT_ID_RADIO_CALL 0x40 // адресное приглашение участника в рацию CHAT-канала
#define ETCP_RT_ID_MULTICAST_REPORT 0x41 // отчёты о переполнении multicast FIFO
#define ETCP_RT_ID_DEVGROUP 0x43 // объединение устройств, E2E приглашения
#define ETCP_RT_ID_ATTACHMENT_KEYS 0x42 // запросы и ручная E2E-выдача ключей групповых вложений
// Connection status events (instance-level callback).
+4 -1
View File
@@ -1,6 +1,7 @@
// utun_instance.c - Root instance implementation
#include "utun_instance.h"
#include "chat/chat_local.h"
#include "chat/chat_devgroup.h"
#include "stream/stream.h"
#include "etcp_router.h"
#include "multicast.h"
@@ -854,7 +855,8 @@ int chat_service_start(struct UTUN_INSTANCE* instance) {
snprintf(db_file, sizeof(db_file), "%s/chats.db", instance->config->global.db_path);
if (db_sync_enable(instance) < 0) return -1;
topo_group_invite_init(instance);
if (chat_core_init(instance, db_file) < 0 || chat_sync_init(instance) < 0 || stream_service_init(instance) < 0) goto fail;
if (chat_core_init(instance, db_file) < 0 || chat_sync_init(instance) < 0 ||
chat_devgroup_init(instance) < 0 || stream_service_init(instance) < 0) goto fail;
/* Имя из конфига публикуем подписанным member-блоком после восстановления Merkle. */
if (instance->config->global.chatserver_enabled) chat_core_update_my_member(instance);
instance->media_async = media_async_create();
@@ -909,6 +911,7 @@ fail:
void chat_service_stop(struct UTUN_INSTANCE* instance) {
if (!instance) return;
instance->chat_started = 0;
chat_devgroup_destroy(instance);
chat_sync_quiesce(instance);
chat_local_stop(instance);
radio_call_destroy(instance);
+9
View File
@@ -1482,3 +1482,12 @@ test_etcp_driver_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_etcp_tx_rto_SOURCES = test_etcp_tx_rto.c $(test_clock_sources)
test_etcp_tx_rto_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_etcp_tx_rto_LDFLAGS = $(test_clock_ldflags)
check_PROGRAMS += test_devgroup
test_devgroup_SOURCES = test_devgroup.c
test_devgroup_LDADD = $(LIBUTUN) $(COMMON_LIBS)
check_PROGRAMS += test_devgroup_network
test_devgroup_network_SOURCES = test_devgroup_network.c
test_devgroup_network_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_devgroup_network_LDFLAGS = -Wl,--wrap=etcp_route_send
+10 -3
View File
@@ -564,6 +564,8 @@ static void call_tick(void* arg) {
if (conn) {
etcp_fire_conn_status(conn, ETCP_CONN_STATUS_DOWN);
etcp_fire_conn_status(conn, ETCP_CONN_STATUS_UP);
/* Синтетический ETCP callback не переводит NCD через DOWN/UP: восстановить CHAT-сессию явно. */
if (topo_group_new_conn(g, conn)) { t->result = 2; break; }
}
if (ea->ended || eb->ended) { fprintf(stderr, "temporary path loss ended call\n"); t->result = 2; break; }
@@ -587,7 +589,11 @@ static void call_tick(void* arg) {
}
break;
}
case P_WAIT_MEDIA:
case P_WAIT_MEDIA: {
/* Инъекция callbacks не создаёт нового NCD UP. После штатного backoff доводим запрос до READY. */
struct TOPO_GROUP* group = topo_groups_find(A->topo_groups, strtoull(CH_ID, NULL, 10));
struct ETCP_CONN* conn = instance_find_conn(A, nb);
if (conn && !topo_group_peer_ready(group, nb)) topo_group_new_conn(group, conn);
if (g_media[IDX_B].frames > 0 && g_media[IDX_B].last_call_id == t->call_id
&& g_media[IDX_B].last_len == 16) {
if (g_media[IDX_B].frames != 1 || g_capture_timing.pts_us != sent_timing.pts_us
@@ -597,6 +603,7 @@ static void call_tick(void* arg) {
t->phase = P_BATCH_SEND;
}
break;
}
case P_BATCH_SEND: {
struct call_session *cs = call_session_find(A->call, t->call_id);
cs->audio_batch.target = 10;
@@ -1020,7 +1027,7 @@ static void call_tick(void* arg) {
case P_POLICY_WAIT:
if (t->policy_index == 4 || t->policy_index == 5 || t->policy_index == 6) {
if (!ea->ended) break;
int expected = t->policy_index == 4 ? CALL_REASON_PHONE_BUSY : CALL_REASON_DECLINE;
int expected = t->policy_index == 4 ? CALL_REASON_PHONE_BUSY : CALL_REASON_POLICY;
if (eb->incoming || ea->phone_busy || ea->ended_reason != expected) goto policy_failed;
t->phase = P_POLICY_END; break;
}
@@ -1048,7 +1055,7 @@ static void call_tick(void* arg) {
call_decline(B, t->call_id); t->phase = P_POLICY_END; break;
case P_POLICY_END:
if (!ea->ended || !eb->ended || call_session_find(A->call, t->call_id) || call_session_find(B->call, t->call_id)) break;
if (ea->ended != 1 || eb->ended != 1 || (t->policy_index == 9 && (eb->incoming || ea->ended_reason != CALL_REASON_DECLINE)))
if (ea->ended != 1 || eb->ended != 1 || (t->policy_index == 9 && (eb->incoming || ea->ended_reason != CALL_REASON_POLICY)))
goto policy_failed;
call_continue(A, t->call_id); // Поздняя команда не должна создавать новую сессию.
++t->policy_index; t->phase = P_POLICY_START; break;
+8
View File
@@ -117,6 +117,14 @@ int main(void) {
assert(!hc_response_ok(inst->topo_sqlite_db, response, 10));
unsigned old_observed = observed; chat_event_post(inst, CHAT_EVT_STATUS_REFRESH, NULL, 0); assert(observed == old_observed + 1);
hc_request_line(inst, peer, "{\"id\":11,\"cmd\":\"status\"}\n", response); assert(hc_response_ok(inst->topo_sqlite_db, response, 11));
hc_request_line(inst, peer, "{\"id\":30,\"cmd\":\"devgroup_rename\",\"name\":\"My devices\"}\n", response);
assert(hc_response_ok(inst->topo_sqlite_db, response, 30) && strstr(response, "My devices"));
hc_request_line(inst, peer, "{\"id\":31,\"cmd\":\"devgroup_status\"}\n", response);
assert(hc_response_ok(inst->topo_sqlite_db, response, 31) && strstr(response, "My devices") && !strstr(response, "pin"));
hc_request_line(inst, peer, "{\"id\":32,\"cmd\":\"devgroup_invite\",\"ch\":\"1\",\"peer\":\"invalid\"}\n", response);
assert(!hc_response_ok(inst->topo_sqlite_db, response, 32));
hc_request_line(inst, peer, "{\"id\":33,\"cmd\":\"devgroup_leave\"}\n", response);
assert(hc_response_ok(inst->topo_sqlite_db, response, 33) && !strstr(response, "My devices"));
uint64_t channel = chat_core_create_channel_auto(inst, "media"); assert(channel);
hc_request_line(inst, peer, "{\"id\":20,\"cmd\":\"server_link\"}\n", response);
assert(hc_response_ok(inst->topo_sqlite_db, response, 20));
+73
View File
@@ -0,0 +1,73 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
#include "utun_instance.h"
#include "routing_layer/topo_group.h"
#include "routing_layer/topo_node_sqlite.h"
#include "chat/chat_devgroup.h"
#include "../lib/u_async.h"
#include "../lib/mem.h"
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_DEBUG);
struct UTUN_INSTANCE inst = {0}; struct TOPO_GROUPS groups = {.instance = &inst}; inst.topo_groups = &groups;
inst.ua = uasync_create(); assert(inst.ua);
groups.node_registry = queue_new(inst.ua, 16, 0, 8, "devgroup_nodes");
groups.group_list = queue_new(inst.ua, 0, 0, 0, "devgroup_groups");
assert(groups.node_registry && groups.group_list);
assert(sc_generate_keypair(&inst.my_keys) == SC_OK);
uint8_t xpub[32];
memcpy(inst.my_ed25519_privkey, inst.my_keys.private_key, 32);
assert(!devgroup_public_keys(inst.my_ed25519_privkey, inst.my_keys.private_key, inst.my_ed25519_pubkey, xpub));
inst.node_id = sc_derive_node_id_from_pubkey(inst.my_keys.public_key); strcpy(inst.name, "device");
assert(sqlite3_open(":memory:", &inst.topo_sqlite_db) == SQLITE_OK);
assert(!topo_node_sqlite_init(inst.topo_sqlite_db)); assert(!topo_node_init_self(&inst));
assert(!chat_devgroup_rename(&inst, "Мои устройства"));
struct devgroup_record r, loaded; uint8_t ep[32], xp[32]; struct devgroup_profile p;
assert(devgroup_load_local(&inst, &r, ep, xp) == 1);
assert(memcmp(ep, xp, 32) && !devgroup_profile_read(&r, &p) && p.version == 1);
struct TOPO_NODE* node = topo_node_registry_find(&groups, inst.node_id);
uint64_t before = node->timestamp;
assert(!chat_devgroup_rename(&inst, "Мои устройства") && node->timestamp == before);
assert(!devgroup_verify(&r, inst.node_id, inst.my_keys.public_key, inst.my_ed25519_pubkey));
assert(devgroup_verify(&r, inst.node_id + 1, inst.my_keys.public_key, inst.my_ed25519_pubkey) < 0);
r.json[10] ^= 1; assert(devgroup_profile_read(&r, &p) < 0); r.json[10] ^= 1;
struct devgroup_record conflict = r; struct devgroup_profile original, changed;
assert(!devgroup_profile_read(&r, &original));
assert(!devgroup_sign(&conflict, ep, "Concurrent name", original.version, original.timestamp,
inst.node_id, inst.my_keys.public_key, inst.my_ed25519_pubkey));
assert(!devgroup_profile_read(&conflict, &changed));
assert((devgroup_profile_compare(&original, &changed) > 0) == (memcmp(original.hash, changed.hash, 32) > 0));
changed.version++; assert(devgroup_profile_compare(&changed, &original) > 0);
changed.version--; changed.timestamp++; assert(devgroup_profile_compare(&changed, &original) > 0);
assert(devgroup_decode(&conflict, (const uint8_t*)"", 192) < 0);
uint8_t wire[TOPO_NODE_WIRE_MAX_SIZE]; struct TOPO_GROUP_NODE nq = {0};
int bytes = topo_node_serialize(node, &nq, 0, 0, wire, sizeof(wire), 0); assert(bytes > 0);
struct TOPO_GROUP group = {.instance = &inst}; struct TOPO_NODE* parsed = NULL;
assert(!topo_node_deserialize(&group, wire, bytes, &parsed, NULL, NULL, NULL, NULL));
assert(!topo_node_verify(parsed) && !memcmp(&parsed->devgroup, &r, sizeof(r))); topo_node_destroy(&groups, parsed);
assert(devgroup_latest(inst.topo_sqlite_db, r.ed, &loaded, &p) == 1 && p.version == 1);
assert(sqlite3_exec(inst.topo_sqlite_db, "CREATE TRIGGER deny_devgroup BEFORE UPDATE ON nodes BEGIN SELECT RAISE(ABORT,'test'); END",
NULL, NULL, NULL) == SQLITE_OK);
assert(chat_devgroup_rename(&inst, "Rejected") < 0 && node->timestamp == before);
assert(devgroup_load_local(&inst, &loaded, ep, xp) == 1 && !strcmp(loaded.json, r.json));
assert(sqlite3_exec(inst.topo_sqlite_db, "DROP TRIGGER deny_devgroup", NULL, NULL, NULL) == SQLITE_OK);
assert(!chat_devgroup_rename(&inst, "New name"));
assert(devgroup_load_local(&inst, &loaded, ep, xp) == 1 && !devgroup_profile_read(&loaded, &p) && p.version == 2);
/* Новый runtime восстанавливает обе пары и принадлежность до первой публикации. */
topo_node_registry_unref(&groups, inst.node_id); assert(!topo_node_init_self(&inst));
node = topo_node_registry_find(&groups, inst.node_id); assert(!strcmp(node->devgroup.json, loaded.json));
uint8_t receipt[16] = {1};
assert(!chat_devgroup_import(&inst, &loaded, ep, xp, receipt, 42));
assert(chat_devgroup_receipt(&inst, receipt, 42) == 1);
assert(!chat_devgroup_leave(&inst) && !node->devgroup.json[0]);
assert(!chat_devgroup_import(&inst, &loaded, ep, xp, receipt, 42) && !node->devgroup.json[0]);
assert(devgroup_latest(inst.topo_sqlite_db, r.ed, &loaded, &p) == 0);
assert(!chat_devgroup_leave(&inst));
topo_node_registry_unref(&groups, inst.node_id); assert(!topo_node_init_self(&inst));
node = topo_node_registry_find(&groups, inst.node_id); assert(!node->devgroup.json[0]);
topo_node_registry_unref(&groups, inst.node_id); queue_free(groups.node_registry); queue_free(groups.group_list);
assert(sqlite3_close(inst.topo_sqlite_db) == SQLITE_OK); uasync_destroy(inst.ua, 0);
puts("PASS devgroup: signatures, wire, transactional projection, restart, rename, leave and late import replay"); return 0;
}
+217
View File
@@ -0,0 +1,217 @@
/* Настоящий router/UDP A ↔ B ↔ C: devgroup E2E, повтор PIN, потеря ACK, NODEINFO и выход. */
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_devgroup.h"
#include "chat/chat_member.h"
#include "routing_layer/route_crypto.h"
#include "chat/member_sync.h"
#include "chat/merkle_sync.h"
#include "chat/db_sync.h"
#include "routing_layer/topo_group.h"
#include "routing_layer/topo_node_sqlite.h"
#include "transport_layer/node_conn_direct.h"
#include "transport_layer/etcp_connections.h"
#include "transport_layer/etcp.h"
#include "../lib/platform_compat.h"
#include "../lib/socket_compat.h"
#include "../lib/debug_config.h"
#include "test_net.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static struct UASYNC* ua;
static struct UTUN_INSTANCE* nodes[4];
static char channel[21];
static void hex(const uint8_t* data, size_t len, char* out) {
for (size_t i = 0; i < len; i++) snprintf(out + i * 2, 3, "%02x", data[i]);
}
static struct UTUN_INSTANCE* create_node(unsigned port) {
struct SC_MYKEYS keys; assert(sc_generate_keypair(&keys) == SC_OK);
char private_key[65], public_key[65], config[1024]; hex(keys.private_key, 32, private_key); hex(keys.public_key, 32, public_key);
snprintf(config, sizeof(config),
"[global]\nmy_private_key=%s\nmy_public_key=%s\nkeepalive_adaptive=0\n"
"[server: udp]\naddr=127.0.0.1:%u\ntype=public\n[allowed_keys]\nallow_all=1\n"
"[ntp]\nenabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\n", private_key, public_key, port);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
assert(inst && utun_core_start(inst) == 0 && chat_service_start(inst) == 0); return inst;
}
/* Все три заранее принятых участника имеют настоящие подписи root/join. */
static void seed_members(uint8_t channel_x[32], uint8_t channel_ed[32], uint8_t owner_private[32], int count) {
for (int dst = 0; dst < count; dst++) for (int author = 0; author < count; author++) {
uint8_t body[112], join[64], tree[64];
assert(member_sync_build_join_msg(channel_x, channel_ed, nodes[author]->node_id,
nodes[author]->my_keys.public_key, 1, body, sizeof(body)) == 112);
assert(sc_ed25519_sign(nodes[author]->my_ed25519_privkey, body, sizeof(body), join) == SC_OK);
assert(member_sync_sign_pubkey(owner_private, nodes[author]->my_keys.public_key, tree) == 0);
assert(member_sync_put(nodes[dst], channel, nodes[author]->node_id, nodes[author]->my_keys.public_key,
nodes[author]->my_ed25519_pubkey, join, 1, NULL, 0, "{}", NULL, NULL, 0, 0, tree) >= 0);
}
}
/* Поднимаем транспорт только соседних пар; C получает данные A через B. */
static struct NODE_CONN_DIRECT* connect_node(int from, int to, unsigned port) {
struct TOPO_ADDR4 address = { .addr = {127, 0, 0, 1}, .port = port, .protocol = TOPO_PROTO_UDP };
struct TOPO_NODE node = { .node_id = nodes[to]->node_id, .v4_addrs = &address };
memcpy(node.public_key, nodes[to]->my_keys.public_key, 32); struct NODE_CONN_DIRECT* handle = NULL;
assert(node_conn_direct_open_node(nodes[from], node.node_id, NULL, NULL, &handle, &node, NULL) != NCD_ERR); return handle;
}
static int wait_ready(struct TOPO_PEER_REQUEST* handle) {
uint64_t deadline = get_time_tb() + 15u * 10000u;
while (get_time_tb() < deadline) {
if (topo_group_peer_phase(handle) == TOPO_PEER_READY) return 1;
uasync_poll(ua, 5);
}
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "devgroup network: group readiness timeout phase=%d", topo_group_peer_phase(handle)); return 0;
}
/* C открывает групповой запрос после входящего UP; до него адрес B ещё не получен. */
static int wait_transport(struct NODE_CONN_DIRECT* ab, struct NODE_CONN_DIRECT* bc) {
struct ETCP_CONN *a = NULL, *b = NULL;
uint64_t deadline = get_time_tb() + 15u * 10000u;
while (get_time_tb() < deadline) {
a = node_conn_direct_get_conn(ab); b = node_conn_direct_get_conn(bc);
struct ETCP_CONN* c = instance_find_conn(nodes[2], nodes[1]->node_id);
if (a && b && c && a->initialized && b->initialized && c->initialized
&& a->links_up && b->links_up && c->links_up) return 1;
uasync_poll(ua, 5);
}
DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "devgroup network: bootstrap timeout ab=%p bc=%p", (void*)a, (void*)b); return 0;
}
static struct ll_entry* held_keys;
static int hold_keys;
static int drop_pin_result = 1, drop_key_ack = 1, duplicate_pin, key_sends;
int __real_etcp_route_send(struct UTUN_INSTANCE*, uint64_t, uint64_t, struct ll_entry*, int, int);
int __wrap_etcp_route_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, struct ll_entry* entry, int ttl, int crypto) {
if (entry->len >= 18 && entry->dgram[0] == ETCP_RT_ID_DEVGROUP) {
assert(crypto == (ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT));
if (entry->dgram[1] == 2) duplicate_pin++;
if (entry->dgram[1] == 3) {
key_sends++;
if (hold_keys && !held_keys) { held_keys = entry; return 0; }
}
if (entry->dgram[1] == 4 && entry->len == 20 &&
((entry->dgram[19] == 1 && drop_pin_result-- > 0) || (entry->dgram[19] == 0 && drop_key_ack-- > 0))) {
queue_dgram_free(entry); queue_entry_free(entry); return 0;
}
}
return __real_etcp_route_send(inst, group, peer, entry, ttl, crypto);
}
static int state(int node, const char* id) {
char* json = chat_devgroup_status(nodes[node]); assert(json);
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(nodes[node]->topo_sqlite_db,
"SELECT json_extract(value,'$.state') FROM json_each(?,'$.invitations') WHERE json_extract(value,'$.id')=?", -1, &st, NULL) == SQLITE_OK);
sqlite3_bind_text(st, 1, json, -1, SQLITE_STATIC); sqlite3_bind_text(st, 2, id, -1, SQLITE_STATIC);
int result = sqlite3_step(st) == SQLITE_ROW ? sqlite3_column_int(st, 0) : 0;
sqlite3_finalize(st); u_free(json); return result;
}
static void wait_state(int node, const char* id, int expected) {
uint64_t until = get_time_tb() + 150000;
while (get_time_tb() < until) { if (state(node, id) == expected) return; uasync_poll(ua, 5); }
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup test: timeout node=%d state=%d expected=%d", node, state(node, id), expected); abort();
}
static void wait_profile(int node, uint64_t peer, const char* name) {
uint64_t until = get_time_tb() + 150000;
while (get_time_tb() < until) {
uint8_t id[32]; char actual[64];
int rc = devgroup_member(nodes[node]->topo_sqlite_db, peer, id, actual);
assert(rc >= 0); if (!strcmp(actual, name)) return; uasync_poll(ua, 5);
}
DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup test: profile timeout node=%d peer=%016llx", node, (unsigned long long)peer); abort();
}
int main(void) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
debug_set_category_level(DEBUG_CATEGORY_CHAT_SYNC, DEBUG_LEVEL_DEBUG);
debug_set_category_level(DEBUG_CATEGORY_MEMBER_SYNC, DEBUG_LEVEL_INFO);
ua = uasync_create(); assert(ua); unsigned ports[4];
for (int i = 0; i < 3; i++) {
nodes[i] = create_node(0);
ports[i] = ss_get_port(&test_etcp_bound_socket(nodes[i], "udp")->local_addr);
}
chat_core_create_channel_auto(nodes[0], "Devices");
sqlite3_stmt* st = NULL;
assert(sqlite3_prepare_v2(nodes[0]->topo_sqlite_db, "SELECT channel_id FROM channels", -1, &st, NULL) == SQLITE_OK);
assert(sqlite3_step(st) == SQLITE_ROW); snprintf(channel, sizeof(channel), "%s", sqlite3_column_text(st, 0)); sqlite3_finalize(st);
uint8_t x[32], ed[32], signature[64], owner_private[32];
assert(topo_node_sqlite_channel_get(nodes[0]->topo_sqlite_db, channel, NULL, 0, NULL, x, ed, signature) == 0);
assert(topo_node_sqlite_channel_get_priv(nodes[0]->topo_sqlite_db, channel, owner_private) == 0);
for (int i = 1; i < 3; i++) {
assert(topo_node_sqlite_channel_put(nodes[i]->topo_sqlite_db, channel, "Devices", nodes[0]->node_id,
x, NULL, ed, NULL, signature) == 0); chat_core_ensure_channel_ready(nodes[i], channel);
}
seed_members(x, ed, owner_private, 3);
struct NODE_CONN_DIRECT* ab = connect_node(0, 1, ports[1]), *bc = connect_node(1, 2, ports[2]);
assert(wait_transport(ab, bc));
struct TOPO_PEER_REQUEST *groups[5] = {0}; uint64_t gid = strtoull(channel, NULL, 10);
assert(topo_group_peer_open(topo_groups_find(nodes[0]->topo_groups, gid), nodes[1]->node_id, &groups[0]) == 0);
assert(topo_group_peer_open(topo_groups_find(nodes[1]->topo_groups, gid), nodes[2]->node_id, &groups[1]) == 0);
assert(topo_group_peer_open(topo_groups_find(nodes[2]->topo_groups, gid), nodes[1]->node_id, &groups[2]) == 0);
for (int i = 0; i < 3; i++) assert(wait_ready(groups[i]));
/* Ждём снимок C через B до выбора C как известного участника. */
uint64_t until = get_time_tb() + 150000;
while (!topo_node_registry_find(nodes[0]->topo_groups, nodes[2]->node_id) && get_time_tb() < until) uasync_poll(ua, 5);
char id[33], pin[5], wrong[5];
struct devgroup_record before; uint8_t before_ed[32], before_x[32];
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
assert(devgroup_load_local(nodes[0], &before, before_ed, before_x) == 0);
strcpy(wrong, pin); wrong[0] = pin[0] == '0' ? '1' : '0';
assert(!chat_devgroup_accept(nodes[2], id, wrong)); wait_state(2, id, 1);
assert(duplicate_pin >= 2 && state(0, id) == 1);
assert(devgroup_load_local(nodes[0], &before, before_ed, before_x) == 0);
assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(2, id, 4); wait_state(0, id, 4);
assert(key_sends >= 2);
struct devgroup_record r[3]; uint8_t ep[3][32], xp[3][32];
assert(devgroup_load_local(nodes[0], &r[0], ep[0], xp[0]) == 1);
assert(devgroup_load_local(nodes[1], &r[1], ep[1], xp[1]) == 0);
assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 1);
assert(!memcmp(ep[0], ep[2], 32) && !memcmp(xp[0], xp[2], 32));
assert(!chat_devgroup_rename(nodes[2], "New device group"));
wait_profile(0, nodes[2]->node_id, "New device group");
until = get_time_tb() + 150000;
do {
assert(devgroup_load_local(nodes[0], &r[0], ep[0], xp[0]) == 1);
if (!strcmp(r[0].json, topo_node_registry_find(nodes[2]->topo_groups, nodes[2]->node_id)->devgroup.json)) break;
uasync_poll(ua, 5);
} while (get_time_tb() < until);
assert(!strcmp(r[0].json, topo_node_registry_find(nodes[2]->topo_groups, nodes[2]->node_id)->devgroup.json));
wait_profile(1, nodes[0]->node_id, "New device group");
assert(!chat_devgroup_leave(nodes[2])); wait_profile(0, nodes[2]->node_id, "");
/* После успешного импорта новый ACK может потеряться прямо перед выходом. */
drop_key_ack = 1;
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(2, id, 4);
assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 1);
assert(!memcmp(ep[0], ep[2], 32) && !memcmp(xp[0], xp[2], 32));
assert(!chat_devgroup_leave(nodes[2])); wait_state(0, id, 4);
assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 0);
/* Три разные неверные попытки завершают операцию. */
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
strcpy(wrong, pin); wrong[0] = pin[0] == '0' ? '1' : '0';
for (int i = 0; i < 3; i++) { assert(!chat_devgroup_accept(nodes[2], id, wrong)); wait_state(2, id, i == 2 ? 5 : 1); }
assert(state(0, id) == 5 && chat_devgroup_accept(nodes[2], id, pin) < 0);
/* Выход до первого commit тоже отменяет согласие: поздние ключи не должны присоединить C. */
hold_keys = 1;
assert(!chat_devgroup_invite(nodes[0], gid, nodes[2]->node_id, id, pin)); wait_state(2, id, 1);
assert(!chat_devgroup_accept(nodes[2], id, pin)); wait_state(0, id, 3); assert(held_keys);
assert(!chat_devgroup_leave(nodes[2]) && state(2, id) == 5); hold_keys = 0;
assert(!__real_etcp_route_send(nodes[0], gid, nodes[2]->node_id, held_keys, 0, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT));
held_keys = NULL;
until = get_time_tb() + 5000; while (get_time_tb() < until) uasync_poll(ua, 5);
assert(devgroup_load_local(nodes[2], &r[2], ep[2], xp[2]) == 0);
assert(!chat_devgroup_leave(nodes[0]) && state(0, id) == 7);
for (int i = 0; i < 3; i++) topo_group_peer_close(groups[i]);
node_conn_direct_close(ab); node_conn_direct_close(bc);
for (int i = 0; i < 3; i++) utun_instance_destroy(nodes[i]);
uasync_poll(ua, 0); uasync_destroy(ua, 0);
puts("PASS devgroup: encrypted signed relay, PIN retries, ACK loss, NODEINFO convergence, leave/replay and three attempts");
return 0;
}
+28
View File
@@ -140,6 +140,33 @@ static void test_verified_write(void) {
DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] verified node writes: insert/update, rejected writes, prepare failure and stored state");
}
static void test_snapshot_format_upgrade(struct UTUN_INSTANCE* inst) {
sqlite3* saved = inst->topo_sqlite_db;
sqlite3* db = NULL; assert(sqlite3_open(":memory:", &db) == SQLITE_OK);
inst->topo_sqlite_db = db;
assert(sqlite3_exec(db, "CREATE TABLE node_snapshots(node_id INTEGER PRIMARY KEY,timestamp INTEGER NOT NULL,record BLOB NOT NULL);"
"INSERT INTO node_snapshots VALUES(7,9000000,X'0102030405')", NULL, NULL, NULL) == SQLITE_OK);
assert(!topo_node_sqlite_init(db));
uint64_t timestamp = 0;
assert(!topo_node_sqlite_snapshot_timestamp(db, 7, &timestamp) && timestamp == 9000000);
assert(!topo_node_sqlite_channel_put(db, "123", "upgrade", 7, inst->my_keys.public_key, NULL, inst->my_ed25519_pubkey, NULL,
(const uint8_t[64]){0}));
assert(!topo_node_sqlite_member_placeholder_put(db, "123", 7, inst->my_keys.public_key, inst->my_ed25519_pubkey));
assert(sqlite3_exec(db, "UPDATE peers_123 SET source=0", NULL, NULL, NULL) == SQLITE_OK);
struct topo_connect_peer* peers = NULL; size_t count = 0;
assert(!topo_node_sqlite_get_connect_peers(db, "123", &peers, &count) && count == 1 && peers[0].client_type == UINT8_MAX);
u_free(peers);
struct TOPO_NODE* self = record(inst, 123, "format fixture", 1); assert(self);
assert(!topo_node_sqlite_snapshot_put(db, self));
assert(sqlite3_exec(db, "UPDATE node_snapshots SET record=X'' WHERE format=1", NULL, NULL, NULL) == SQLITE_OK);
assert(!topo_node_sqlite_snapshot_put(db, self)); // Та же версия снова заполняет инвалидированный кеш.
assert(!topo_node_sqlite_init(db));
struct TOPO_NODE* loaded = topo_node_sqlite_snapshot_load(db, inst->topo_groups, inst->node_id);
assert(loaded && loaded->timestamp == self->timestamp); topo_node_destroy(inst->topo_groups, loaded);
topo_node_destroy(inst->topo_groups, self); sqlite3_close(db); inst->topo_sqlite_db = saved;
DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] NODEINFO format upgrade: old blobs ignored, candidate scan works, timestamps and new snapshots survive restart");
}
/* Новый wire и SQL сохраняют адреса, протоколы, классификацию и REALITY; смена формата оставляет bootstrap. */
static void test_compact_addresses(struct UTUN_INSTANCE *inst) {
test_zero_port_schema();
@@ -358,6 +385,7 @@ int main(void) {
assert(sqlite3_open(":memory:", &inst.topo_sqlite_db) == SQLITE_OK);
assert(topo_node_sqlite_init(inst.topo_sqlite_db) == 0);
test_verified_write();
test_snapshot_format_upgrade(&inst);
test_compact_addresses(&inst);
test_local_group_update(&inst);
+110 -9
View File
@@ -1,3 +1,6 @@
#include "../src/chat/chat_devgroup.h"
#include "../src/call/call_priv.h"
#include "../src/call/call_proto.h"
// test_radio.c — интеграционный тест рации (PTT мультикаст голоса).
//
// Однопоточный: 3 узла (A, B, C) в одном процессе, один UASYNC, без fork.
@@ -132,6 +135,24 @@ static void call_packet_loss(struct ETCP_CONN* conn, struct ll_entry* entry) {
g_call_recv[idx](conn, entry);
}
static void (*device_signal_recv[3])(struct ETCP_CONN*, struct ll_entry*);
static int device_drop_select = 1, device_drop_selected = 1;
static void device_signal_loss(struct ETCP_CONN* conn, struct ll_entry* entry) {
int idx = -1;
for (int i = 0; i < 3; ++i) if (conn->instance->node_id == g_sh.nid[i]) idx = i;
if (idx < 0) abort();
if (entry->len >= ROUTER_SVC_PAYLOAD_OFF + 9) {
const uint8_t* p = entry->dgram + ROUTER_SVC_PAYLOAD_OFF;
uint64_t id; memcpy(&id, p + 1, 8);
if (id == 20001 && ((p[0] == CALL_SUBCMD_SELECT && device_drop_select-- > 0) ||
(p[0] == CALL_SUBCMD_SELECTED && device_drop_selected-- > 0))) {
DEBUG_INFO(DEBUG_CATEGORY_CALL, "test drops selection id=%llx command=%u", (unsigned long long)id, p[0]);
queue_dgram_free(entry); queue_entry_free(entry); return;
}
}
device_signal_recv[idx](conn, entry);
}
static void radio_action(struct UTUN_INSTANCE* inst, uint64_t id, int action, uint64_t peer) {
struct radio_call_request req = {inst, id, strtoull(CH_ID, NULL, 10), peer, action};
radio_call_execute(&req);
@@ -172,6 +193,10 @@ static void member_event(struct UTUN_INSTANCE* inst, int type, const uint8_t* da
++g_calls[id - 100][idx].incoming;
if (g_call_ready) radio_action(inst, id, RADIO_CALL_READY, 0);
}
if (data[24] == RADIO_CALL_SELECTED && id != 118) {
radio_set_active(inst, group, 1);
radio_action(inst, id, RADIO_CALL_ACCEPT, 0);
}
if (data[24] == RADIO_CALL_FINISHED) {
++g_calls[id - 100][idx].finished; g_calls[id - 100][idx].finished_tb = get_time_tb();
if (data[25] == RADIO_CALL_ACCEPTED) radio_set_active(inst, group, 1);
@@ -543,7 +568,7 @@ static void rad_tick(void* arg) {
case 2:
if (g_calls[1][IDX_A].state == RADIO_CALL_RINGING) {
radio_set_active(C, gid, 1);
radio_action(C, 101, RADIO_CALL_ACCEPT, 0); ++t->call_step;
radio_action(C, 101, RADIO_CALL_ANSWER, 0); ++t->call_step;
}
break;
case 3:
@@ -655,7 +680,7 @@ static void rad_tick(void* arg) {
radio_action(A, 112, RADIO_CALL_START, nc); ++t->call_step; break;
case 19:
if (!g_calls[12][IDX_A].finished) break;
if (g_calls[12][IDX_A].reason != RADIO_CALL_DECLINED || g_calls[12][IDX_C].incoming) goto phone_failed;
if (g_calls[12][IDX_A].reason != RADIO_CALL_POLICY || g_calls[12][IDX_C].incoming) goto phone_failed;
call_set_phone_state(C, 2, CALL_POLICY_WARN_PHONE);
t->call_started = now;
radio_action(A, 113, RADIO_CALL_START, nc); ++t->call_step; break;
@@ -666,17 +691,93 @@ static void rad_tick(void* arg) {
call_set_phone_state(C, 0, 0);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "phone policy passed: hidden/published BGP, warn, override with loss, reject, independent blocks, 60s choice");
radio_set_active(C, gid, 1);
call_set_phone_state(C, 2, CALL_POLICY_BLOCK_RADIO | CALL_POLICY_WARN_PHONE | CALL_POLICY_REJECT_PHONE);
call_set_phone_state(C, 0, 0);
g_drop_result = 1; g_drop_ack = 1;
radio_action(A, 114, RADIO_CALL_START, nc); ++t->call_step; break;
case 21:
if (g_calls[14][IDX_A].state != RADIO_CALL_RINGING) break;
if (g_calls[14][IDX_C].incoming != 1 || !radio_is_active(C, gid)) goto phone_failed;
radio_action(C, 114, RADIO_CALL_ANSWER, 0); ++t->call_step; break;
case 22:
if (!g_calls[14][IDX_A].finished) break;
if (g_calls[14][IDX_A].reason != RADIO_CALL_ALREADY_ACTIVE || g_calls[14][IDX_C].incoming ||
!radio_is_active(C, gid) || radio_is_active(A, gid)) goto phone_failed;
radio_action(A, 114, RADIO_CALL_CONTINUE, 0);
radio_set_active(C, gid, 0);
call_set_phone_state(C, 0, 0);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "already-active reply delivered with RESULT/ACK loss, no incoming event and no caller radio activation");
if (g_calls[14][IDX_A].reason != RADIO_CALL_ACCEPTED || !radio_is_active(C, gid)) goto phone_failed;
radio_set_active(A, gid, 0);
/* Настоящий импорт сохраняет принадлежность при последующих NODEINFO. */
{
struct devgroup_record record; uint8_t ep[32], xp[32], receipt[16] = {1};
if (chat_devgroup_rename(B, "Devices") || devgroup_load_local(B, &record, ep, xp) != 1 ||
chat_devgroup_import(C, &record, ep, xp, receipt, nb)) goto phone_failed;
OPENSSL_cleanse(ep, sizeof(ep)); OPENSSL_cleanse(xp, sizeof(xp));
}
t->call_step = 35; break;
case 35: {
uint64_t peers[3];
if (call_resolve_targets(A, gid, nc, 1, peers, 3) != 2) break;
struct radio_call_request req = {A, 115, gid, nc, RADIO_CALL_START, 1};
radio_call_execute(&req); t->call_step = 23; break;
}
case 23:
if (!g_calls[15][IDX_B].incoming || !g_calls[15][IDX_C].incoming) break;
radio_action(B, 115, RADIO_CALL_ANSWER, 0); radio_action(C, 115, RADIO_CALL_ANSWER, 0);
++t->call_step; break;
case 24:
if (!g_calls[15][IDX_A].finished || !g_calls[15][IDX_B].finished || !g_calls[15][IDX_C].finished) break;
if (g_calls[15][IDX_A].reason != RADIO_CALL_ACCEPTED ||
(g_calls[15][IDX_B].reason == RADIO_CALL_ACCEPTED) + (g_calls[15][IDX_C].reason == RADIO_CALL_ACCEPTED) != 1 ||
!radio_is_active(C, gid)) goto phone_failed;
{
struct radio_call_request req = {A, 116, gid, nc, RADIO_CALL_START, 1};
radio_call_execute(&req);
}
++t->call_step; break;
case 25:
if (!g_calls[16][IDX_B].incoming || !g_calls[16][IDX_C].incoming) break;
radio_action(C, 116, RADIO_CALL_DECLINE, 0); ++t->call_step; break;
case 26:
if (!g_calls[16][IDX_A].finished || !g_calls[16][IDX_B].finished || !g_calls[16][IDX_C].finished) break;
if (g_calls[16][IDX_A].reason != RADIO_CALL_DECLINED || g_calls[16][IDX_B].reason != RADIO_CALL_CANCELLED ||
!radio_is_active(C, gid)) goto phone_failed;
for (int i = 0; i < 3; ++i) {
device_signal_recv[i] = t->inst[i]->router_bindings.callbacks[ETCP_RT_ID_CALL];
t->inst[i]->router_bindings.callbacks[ETCP_RT_ID_CALL] = device_signal_loss;
}
call_start_devices(A, CH_ID, nc, 20001); ++t->call_step; break;
case 27: {
struct call_session* b = call_session_find(B->call, 20001), *c = call_session_find(C->call, 20001);
if (!b || !c) break;
if (b->state != CALL_ST_RINGING || c->state != CALL_ST_RINGING) goto phone_failed;
call_accept(B, 20001); call_accept(C, 20001);
if (b->state != CALL_ST_ANSWERING || c->state != CALL_ST_ANSWERING) goto phone_failed;
++t->call_step; break;
}
case 28: {
struct call_session* a = call_session_find(A->call, 20001);
if (!a || a->state != CALL_ST_ACTIVE) break;
struct call_session* b = call_session_find(B->call, 20001), *c = call_session_find(C->call, 20001);
if ((b && b->state == CALL_ST_ACTIVE) + (c && c->state == CALL_ST_ACTIVE) != 1 || !a->selected) goto phone_failed;
if (device_drop_select > 0 || device_drop_selected > 0) goto phone_failed;
call_hangup(A, 20001); ++t->call_step; break;
}
case 29:
if (call_busy(A) || call_busy(B) || call_busy(C)) break;
call_start_devices(A, CH_ID, nc, 20002); ++t->call_step; break;
case 30:
if (!call_session_find(B->call, 20002) || !call_session_find(C->call, 20002)) break;
call_decline(C, 20002); ++t->call_step; break;
case 31:
if (call_busy(A) || call_busy(B) || call_busy(C)) break;
radio_action(A, 118, RADIO_CALL_START, nc); ++t->call_step; break;
case 32:
if (g_calls[18][IDX_A].state != RADIO_CALL_RINGING) break;
radio_action(C, 118, RADIO_CALL_ANSWER, 0); ++t->call_step; break;
case 33:
if (g_calls[18][IDX_C].state != RADIO_CALL_SELECTED) break;
radio_action(A, 118, RADIO_CALL_CANCEL, 0); ++t->call_step; break;
case 34:
if (!g_calls[18][IDX_C].finished) break;
radio_action(C, 118, RADIO_CALL_ACCEPT, 0); // Запоздалое завершение GUI не оживляет приглашение.
if (!radio_is_active(C, gid) || radio_call_busy(A) || radio_call_busy(C)) goto phone_failed;
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "device calls passed: listening ring, simultaneous answers, group decline, cancel during selection");
t->phase = P_RADIO_ON; break;
phone_failed:
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "phone policy failed step=%d force_drops_left=%d incoming108=%d state108=%d reason108=%d",
+29 -2
View File
@@ -136,9 +136,10 @@ static void fixture_packet(struct wire_fixture *f, uint8_t flags, uint8_t hops)
a.addr[15] = i + 1; memcpy(p, &a, sizeof(a)); p += sizeof(a);
}
for (unsigned i = 0; i < 2; i++) {
struct TOPOMSG_REALITY_SOCK r = {.socket_id = i};
memcpy(r.server_name, i ? "second.test" : "first.test", i ? 12 : 11);
struct TOPOMSG_REALITY_SOCK r = {.socket_id = i, .server_name_len = i ? 11 : 10};
memcpy(p, &r, sizeof(r)); p += sizeof(r);
memcpy(p, i ? "second.test" : "first.test", r.server_name_len); p += r.server_name_len;
h->reality_size += sizeof(r) + r.server_name_len;
}
f->subnet4_offset = p - f->wire;
if (flags & TOPO_FLAG_SEND_SUBNETS) for (unsigned i = 0; i < 2; i++) {
@@ -200,9 +201,34 @@ static void test_invalid_packets(struct wire_fixture *f) {
}
struct TOPOMSG_SOCKMETA4 *meta = (struct TOPOMSG_SOCKMETA4 *)(f->wire + TOPOMSG_NODE_HDR_SIZE + 4);
meta[1].mapping = UINT8_MAX; expect_rejected(f, f->length, 1);
fixture_packet(f, TOPO_FLAG_SEND_SUBNETS, 2);
size_t reality = f->subnet4_offset - h->reality_size;
struct TOPOMSG_REALITY_SOCK* r = (struct TOPOMSG_REALITY_SOCK*)(f->wire + reality);
r->server_name_len = 255; expect_rejected(f, f->length, 1);
fixture_packet(f, TOPO_FLAG_SEND_SUBNETS, 2);
f->wire[reality + sizeof(*r)] = 0; expect_rejected(f, f->length, 1);
fixture_packet(f, TOPO_FLAG_SEND_SUBNETS, 2);
h->local_reality_socks = 0; expect_rejected(f, f->length, 1);
fixture_packet(f, TOPO_FLAG_SEND_SUBNETS, 2);
assert(h->reality_size == 2 * sizeof(*r) + 21); // Имена10+11, без прежних256 байт на каждое.
DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] NODEINFO malformed counters, all truncations, tail, prefixes and partial metadata");
}
static void test_compact_reality(struct wire_fixture* f) {
fixture_packet(f, 0, 0);
struct TOPO_NODE *node = NULL, *copy = NULL;
assert(!topo_node_deserialize(&f->group, f->wire, f->length, &node, NULL, NULL, NULL, NULL));
memset(node->reality_socks->server_name, 'a', 255); node->reality_socks->server_name[255] = 0;
struct TOPO_GROUP_NODE nq = {0}; uint8_t wire[2048];
int n = topo_node_serialize(node, &nq, 0, 0, wire, sizeof(wire), 0);
assert(n == (int)f->length + 245); // Имя выросло с10 до255, прочее неизменно.
assert(!topo_node_deserialize(&f->group, wire, n, &copy, NULL, NULL, NULL, NULL));
assert(strlen(copy->reality_socks->server_name) == 255);
assert(!strcmp(copy->reality_socks->next->server_name, "second.test"));
topo_node_destroy(&f->groups, node); topo_node_destroy(&f->groups, copy);
DEBUG_INFO(DEBUG_CATEGORY_BGP, "[PASS] compact REALITY: exact size and255-byte SNI roundtrip");
}
static void test_optional_outputs(struct wire_fixture *f) {
for (unsigned with_subnets = 0; with_subnets < 2; with_subnets++) for (unsigned with_hops = 0; with_hops < 2; with_hops++) {
fixture_packet(f, with_subnets ? TOPO_FLAG_SEND_SUBNETS : 0, with_hops ? 2 : 0);
@@ -501,6 +527,7 @@ int main(void) {
struct wire_fixture fixture;
fixture_init(&fixture);
test_invalid_packets(&fixture);
test_compact_reality(&fixture);
test_optional_outputs(&fixture);
test_encoder(&fixture);
test_empty_and_arguments(&fixture);
+13
View File
@@ -247,6 +247,19 @@ def main():
try:
if cmd == "ping": cmd_ping()
elif cmd in ("poll", "vote", "react", "feedback", "reactions"): cmd_interaction(cmd, args[1:])
elif cmd == "devgroup":
parser = argparse.ArgumentParser(prog="chatcli devgroup")
sub = parser.add_subparsers(dest="action", required=True)
sub.add_parser("status"); sub.add_parser("leave")
rename = sub.add_parser("rename"); rename.add_argument("name")
invite = sub.add_parser("invite"); invite.add_argument("ch"); invite.add_argument("peer")
accept = sub.add_parser("accept"); accept.add_argument("invite")
opts = vars(parser.parse_args(args[1:])); action = opts.pop("action")
if action == "accept":
import getpass
opts["pin"] = getpass.getpass("PIN from the inviting device: ")
if action == "invite": opts["peer"] = f"{int(opts['peer'], 16):016x}"
_req("devgroup_" + action, **opts)
elif cmd == "status": cmd_status()
elif cmd == "channels": cmd_channels()
elif cmd == "members": cmd_members(*args[1:2] if len(args) > 1 else (_die("usage: members <ch_id>"),))
+11
View File
@@ -142,3 +142,14 @@ listen
---------------------
chatcli --host 192.168.1.1 --port 8888 channels
CHATCLI_HOST=10.0.0.1 CHATCLI_PORT=7777 chatcli status
Группа устройств (devgroup):
devgroup status своя группа и состояния приглашений
devgroup rename "Имя" создать группу либо переименовать свою
devgroup invite <ch_id> <node_hex> пригласить известное устройство; ответ содержит PIN
devgroup accept <invite_id> ввод PIN с экрана приглашающего устройства (без argv)
devgroup leave выйти, удалить локальные ключи и отменить ожидания
Headless JSON: cmd=devgroup_status/rename/invite/accept/leave; параметры name, ch+peer,
invite+pin соответственно. peer — 16 строчных hex-цифр, invite — 32. PIN не выводится в status.
Состояния:1 ждёт PIN,2 проверяет,3 передаёт ключи,4 импорт подтверждён,5 отказ,6 истёк,7 результат неизвестен.
Остановка сервиса отменяет ожидания; сохранённые ключи и квитанции импорта переживают перезапуск.
@@ -51,9 +51,10 @@ object AppEventHandler : ChatEventCallback {
if (type == 13 && !NativeLib.isCoreStopping())
deliver("service stop") { com.utun.chat.headless.HeadlessService.onCoreStopped() }
if (type == 12 || type == 13 || type == 65) deliver("radio call") { com.utun.chat.data.RadioCallController.onEvent(type, data) }
if (type == 12 || type == 13 || type == 70 || type == 71) deliver("devgroup") { com.utun.chat.data.DevgroupRepository.onEvent(type, data) }
if (type == 13 || type in 61..64) deliver("discovery") { com.utun.chat.data.LocalDiscovery.onEvent(type, data) }
if (type == 13 || type in 49..50) deliver("member files") { ChatApplication.instance.memberFiles.onEvent(type, data) }
if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31, AvatarProtocol.GROUP_SNAPSHOT_EVENT) || type in 49..52) {
if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31, 70, AvatarProtocol.GROUP_SNAPSHOT_EVENT) || type in 49..52) {
deliver("members") { ChatApplication.instance.members.onEvent(type, data) }
deliver("avatars") { ChatApplication.instance.avatars.onEvent(type, data) }
}
@@ -266,7 +266,7 @@ class MainActivity : AppCompatActivity() {
if (RadioCallController.activation.value?.id != activation.id) return@LaunchedEffect
if (channels == null) { RadioCallController.activated(activation.id, false); return@LaunchedEffect }
screen = Screen.Chat
vm.enableRadioForCall(activation.channelId) { success -> RadioCallController.activated(activation.id, success) }
vm.enableRadioForCall(activation.channelId, activation.id) { success -> RadioCallController.activated(activation.id, success) }
}
BackHandler(radioCall != null && radioCall?.phase != RadioCallPhase.ENDED) {
if (radioCall?.phase != RadioCallPhase.PREPARING) RadioCallController.end()
@@ -639,6 +639,7 @@ class MainActivity : AppCompatActivity() {
dismissButton = { TextButton(onClick = CallController::cancelOutgoing) { Text(stringResource(R.string.cancel)) } }
)
}
com.utun.chat.ui.screens.DevicePairingDialogs()
val localInvitations by com.utun.chat.data.LocalDiscovery.invitations.collectAsState()
localInvitations.firstOrNull()?.let { invitation ->
AlertDialog(
@@ -26,7 +26,7 @@ import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
data class OutgoingCallRequest(val channelId: String, val peerNodeId: Long, val peerName: String)
data class OutgoingCallRequest(val channelId: String, val peerNodeId: Long, val peerName: String, val devices: Boolean = false)
/**
* Единственный владелец звонковой сессии в процессе приложения. Все операции — main thread.
@@ -72,7 +72,7 @@ object CallController : TelecomCallManager.Listener {
fun confirmOutgoing() {
val request = mutableOutgoing.value ?: return
mutableOutgoing.value = null
start(request.channelId, request.peerNodeId, request.peerName, mediaConfirmed = true)
start(request.channelId, request.peerNodeId, request.peerName, mediaConfirmed = true, devices = request.devices)
}
private val _video = MutableStateFlow<CallVideoEngine?>(null)
val video: StateFlow<CallVideoEngine?> = _video
@@ -158,7 +158,7 @@ object CallController : TelecomCallManager.Listener {
CoreActivity.call(_state.value?.ongoing == true)
}
fun start(channelId: String, peerNodeId: Long, peerName: String, mediaConfirmed: Boolean = false) {
fun start(channelId: String, peerNodeId: Long, peerName: String, mediaConfirmed: Boolean = false, devices: Boolean = false) {
checkMain()
if (RadioCallController.busy()) {
LogManager.addLog("WARN", "Call", "start rejected: pending radio invitation")
@@ -177,19 +177,19 @@ object CallController : TelecomCallManager.Listener {
return
}
if (!mediaConfirmed && hasMedia()) {
mutableOutgoing.value = OutgoingCallRequest(channelId, peerNodeId, peerName)
mutableOutgoing.value = OutgoingCallRequest(channelId, peerNodeId, peerName, devices)
LogManager.addLog("INFO", "Call", "outgoing requires media confirmation peer=$peerNodeId")
return
}
if (!MicrophonePermission.granted()) {
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName, mediaConfirmed) }
MicrophonePermission.request("Outgoing call") { start(channelId, peerNodeId, peerName, mediaConfirmed, devices) }
return
}
var id = java.security.SecureRandom().nextLong()
if (id == 0L) id = 1
mutableOutgoing.value = null
_state.value = CallState(id, channelId, peerNodeId, peerName.ifEmpty { "0x%016x".format(peerNodeId) },
CallPhase.OUTGOING, preparing = true)
CallPhase.OUTGOING, preparing = true, devices = devices)
syncCoreActivity()
requestService(id)
if (_state.value?.callId == id && _state.value?.ongoing == true) prepareMedia(id)
@@ -283,7 +283,7 @@ object CallController : TelecomCallManager.Listener {
if (service == null || !st.ongoing || st.preparing || signallingStarted) return
val id = st.callId
if (st.phase == CallPhase.OUTGOING) {
if (!NativeLib.callStart(st.channelId, st.peerNodeId, id)) {
if (!NativeLib.callStart(st.channelId, st.peerNodeId, id, st.devices)) {
LogManager.addLog("ERROR", "Call", "start queue rejected id=$id; releasing reserved service")
finish(id, 7); return
}
@@ -540,7 +540,7 @@ object CallController : TelecomCallManager.Listener {
return
}
if (data == null || data.size < 8 || ((type == 37 || type == 38 || type == 40 || type == 42) && data.size < 9) ||
(type == 39 && data.size != 24) ||
(type == 36 && data.size != 17) || (type == 39 && data.size != 24) ||
(type == 44 && (data.size != 17 || (data[8].toInt() and 0xFF) > 3))) { badEvent(type, data); return }
val id = ByteBuffer.wrap(data, 0, 8).order(ByteOrder.LITTLE_ENDIAN).long
if (type in listOf(37, 38, 40)) pendingEnds.value = pendingEnds.value - id
@@ -576,7 +576,11 @@ object CallController : TelecomCallManager.Listener {
}
accepting = false
_video.value = owner.video
_state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0, startedAtElapsedRealtime = SystemClock.elapsedRealtime())
val peer = ByteBuffer.wrap(data, 9, 8).order(ByteOrder.LITTLE_ENDIAN).long
val name = if (peer == st.peerNodeId && !st.devices) st.peerName else ChatRepository().getMembers(st.channelId)
.firstOrNull { it.nodeId == peer }?.name?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(peer)
_state.value = st.copy(phase = CallPhase.ACTIVE, peerNodeId = peer, peerName = name,
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)
@@ -39,6 +39,7 @@ data class CallState(
val startedAtElapsedRealtime: Long = 0,
val connectionType: CallConnectionType = CallConnectionType.NONE,
val connectionNextHop: Long = 0,
val devices: Boolean = false, // Вызов devgroup указанного peer.
val preparing: Boolean = false // Освобождаем рацию/трансляцию до сигналинга и запуска звонкового аудио.
) {
val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED
@@ -46,7 +46,8 @@ data class ChatMember(val nodeId: Long, val name: String, val online: Boolean =
val directPresence: Boolean = false, val indirectPresence: Boolean = false,
val bgpPresence: Boolean = false, val bgpUp: Boolean = false, val bgpRtt: Int = 0,
val bgpNodes: Int = 0, val connecting: Boolean = false,
val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false) {
val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false,
val devgroup: String = "", val devgroupName: String = "") {
val flags: Int get() = (if (isSuper) 1 else 0) or (if (isAdmin) 2 else 0) or (if (isModer) 4 else 0)
}
@@ -161,14 +162,15 @@ fun parseConnList(data: ByteArray): List<ConnInfo> {
}
/* Размер общего снимка из chat_member.h; одинаковый для desktop и Android. */
const val MEMBER_WIRE_SIZE = 164
const val MEMBER_WIRE_SIZE = 260
data class MemberWire(
val nodeId: Long, val online: Int, val isSelf: Boolean,
val flags: Int, val rtt: Int, val natType: Int,
val storage: Boolean, val name: String,
val avatarServer: Long = 0, val avatarFile: String = "", val storageRole: Boolean = false,
val verified: Boolean = false, val nodeType: Int = 0,
val presence: Int = 0, val up: Int = 0, val intermediaries: Int = 0
val presence: Int = 0, val up: Int = 0, val intermediaries: Int = 0,
val devgroup: String = "", val devgroupName: String = ""
)
fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? {
@@ -192,13 +194,17 @@ fun parseMemberWire(data: ByteArray, offset: Int = 0): MemberWire? {
val presence = buf.get().toInt() and 0xff
val up = buf.get().toInt() and 0xff
val intermediaries = buf.short.toInt() and 0xffff
val groupBytes = ByteArray(32); buf.get(groupBytes)
val devgroup = if (groupBytes.all { it == 0.toByte() }) "" else groupBytes.joinToString("") { "%02x".format(it.toInt() and 255) }
val groupName = ByteArray(64); buf.get(groupName)
val devgroupName = String(groupName, Charsets.UTF_8).substringBefore('\u0000')
return MemberWire(nodeId, online, isSelf, flags, rtt, natType, storage, name, avatarServer, avatarFile,
storageRole, verified, nodeType, presence, up, intermediaries)
storageRole, verified, nodeType, presence, up, intermediaries, devgroup, devgroupName)
}
/* Полный снимок заменяет все свойства; connecting — локальное состояние запроса GUI. */
fun MemberWire.toChatMember(previous: ChatMember = ChatMember(nodeId, name)): ChatMember = previous.copy(
name = name, online = online != 0,
name = name, online = online != 0, devgroup = devgroup, devgroupName = devgroupName,
isAdmin = (flags and 2) != 0, isSuper = (flags and 1) != 0,
isModer = (flags and 4) != 0, isStorage = storage,
isDeleted = (flags and 8) != 0, radioActive = (flags and 0x10) != 0, radioMuted = (flags and 0x20) != 0,
@@ -0,0 +1,54 @@
package com.utun.chat.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.ByteOrder
data class DeviceInvitation(val id: String, val peer: String, val outgoing: Boolean, val state: Int)
data class DevgroupState(val name: String = "", val member: Boolean = false, val invitations: List<DeviceInvitation> = emptyList(),
val pin: String = "", val failed: Boolean = false, val outgoingVisible: Boolean = false,
val leaving: Boolean = false, val leaveFailed: Boolean = false,
val outgoingId: String = "", val accepting: String = "", val dismissed: Set<String> = emptySet())
/** Только main thread; PIN хранится до закрытия диалога, входящие/результаты не пишутся в лог. */
object DevgroupRepository {
private val mutable = MutableStateFlow(DevgroupState())
val state = mutable.asStateFlow()
private var nextId = 0L
private val pending = mutableMapOf<Long, Int>()
fun command(action: Int = 0, group: Long = 0, peer: Long = 0, text: String = "", pin: String = "") {
if (action == 0 && pending.containsValue(0)) return
if (action == 1) mutable.value = mutable.value.copy(outgoingVisible = true, outgoingId = "", pin = "", failed = false)
if (action == 2) mutable.value = mutable.value.copy(accepting = text, failed = false)
if (action == 4) mutable.value = mutable.value.copy(leaving = true, leaveFailed = false)
val id = ++nextId
if (NativeLib.devgroupCommand(action, group, peer, text, pin, id)) pending[id] = action
else { LogManager.addLog("WARN", "Devgroup", "Core rejected request action=$action"); mutable.value = mutable.value.copy(failed = true, accepting = "", leaving = false, leaveFailed = action == 4) }
}
fun dismissLeaveError() { mutable.value = mutable.value.copy(leaveFailed = false) }
fun closeOutgoing() { mutable.value = mutable.value.copy(outgoingVisible = false, outgoingId = "", pin = "", failed = false) }
fun dismissIncoming(id: String) { mutable.value = mutable.value.copy(dismissed = mutable.value.dismissed + id, failed = false) }
fun onEvent(type: Int, data: ByteArray?) {
if (type == 13) { pending.clear(); mutable.value = DevgroupState(); return }
if (type == 12) { command(); return }
if (type == 70) { command(); return }
if (type != 71 || data == null || data.size < 9) return
val id = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).long
val action = pending.remove(id) ?: return
if (data[8].toInt() != 1) { mutable.value = mutable.value.copy(failed = true, accepting = "", leaving = false, leaveFailed = action == 4); return }
val json = JSONObject(String(data, 9, data.size - 9, Charsets.UTF_8))
if (action == 1) {
if (mutable.value.outgoingVisible) mutable.value = mutable.value.copy(pin = json.getString("pin"), outgoingId = json.getString("id"), failed = false)
return
}
val invitations = json.getJSONArray("invitations")
mutable.value = mutable.value.copy(name = json.getString("name"), member = json.getString("group").isNotEmpty(),
invitations = List(invitations.length()) { i -> val entry = invitations.getJSONObject(i)
DeviceInvitation(entry.getString("id"), entry.getString("peer"), entry.getInt("outgoing") != 0, entry.getInt("state")) },
leaving = if (action == 4) false else mutable.value.leaving,
accepting = if (action == 2) "" else mutable.value.accepting,
failed = if (action == 0) mutable.value.failed else false)
}
}
@@ -55,6 +55,7 @@ class MemberRepository(
active = false; generation++; loaded.clear(); pending.clear(); _members.value = emptyMap()
log("INFO", "Core stopped: member snapshots cleared generation=$generation"); return
}
if (type == 70) { loaded.toList().forEach(::refresh); return }
if (type !in listOf(23, 24, 25, 31)) return
if (!active) { log("DEBUG", "Ignored member event after stop type=$type"); return }
try {
@@ -383,9 +383,9 @@ object NativeLib {
/* ── P2P аудио-звонок (сигналинг) ── */
/** true — копия команды принята живым worker; результат согласования приходит CALL_* событием. */
fun callStart(channelId: String, peerNodeId: Long, callId: Long): Boolean {
fun callStart(channelId: String, peerNodeId: Long, callId: Long, devices: Boolean = false): Boolean {
if (coreStopping) { LogManager.addLog("WARN", "Call", "Start rejected during core teardown id=$callId"); return false }
return nativeCallStart(channelId, peerNodeId, callId)
return nativeCallStart(channelId, peerNodeId, callId, devices)
}
fun callAccept(callId: Long): Boolean {
if (coreStopping) { LogManager.addLog("WARN", "Call", "Accept rejected during core teardown id=$callId"); return false }
@@ -402,8 +402,9 @@ object NativeLib {
* Для START (1) group/peer также ненулевые; прочие действия используют id.
* true — копия команды принята очередью живого uasync; результат сигналинга приходит событием 65
* (id/group/peer:8LE + phase/reason/incoming:1). false — отказ с native-логом; restart отменяет старые запросы. */
fun radioCall(id: Long, action: Int, group: Long = 0, peer: Long = 0): Boolean = nativeRadioCall(id, group, peer, action)
private external fun nativeRadioCall(id: Long, group: Long, peer: Long, action: Int): Boolean
fun radioCall(id: Long, action: Int, group: Long = 0, peer: Long = 0, devices: Boolean = false): Boolean =
nativeRadioCall(id, group, peer, action, devices)
private external fun nativeRadioCall(id: Long, group: Long, peer: Long, action: Int, devices: Boolean): Boolean
/* ── P2P аудио-звонок (медиа) ── */
fun callAudioStart(callId: Long): Boolean {
@@ -491,6 +492,9 @@ object NativeLib {
private external fun nativeConnectMemberNode(channelId: String, nodeId: Long)
private external fun nativeGetChannels(): String?
private external fun nativeGetMessages(channelId: String, limit: Int, anchor: ByteArray, exact: Boolean): String?
fun devgroupCommand(action: Int, group: Long, peer: Long, text: String, pin: String, requestId: Long): Boolean =
nativeDevgroupCommand(action, group, peer, text.toByteArray(Charsets.UTF_8), pin.toByteArray(Charsets.UTF_8), requestId)
private external fun nativeDevgroupCommand(action: Int, group: Long, peer: Long, text: ByteArray, pin: ByteArray, requestId: Long): Boolean
private external fun nativeFeedbackCommand(json: ByteArray, requestId: Long): Boolean
private external fun nativeSetDebugLevel(category: String, level: String)
private external fun nativeGetDebugCategories(): String?
@@ -568,7 +572,7 @@ object NativeLib {
private external fun nativeVoiceDecodeClose(handle: Long)
/* ── P2P аудио-звонок JNI ── */
private external fun nativeCallStart(channelId: String, peerNodeId: Long, callId: Long): Boolean
private external fun nativeCallStart(channelId: String, peerNodeId: Long, callId: Long, devices: Boolean): Boolean
private external fun nativeCallAccept(callId: Long): Boolean
private external fun nativeCallDecline(callId: Long): Boolean
private external fun nativeCallHangup(callId: Long): Boolean
@@ -124,7 +124,23 @@ class RadioAudioEngine {
/** На main запросить прослушивание ненулевой группы. Setup/cleanup выполняет io; onReady вызывается на main.
* Ошибка очистки/запуска даёт false. Новый start/stop инвалидирует прежний результат, также возвращая false. */
fun start(group: Long, onReady: ((Boolean) -> Unit)? = null) {
private var invitationOwner = 0L // ID незавершённого приглашения, только для вновь созданного аудио.
fun start(group: Long, onReady: ((Boolean) -> Unit)? = null) = startOwned(group, 0, onReady)
/** После SELECT ядра; уже работающее аудио сохраняет независимое владение. */
fun startForInvitation(group: Long, id: Long, onReady: (Boolean) -> Unit) = startOwned(group, id, onReady)
/** На main отменить только аудио, созданное данной попыткой. false — оно уже принадлежит другому запуску. */
fun cancelInvitation(id: Long): Boolean {
checkMain("cancelInvitation")
if (invitationOwner != id || id == 0L) return false
stopForCall()
LogManager.addLog("INFO", "radio", "Cancelled invitation audio id=$id")
return true
}
private fun startOwned(group: Long, invitation: Long, onReady: ((Boolean) -> Unit)?) {
checkMain("start")
if (NativeLib.isCoreStopping()) {
LogManager.addLog("WARN", "radio", "start rejected during core cleanup group=$group")
@@ -141,12 +157,14 @@ class RadioAudioEngine {
return
}
if (active && groupId == group) {
if (invitation == 0L) invitationOwner = 0 // Ручное действие сохраняет аудио независимо от приглашения.
val version = generation
io.execute { postReady(group, version, active && generation == version && audioTrack != null, onReady) }
return
}
mainHandler.removeCallbacks(releaseTalk)
val version = ++generation
invitationOwner = invitation
active = true
groupId = group
mutableGroup.value = group
@@ -208,6 +226,7 @@ class RadioAudioEngine {
* Повторный stop повторяет незавершённые операции. Ждать Future нужно вне main. */
fun stop(): Future<*> {
checkMain("stop")
invitationOwner = 0
mainHandler.removeCallbacks(releaseTalk)
active = false
mutableGroup.value = null
@@ -19,7 +19,7 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Состояния карточки; PREPARING локален, ENDED соответствует FINISHED ядра либо локальному отказу очереди. */
enum class RadioCallPhase { OUTGOING, INCOMING, RINGING, PREPARING, ENDED, WAIT_DECISION }
enum class RadioCallPhase { OUTGOING, INCOMING, RINGING, PREPARING, ENDED, WAIT_DECISION, ANSWERING }
/** id/peer — ненулевые uint64 как биты Long, channelId — десятичный uint64.
* Имена — снимок для UI; incoming фиксирует роль, reason — radio_call_reason, без перевода в модели. */
data class RadioCallState(
@@ -42,6 +42,8 @@ object RadioCallController {
private const val FAIL = 6
private const val BUSY = 7
private const val CONTINUE = 8
private const val ANSWER = 9
private var activationCleanup: (() -> Unit)? = null
private const val RESULT_NOTIFICATION_ID = 1006 // Отдельно от incoming, FGS звонка/трансляции/оверлея и результата P2P-звонка.
private data class PendingStart(val id: Long, val group: Long, val peer: Long)
private var pendingStart: PendingStart? = null // START принят приложением, первое событие ядра ещё не получено.
@@ -58,7 +60,7 @@ object RadioCallController {
fun busy(): Boolean = pendingStart != null || _state.value?.phase?.let { it != RadioCallPhase.ENDED } == true
/** На main запросить START с ненулевыми ID канала и участника. Busy снимает первое событие либо reset/отказ очереди. */
fun start(channel: String, peer: Long) {
fun start(channel: String, peer: Long, devices: Boolean = false) {
checkMain()
val nativeReady = NativeLib.isReady()
val phoneCall = CallController.state.value?.ongoing == true
@@ -76,7 +78,7 @@ object RadioCallController {
if (id == 0L) id = 1
pendingStart = PendingStart(id, group, peer)
syncActivity()
if (!command(id, START, group, peer)) { pendingStart = null; syncActivity(); showOutcome(5) }
if (!command(id, START, group, peer, devices)) { pendingStart = null; syncActivity(); showOutcome(5) }
}
/** Activity onStart/onStop: отложенный ответ из уведомления допустим только своему ещё входящему вызову. */
@@ -100,7 +102,7 @@ object RadioCallController {
if (visible) accept() else answerOnVisible = id
}
/** На main запросить микрофон и включение рации; повтор/невидимая Activity не создают новую активацию. */
/** На main отправить решение пользователя; активацию разрешит только SELECTED от ядра. */
fun accept() {
checkMain()
val st = _state.value ?: return
@@ -117,8 +119,8 @@ object RadioCallController {
finish(st, 7); return
}
IncomingCallManager.stopRinging()
_state.value = st.copy(phase = RadioCallPhase.PREPARING)
_activation.value = RadioCallActivation(st.id, st.channelId)
_state.value = st.copy(phase = RadioCallPhase.ANSWERING)
if (!command(st.id, ANSWER)) finish(st, 7)
LogManager.addLog("INFO", "radio", "Call audio requested id=${st.id} channel=${st.channelId} incoming=true")
}
@@ -131,11 +133,10 @@ object RadioCallController {
LogManager.addLog("DEBUG", "radio", "Call activation ignored id=$id success=$success current=${st.id}/${st.phase}")
return
}
_activation.value = null
LogManager.addLog(if (success) "INFO" else "ERROR", "radio", "Call activation id=$id success=$success incoming=${st.incoming}")
if (st.incoming) {
if (!command(id, if (success) ACCEPT else FAIL)) finish(st, 7)
} else if (success) clear() else finish(st, 7)
} else if (success) { activationCleanup = null; clear() } else finish(st, 7)
}
/** На main завершить своё приглашение: DECLINE входящего, иначе CANCEL. Локальная очистка не ждёт очереди. */
@@ -165,7 +166,7 @@ object RadioCallController {
if (type == 12) { ready = NativeLib.isReady(); return }
if (type == 13) { reset(); return }
if (type != 65 || !ready || !NativeLib.isReady()) return
if (data == null || data.size != 27 || data[24].toInt() !in 1..5 || data[25].toInt() !in 0..9 || data[26].toInt() !in 0..1) {
if (data == null || data.size != 27 || data[24].toInt() !in 1..7 || data[25].toInt() !in 0..9 || data[26].toInt() !in 0..1) {
LogManager.addLog("ERROR", "radio", "Invalid call event size=${data?.size} phase=${data?.getOrNull(24)?.toUByte()} " +
"reason=${data?.getOrNull(25)?.toUByte()} incoming=${data?.getOrNull(26)?.toUByte()}")
return
@@ -180,7 +181,7 @@ object RadioCallController {
}
val current = _state.value
val pending = pendingStart
if ((current?.id == id && (current.channelId != group.toULong().toString() || current.peer != peer || current.incoming != incoming)) ||
if ((current?.id == id && (current.channelId != group.toULong().toString() || (current.incoming && current.peer != peer) || current.incoming != incoming)) ||
(pending?.id == id && (pending.group != group || pending.peer != peer || incoming))) {
LogManager.addLog("WARN", "radio", "Call event identity mismatch id=$id group=$group peer=$peer incoming=$incoming " +
"current=$current pending=$pending")
@@ -226,6 +227,12 @@ object RadioCallController {
return
}
if (current.phase == RadioCallPhase.ENDED) return
if (phase == 7 && incoming && current.phase == RadioCallPhase.ANSWERING) {
_state.value = current.copy(phase = RadioCallPhase.PREPARING)
_activation.value = RadioCallActivation(id, current.channelId)
return
}
if (phase == 6 && incoming) return
if (phase == 5 && !incoming && current.phase == RadioCallPhase.OUTGOING) {
CallRingback.stop()
_state.value = current.copy(phase = RadioCallPhase.WAIT_DECISION)
@@ -235,9 +242,10 @@ object RadioCallController {
} else if (phase == 4 && (incoming || current.phase != RadioCallPhase.PREPARING)) {
IncomingCallManager.stopRinging(); CallRingback.stop()
if (reason == 1) {
if (incoming) clear()
if (incoming) { activationCleanup = null; clear() }
else {
_state.value = current.copy(phase = RadioCallPhase.PREPARING)
val name = if (peer == current.peer) current.peerName else "0x%016x".format(peer)
_state.value = current.copy(phase = RadioCallPhase.PREPARING, peer = peer, peerName = name)
_activation.value = RadioCallActivation(id, current.channelId)
LogManager.addLog("INFO", "radio", "Call audio requested id=$id channel=${current.channelId} incoming=false")
}
@@ -247,6 +255,7 @@ object RadioCallController {
private fun finish(st: RadioCallState, reason: Int) {
IncomingCallManager.stopRinging(); CallRingback.stop()
activationCleanup?.invoke(); activationCleanup = null
_activation.value = null; answerOnVisible = 0
val ended = st.copy(phase = RadioCallPhase.ENDED, reason = reason)
_state.value = ended
@@ -271,6 +280,7 @@ object RadioCallController {
private fun clear() {
if (busy()) { IncomingCallManager.stopRinging(); CallRingback.stop() }
clearJob?.cancel(); clearJob = null
activationCleanup?.invoke(); activationCleanup = null
_state.value = null; _activation.value = null; answerOnVisible = 0; pendingStart = null
syncActivity()
}
@@ -283,7 +293,7 @@ object RadioCallController {
6 -> R.string.radio_call_no_answer
7 -> R.string.radio_call_audio_error
8 -> R.string.phone_busy_warning
9 -> R.string.radio_call_already_active
9 -> R.string.radio_call_declined
else -> R.string.radio_call_unavailable
}
@@ -305,9 +315,17 @@ object RadioCallController {
}
/** Отказ/исключение JNI — отказ постановки команды, а true ещё не означает успех сигналинга. */
private fun command(id: Long, action: Int, group: Long = 0, peer: Long = 0): Boolean {
/** На main зарегистрировать отмену только ещё актуальной активации; успех приглашения снимает её без вызова. */
fun ownActivation(id: Long, cleanup: () -> Unit): Boolean {
checkMain()
if (_activation.value?.id != id || _state.value?.phase != RadioCallPhase.PREPARING) return false
activationCleanup = cleanup
return true
}
private fun command(id: Long, action: Int, group: Long = 0, peer: Long = 0, devices: Boolean = false): Boolean {
try {
if (NativeLib.radioCall(id, action, group, peer)) {
if (NativeLib.radioCall(id, action, group, peer, devices)) {
LogManager.addLog("DEBUG", "radio", "Call command queued id=$id action=$action group=$group peer=$peer")
return true
}
@@ -0,0 +1,74 @@
package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.utun.chat.R
import com.utun.chat.data.DevgroupRepository
import kotlinx.coroutines.delay
private fun deviceInvitationState(state: Int): Int = when (state) {
1 -> R.string.devgroup_waiting; 2 -> R.string.devgroup_checking; 3 -> R.string.devgroup_transferring
4 -> R.string.devgroup_joined; 5 -> R.string.devgroup_rejected; 6 -> R.string.devgroup_expired
else -> R.string.devgroup_unknown
}
/** Диалоги живут на уровне Activity, входящее приглашение видно с любого экрана. */
@Composable
fun DevicePairingDialogs() {
val state by DevgroupRepository.state.collectAsState()
var current by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf("") }
var pin by androidx.compose.runtime.saveable.rememberSaveable(current) { mutableStateOf("") }
var submitted by androidx.compose.runtime.saveable.rememberSaveable(current) { mutableStateOf(false) }
LaunchedEffect(state.invitations, state.dismissed, current) {
if (current.isNotEmpty() && state.invitations.none { it.id == current }) current = ""
if (current.isEmpty()) current = state.invitations.firstOrNull {
!it.outgoing && it.state == 1 && it.id !in state.dismissed
}?.id ?: ""
}
LaunchedEffect(state.outgoingVisible, current) {
if (state.outgoingVisible || current.isNotEmpty()) while (true) { DevgroupRepository.command(); delay(1000) }
}
if (state.leaveFailed) {
AlertDialog(onDismissRequest = DevgroupRepository::dismissLeaveError,
title = { Text(stringResource(R.string.devgroup_disconnect)) },
text = { Text(stringResource(R.string.devgroup_failed)) },
confirmButton = { TextButton(onClick = DevgroupRepository::dismissLeaveError) { Text(stringResource(android.R.string.ok)) } })
return
}
if (state.outgoingVisible) {
val invitation = state.invitations.firstOrNull { it.id == state.outgoingId }
AlertDialog(onDismissRequest = DevgroupRepository::closeOutgoing,
title = { Text(stringResource(R.string.devgroup_add_device)) },
text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (state.pin.isNotEmpty() && (invitation?.state ?: 1) < 4) Text(stringResource(R.string.devgroup_pin, state.pin))
Text(stringResource(deviceInvitationState(invitation?.state ?: 1)))
if (state.failed) Text(stringResource(R.string.devgroup_failed), color = MaterialTheme.colorScheme.error)
} }, confirmButton = { TextButton(onClick = DevgroupRepository::closeOutgoing) { Text(stringResource(R.string.close)) } })
return
}
val invitation = state.invitations.firstOrNull { it.id == current } ?: return
val busy = invitation.state == 2 || state.accepting == current
val finished = invitation.state >= 4
val close = { DevgroupRepository.dismissIncoming(current); current = ""; Unit }
AlertDialog(onDismissRequest = close,
title = { Text(stringResource(R.string.devgroup_add_device)) },
text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(invitation.peer)
if (finished || busy) Text(stringResource(deviceInvitationState(invitation.state)))
else {
OutlinedTextField(value = pin, onValueChange = { if (it.length <= 4 && it.all { c -> c in '0'..'9' }) pin = it },
label = { Text(stringResource(R.string.devgroup_pin_hint)) }, singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.NumberPassword))
if (submitted || state.failed) Text(stringResource(R.string.devgroup_pin_retry), color = MaterialTheme.colorScheme.error)
}
} }, confirmButton = {
if (finished) TextButton(onClick = close) { Text(stringResource(android.R.string.ok)) }
else TextButton(enabled = !busy && pin.length == 4, onClick = {
submitted = true; DevgroupRepository.command(2, text = current, pin = pin); pin = ""
}) { Text(stringResource(android.R.string.ok)) }
}, dismissButton = { TextButton(onClick = close) { Text(stringResource(R.string.close)) } })
}
@@ -12,7 +12,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import com.utun.chat.ui.components.UserAvatar
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -22,6 +22,7 @@ import com.utun.chat.data.LogManager
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Bolt
@@ -29,9 +30,13 @@ import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import com.utun.chat.data.DevgroupRepository
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -40,6 +45,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
import com.utun.chat.data.ChatMember
@@ -67,11 +73,26 @@ fun MemberListScreen(
) {
var showEditDialog by remember(channel.id) { mutableStateOf(false) }
var editMember by remember(channel.id) { mutableStateOf<ChatMember?>(null) }
var expandedGroups by rememberSaveable(channel.id) { mutableStateOf(listOf<String>()) }
val devices by DevgroupRepository.state.collectAsState()
val orderedMembers = remember(members) { members.sortedByDescending { it.isSelf } }
val rows = remember(orderedMembers, expandedGroups) {
buildList<Pair<ChatMember, Boolean>> {
val seen = mutableSetOf<String>()
for (member in orderedMembers) {
if (member.devgroup.isEmpty()) add(member to false)
else if (seen.add(member.devgroup)) {
add(member to true)
if (member.devgroup in expandedGroups) orderedMembers.filter { it.devgroup == member.devgroup }.forEach { add(it to false) }
}
}
}
}
val selection = selectedMember?.takeIf { it.channelId == channel.id }
LaunchedEffect(channel.id) {
LogManager.addLog("DEBUG", "Members", "Member list: channel=${channel.id} actionWidth=36dp statusIconGap=2dp")
viewModel.refreshMembers(channel.id)
LogManager.addLog("DEBUG", "Members", "Member list: channel=${channel.id} order=self-first actionWidth=36dp statusIconGap=2dp")
while (true) { viewModel.refreshMembers(channel.id); kotlinx.coroutines.delay(2000) }
}
val memberLinks by viewModel.memberLinks.collectAsState()
@@ -103,17 +124,40 @@ fun MemberListScreen(
.weight(1f)
.fillMaxWidth()
) {
items(members, key = { it.nodeId }) { m ->
itemsIndexed(rows, key = { _, item -> if (item.second) "group:" + item.first.devgroup else "node:" + item.first.nodeId }) { rowIndex, (m, header) ->
val accounts = if (header) orderedMembers.filter { it.devgroup == m.devgroup && !it.isDeleted } else listOf(m)
val target = if (header) accounts.firstOrNull { it.online && !it.isSelf } else m
val device = target ?: m
val groupRadio = accounts.any { it.radioActive }
val groupPhoneKnown = accounts.any { it.phoneStatusKnown }
val groupPhoneBusy = accounts.any { it.phoneBusy }
LaunchedEffect(header, target?.nodeId, groupRadio, groupPhoneKnown, groupPhoneBusy) {
if (header) LogManager.addLog("DEBUG", "Members", "Device group: group=${m.devgroup} " +
"target=${target?.nodeId} radio=$groupRadio phoneKnown=$groupPhoneKnown phoneBusy=$groupPhoneBusy speakerColumn=right")
}
MemberListItem(
member = m,
member = device,
avatarMember = m,
groupName = if (header) m.devgroupName else null,
expanded = m.devgroup in expandedGroups,
groupChild = !header && m.devgroup.isNotEmpty(),
lastGroupChild = rows.getOrNull(rowIndex + 1)?.let { it.second || it.first.devgroup != m.devgroup } ?: true,
actionsEnabled = target != null,
radioEnabled = groupRadio,
phoneStatusKnown = groupPhoneKnown,
phoneBusy = groupPhoneBusy,
onToggleGroup = {
expandedGroups = if (m.devgroup in expandedGroups) expandedGroups - m.devgroup else expandedGroups + m.devgroup
LogManager.addLog("DEBUG", "Members", "Device group: toggled group=${m.devgroup} expanded=${m.devgroup in expandedGroups}")
},
group = channel.id,
speaking = radioActive && if (m.isSelf) transmittingGroup == groupId && groupId != null
else radioTalkState.groupId == groupId && m.nodeId in radioTalkState.speakers,
isSelected = selection?.nodeId == m.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) },
onRadioCall = { com.utun.chat.data.RadioCallController.start(channel.id, m.nodeId) },
onRadioMute = { viewModel.toggleMemberRadioMute(channel.id, m.nodeId) }
speaking = target != null && radioActive && if (device.isSelf) transmittingGroup == groupId && groupId != null
else radioTalkState.groupId == groupId && device.nodeId in radioTalkState.speakers,
isSelected = !header && selection?.nodeId == device.nodeId,
onClick = { viewModel.showMemberDetail(channel.id, device.nodeId) },
onCall = { target?.let { viewModel.startCall(channel.id, it.nodeId, if (header) m.devgroupName else it.name, devices = header) } },
onRadioCall = { target?.let { com.utun.chat.data.RadioCallController.start(channel.id, it.nodeId, devices = header) } },
onRadioMute = { target?.let { viewModel.toggleMemberRadioMute(channel.id, it.nodeId) } }
)
}
}
@@ -132,6 +176,9 @@ fun MemberListScreen(
isSelf = member?.isSelf == true,
canEdit = member != null,
onFiles = { onFiles(detail.nodeId) },
deviceActionEnabled = member != null && !member.isDeleted && !devices.leaving && (!member.isSelf || devices.member),
onDeviceAction = { if (member?.isSelf == true) DevgroupRepository.command(4)
else DevgroupRepository.command(1, channel.id.toULong().toLong(), detail.nodeId) },
onInvite = { viewModel.showPmInvite(detail.nodeId, member?.name ?: detail.name, channel.id) },
onMessage = { viewModel.startDm(channel.id, detail.nodeId, member?.name ?: detail.name) },
onConnect = {
@@ -176,6 +223,9 @@ fun MemberListScreen(
viewModel.setMemberFlags(channel.id, editingMember.nodeId, admin, supernode, moder, storage, verified, deleted)
showEditDialog = false
},
canDisconnect = devices.member && !devices.leaving,
onDeviceAction = { if (editingMember.isSelf) DevgroupRepository.command(4)
else DevgroupRepository.command(1, channel.id.toULong().toLong(), editingMember.nodeId) },
onTransferAdmin = {
viewModel.transferAdmin(channel.id, editingMember.nodeId)
showEditDialog = false
@@ -188,6 +238,16 @@ fun MemberListScreen(
@Composable
private fun MemberListItem(
member: ChatMember,
avatarMember: ChatMember = member,
groupName: String? = null,
expanded: Boolean = false,
groupChild: Boolean = false,
lastGroupChild: Boolean = false,
actionsEnabled: Boolean = true,
radioEnabled: Boolean = member.radioActive,
phoneStatusKnown: Boolean = member.phoneStatusKnown,
phoneBusy: Boolean = member.phoneBusy,
onToggleGroup: () -> Unit = {},
group: String,
speaking: Boolean,
isSelected: Boolean = false,
@@ -196,27 +256,46 @@ private fun MemberListItem(
onRadioCall: () -> Unit,
onRadioMute: () -> Unit
) {
val header = groupName != null
val onlineColor = when {
member.connecting -> Color(0xFFFFEB3B) // yellow: connecting
member.online -> Color(0xFF81C784) // green: reachable (direct/indirect/relay)
else -> Color(0xFFAEBCC8) // gray: offline
}
val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent
val branchColor = MaterialTheme.colorScheme.outlineVariant
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
.clickable(onClick = if (header) onToggleGroup else onClick),
color = bgColor,
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
modifier = Modifier.drawBehind {
val rtl = layoutDirection == LayoutDirection.Rtl
val x = if (rtl) size.width - 28.dp.toPx() else 28.dp.toPx()
val endX = if (rtl) size.width - 36.dp.toPx() else 36.dp.toPx()
val midY = size.height / 2
val stroke = 1.dp.toPx()
if (header && expanded) drawLine(branchColor, Offset(x, midY), Offset(x, size.height), stroke)
if (groupChild) {
drawLine(branchColor, Offset(x, 0f), Offset(x, if (lastGroupChild) midY else size.height), stroke)
drawLine(branchColor, Offset(x, midY), Offset(endX, midY), stroke)
}
}.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
if (header) IconButton(onClick = onToggleGroup, modifier = Modifier.size(width = 24.dp, height = 48.dp)) {
Icon(if (expanded) Icons.Filled.Remove else Icons.Filled.Add,
stringResource(if (expanded) R.string.devgroup_collapse else R.string.devgroup_expand), modifier = Modifier.size(18.dp))
} else Spacer(Modifier.width(24.dp))
}
Box {
UserAvatar(group, member.nodeId, member.name)
if (member.isSelf) Text("✓", modifier = Modifier.align(Alignment.BottomEnd),
UserAvatar(group, avatarMember.nodeId, avatarMember.name)
if (member.isSelf && !header) Text("✓", modifier = Modifier.align(Alignment.BottomEnd),
fontSize = 12.sp, color = Color(0xFF81C784))
else Box(Modifier.align(Alignment.BottomEnd).size(10.dp).clip(CircleShape).background(onlineColor))
}
@@ -224,33 +303,33 @@ private fun MemberListItem(
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.isSuper) {
if (!header && member.isSuper) {
Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isAdmin) {
if (!header && member.isAdmin) {
Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isModer) {
if (!header && member.isModer) {
Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (member.isStorage) {
if (!header && member.isStorage) {
Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
if (!member.isVerified) {
if (!header && !member.isVerified) {
Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(2.dp))
}
Text(
text = member.name,
fontWeight = FontWeight.Medium,
text = groupName ?: member.name,
fontWeight = if (header) FontWeight.Bold else FontWeight.Medium,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textDecoration = if (member.isDeleted) TextDecoration.LineThrough else TextDecoration.None,
textDecoration = if (!header && member.isDeleted) TextDecoration.LineThrough else TextDecoration.None,
color = if (member.isSelf) Color(0xFF81C784) else Color.Unspecified,
modifier = Modifier.weight(1f)
)
@@ -264,7 +343,7 @@ private fun MemberListItem(
else -> stringResource(R.string.offline)
}
Text(
text = connectionLabel, fontSize = 11.sp,
text = if (header && actionsEnabled) member.name + " · " + connectionLabel else connectionLabel, fontSize = 11.sp,
color = if (member.isSelf || member.online) Color(0xFF81C784) else Color(0xFFAEBCC8)
)
}
@@ -273,27 +352,29 @@ private fun MemberListItem(
if (member.radioMuted) " · " + stringResource(R.string.radio_member_muted) else ""
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
val actionModifier = Modifier.size(width = 36.dp, height = 48.dp)
if (if (header) actionsEnabled else !member.isSelf) {
IconButton(onClick = onRadioCall, enabled = !member.isSelf && actionsEnabled && member.online, modifier = actionModifier) {
val label = stringResource(R.string.radio_call_start) +
if (radioEnabled) " · " + stringResource(R.string.radio_active) else ""
PortableRadioIcon(label, tint = if (radioEnabled) Color(0xFF4CAF50) else LocalContentColor.current)
}
IconButton(onClick = onCall, enabled = !member.isSelf && actionsEnabled && member.online, modifier = actionModifier) {
val status = if (!member.online || !phoneStatusKnown) R.string.phone_status_unknown
else if (phoneBusy) R.string.phone_status_busy else R.string.phone_status_free
Icon(Icons.Filled.Call, stringResource(R.string.call) + " · " + stringResource(status),
tint = if (!member.online || !phoneStatusKnown) LocalContentColor.current
else if (phoneBusy) Color(0xFFE53935) else Color(0xFF4CAF50))
}
}
if (member.isSelf) {
Box(actionModifier, contentAlignment = Alignment.Center) {
RadioSpeakerIcon(speaking, false, radioStatus)
}
} else {
IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }, modifier = actionModifier) {
IconToggleButton(checked = member.radioMuted, enabled = actionsEnabled, onCheckedChange = { onRadioMute() }, modifier = actionModifier) {
RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " +
stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute))
}
IconButton(onClick = onRadioCall, enabled = member.online, modifier = actionModifier) {
val label = stringResource(R.string.radio_call_start) +
if (member.radioActive) " · " + stringResource(R.string.radio_active) else ""
PortableRadioIcon(label, tint = if (member.radioActive) Color(0xFF4CAF50) else LocalContentColor.current)
}
IconButton(onClick = onCall, enabled = member.online, modifier = actionModifier) {
val status = if (!member.online || !member.phoneStatusKnown) R.string.phone_status_unknown
else if (member.phoneBusy) R.string.phone_status_busy else R.string.phone_status_free
Icon(Icons.Filled.Call, stringResource(R.string.call) + " · " + stringResource(status),
tint = if (!member.online || !member.phoneStatusKnown) LocalContentColor.current
else if (member.phoneBusy) Color(0xFFE53935) else Color(0xFF4CAF50))
}
}
}
}
@@ -314,6 +395,8 @@ private fun MemberDetailCard(
localSocketStates: List<LocalSocketState>,
isSelf: Boolean = false,
canEdit: Boolean,
deviceActionEnabled: Boolean,
onDeviceAction: () -> Unit,
onFiles: () -> Unit,
onInvite: () -> Unit,
onMessage: () -> Unit,
@@ -410,6 +493,9 @@ private fun MemberDetailCard(
}
}
TextButton(onClick = onDeviceAction, enabled = deviceActionEnabled) {
Text(stringResource(if (isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device))
}
if (!isSelf) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onMessage) {
@@ -549,6 +635,8 @@ private fun MemberEditDialog(
onDismiss: () -> Unit,
onSave: (nick: String, admin: Boolean, supernode: Boolean, moder: Boolean, storage: Boolean,
verified: Boolean, deleted: Boolean) -> Unit,
canDisconnect: Boolean,
onDeviceAction: () -> Unit,
onTransferAdmin: () -> Unit
) {
var nick by remember { mutableStateOf(member.name) }
@@ -574,6 +662,9 @@ private fun MemberEditDialog(
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
TextButton(onClick = onDeviceAction, enabled = !member.isDeleted && (!member.isSelf || canDisconnect)) {
Text(stringResource(if (member.isSelf) R.string.devgroup_disconnect else R.string.devgroup_add_device))
}
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = admin, onCheckedChange = { admin = it }, enabled = isOwner)
@@ -45,7 +45,7 @@ fun RadioCallDialog(state: RadioCallState) {
RadioCallPhase.OUTGOING -> R.string.radio_call_checking
RadioCallPhase.INCOMING -> R.string.radio_call_waiting
RadioCallPhase.RINGING -> R.string.radio_call_ringing
RadioCallPhase.PREPARING -> R.string.radio_call_preparing
RadioCallPhase.PREPARING, RadioCallPhase.ANSWERING -> R.string.radio_call_preparing
RadioCallPhase.WAIT_DECISION -> R.string.phone_busy_warning
RadioCallPhase.ENDED -> RadioCallController.reasonText(state.reason)
}))
@@ -985,7 +985,8 @@ class ChatViewModel : ViewModel() {
}
/* Звонком владеет CallController; ViewModel передаёт только пользовательские команды. */
fun startCall(channelId: String, peerNodeId: Long, peerName: String) = CallController.start(channelId, peerNodeId, peerName)
fun startCall(channelId: String, peerNodeId: Long, peerName: String, devices: Boolean = false) =
CallController.start(channelId, peerNodeId, peerName, devices = devices)
fun acceptCall() = CallController.accept()
fun declineCall() = CallController.end(reason = 2)
fun hangupCall() = CallController.end()
@@ -999,18 +1000,30 @@ class ChatViewModel : ViewModel() {
}
/** Вызов выбирает конкретный канал; повторное принятие не выключает уже работающую рацию. */
fun enableRadioForCall(channelId: String, onReady: (Boolean) -> Unit) {
fun enableRadioForCall(channelId: String, id: Long, onReady: (Boolean) -> Unit) {
val channel = _channels.value.firstOrNull { it.id == channelId }
if (channel == null) {
LogManager.addLog("ERROR", "radio", "Call activation channel missing id=$channelId")
onReady(false); return
}
selectChannel(channel)
if (radioChannelId == channel.id) {
radioAudio.start(channel.id.toULong().toLong(), onReady)
if (!RadioCallController.ownActivation(id) {
if (radioAudio.cancelInvitation(id)) { _radioActive.value = false; syncRadioOverlay() }
}) {
LogManager.addLog("DEBUG", "radio", "Cancelled call activation ignored id=$id")
return
}
setRadioEnabled(channel, true, onReady)
selectChannel(channel)
val group = channelId.toULong().toLong()
val previous = radioChannelId
if (previous != null && previous != channelId) NativeLib.radioSetActive(previous.toULong().toLong(), false)
NativeLib.radioSetActive(group, true)
radioAudio.startForInvitation(group, id) { ready ->
if (!ready) radioAudio.cancelInvitation(id)
_radioActive.value = radioChannelId == _currentChannel.value?.id
onReady(ready)
}
_radioActive.value = radioChannelId == channelId
syncRadioOverlay()
}
private fun setRadioEnabled(ch: Channel, next: Boolean, onReady: ((Boolean) -> Unit)? = null) {
@@ -673,4 +673,19 @@
<string name="audio_graph_kib">КиБ</string>
<string name="audio_graph_bps">байт/с</string>
<string name="audio_graph_kibps">КиБ/с</string>
<string name="devgroup_pin">PIN: %1$s — введите его на другом устройстве в течение 2 минут.</string>
<string name="devgroup_failed">Операция не выполнена. Проверьте соединение и состояние приглашения.</string>
<string name="devgroup_waiting">Ожидается PIN</string>
<string name="devgroup_checking">Проверка PIN</string>
<string name="devgroup_transferring">Передача ключей</string>
<string name="devgroup_joined">Устройство добавлено</string>
<string name="devgroup_rejected">Отказ</string>
<string name="devgroup_expired">Истёк срок</string>
<string name="devgroup_unknown">Результат неизвестен</string>
<string name="devgroup_pin_hint">PIN с экрана приглашающего устройства</string>
<string name="devgroup_add_device">Добавить устройство</string>
<string name="devgroup_pin_retry">PIN не принят. Попробуйте ещё раз.</string>
<string name="devgroup_disconnect">Отключить от моих устройств</string>
<string name="devgroup_expand">Развернуть устройства</string>
<string name="devgroup_collapse">Свернуть устройства</string>
</resources>
@@ -672,4 +672,19 @@
<string name="audio_graph_kib">KiB</string>
<string name="audio_graph_bps">B/s</string>
<string name="audio_graph_kibps">KiB/s</string>
<string name="devgroup_pin">PIN: %1$s — enter it on the other device within 2 minutes.</string>
<string name="devgroup_failed">Operation failed. Check connection and invitation status.</string>
<string name="devgroup_waiting">Waiting for PIN</string>
<string name="devgroup_checking">Checking PIN</string>
<string name="devgroup_transferring">Transferring keys</string>
<string name="devgroup_joined">Joined</string>
<string name="devgroup_rejected">Rejected</string>
<string name="devgroup_expired">Expired</string>
<string name="devgroup_unknown">Result unknown</string>
<string name="devgroup_pin_hint">PIN shown on the inviting device</string>
<string name="devgroup_add_device">Add device</string>
<string name="devgroup_pin_retry">PIN was not accepted. Try again.</string>
<string name="devgroup_disconnect">Disconnect from my devices</string>
<string name="devgroup_expand">Expand devices</string>
<string name="devgroup_collapse">Collapse devices</string>
</resources>
@@ -6,6 +6,16 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder
class MemberRepositoryTest {
@org.junit.Test fun deviceGroupSurvivesTheSharedMemberSnapshot() {
val bytes = ByteArray(MEMBER_WIRE_SIZE)
java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN).putLong(7)
bytes[164] = 42
"Мои устройства".toByteArray(Charsets.UTF_8).copyInto(bytes, 196)
val member = requireNotNull(parseMemberWire(bytes)).toChatMember()
org.junit.Assert.assertEquals("2a" + "00".repeat(31), member.devgroup)
org.junit.Assert.assertEquals("Мои устройства", member.devgroupName)
org.junit.Assert.assertEquals(7L, member.nodeId)
}
private fun row(node: Long, flags: Int = 0x40, online: Boolean = true, up: Int = 5, rtt: Int = 30): ByteArray =
ByteArray(MEMBER_WIRE_SIZE).also { bytes ->
ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putLong(0, node).putShort(11, rtt.toShort())
@@ -1,3 +1,4 @@
#include "chat/chat_devgroup.h"
/*
* android_jni_bridge.c — JNI bridge implementation
*
@@ -1467,7 +1468,7 @@ void utun_bridge_voice_decode_close(void* handle) {
* P2P аудио-звонок (сигналинг)
* ────────────────────────────────────────────────────────────────── */
int utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id) {
static int bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t call_id, int devices) {
if (!ch_id || !ch_id[0] || strlen(ch_id) >= sizeof(((struct call_start_arg*)0)->ch_id) || peer_node_id == 0 || call_id == 0) {
bridge_log(BLEV_ERROR, "call_start: bad args ch=%s peer=0x%llx id=0x%llx",
ch_id ? ch_id : "(null)",
@@ -1482,9 +1483,12 @@ int utun_bridge_call_start(const char* ch_id, uint64_t peer_node_id, uint64_t ca
a->call_id = call_id;
bridge_log(BLEV_INFO, "call_start ch=%s peer=0x%llx id=0x%llx",
ch_id, (unsigned long long)peer_node_id, (unsigned long long)call_id);
a->devices = devices;
if (bridge_post_request(call_start_trampoline, a, &a->inst)) return -1;
return 0;
}
int utun_bridge_call_start(const char* ch, uint64_t peer, uint64_t id) { return bridge_call_start(ch, peer, id, 0); }
static int bridge_call_id_action(uint64_t call_id, const char* name,
void (*trampoline)(void*)) {
@@ -2283,6 +2287,25 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages(
}
/* UTF-8 JSON приходит байтами, чтобы emoji не превращались в JNI modified UTF-8. */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeDevgroupCommand(
JNIEnv* env, jobject thiz, jint action, jlong group, jlong peer, jbyteArray text, jbyteArray pin, jlong request_id) {
(void)thiz;
if (!text || !pin || !instance_lite_is_running()) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: core unavailable/invalid request"); return JNI_FALSE;
}
jsize n = (*env)->GetArrayLength(env, text), m = (*env)->GetArrayLength(env, pin);
if (n >= 128 || m >= 5) { DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: input too long"); return JNI_FALSE; }
struct chat_devgroup_request* req = u_calloc(1, sizeof(*req));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup JNI: allocation failed"); return JNI_FALSE; }
req->action = action; req->group = (uint64_t)group; req->peer = (uint64_t)peer; req->request_id = (uint64_t)request_id;
(*env)->GetByteArrayRegion(env, text, 0, n, (jbyte*)req->text);
(*env)->GetByteArrayRegion(env, pin, 0, m, (jbyte*)req->pin);
if ((*env)->ExceptionCheck(env) || memchr(req->text, 0, n) || memchr(req->pin, 0, m)) {
memset(req, 0, sizeof(*req)); u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup JNI: invalid text"); return JNI_FALSE;
}
return bridge_post_request(chat_devgroup_trampoline, req, &req->inst) == 0 ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeFeedbackCommand(
JNIEnv* env, jobject thiz, jbyteArray json, jlong request_id) {
(void)thiz;
@@ -2983,12 +3006,12 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCollectConnMetric
/* ── P2P аудио-звонок (сигналинг) ── */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallStart(
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId, jlong callId) {
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId, jlong callId, jboolean devices) {
(void)thiz;
if (!channelId) { bridge_log(BLEV_ERROR, "call_start JNI: missing channel"); return JNI_FALSE; }
const char* ch = jni_utf8(env, channelId);
if (!ch) { bridge_log(BLEV_ERROR, "call_start JNI: channel copy failed"); return JNI_FALSE; }
int rc = utun_bridge_call_start(ch, (uint64_t)peerNodeId, (uint64_t)callId);
int rc = bridge_call_start(ch, (uint64_t)peerNodeId, (uint64_t)callId, devices);
u_free((void*)ch);
return rc == 0 ? JNI_TRUE : JNI_FALSE;
}
@@ -3001,9 +3024,9 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallAccept(
/* Команда адресного вызова рации: копируем запрос до передачи в поток ядра. */
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCall(
JNIEnv* env, jobject thiz, jlong id, jlong group, jlong peer, jint action) {
JNIEnv* env, jobject thiz, jlong id, jlong group, jlong peer, jint action, jboolean devices) {
(void)env; (void)thiz;
if (!instance_lite_is_running() || !id || action < RADIO_CALL_START || action > RADIO_CALL_CONTINUE) {
if (!instance_lite_is_running() || !id || action < RADIO_CALL_START || action > RADIO_CALL_ANSWER) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call JNI rejected id=%lld action=%d", (long long)id, action);
return JNI_FALSE;
}
@@ -3011,7 +3034,7 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCall(
if (!req) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call JNI allocation failed"); u_free(req); return JNI_FALSE;
}
req->id = (uint64_t)id; req->group = (uint64_t)group; req->peer = (uint64_t)peer; req->action = action;
req->id = (uint64_t)id; req->group = (uint64_t)group; req->peer = (uint64_t)peer; req->action = action; req->devices = devices;
int posted = bridge_post_request(radio_call_trampoline, req, &req->inst);
return posted == 0 ? JNI_TRUE : JNI_FALSE;
}
+3 -2
View File
@@ -324,6 +324,7 @@ add_executable(vibechat WIN32
src/attachment_key_ui.cpp
src/messagefeedback.cpp
src/accountlist.cpp
src/devgroupdialog.cpp
src/accountdelegate.cpp
src/flagpainter.cpp
src/memberpropsdialog.cpp
@@ -608,7 +609,7 @@ endif()
add_test(NAME test_call_commands COMMAND test_call_commands)
set_tests_properties(test_call_commands PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 30)
add_executable(test_member_dialogs tests/test_member_dialogs.cpp src/renamedialog.cpp src/memberpropsdialog.cpp
add_executable(test_member_dialogs tests/test_member_dialogs.cpp src/renamedialog.cpp src/memberpropsdialog.cpp src/devgroupdialog.cpp
db/db_manager.cpp transport/gui_bridge_impl.cpp src/connmonitorwindow.cpp)
target_link_libraries(test_member_dialogs PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun utun_voice pthread ${CMAKE_DL_LIBS})
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
@@ -845,7 +846,7 @@ target_link_libraries(test_radio_member_mute PRIVATE ${QT_CORE} utun_voice utun
add_test(NAME test_radio_member_mute COMMAND test_radio_member_mute)
set(RADIO_MEMBER_TEST_SOURCES src/memberlistmodel.cpp src/flagpainter.cpp src/avatars/avatarcache.cpp src/avatars/avatarmanager.cpp
src/avatars/avatarcrop.cpp src/avatars/avatargallery.cpp src/imageviewer_window.cpp db/db_manager.cpp)
add_executable(test_member_detail tests/test_member_detail.cpp src/accountlist.cpp src/accountdelegate.cpp
add_executable(test_member_detail tests/test_member_detail.cpp src/accountlist.cpp src/devgroupdialog.cpp src/accountdelegate.cpp
src/memberpropsdialog.cpp src/renamedialog.cpp ${RADIO_MEMBER_TEST_SOURCES})
target_include_directories(test_member_detail PRIVATE ${UTUN_INCLUDE_DIRS})
target_link_libraries(test_member_detail PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread)
+65 -18
View File
@@ -16,7 +16,7 @@ extern "C" {
}
QRect AccountDelegate::actionRect(const QRect& row, Action action) {
const int offset = action == Action::RadioMute ? 95 : action == Action::RadioCall ? 63 : 31;
const int offset = action == Action::RadioMute ? 31 : action == Action::RadioCall ? 95 : 63;
return QRect(row.right() - offset, row.center().y() - 14, 28, 28);
}
@@ -59,12 +59,13 @@ static void drawRadioMuteButton(QPainter* painter, const QRect& button, bool mut
static QRect avatarRect(const QRect& row) {
const int size = MemberListModel::kAvatarSize;
return QRect(row.left() + 4, row.top() + (row.height() - size) / 2, size, size);
return QRect(row.left() + GroupedMemberModel::DisclosureWidth + 4, row.top() + (row.height() - size) / 2, size, size);
}
AccountDelegate::Action AccountDelegate::actionAt(const QPoint& position, const QRect& row, bool self) {
AccountDelegate::Action AccountDelegate::actionAt(const QPoint& position, const QRect& row, bool showCalls) {
if (avatarRect(row).contains(position)) return Action::Avatar;
if (!self) for (Action action : {Action::RadioMute, Action::RadioCall, Action::Call})
if (actionRect(row, Action::RadioMute).contains(position)) return Action::RadioMute;
if (showCalls) for (Action action : {Action::RadioCall, Action::Call})
if (actionRect(row, action).contains(position)) return action;
return Action::None;
}
@@ -73,6 +74,9 @@ AccountDelegate::AccountDelegate(QObject *parent)
: QStyledItemDelegate(parent) {
GUI_DEBUG("AccountDelegate: row height=%dpx avatar=%dpx gap=%dpx", MemberListModel::kRowHeight,
MemberListModel::kAvatarSize, MemberListModel::kRowHeight - MemberListModel::kAvatarSize);
if (const auto* view = qobject_cast<QWidget*>(parent))
GUI_DEBUG("AccountDelegate: tree text=%s background=%s alpha=160 width=1.5px",
qPrintable(view->palette().color(QPalette::Text).name()), qPrintable(view->palette().color(QPalette::AlternateBase).name()));
}
void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
@@ -86,16 +90,41 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
else
painter->fillRect(option.rect, option.palette.alternateBase());
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool();
const bool expanded = index.data(GroupedMemberModel::GroupExpandedRole).toBool();
const int branch = index.data(GroupedMemberModel::GroupBranchRole).toInt();
const qreal branchX = option.rect.left() + 12, branchY = option.rect.center().y();
QColor branchColor = option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text);
branchColor.setAlpha(160);
painter->setRenderHint(QPainter::Antialiasing); painter->setPen(QPen(branchColor, 1.5));
if (header && expanded) painter->drawLine(QPointF(branchX, branchY + 7), QPointF(branchX, option.rect.bottom() + 1));
if (branch) {
painter->drawLine(QPointF(branchX, option.rect.top()), QPointF(branchX, branch == 2 ? branchY : option.rect.bottom() + 1));
painter->drawLine(QPointF(branchX, branchY), QPointF(avatarRect(option.rect).left() - 4, branchY));
}
if (header) {
const QRectF box(option.rect.left() + 5, option.rect.center().y() - 7, 14, 14);
painter->setRenderHint(QPainter::Antialiasing); painter->setBrush(Qt::NoBrush);
painter->setPen(QPen(option.palette.color(QPalette::Text), 1.3)); painter->drawRoundedRect(box, 2, 2);
painter->drawLine(QPointF(box.left() + 3, box.center().y()), QPointF(box.right() - 3, box.center().y()));
if (!expanded)
painter->drawLine(QPointF(box.center().x(), box.top() + 3), QPointF(box.center().x(), box.bottom() - 3));
}
const int margin = 4;
const int iconSz = MemberListModel::kAvatarSize;
QIcon icon = qvariant_cast<QIcon>(index.data(Qt::DecorationRole));
if (!icon.isNull())
icon.paint(painter, avatarRect(option.rect));
if (header) {
const QRect avatar = avatarRect(option.rect); const QColor color(index.data(MemberListModel::OnlineRole).toBool() ? "#4CAF50" : "#9E9E9E");
painter->setPen(QPen(color, 1)); painter->setBrush(color); painter->drawEllipse(QRect(avatar.right() - 8, avatar.bottom() - 8, 8, 8));
}
const int x = option.rect.left() + iconSz + margin * 3;
const int x = option.rect.left() + GroupedMemberModel::DisclosureWidth + iconSz + margin * 3;
const bool self = index.data(Qt::UserRole + 2).toBool();
const int w = qMax(0, option.rect.right() - x - margin - (self ? 32 : 96));
const bool online = index.data(MemberListModel::OnlineRole).toBool(), showCalls = header ? index.data(GroupedMemberModel::CallAvailableRole).toBool() : !self;
const int w = qMax(0, option.rect.right() - x - margin - (showCalls ? 96 : 32));
const int yMid = option.rect.top() + option.rect.height() / 2;
uint8_t flags = (uint8_t)(index.data(Qt::UserRole + 3).toInt() & 0xFF);
@@ -103,7 +132,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
QFont nameFont = painter->font();
nameFont.setBold(true);
nameFont.setPixelSize(13);
if (flags & MEMBER_FLAG_DELETED) nameFont.setStrikeOut(true);
if (!header && (flags & MEMBER_FLAG_DELETED)) nameFont.setStrikeOut(true);
painter->setFont(nameFont);
QColor textColor = option.palette.color(QPalette::Text);
@@ -113,7 +142,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
painter->save();
painter->setClipRect(QRect(x, option.rect.top(), w, option.rect.height()), Qt::IntersectClip);
const uint8_t visualFlags = flags & (MEMBER_FLAG_ADMIN | MEMBER_FLAG_MODER);
const uint8_t visualFlags = header ? 0 : flags & (MEMBER_FLAG_ADMIN | MEMBER_FLAG_MODER);
int flagX = x;
if (visualFlags) {
FlagPainter::draw(painter, flags, flagX, option.rect.top() + (option.rect.height() - nameFont.pixelSize()) / 2, nameFont.pixelSize());
@@ -136,10 +165,12 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
Qt::AlignLeft | Qt::AlignTop, sub);
painter->restore();
drawRadioMuteButton(painter, actionRect(option.rect, self ? Action::Call : Action::RadioMute), flags & CHAT_MEMBER_FLAG_RADIO_MUTED,
const bool available = !header || index.data(MemberListModel::OnlineRole).toBool();
painter->save(); if (!available) painter->setOpacity(0.35);
drawRadioMuteButton(painter, actionRect(option.rect, Action::RadioMute), flags & CHAT_MEMBER_FLAG_RADIO_MUTED,
index.data(MemberListModel::RadioSpeakingRole).toBool());
if (!self) {
const QColor color = index.data(MemberListModel::OnlineRole).toBool() ? textColor :
if (showCalls) {
const QColor color = online ? option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text) :
option.palette.color(QPalette::Disabled, QPalette::Text);
const QRect radio = actionRect(option.rect, Action::RadioCall);
const QColor radioColor = flags & CHAT_MEMBER_FLAG_RADIO ? QColor("#4CAF50") : color;
@@ -149,6 +180,7 @@ void AccountDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
drawCallButton(painter, actionRect(option.rect, Action::Call), phoneColor);
}
painter->restore();
painter->restore();
}
bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option,
@@ -168,43 +200,58 @@ bool AccountDelegate::editorEvent(QEvent* event, QAbstractItemModel* model, cons
event->type() != QEvent::MouseButtonDblClick) return false;
auto* mouse = static_cast<QMouseEvent*>(event);
if (mouse->button() != Qt::LeftButton) return false;
const Action action = actionAt(mouse->pos(), option.rect, index.data(Qt::UserRole + 2).toBool());
const bool enabled = action == Action::Avatar || action == Action::RadioMute || index.data(MemberListModel::OnlineRole).toBool();
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool();
const bool self = index.data(Qt::UserRole + 2).toBool(), online = index.data(MemberListModel::OnlineRole).toBool();
Action action = actionAt(mouse->pos(), option.rect, header ? index.data(GroupedMemberModel::CallAvailableRole).toBool() : !self);
if (header && action == Action::None) action = Action::ToggleGroup;
const bool isCall = action == Action::Call || action == Action::RadioCall;
const bool enabled = action == Action::Avatar || action == Action::ToggleGroup ||
(isCall ? index.data(GroupedMemberModel::CallAvailableRole).toBool() : !self && (!header || online));
if (event->type() == QEvent::MouseButtonPress) {
m_pressedIndex = action != Action::None && enabled ? index : QModelIndex();
m_pressedNodeId = index.data(GroupedMemberModel::ActionNodeIdRole).toULongLong();
m_pressedAction = action; m_pressedPosition = mouse->pos();
return action != Action::None;
}
if (event->type() == QEvent::MouseButtonDblClick) return action != Action::None;
const bool handled = action != Action::None || m_pressedIndex.isValid();
const bool activate = enabled && action != Action::None && action == m_pressedAction && m_pressedIndex == index &&
m_pressedNodeId == index.data(GroupedMemberModel::ActionNodeIdRole).toULongLong() &&
(mouse->pos() - m_pressedPosition).manhattanLength() < ClickDistance;
m_pressedIndex = QPersistentModelIndex(); m_pressedAction = Action::None;
if (activate) {
GUI_DEBUG("AccountDelegate: action activated row=%d action=%d", index.row(), int(action));
GUI_DEBUG("AccountDelegate: action activated row=%d group=%d action=%d target=%016llx online=%d flags=%02x",
index.row(), header, int(action), (unsigned long long)m_pressedNodeId, online, index.data(Qt::UserRole + 3).toInt());
if (action == Action::Avatar) emit avatarRequested(index);
else if (action == Action::RadioMute) emit radioMuteRequested(index);
else if (action == Action::RadioCall) emit radioCallRequested(index);
else if (action == Action::Call) emit callRequested(index);
else if (action == Action::ToggleGroup) emit groupToggleRequested(index);
}
return handled;
}
bool AccountDelegate::helpEvent(QHelpEvent* event, QAbstractItemView* view, const QStyleOptionViewItem& option,
const QModelIndex& index) {
if (index.data(GroupedMemberModel::GroupHeaderRole).toBool() && event &&
event->pos().x() < option.rect.left() + GroupedMemberModel::DisclosureWidth) {
QToolTip::showText(event->globalPos(), QCoreApplication::translate("ChatGui",
index.data(GroupedMemberModel::GroupExpandedRole).toBool() ? "Collapse devices" : "Expand devices"), view); return true;
}
const bool self = index.data(Qt::UserRole + 2).toBool();
if (event && actionRect(option.rect, self ? Action::Call : Action::RadioMute).contains(event->pos())) {
const bool header = index.data(GroupedMemberModel::GroupHeaderRole).toBool(), online = index.data(MemberListModel::OnlineRole).toBool();
if (event && actionRect(option.rect, Action::RadioMute).contains(event->pos())) {
const bool muted = index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO_MUTED;
const bool speaking = index.data(MemberListModel::RadioSpeakingRole).toBool();
QString text = QCoreApplication::translate("ChatGui", speaking ? "Radio: speaking" : "Radio: silent");
if (muted) text += " · " + QCoreApplication::translate("ChatGui", "Radio: muted");
if (!index.data(Qt::UserRole + 2).toBool()) text += '\n' + QCoreApplication::translate("ChatGui",
if (!self && (!header || online)) text += '\n' + QCoreApplication::translate("ChatGui",
muted ? "Listen to this member in radio" : "Mute this member in radio");
QToolTip::showText(event->globalPos(), text, view);
return true;
}
if (event && !self) {
const Action action = actionAt(event->pos(), option.rect, false);
if (event && (header ? index.data(GroupedMemberModel::CallAvailableRole).toBool() : !self)) {
const Action action = actionAt(event->pos(), option.rect, true);
if (action == Action::RadioCall || action == Action::Call) {
QString text = QCoreApplication::translate("ChatGui", action == Action::RadioCall ? "Call by radio" : "Call peer");
if (action == Action::RadioCall && (index.data(Qt::UserRole + 3).toInt() & CHAT_MEMBER_FLAG_RADIO))
+9 -3
View File
@@ -3,7 +3,11 @@
* UserRole+1 — подпись, +2 — self, +3 — CHAT_MEMBER_FLAG_*, +4 — storage;
* MemberListModel::OnlineRole/RadioSpeakingRole определяют доступность и индикацию.
* Аватар доступен всем, mute — чужим участникам независимо от online, вызовы — online-пирам.
* Собственная строка показывает состояние рации без кнопок mute/вызова.
* Динамик всегда справа. Собственная строка показывает состояние рации без кнопок mute/вызова.
* Под раскрытие зарезервирована общая колонка; заголовок группы получает аватар и действия
* через GroupedMemberModel. Ветка от раскрытого заголовка заканчивается уголком у последнего устройства.
* Трубка/рация группы видны при наличии online-устройства,
* в том числе self; вызовы себе недоступны. Их состояния общие для группы.
* Сигнал только при LeftButton release в том же действии/строке после press:
* MouseMove со смещением >=5 по Manhattan или без LeftButton отменяет жест, включая возврат курсора назад. */
#pragma once
@@ -30,12 +34,14 @@ signals:
void callRequested(const QModelIndex& index);
void avatarRequested(const QModelIndex& index);
void radioMuteRequested(const QModelIndex& index);
void groupToggleRequested(const QModelIndex& index);
private:
static constexpr int ClickDistance = 5;
enum class Action { None, Avatar, RadioMute, RadioCall, Call };
enum class Action { None, Avatar, RadioMute, RadioCall, Call, ToggleGroup };
static QRect actionRect(const QRect& row, Action action);
static Action actionAt(const QPoint& position, const QRect& row, bool self);
static Action actionAt(const QPoint& position, const QRect& row, bool showCalls);
QPersistentModelIndex m_pressedIndex; /* строка, на которой начался клик */
QPoint m_pressedPosition; // координаты viewport при press
quint64 m_pressedNodeId = 0; // смена online-устройства между press/release отменяет действие
Action m_pressedAction = Action::None; // ожидаемое действие; сбрасывается при release/отмене жеста
};
+29 -17
View File
@@ -1,3 +1,5 @@
#include "devgroupdialog.h"
#include <QPushButton>
#include <QCoreApplication>
#include "accountlist.h"
#include "addressprofiletext.h"
@@ -83,36 +85,46 @@ AccountList::AccountList(DbManager* db, QWidget *parent)
m_header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);");
layout->addWidget(m_header);
m_listView->setModel(m_model);
m_grouped = new GroupedMemberModel(m_model, this);
m_listView->setModel(m_grouped);
auto* delegate = new AccountDelegate(m_listView);
m_listView->setItemDelegate(delegate);
connect(delegate, &AccountDelegate::groupToggleRequested, this, [this](const QModelIndex& index) {
if (m_grouped->toggle(index.row())) clearMemberDetail();
});
connect(delegate, &AccountDelegate::radioMuteRequested, this, [this](const QModelIndex& index) {
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), m_model->nodeIdAt(index.row())) < 0)
const quint64 peer = m_model->nodeIdAt(m_grouped->actionSourceRow(index.row()));
if (!peer) return;
if (gui_bridge_toggle_member_radio_mute(m_channelId.toUtf8().constData(), peer) < 0)
QMessageBox::warning(this, QCoreApplication::translate("ChatGui", "Error"),
QCoreApplication::translate("ChatGui", "Cannot change radio listening setting"));
});
connect(delegate, &AccountDelegate::avatarRequested, this, [this](const QModelIndex& index) {
showAvatarGallery(m_channelId, m_model->nodeIdAt(index.row()));
showAvatarGallery(m_channelId, m_model->nodeIdAt(m_grouped->avatarSourceRow(index.row())));
});
connect(delegate, &AccountDelegate::radioCallRequested, this, [this](const QModelIndex& index) {
const quint64 peer = m_model->nodeIdAt(index.row());
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(index.row())) {
const int source = m_grouped->actionSourceRow(index.row()); const quint64 peer = m_model->nodeIdAt(source);
const bool devices = index.data(GroupedMemberModel::GroupHeaderRole).toBool();
if (!peer || (!devices && index.data(Qt::UserRole + 2).toBool()) || !m_model->isOnlineAt(source)) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "member radio call ignored: peer=%016llx online=%d",
(unsigned long long)peer, m_model->isOnlineAt(index.row())); return;
(unsigned long long)peer, m_model->isOnlineAt(source)); return;
}
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "member radio call clicked ch=%s peer=%016llx",
qPrintable(m_channelId), (unsigned long long)peer);
emit radioCallRequested(m_channelId, peer);
emit radioCallRequested(m_channelId, peer, devices);
});
connect(delegate, &AccountDelegate::callRequested, this, [this](const QModelIndex& index) {
const quint64 peer = m_model->nodeIdAt(index.row());
if (!peer || index.data(Qt::UserRole + 2).toBool() || !m_model->isOnlineAt(index.row())) {
const int source = m_grouped->actionSourceRow(index.row()); const quint64 peer = m_model->nodeIdAt(source);
const bool devices = index.data(GroupedMemberModel::GroupHeaderRole).toBool();
if (!peer || (!devices && index.data(Qt::UserRole + 2).toBool()) || !m_model->isOnlineAt(source)) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "member call ignored: peer=%016llx online=%d",
(unsigned long long)peer, m_model->isOnlineAt(index.row())); return;
(unsigned long long)peer, m_model->isOnlineAt(source)); return;
}
DEBUG_INFO(DEBUG_CATEGORY_CALL, "member call clicked ch=%s peer=%016llx",
qPrintable(m_channelId), (unsigned long long)peer);
emit callRequested(m_channelId, peer, index.data(Qt::DisplayRole).toString());
const auto member = m_model->member(peer);
emit callRequested(m_channelId, peer, devices ? index.data(Qt::DisplayRole).toString() :
member ? QString::fromUtf8(member->name) : QString(), devices);
});
m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_listView->setSelectionMode(QAbstractItemView::SingleSelection);
@@ -217,8 +229,8 @@ void AccountList::setChannel(const QString& channelId) {
void AccountList::refresh() { m_model->refresh(); }
void AccountList::onMemberClicked(const QModelIndex& index) {
if (!index.isValid()) return;
quint64 nodeId = m_model->nodeIdAt(index.row());
if (!index.isValid() || index.data(GroupedMemberModel::GroupHeaderRole).toBool()) return;
quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
if (nodeId) showMemberDetail(nodeId);
}
@@ -251,8 +263,8 @@ void AccountList::onSnapshotChanged(quint64 nodeId) {
}
if (!m_model->member(m_detailNodeId)) { clearMemberDetail(); return; }
m_detailDirty = true; m_detailHops.clear();
for (int row = 0; row < m_model->rowCount(); row++) if (m_model->nodeIdAt(row) == m_detailNodeId) {
m_listView->setCurrentIndex(m_model->index(row)); break;
for (int row = 0; row < m_grouped->rowCount(); row++) if (m_model->nodeIdAt(m_grouped->sourceRow(row)) == m_detailNodeId) {
m_listView->setCurrentIndex(m_grouped->index(row)); break;
}
updateDetailDisplay();
requestMemberDetail();
@@ -514,14 +526,14 @@ void AccountList::editMember(quint64 nodeId, bool properties) {
}
void AccountList::onMemberDoubleClicked(const QModelIndex& index) {
if (index.isValid()) editMember(m_model->nodeIdAt(index.row()), true);
if (index.isValid()) editMember(m_model->nodeIdAt(m_grouped->sourceRow(index.row())), true);
}
/* Вложенный event loop меню допускает смену канала, снимка и уничтожение панели. */
void AccountList::onMemberContextMenu(const QPoint& pos) {
const QModelIndex index = m_listView->indexAt(pos);
if (!index.isValid()) return;
const quint64 nodeId = m_model->nodeIdAt(index.row());
const quint64 nodeId = m_model->nodeIdAt(m_grouped->sourceRow(index.row()));
const auto member = m_model->member(nodeId);
if (!nodeId || !member) return;
const QString channel = m_channelId;
+4 -2
View File
@@ -21,6 +21,7 @@
#include <stdint.h>
class MemberListModel;
class GroupedMemberModel;
class DbManager;
struct DetailAddress {
@@ -72,8 +73,8 @@ public:
signals:
void memberRenamed(); // приняты изменения rename/properties, список уже запрошен заново
void callRequested(const QString& channelId, quint64 nodeId, const QString& name);
void radioCallRequested(const QString& channelId, quint64 nodeId);
void callRequested(const QString& channelId, quint64 nodeId, const QString& name, bool devices = false);
void radioCallRequested(const QString& channelId, quint64 nodeId, bool devices = false);
void pmRequested(const QString& channelId, quint64 nodeId, const QString& name);
void inviteRequested(const QString& channelId, quint64 nodeId, const QString& name);
void filesRequested(const QString& channelId, quint64 nodeId);
@@ -101,6 +102,7 @@ private:
QListView *m_listView;
MemberListModel *m_model;
GroupedMemberModel* m_grouped;
QPointer<DbManager> m_db; // не владеет ни объектом, ни SQLite; доступ к handle сериализует внешний lifecycle
QLabel *m_header;
QWidget *m_detailPanel;
+1 -1
View File
@@ -730,7 +730,7 @@ void CallWindow::onAccept() {
m_statusLabel->setText(callText("Could not send the call command")); return;
}
SoundManager::instance()->stopLoop();
setState(Active);
setState(Outgoing); // Медиа разрешит только CALL_ACCEPTED после подтверждения выбора ядром.
}
void CallWindow::onDecline() {
+1 -1
View File
@@ -52,7 +52,7 @@ protected:
void closeEvent(QCloseEvent* event) override;
private slots:
void onAccept(); // После принятия команды мостом прекращает гудок и переводит GUI в Active.
void onAccept(); // После принятия команды мостом ждёт CALL_ACCEPTED; локальный ответ не запускает разговор.
void onDecline(); // Ставит Decline; закрытие по native-результату выполняет MainWindow.
void onHangup(); // Ставит Hangup; при отказе сохраняет окно и показывает ошибку.
void updateDuration(); // Только Active: прошедшее wall-clock время в минуты:секунды.
+205
View File
@@ -0,0 +1,205 @@
#include <QCoreApplication>
#include "devgroupdialog.h"
#include "../transport/gui_bridge.h"
#include "debug_ui.h"
#include <QDialog>
#include <QVBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QLineEdit>
#include <QInputDialog>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QTimer>
#include <QPointer>
#include <QHash>
#include <QMessageBox>
#include <QSet>
#include <QDialogButtonBox>
#include <QRegularExpressionValidator>
extern "C" {
#include "../../../src/chat/chat_devgroup.h"
#include "../../../lib/mem.h"
}
class DevgroupDialog;
static QPointer<DevgroupDialog> active;
static QHash<quint64, QPointer<QPushButton>> leaveRequests;
static quint64 nextRequest = 0;
static QPointer<QWidget> invitationWindow;
static quint64 statusRequest = 0, lastAutomaticSnapshot = 0;
static QHash<quint64, QString> acceptRequests;
static QHash<QString, QPointer<QInputDialog>> pinDialogs;
static QSet<QString> dismissedInvites, awaitingPins, attemptedPins;
static void refreshInvitations();
static void automaticInvitations(const QJsonObject& snapshot);
static void devgroupResult(const uint8_t* data, int len);
class DevgroupDialog : public QDialog {
public:
static QString tr(const char* text) { return QCoreApplication::translate("DevgroupDialog", text); }
void refresh() { send(CHAT_DEVGROUP_STATUS); }
DevgroupDialog(QWidget* parent, const QString& channel, quint64 peer) : QDialog(parent), channel(channel), peer(peer) {
setAttribute(Qt::WA_DeleteOnClose); setModal(true); setWindowTitle(tr("Add device")); resize(420, 140);
auto* layout = new QVBoxLayout(this);
status = new QLabel(tr("Waiting for PIN"), this); status->setWordWrap(true);
status->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(status);
auto* close = new QDialogButtonBox(QDialogButtonBox::Close, this); layout->addWidget(close);
connect(close, &QDialogButtonBox::rejected, this, &QDialog::close);
auto* timer = new QTimer(this); timer->setInterval(1000);
connect(timer, &QTimer::timeout, this, [this] {
if (!gui_bridge_is_uasync_ready()) { pending.clear(); status->setText(tr("Core unavailable")); }
else if (!pending.values().contains(CHAT_DEVGROUP_STATUS)) refresh();
});
timer->start(); send(CHAT_DEVGROUP_INVITE);
}
void result(const uint8_t* data, int len) {
if (len < 9) return;
quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8*i);
if (!pending.contains(id)) return;
const int action = pending.take(id);
if (!data[8]) { status->setText(tr("Operation failed. Check connection and invitation status.")); return; }
const auto json = QJsonDocument::fromJson(QByteArray(reinterpret_cast<const char*>(data+9), len-9)).object();
if (action == CHAT_DEVGROUP_INVITE) {
outgoingId = json["id"].toString(); pinText = tr("PIN: %1 — enter it on the other device within 2 minutes.").arg(json["pin"].toString());
status->setText(pinText); return;
}
const QStringList states = {{}, tr("Waiting for PIN"), tr("Checking PIN"), tr("Transferring keys"), tr("Joined"),
tr("Rejected"), tr("Expired"), tr("Result unknown")};
for (const auto value : json["invitations"].toArray()) {
const auto invitation = value.toObject(); const int state = invitation["state"].toInt();
if (invitation["id"].toString() == outgoingId) status->setText((state < 4 ? pinText + "\n" : QString()) + states.value(state));
}
}
private:
void send(chat_devgroup_action action) {
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: allocation failed"); return; }
req->inst = gui_bridge_get_inst(); req->request_id = ++nextRequest; req->group = channel.toULongLong(); req->peer = peer;
req->action = action; const quint64 id = req->request_id;
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
u_free(req); status->setText(tr("Core unavailable")); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: core unavailable"); return;
}
pending.insert(id, action);
}
QString channel, outgoingId, pinText; quint64 peer;
QLabel* status;
QHash<quint64, int> pending;
};
static void devgroupResult(const uint8_t* data, int len) {
if (!len) {
refreshInvitations();
if (active) active->refresh();
} else {
if (len >= 9) {
quint64 id = 0; for (int i = 0; i < 8; ++i) id |= quint64(data[i]) << (8*i);
if (leaveRequests.contains(id)) {
const auto button = leaveRequests.take(id);
if (!data[8]) {
DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect refused");
if (button) { button->setEnabled(true); QMessageBox::warning(button->window(),
QCoreApplication::translate("ChatGui", "Disconnect from my devices"),
DevgroupDialog::tr("Operation failed. Check connection and invitation status.")); }
} else DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: own device disconnected");
return;
}
const bool automatic = id == statusRequest || acceptRequests.contains(id);
if (id == statusRequest) statusRequest = 0;
if (acceptRequests.contains(id)) awaitingPins.remove(acceptRequests.take(id));
if (automatic) {
if (data[8] && id >= lastAutomaticSnapshot) {
lastAutomaticSnapshot = id;
automaticInvitations(QJsonDocument::fromJson(QByteArray(reinterpret_cast<const char*>(data+9), len-9)).object());
}
else if (!data[8]) DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: automatic PIN/status request refused");
}
}
if (active) active->result(data, len);
}
}
static void refreshInvitations() {
if (!invitationWindow || statusRequest || !gui_bridge_is_uasync_ready()) return;
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: status allocation failed"); return; }
req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_STATUS; req->request_id = ++nextRequest;
const quint64 id = req->request_id;
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: status enqueue failed"); return;
}
statusRequest = id;
}
static void automaticInvitations(const QJsonObject& snapshot) {
if (!invitationWindow) return;
QSet<QString> waiting;
for (const auto value : snapshot["invitations"].toArray()) {
const auto entry = value.toObject(); const QString id = entry["id"].toString();
if (entry["outgoing"].toInt() || entry["state"].toInt() != 1) continue;
waiting.insert(id);
if (pinDialogs.value(id) || dismissedInvites.contains(id) || awaitingPins.contains(id)) continue;
auto* dialog = new QInputDialog(invitationWindow); pinDialogs.insert(id, dialog);
dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setObjectName("devgroupIncomingPin");
dialog->setWindowTitle(DevgroupDialog::tr("Add device"));
dialog->setLabelText(entry["peer"].toString() + "\n" + DevgroupDialog::tr(attemptedPins.contains(id) ?
"PIN was not accepted. Try again." : "PIN shown on the inviting device"));
dialog->setInputMode(QInputDialog::TextInput); dialog->setWindowModality(Qt::ApplicationModal);
if (auto* edit = dialog->findChild<QLineEdit*>()) {
edit->setMaxLength(4); edit->setValidator(new QRegularExpressionValidator(QRegularExpression("[0-9]{0,4}"), edit));
}
QObject::connect(dialog, &QInputDialog::textValueChanged, dialog, [dialog](const QString& text) {
if (auto* box = dialog->findChild<QDialogButtonBox*>()) box->button(QDialogButtonBox::Ok)->setEnabled(text.size() == 4);
});
QObject::connect(dialog, &QDialog::finished, dialog, [dialog, id](int result) {
pinDialogs.remove(id);
if (result != QDialog::Accepted) { dismissedInvites.insert(id); return; }
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN request allocation failed"); return; }
const auto pin = dialog->textValue().toLatin1(), invite = id.toLatin1();
if (pin.size() != 4 || invite.size() != 32) { u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: invalid PIN request"); return; }
req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_ACCEPT; req->request_id = ++nextRequest;
memcpy(req->text, invite.constData(), 32); memcpy(req->pin, pin.constData(), 4);
const quint64 request = req->request_id;
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
memset(req, 0, sizeof(*req)); u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN enqueue failed"); return;
}
awaitingPins.insert(id); attemptedPins.insert(id); acceptRequests.insert(request, id);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: PIN submitted invite=%s", qPrintable(id));
});
dialog->open();
if (auto* box = dialog->findChild<QDialogButtonBox*>()) box->button(QDialogButtonBox::Ok)->setEnabled(false);
DEBUG_INFO(DEBUG_CATEGORY_CHAT, "devgroup UI: incoming PIN dialog opened invite=%s", qPrintable(id));
}
for (const auto& id : pinDialogs.keys()) if (!waiting.contains(id)) {
auto dialog = pinDialogs.take(id); if (dialog) { dialog->disconnect(); dialog->close(); }
}
}
void initDevgroupUi(QWidget* window) {
if (invitationWindow) return;
statusRequest = 0; lastAutomaticSnapshot = 0; acceptRequests.clear(); dismissedInvites.clear(); awaitingPins.clear(); attemptedPins.clear();
invitationWindow = window; gui_bridge_set_devgroup_result_cb(devgroupResult);
auto* timer = new QTimer(window); timer->setInterval(1000);
QObject::connect(timer, &QTimer::timeout, window, [] {
if (!gui_bridge_is_uasync_ready()) {
statusRequest = 0; lastAutomaticSnapshot = 0; acceptRequests.clear(); awaitingPins.clear(); attemptedPins.clear(); dismissedInvites.clear();
automaticInvitations(QJsonObject{{"invitations", QJsonArray{}}}); return;
}
refreshInvitations();
});
timer->start(); refreshInvitations();
}
void inviteMyDevice(QWidget* parent, const QString& channel, quint64 peer) {
if (active) { active->close(); active = nullptr; }
gui_bridge_set_devgroup_result_cb(devgroupResult);
active = new DevgroupDialog(parent, channel, peer); active->show();
}
void disconnectMyDevice(QPushButton* button) {
auto* req = static_cast<chat_devgroup_request*>(u_calloc(1, sizeof(chat_devgroup_request)));
if (!req) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect allocation failed"); return; }
req->inst = gui_bridge_get_inst(); req->action = CHAT_DEVGROUP_LEAVE; req->request_id = ++nextRequest;
const quint64 id = req->request_id; gui_bridge_set_devgroup_result_cb(devgroupResult);
if (gui_bridge_post_uasync_owned_fn(chat_devgroup_trampoline, req) < 0) {
u_free(req); DEBUG_WARN(DEBUG_CATEGORY_CHAT, "devgroup UI: disconnect enqueue failed");
QMessageBox::warning(button->window(), QCoreApplication::translate("ChatGui", "Disconnect from my devices"),
DevgroupDialog::tr("Core unavailable")); return;
}
leaveRequests.insert(id, button); button->setEnabled(false);
}
+14
View File
@@ -0,0 +1,14 @@
/* Диалоги сопряжения устройств и отключение self. Все операции передаются в uasync; секреты не читаются GUI. */
#ifndef DEVGROUPDIALOG_H
#define DEVGROUPDIALOG_H
#include <QtGlobal>
#include <QString>
class QWidget;
class QPushButton;
/* Один обработчик входящих приглашений на главное окно; диалог появляется независимо от текущего экрана. */
void initDevgroupUi(QWidget* window);
/* Сразу создать приглашение выбранному устройству и показать PIN. */
void inviteMyDevice(QWidget* parent, const QString& channel, quint64 peer);
/* Отключить self; кнопка блокируется до результата, при отказе восстанавливается и показывается ошибка. */
void disconnectMyDevice(QPushButton* button);
#endif
+30 -16
View File
@@ -1,3 +1,4 @@
#include "devgroupdialog.h"
#include <QCoreApplication>
#include "mainwindow.h"
#include "single_instance.h"
@@ -172,6 +173,7 @@ MainWindow::MainWindow(QWidget *parent, DbManager* db, const QString& cfgPath,
new GroupAvatarManager(m_db, this);
setupUi();
setupTray();
initDevgroupUi(this);
setupSoundFromConfig();
if (m_db) {
setupBridgeCallbacks();
@@ -696,8 +698,8 @@ void MainWindow::setupBridgeCallbacks() {
window->setState(CallWindow::WaitDecision);
window->show(); window->raise();
});
gui_bridge_set_call_accepted_cb([](uint64_t call_id) {
if (s_mainWindow) s_mainWindow->onCallAccepted(call_id);
gui_bridge_set_call_accepted_cb([](uint64_t call_id, uint64_t peer) {
if (s_mainWindow) s_mainWindow->onCallAccepted(call_id, peer);
});
gui_bridge_set_call_ended_cb([](uint64_t call_id, uint8_t reason) {
if (s_mainWindow) s_mainWindow->onCallEnded(call_id, reason);
@@ -904,7 +906,9 @@ void MainWindow::setupConnects() {
m_fileBrowser->uploadOwnFiles(channel, paths);
});
connect(m_accountList, &AccountList::callRequested, this, &MainWindow::onCallRequested);
connect(m_messageList, &MessageList::callRequested, this, &MainWindow::onCallRequested);
connect(m_messageList, &MessageList::callRequested, this, [this](const QString& ch, quint64 peer, const QString& name) {
onCallRequested(ch, peer, name);
});
connect(m_accountList, &AccountList::pmRequested, this, &MainWindow::onPmRequested);
connect(m_accountList, &AccountList::inviteRequested, this, &MainWindow::onInviteToPmRequested);
connect(m_messageList, &MessageList::inviteRequested, this, &MainWindow::onInviteToPmRequested);
@@ -929,18 +933,18 @@ void MainWindow::showMemberFiles(const QString& channel, quint64 owner) {
}
/* GUI передаёт мосту значения; ядро и время жизни запроса выбирает мост. */
void MainWindow::onRadioCallRequested(const QString& channel, quint64 peer) {
void MainWindow::onRadioCallRequested(const QString& channel, quint64 peer, bool devices) {
if (m_radioCallWindow) { m_radioCallWindow->show(); m_radioCallWindow->raise(); return; }
quint64 id = QRandomGenerator::global()->generate64();
if (gui_bridge_radio_call(GUI_RADIO_CALL_START, id ? id : 1, channel.toULongLong(), peer) < 0)
if (gui_bridge_radio_call(devices ? GUI_RADIO_CALL_START_DEVICES : GUI_RADIO_CALL_START, id ? id : 1, channel.toULongLong(), peer) < 0)
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "desktop radio call start was not queued channel=%s peer=%llu",
qPrintable(channel), (unsigned long long)peer);
}
/* Один диалог на незавершённое приглашение; принятая рация живёт своим обычным циклом. */
void MainWindow::onRadioCallEvent(const uint8_t* data, int len) {
if (!data || len != 27 || data[24] < RADIO_CALL_OUTGOING || data[24] > RADIO_CALL_WAIT_DECISION ||
data[25] > RADIO_CALL_ALREADY_ACTIVE || data[26] > 1) {
if (!data || len != 27 || data[24] < RADIO_CALL_OUTGOING || data[24] > RADIO_CALL_SELECTED ||
data[25] > RADIO_CALL_POLICY || data[26] > 1) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "desktop invalid radio call event bytes=%d", len); return;
}
quint64 id, group, peer;
@@ -948,6 +952,7 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) {
int state = data[24], reason = data[25]; bool incoming = data[26];
const QString channel = QString::number(group);
if (state == RADIO_CALL_FINISHED) {
m_messageList->finishRadioCall(id, reason == RADIO_CALL_ACCEPTED);
if (m_radioCallId == id) {
m_radioCallId = 0;
SoundManager::instance()->stopLoop();
@@ -962,8 +967,7 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) {
if (reason == RADIO_CALL_ACCEPTED) return;
}
if ((incoming && reason != RADIO_CALL_AUDIO_ERROR) || reason == RADIO_CALL_CANCELLED) return;
const char* text = reason == RADIO_CALL_ALREADY_ACTIVE ? "Subscriber's radio is already on in this channel" :
reason == RADIO_CALL_PHONE_BUSY ? "Subscriber is on a phone call" : reason == RADIO_CALL_DECLINED ? "Radio call declined" :
const char* text = reason == RADIO_CALL_PHONE_BUSY ? "Subscriber is on a phone call" : (reason == RADIO_CALL_DECLINED || reason == RADIO_CALL_POLICY) ? "Radio call declined" :
reason == RADIO_CALL_BUSY ? "Subscriber is busy" : reason == RADIO_CALL_NO_ANSWER ? "No answer" :
reason == RADIO_CALL_AUDIO_ERROR ? "Could not start channel radio" : "Subscriber is unavailable";
auto* notice = new QMessageBox(QMessageBox::Information, QCoreApplication::translate("ChatGui", "Radio call"),
@@ -973,6 +977,14 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) {
m_trayIcon->showMessage(QCoreApplication::translate("ChatGui", "Radio call"), QCoreApplication::translate("ChatGui", text));
return;
}
if (state == RADIO_CALL_SELECTED && incoming && m_radioCallId == id) {
SoundManager::instance()->stopLoop();
m_channelList->selectChannel(channel);
bool ready = m_currentChannelId == channel && m_messageList->enableRadioForCall(id);
gui_bridge_radio_call(ready ? GUI_RADIO_CALL_ACCEPT : GUI_RADIO_CALL_FAIL, id, 0, 0);
return;
}
if (state == RADIO_CALL_ANSWERING && incoming) return;
if (state == RADIO_CALL_RINGING) {
if (m_radioCallId == id && m_radioCallWindow) {
m_radioCallWindow->setInformativeText(QCoreApplication::translate("ChatGui", "Ringing…"));
@@ -1007,9 +1019,7 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) {
m_radioCallAnswered = true;
if (button != accept) { gui_bridge_radio_call(GUI_RADIO_CALL_DECLINE, id, 0, 0); return; }
SoundManager::instance()->stopLoop();
m_channelList->selectChannel(channel);
bool ready = m_currentChannelId == channel && m_messageList->enableRadio();
gui_bridge_radio_call(ready ? GUI_RADIO_CALL_ACCEPT : GUI_RADIO_CALL_FAIL, id, 0, 0);
gui_bridge_radio_call(GUI_RADIO_CALL_ANSWER, id, 0, 0);
});
} else if (state == RADIO_CALL_WAIT_DECISION) {
box->setInformativeText(QCoreApplication::translate("ChatGui", "Subscriber is on a phone call"));
@@ -1042,7 +1052,7 @@ void MainWindow::onRadioCallEvent(const uint8_t* data, int len) {
}
}
void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QString& name) {
void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QString& name, bool devices) {
if (chId.startsWith(QStringLiteral("dm:")) && !m_activeCalls.isEmpty()) {
auto* w = m_activeCalls.constBegin().value();
DEBUG_INFO(DEBUG_CATEGORY_CALL, "PM call: existing call window id=%llu", (unsigned long long)w->callId());
@@ -1052,7 +1062,7 @@ void MainWindow::onCallRequested(const QString& chId, quint64 nodeId, const QStr
quint64 callId = QRandomGenerator::global()->generate64();
if (!callId) callId = 1;
if (gui_bridge_call_start(chId.toUtf8().constData(), nodeId, callId) < 0) {
if ((devices ? gui_bridge_call_start_devices : gui_bridge_call_start)(chId.toUtf8().constData(), nodeId, callId) < 0) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "desktop call start was not queued id=%llu", (unsigned long long)callId); return;
}
@@ -1098,10 +1108,14 @@ void MainWindow::onCallRinging(quint64 callId) {
SoundManager::instance()->playLoop("call_ringing");
}
void MainWindow::onCallAccepted(quint64 callId) {
void MainWindow::onCallAccepted(quint64 callId, quint64 peer) {
SoundManager::instance()->stopLoop();
CallWindow* w = m_activeCalls.value(callId, nullptr);
if (w) w->setState(CallWindow::Active);
if (w) {
QString name = m_db->getDisplayName(peer);
w->setPeerName(name.isEmpty() ? QStringLiteral("0x%1").arg(peer, 16, 16, QChar('0')) : name);
w->setState(CallWindow::Active);
}
if (!s_mainWindow) return;
if (CallAudioEngine::instance()->start(callId) != 0) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "desktop call cannot acquire audio; hangup id=%llu", (unsigned long long)callId);
+3 -3
View File
@@ -95,13 +95,13 @@ public:
/* звонки */
// Создать исходящее окно после принятия START мостом; chId может быть dm:<conv_id>.
void onCallRequested(const QString& chId, quint64 nodeId, const QString& name);
void onCallRequested(const QString& chId, quint64 nodeId, const QString& name, bool devices = false);
// Показать входящий callId с именем caller из DbManager; повтор ID не создаёт окно.
void onCallIncoming(const QString& chId, quint64 caller, quint64 callId);
// Обновить состояние существующего исходящего окна и тон.
void onCallRinging(quint64 callId);
// Активировать callId/audio; при отказе capture послать hangup.
void onCallAccepted(quint64 callId);
void onCallAccepted(quint64 callId, quint64 peer);
// Завершить audio/окно, при PHONE_BUSY показать причину.
void onCallEnded(quint64 callId, uint8_t reason);
// Залогировать протокольную ошибку и закрыть окно callId.
@@ -111,7 +111,7 @@ public:
// Снять callId из map до deleteLater; повтор безопасен.
void closeCallWindow(quint64 callId);
// Передать START для CHAT-канала и peer с новым ненулевым ID.
void onRadioCallRequested(const QString& channel, quint64 peer);
void onRadioCallRequested(const QString& channel, quint64 peer, bool devices = false);
// Применить 27-байтный radio_call event, управлять диалогом/рингтоном и READY/ACCEPT.
void onRadioCallEvent(const uint8_t* data, int len);
+96
View File
@@ -318,3 +318,99 @@ void MemberListModel::onRadioTalk(quint64 groupId, quint64 nodeId, bool on) {
return;
}
}
static QByteArray memberGroup(const MemberListModel* source, int row) {
const auto member = source->member(source->nodeIdAt(row));
if (!member || !member->devgroup_name[0]) return {};
return QByteArray(reinterpret_cast<const char*>(member->devgroup), 32);
}
GroupedMemberModel::GroupedMemberModel(MemberListModel* source, QObject* parent) : QAbstractListModel(parent), m_source(source) {
connect(source, &QAbstractItemModel::modelReset, this, [this] { rebuild(true); });
connect(source, &QAbstractItemModel::rowsInserted, this, [this] { rebuild(); });
connect(source, &QAbstractItemModel::rowsRemoved, this, [this] { rebuild(); });
connect(source, &QAbstractItemModel::dataChanged, this, [this] { rebuild(); });
rebuild();
}
void GroupedMemberModel::rebuild(bool forceReset) {
QVector<int> rows; QSet<QByteArray> seen;
QVector<int> order; order.reserve(m_source->rowCount());
for (int i = 0; i < m_source->rowCount(); ++i) order.append(i);
std::stable_partition(order.begin(), order.end(), [this](int row) { return m_source->index(row).data(Qt::UserRole + 2).toBool(); });
for (int i : order) {
const auto group = memberGroup(m_source, i);
if (group.isEmpty()) { rows.append(i); continue; }
if (seen.contains(group)) continue;
seen.insert(group); rows.append(-i-1);
if (m_expanded.contains(group)) for (int j : order)
if (memberGroup(m_source, j) == group) rows.append(j);
}
if (rows != m_rows) GUI_DEBUG("Member order: self_first=%d rows=%d first_node=%016llx",
!order.isEmpty() && m_source->index(order[0]).data(Qt::UserRole + 2).toBool(), int(rows.size()),
(unsigned long long)m_source->nodeIdAt(order.value(0, -1)));
if (forceReset || rows != m_rows) { beginResetModel(); m_rows = rows; endResetModel(); }
else if (!rows.isEmpty()) emit dataChanged(index(0), index(rows.size()-1));
}
int GroupedMemberModel::rowCount(const QModelIndex& parent) const { return parent.isValid() ? 0 : m_rows.size(); }
int GroupedMemberModel::sourceRow(int row) const { return row >= 0 && row < m_rows.size() ? m_rows[row] : -1; }
bool GroupedMemberModel::toggle(int row) {
if (row < 0 || row >= m_rows.size() || m_rows[row] >= 0) return false;
const auto group = memberGroup(m_source, -m_rows[row]-1);
if (!m_expanded.remove(group)) m_expanded.insert(group);
GUI_DEBUG("Device group: toggled group=%s expanded=%d", group.toHex().constData(), m_expanded.contains(group));
rebuild(); return true;
}
int GroupedMemberModel::avatarSourceRow(int row) const {
if (row < 0 || row >= m_rows.size()) return -1;
return m_rows[row] >= 0 ? m_rows[row] : -m_rows[row]-1;
}
int GroupedMemberModel::actionSourceRow(int row) const {
if (row < 0 || row >= m_rows.size()) return -1;
if (m_rows[row] >= 0) return m_rows[row];
const int first = -m_rows[row]-1;
const auto group = memberGroup(m_source, first);
if (m_source->isOnlineAt(first) && !(m_source->index(first).data(Qt::UserRole + 3).toInt() & MEMBER_FLAG_DELETED)) return first;
for (int i = 0; i < m_source->rowCount(); ++i)
if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i) &&
!(m_source->index(i).data(Qt::UserRole + 3).toInt() & MEMBER_FLAG_DELETED)) return i;
return -1;
}
QVariant GroupedMemberModel::data(const QModelIndex& idx, int role) const {
if (!idx.isValid() || idx.model() != this || idx.row() < 0 || idx.row() >= m_rows.size()) return {};
const int row = m_rows[idx.row()], target = actionSourceRow(idx.row());
if (role == CallAvailableRole) {
if (row >= 0) return m_source->isOnlineAt(row) && !m_source->index(row).data(Qt::UserRole + 2).toBool();
const auto group = memberGroup(m_source, -row-1);
for (int i = 0; i < m_source->rowCount(); ++i)
if (memberGroup(m_source, i) == group && m_source->isOnlineAt(i) &&
!m_source->index(i).data(Qt::UserRole + 2).toBool() &&
!(m_source->index(i).data(Qt::UserRole + 3).toInt() & MEMBER_FLAG_DELETED)) return true;
return false;
}
if (role == GroupHeaderRole) return row < 0;
if (role == GroupBranchRole) {
const auto group = row < 0 ? QByteArray() : memberGroup(m_source, row);
if (group.isEmpty()) return 0;
const int next = idx.row() + 1;
return next < m_rows.size() && m_rows[next] >= 0 && memberGroup(m_source, m_rows[next]) == group ? 1 : 2;
}
if (role == ActionNodeIdRole) return QVariant::fromValue(m_source->nodeIdAt(target));
if (row >= 0) return m_source->data(m_source->index(row), role);
const int avatar = avatarSourceRow(idx.row()); const auto group = memberGroup(m_source, avatar);
const auto member = m_source->member(m_source->nodeIdAt(avatar)); if (!member) return {};
if (role == GroupExpandedRole) return m_expanded.contains(group);
if (role == Qt::DisplayRole) return QString::fromUtf8(member->devgroup_name);
if (role == Qt::DecorationRole) return m_source->data(m_source->index(avatar), role);
if (role == MemberListModel::OnlineRole) return target >= 0;
if (role == Qt::UserRole + 3) {
const int shared = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY;
int flags = target < 0 ? 0 : m_source->index(target).data(role).toInt() & ~shared;
for (int i = 0; i < m_source->rowCount(); ++i) if (memberGroup(m_source, i) == group) {
const int deviceFlags = m_source->index(i).data(role).toInt();
if (!(deviceFlags & MEMBER_FLAG_DELETED)) flags |= deviceFlags & shared;
}
return flags;
}
if (role == Qt::UserRole + 1) return target < 0 ? QCoreApplication::translate("ChatGui", "offline") :
m_source->data(m_source->index(target), Qt::DisplayRole).toString() + " · " + m_source->data(m_source->index(target), role).toString();
return target < 0 ? QVariant() : m_source->data(m_source->index(target), role);
}
+26
View File
@@ -78,4 +78,30 @@ private:
QTimer* m_rttTimer; // QObject-child, останавливается при уничтожении модели
};
/* Представление поверх снимков: заголовки не являются устройствами; аватар берётся у первого
* участника группы, кнопки действуют на первое online-устройство. RADIO/PHONE-флаги заголовка
* объединяют состояния всех неудалённых участников; mute/self относятся к цели кнопок.
* Self/его группа всегда первые, в раскрытой группе self тоже первый; порядок остальных сохраняется. Источник заимствован. */
class GroupedMemberModel : public QAbstractListModel {
Q_OBJECT
public:
static constexpr int GroupHeaderRole = Qt::UserRole + 7;
static constexpr int GroupExpandedRole = Qt::UserRole + 8;
static constexpr int ActionNodeIdRole = Qt::UserRole + 9; // конкретное устройство для кнопок; 0 если в группе нет online
static constexpr int GroupBranchRole = Qt::UserRole + 10; // 0 — без ветки; 1 — продолжение; 2 — последнее устройство
static constexpr int CallAvailableRole = Qt::UserRole + 11; // Есть доступный адресат кроме локального узла.
static constexpr int DisclosureWidth = 24; // одинаковая левая колонка во всех строках
explicit GroupedMemberModel(MemberListModel* source, QObject* parent = nullptr);
int rowCount(const QModelIndex& parent = {}) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
int sourceRow(int row) const; // -1 для заголовка/неверной строки
int actionSourceRow(int row) const; // заголовок: первое online-устройство, -1 если их нет
int avatarSourceRow(int row) const; // заголовок: первое устройство независимо от online
bool toggle(int row); // true — обработан заголовок
private:
void rebuild(bool forceReset = false);
MemberListModel* m_source;
QVector<int> m_rows; // >=0 — строка источника, <0 — заголовок группы строки -value-1
QSet<QByteArray> m_expanded;
};
#endif
+12 -4
View File
@@ -1,3 +1,4 @@
#include "devgroupdialog.h"
#include <QCoreApplication>
#include "memberpropsdialog.h"
#include "../db/db_manager.h"
@@ -40,14 +41,14 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
QString curNick;
bool curVerified = false;
bool curDeleted = false;
bool curStorage = false;
bool curStorage = false, inDeviceGroup = false;
m_currentVer = 0;
bool loaded = false;
if (m_db && m_db->m_db) {
QString tbl = m_db->peersTableName(channelId);
QByteArray sql = QString(
"SELECT p.adm_tags,p.local_nick,length(c.ed25519_privkey) FROM \"%1\" p "
"JOIN channels c ON c.channel_id=? WHERE p.node_id=?"
"SELECT p.adm_tags,p.local_nick,length(c.ed25519_privkey),length(n.devgroup_ed) FROM \"%1\" p "
"JOIN channels c ON c.channel_id=? LEFT JOIN nodes n ON n.node_id=p.node_id WHERE p.node_id=?"
).arg(tbl).toUtf8();
sqlite3_stmt* st = nullptr;
int rc = sqlite3_prepare_v2(m_db->m_db, sql.constData(), -1, &st, nullptr);
@@ -65,7 +66,7 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
curDeleted = tags.value("deleted").toString() == "yes";
curStorage = tags.value("storage").toString() == "yes";
curNick = DbManager::colText(st, 1);
m_haveAdminKey = sqlite3_column_int(st, 2) == 32;
m_haveAdminKey = sqlite3_column_int(st, 2) == 32; inDeviceGroup = sqlite3_column_int(st, 3) == 32;
}
int finish = sqlite3_finalize(st); loaded = rc == SQLITE_ROW && finish == SQLITE_OK;
if (!loaded) DEBUG_ERROR(DEBUG_CATEGORY_CHAT_SYNC, "member properties: read ch=%s node=%016llx rc=%d finalize=%d: %s",
@@ -123,6 +124,13 @@ MemberPropsDialog::MemberPropsDialog(DbManager* db, const QString& channelId,
layout->addWidget(hint);
}
auto* addDevice = new QPushButton(QCoreApplication::translate("ChatGui", isOwn ? "Disconnect from my devices" : "Add device"), this);
addDevice->setObjectName("memberAddMyDevice"); addDevice->setEnabled(loaded && !curDeleted && (!isOwn || inDeviceGroup));
connect(addDevice, &QPushButton::clicked, this, [this, addDevice] {
if (m_isOwn) disconnectMyDevice(addDevice); else inviteMyDevice(this, m_channelId, m_nodeId);
});
layout->addWidget(addDevice);
m_transferBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Transfer admin rights"), this);
m_transferBtn->setEnabled(haveKey && !isOwn);
m_transferBtn->setToolTip(QCoreApplication::translate("ChatGui", "Send the channel private key to this member (encrypted + signed)"));
+19
View File
@@ -1725,6 +1725,24 @@ void MessageList::onStreamSources(const uint8_t* data, int len) {
(unsigned long long)group, (int)m_streamSources.size(), (int)remote, remote > 0);
}
bool MessageList::enableRadioForCall(quint64 id) {
const bool wasActive = m_radioOn && RadioAudioEngine::instance()->isActive()
&& RadioAudioEngine::instance()->groupId() == m_currentChannelId.toULongLong();
bool ready = enableRadio();
if (ready && !wasActive) m_radioCallOwner = id;
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio call activation id=%llx ready=%d owned=%d", (unsigned long long)id, ready, !wasActive);
return ready;
}
void MessageList::finishRadioCall(quint64 id, bool accepted) {
if (m_radioCallOwner != id) return;
m_radioCallOwner = 0;
if (!accepted) {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "radio call activation cancelled id=%llx", (unsigned long long)id);
onRadioToggled(false);
}
}
bool MessageList::enableRadio() {
if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return false;
if (!m_radioOn) onRadioToggled(true);
@@ -1735,6 +1753,7 @@ bool MessageList::enableRadio() {
}
void MessageList::onRadioToggled(bool on) {
m_radioCallOwner = 0;
if (m_currentChannelId.isEmpty() || isDmChannel(m_currentChannelId)) return;
quint64 groupId = m_currentChannelId.toULongLong();
+3
View File
@@ -79,6 +79,8 @@ public:
// Обновить encrypted-вложение и диалог выдачи ключей текущего канала.
void onAttachmentKeysChanged(const uint8_t* data, int len);
void onRadioTalk(quint64 groupId, quint64 srcNodeId, bool on); /* индикация говорящего */
bool enableRadioForCall(quint64 id); /* Только после SELECT; учитывает собственное включение попытки. */
void finishRadioCall(quint64 id, bool accepted); /* Отбой откатывает только собственное ещё не принятое включение. */
bool enableRadio(); /* идемпотентное включение рации выбранного канала, false при ошибке аудио */
// Применить проверенный полный каталог выбранной CHAT-группы.
void onStreamSources(const uint8_t* data, int len);
@@ -143,6 +145,7 @@ private:
quint64 m_streamPublisherGroup = 0; // Канал локального publisher, независимо от снимков каталога.
QList<LiveStreamSource> m_streamSources; // Проверенный полный снимок выбранной группы.
RadioPanel* m_radioPanel = nullptr; /* верхняя панель устройств, VAD и PTT */
quint64 m_radioCallOwner = 0; // Сбрасывается ручным переключением/сменой канала.
bool m_radioOn = false; // Локальный audio engine успешно включён для текущей группы.
std::shared_ptr<MediaPreviewCache::Cache> m_previewCache; // Общий кеш картинок/первых кадров; workers удерживают собственную ссылку.
QThreadPool m_previewPool; // Не более двух workers, включая очистку; деструктор дожидается jobs.
+6 -5
View File
@@ -468,7 +468,7 @@ private slots:
QSignalSpy radios(&delegate, &AccountDelegate::radioCallRequested);
QSignalSpy calls(&delegate, &AccountDelegate::callRequested);
const QRect row = view.visualRect(model.index(0, 0));
const QPoint radio(row.right() - 50, row.center().y()), phone(row.right() - 17, row.center().y());
const QPoint radio(row.right() - 82, row.center().y()), phone(row.right() - 50, row.center().y());
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, radio);
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, phone);
QCOMPARE(radios.count(), 0); QCOMPARE(calls.count(), 0);
@@ -507,7 +507,7 @@ private slots:
QPainter painter(&image); delegate.paint(&painter, option, model.index(0, 0)); painter.end();
int count = 0;
for (int y = 0; y < image.height(); ++y)
for (int x = image.width() - 31; x < image.width(); ++x) count += image.pixelColor(x, y) == color;
for (int x = image.width() - 64; x < image.width() - 36; ++x) count += image.pixelColor(x, y) == color;
return count;
};
const int known = CHAT_MEMBER_FLAG_PHONE_KNOWN, busy = CHAT_MEMBER_FLAG_PHONE_BUSY;
@@ -540,7 +540,8 @@ private slots:
action == 2 ? &AccountDelegate::callRequested : &AccountDelegate::radioMuteRequested;
QSignalSpy activated(&delegate, signal);
const QRect row = view.visualRect(model.index(0, 0));
const QPoint radio(action == 0 ? row.left() + 21 : row.right() - (action == 1 ? 50 : action == 2 ? 17 : 82), row.center().y());
const QPoint radio(action == 0 ? row.left() + GroupedMemberModel::DisclosureWidth + 21 :
row.right() - (action == 1 ? 82 : action == 2 ? 50 : 18), row.center().y());
QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, radio);
for (QPoint point : {radio + QPoint(-15, 0), radio}) {
QMouseEvent move(QEvent::MouseMove, point, view.viewport()->mapToGlobal(point), Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
@@ -566,14 +567,14 @@ private slots:
});
const QModelIndex index = model.index(0, 0);
const QRect row = view.visualRect(index);
const QPoint button(row.right() - 82, row.center().y());
const QPoint button(row.right() - 18, row.center().y());
auto colorCount = [&](QRgb color) {
QImage image(view.viewport()->size(), QImage::Format_RGB32); image.fill(Qt::white);
QStyleOptionViewItem option; option.initFrom(&view); option.rect = row;
QPainter painter(&image); delegate.paint(&painter, option, index); painter.end();
int count = 0;
for (int y = row.top(); y <= row.bottom(); ++y)
for (int x = row.right() - 95; x < row.right() - 67; ++x) count += image.pixel(x, y) == color;
for (int x = row.right() - 31; x < row.right() - 3; ++x) count += image.pixel(x, y) == color;
return count;
};
QVERIFY(colorCount(QColor("#9E9E9E").rgb()) > 0); // Молчащий участник серый; зелёный означает передачу.
+280 -2
View File
@@ -1,5 +1,11 @@
/* Настоящие Qt-карточка и модель; очередь ядра задерживает ответы для проверки смены выбора. */
#include "../src/accountlist.h"
#include "../src/accountdelegate.h"
#include "../src/devgroupdialog.h"
#include <QInputDialog>
#include <QDialogButtonBox>
#include <QLabel>
#include <QPushButton>
#include "../src/memberlistmodel.h"
#include "../transport/gui_bridge.h"
#include "../transport/member_edit.h"
@@ -8,6 +14,8 @@
#include <QListView>
#include <QAbstractItemModelTester>
#include <QMenu>
#include <QPainter>
#include <QMouseEvent>
#include <QScopeGuard>
#include <memory>
#include <functional>
@@ -16,12 +24,15 @@ extern "C" {
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_member.h"
#include "chat/chat_devgroup.h"
#include "member_files/protocol.h"
#include "debug_config.h"
#include "mem.h"
}
static UTUN_INSTANCE instance{};
static std::vector<chat_devgroup_request> devgroupRequests;
static gui_dm_messages_fn devgroupCallback;
static std::vector<chat_member_detail_req> requests;
static gui_member_detail_fn detailCallback;
static bool ready = true;
@@ -29,17 +40,19 @@ static bool rejectOwned = false;
static int ownedPosts = 0;
static std::vector<chat_member_list_req> memberRequests;
static std::vector<chat_connect_node_req> connectRequests;
static std::vector<uint64_t> muteRequests;
extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; }
extern "C" int gui_bridge_is_uasync_ready() { return ready; }
extern "C" void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb) { detailCallback = cb; }
extern "C" void gui_bridge_set_radio_mute_result_cb(gui_dm_messages_fn) {}
extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t) { return 0; }
extern "C" int gui_bridge_toggle_member_radio_mute(const char*, uint64_t node) { muteRequests.push_back(node); return 0; }
extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) {
if (fn == chat_core_collect_member_detail_trampoline) requests.push_back(*static_cast<chat_member_detail_req*>(arg));
if (fn == chat_core_request_member_list_trampoline || fn == chat_core_request_member_rtt_trampoline)
memberRequests.push_back(*static_cast<chat_member_list_req*>(arg));
if (fn == chat_core_connect_node_trampoline) connectRequests.push_back(*static_cast<chat_connect_node_req*>(arg));
if (fn == chat_devgroup_trampoline) devgroupRequests.push_back(*static_cast<chat_devgroup_request*>(arg));
u_free(arg);
}
extern "C" int gui_bridge_post_uasync_owned_fn(void (*fn)(void*), void* arg) {
@@ -123,10 +136,58 @@ class MemberDetailTest : public QObject {
}
private slots:
void init() {
requests.clear(); memberRequests.clear(); connectRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true;
requests.clear(); memberRequests.clear(); connectRequests.clear(); muteRequests.clear(); ownedPosts = 0; rejectOwned = false; ready = true;
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
}
void deviceInvitationOpensPinAutomaticallyAndReturnsEnteredPin() {
QWidget window; devgroupRequests.clear(); initDevgroupUi(&window);
QVERIFY(devgroupCallback && !devgroupRequests.empty());
auto respond = [](quint64 request, const QByteArray& json) {
QByteArray bytes(9, 0); mf_put64(reinterpret_cast<uint8_t*>(bytes.data()), request); bytes[8] = 1; bytes += json;
devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
};
const QByteArray invite = "0123456789abcdef0123456789abcdef";
const QByteArray snapshot = "{\"group\":\"\",\"name\":\"\",\"invitations\":[{\"id\":\"" + invite +
"\",\"peer\":\"0000000000000007\",\"channel\":\"123\",\"outgoing\":0,\"state\":1}]}";
respond(devgroupRequests.back().request_id, snapshot);
auto* dialog = window.findChild<QInputDialog*>("devgroupIncomingPin"); QVERIFY(dialog && dialog->isVisible());
auto* box = dialog->findChild<QDialogButtonBox*>(); QVERIFY(box); QVERIFY(!box->button(QDialogButtonBox::Ok)->isEnabled());
dialog->setTextValue("1234"); QVERIFY(box->button(QDialogButtonBox::Ok)->isEnabled()); dialog->accept();
QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_ACCEPT);
QCOMPARE(QByteArray(devgroupRequests.back().text), invite); QCOMPARE(QByteArray(devgroupRequests.back().pin), QByteArray("1234"));
// Неверный PIN возвращает waiting: поле появляется повторно, сетевые повторы его не дублируют.
respond(devgroupRequests.back().request_id, snapshot);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
dialog = window.findChild<QInputDialog*>("devgroupIncomingPin"); QVERIFY(dialog && dialog->isVisible());
dialog->reject();
devgroupCallback(nullptr, 0); respond(devgroupRequests.back().request_id, snapshot);
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
QVERIFY(!window.findChild<QInputDialog*>("devgroupIncomingPin"));
}
void addMyDeviceImmediatelyRequestsInvitationAndShowsPin() {
QWidget parent; devgroupRequests.clear(); inviteMyDevice(&parent, "123", 7);
QVERIFY(!devgroupRequests.empty()); const auto request = devgroupRequests.back();
QCOMPARE(request.action, CHAT_DEVGROUP_INVITE); QCOMPARE(request.group, uint64_t(123)); QCOMPARE(request.peer, uint64_t(7));
QByteArray bytes(9, 0); mf_put64(reinterpret_cast<uint8_t*>(bytes.data()), request.request_id); bytes[8] = 1;
bytes += "{\"id\":\"0123456789abcdef0123456789abcdef\",\"pin\":\"1234\"}";
devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
bool shown = false; for (auto* label : parent.findChildren<QLabel*>()) shown |= label->text().contains("1234");
QVERIFY(shown);
}
void disconnectSelfUsesLeaveCommandAndDisablesButton() {
QWidget parent; QPushButton button("Disconnect from my devices", &parent); devgroupRequests.clear();
disconnectMyDevice(&button);
QVERIFY(!button.isEnabled()); QVERIFY(!devgroupRequests.empty());
QCOMPARE(devgroupRequests.back().action, CHAT_DEVGROUP_LEAVE);
QCOMPARE(devgroupRequests.back().peer, uint64_t(0));
QByteArray bytes(9, 0); mf_put64(reinterpret_cast<uint8_t*>(bytes.data()), devgroupRequests.back().request_id); bytes[8] = 1;
bytes += "{}"; devgroupCallback(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
QVERIFY(!button.isEnabled());
}
void actualRouteText_data() {
QTest::addColumn<int>("up"); QTest::addColumn<int>("hops"); QTest::addColumn<int>("localNat"); QTest::addColumn<int>("peerNat");
QTest::addColumn<int>("rtt"); QTest::addColumn<bool>("online"); QTest::addColumn<QString>("compact"); QTest::addColumn<QString>("detail");
@@ -156,6 +217,221 @@ private slots:
QCOMPARE(body(panel).section('\n', 0, 0), QString("DIR(NONE-STRICT) RTT=5ms")); QCOMPARE(requests.size(), previousRequests);
}
void devgroupHeadersAreExpandableAndNeverRepresentADevice() {
MemberListModel source; source.setChannel("123", false);
GroupedMemberModel grouped(&source);
QAbstractItemModelTester tester(&grouped, QAbstractItemModelTester::FailureReportingMode::QtTest);
auto first = row(7, true), second = row(8, false), other = row(9, false);
for (auto* bytes : {&first, &second}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
auto data = first + second + other;
source.onMemberList("123", 3, reinterpret_cast<const uint8_t*>(data.constData()), data.size());
QCOMPARE(grouped.rowCount(), 2); QCOMPARE(grouped.sourceRow(0), -1);
QVERIFY(grouped.index(0).data(GroupedMemberModel::GroupHeaderRole).toBool());
QVERIFY(grouped.index(0).data(MemberListModel::OnlineRole).toBool());
QCOMPARE(grouped.index(0).data(GroupedMemberModel::GroupBranchRole).toInt(), 0);
QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 0);
QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 4);
QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(7));
QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(8));
QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 1);
QCOMPARE(grouped.index(2).data(GroupedMemberModel::GroupBranchRole).toInt(), 2);
QCOMPARE(grouped.index(3).data(GroupedMemberModel::GroupBranchRole).toInt(), 0);
source.onMemberRemoved("123", 7);
QVERIFY(!grouped.index(0).data(MemberListModel::OnlineRole).toBool());
QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(8));
QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 2);
QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 2);
QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 0);
}
void selfIsFirstIncludingLateArrivalAndExpandedDeviceGroup() {
debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG);
MemberListModel source; source.setChannel("123", false);
GroupedMemberModel grouped(&source);
QAbstractItemModelTester tester(&grouped, QAbstractItemModelTester::FailureReportingMode::QtTest);
const auto initial = row(7) + row(8);
source.onMemberList("123", 2, reinterpret_cast<const uint8_t*>(initial.constData()), initial.size());
auto self = row(21, true, 0, 0xffff, 255, 0, true);
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(self.constData()));
QCOMPARE(source.nodeIdAt(grouped.sourceRow(0)), quint64(21));
QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(7));
QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(8));
auto peer = row(7);
for (auto* bytes : {&self, &peer}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(peer.constData()));
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(self.constData()));
QCOMPARE(grouped.rowCount(), 2);
QVERIFY(grouped.index(0).data(GroupedMemberModel::GroupHeaderRole).toBool());
QCOMPARE(source.nodeIdAt(grouped.avatarSourceRow(0)), quint64(21));
QCOMPARE(grouped.index(0).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(21));
QVERIFY(grouped.toggle(0)); QCOMPARE(grouped.rowCount(), 4);
QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(21));
QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(7));
QCOMPARE(source.nodeIdAt(grouped.sourceRow(3)), quint64(8));
QCOMPARE(grouped.index(1).data(GroupedMemberModel::GroupBranchRole).toInt(), 1);
QCOMPARE(grouped.index(2).data(GroupedMemberModel::GroupBranchRole).toInt(), 2);
// Полный refresh с self в середине сохраняет раскрытие и порядок остальных.
const auto refresh = row(8) + self + peer;
source.onMemberList("123", 3, reinterpret_cast<const uint8_t*>(refresh.constData()), refresh.size());
QCOMPARE(grouped.rowCount(), 4);
QCOMPARE(source.nodeIdAt(grouped.sourceRow(1)), quint64(21));
QCOMPARE(source.nodeIdAt(grouped.sourceRow(2)), quint64(7));
source.onMemberRemoved("123", 21);
QCOMPARE(grouped.rowCount(), 3);
QCOMPARE(source.nodeIdAt(grouped.sourceRow(0)), quint64(8));
QCOMPARE(grouped.index(1).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(7));
}
void singleDeviceBranchIsVisible_data() {
QTest::addColumn<bool>("dark");
QTest::newRow("light") << false;
QTest::newRow("actual-dark-palette") << true;
}
void singleDeviceBranchIsVisible() {
QFETCH(bool, dark);
debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG);
AccountList panel; panel.resize(380, 240);
QPalette palette = panel.palette();
palette.setColor(QPalette::Window, dark ? QColor(32, 32, 32) : QColor(240, 240, 240));
palette.setColor(QPalette::WindowText, dark ? QColor(220, 220, 220) : Qt::black);
palette.setColor(QPalette::Base, dark ? QColor(24, 24, 24) : Qt::white);
palette.setColor(QPalette::AlternateBase, dark ? QColor(38, 38, 38) : QColor(247, 247, 247));
palette.setColor(QPalette::Text, dark ? QColor(220, 220, 220) : Qt::black);
palette.setColor(QPalette::Mid, dark ? QColor(30, 30, 30) : QColor(160, 160, 160));
panel.setPalette(palette); panel.show();
auto device = row(7);
memset(device.data() + 164, 42, 32); memcpy(device.data() + 196, "My devices", 11);
fill(panel, "123", device + row(8));
auto* view = panel.findChild<QListView*>();
QTest::qWait(10);
const QRect header = view->visualRect(view->model()->index(0, 0));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(header.left() + 12, header.center().y()));
QCOMPARE(view->model()->rowCount(), 3);
QCOMPARE(view->model()->index(1, 0).data(GroupedMemberModel::GroupBranchRole).toInt(), 2);
const QRect child = view->visualRect(view->model()->index(1, 0));
const QImage image = view->viewport()->grab().toImage();
const QColor background = palette.color(QPalette::AlternateBase);
auto contrast = [&](int x, int y) { return qAbs(image.pixelColor(x, y).lightness() - background.lightness()); };
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "single device branch: dark=%d background=%s mid=%s text=%s vertical_contrast=%d corner_contrast=%d",
dark, qPrintable(background.name()), qPrintable(palette.color(QPalette::Mid).name()),
qPrintable(palette.color(QPalette::Text).name()), contrast(child.left() + 12, child.top() + 4),
contrast(child.left() + 18, child.center().y()));
const QString screenshot = qEnvironmentVariable("UTUN_TEST_SINGLE_GROUP_SCREENSHOT");
if (!screenshot.isEmpty() && dark) QVERIFY(panel.grab().save(screenshot));
QVERIFY2(contrast(child.left() + 12, child.top() + 4) >= 40, "Vertical branch must be visible against the row background");
QVERIFY2(contrast(child.left() + 18, child.center().y()) >= 40, "Final corner must be visible against the row background");
QCOMPARE(contrast(child.left() + 12, child.bottom() - 4), 0); // Линия заканчивается на единственном устройстве.
}
void devgroupControlsFollowFirstOnlineDeviceAndKeepFirstAvatar() {
AccountList panel; panel.resize(380, 350); panel.show();
auto first = row(7, false), second = row(8), third = row(9);
for (auto* bytes : {&first, &second, &third}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
fill(panel, "123", first + second + third + row(10));
auto* view = panel.findChild<QListView*>();
auto* grouped = qobject_cast<GroupedMemberModel*>(view->model()); QVERIFY(grouped);
QSignalSpy calls(&panel, &AccountList::callRequested), radioCalls(&panel, &AccountList::radioCallRequested);
const auto group = grouped->index(0);
QCOMPARE(group.data(Qt::DisplayRole).toString(), QString("My devices"));
QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(8));
QCOMPARE(grouped->avatarSourceRow(0), 0); QCOMPARE(grouped->sourceRow(0), -1);
const auto avatar = qvariant_cast<QIcon>(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage();
QCOMPARE(avatar, qvariant_cast<QIcon>(panel.model()->index(0).data(Qt::DecorationRole)).pixmap(34, 34).toImage());
QTest::qWait(10);
const QRect rect = view->visualRect(group);
const QPoint call(rect.right() - 49, rect.center().y()), radio(rect.right() - 81, rect.center().y());
const QPoint mute(rect.right() - 17, rect.center().y());
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 1); QCOMPARE(calls.last()[1].toULongLong(), quint64(8));
QCOMPARE(calls.last()[2].toString(), QString("My devices"));
QVERIFY(calls.last()[3].toBool());
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, radio);
QCOMPARE(radioCalls.size(), 1); QCOMPARE(radioCalls.last()[1].toULongLong(), quint64(8));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute);
QCOMPARE(muteRequests.size(), size_t(1)); QCOMPARE(muteRequests.back(), uint64_t(8));
QCOMPARE(grouped->rowCount(), 2); // Кнопки не раскрывают группу.
QTest::mousePress(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
second[8] = 0; // Устройство уходит offline между press и release.
panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(second.constData()));
QTest::mouseRelease(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 1);
QCOMPARE(group.data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(9));
QCOMPARE(qvariant_cast<QIcon>(group.data(Qt::DecorationRole)).pixmap(34, 34).toImage(), avatar);
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QCOMPARE(calls.size(), 2); QCOMPARE(calls.last()[1].toULongLong(), quint64(9));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(rect.left() + 12, rect.center().y()));
QCOMPARE(grouped->rowCount(), 5); QVERIFY(grouped->index(0).data(GroupedMemberModel::GroupExpandedRole).toBool());
const QString screenshot = qEnvironmentVariable("UTUN_TEST_GROUP_SCREENSHOT");
if (!screenshot.isEmpty()) QVERIFY(panel.grab().save(screenshot));
third[8] = 0; panel.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(third.constData()));
QCOMPARE(grouped->index(0).data(GroupedMemberModel::ActionNodeIdRole).toULongLong(), quint64(0));
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, call);
QTest::mouseClick(view->viewport(), Qt::LeftButton, Qt::NoModifier, mute);
QCOMPARE(calls.size(), 2); QCOMPARE(muteRequests.size(), size_t(1));
}
void devgroupIndicatorsCombineDevicesEvenWhenFirstOnlineIsSelf() {
MemberListModel source; source.setChannel("123", true);
GroupedMemberModel grouped(&source);
auto self = row(7, true, 0, 0xffff, 255, 0, true), busy = row(8), removed = row(9);
for (auto* bytes : {&self, &busy, &removed}) {
memset(bytes->data() + 164, 42, 32); memcpy(bytes->data() + 196, "My devices", 11);
}
const int active = CHAT_MEMBER_FLAG_RADIO | CHAT_MEMBER_FLAG_PHONE_KNOWN | CHAT_MEMBER_FLAG_PHONE_BUSY;
busy[10] = char(active | CHAT_MEMBER_FLAG_RADIO_MUTED);
removed[10] = char(active | CHAT_MEMBER_FLAG_DELETED);
const auto bytes = self + busy + removed;
source.onMemberList("123", 3, reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
const auto header = grouped.index(0);
QVERIFY(header.data(Qt::UserRole + 2).toBool());
QCOMPARE(header.data(Qt::UserRole + 3).toInt(), active); // mute относится только к цели кнопок.
AccountDelegate delegate;
QStyleOptionViewItem option; option.rect = QRect(0, 0, 340, MemberListModel::kRowHeight);
const QColor background("#FFFFFF");
option.palette.setColor(QPalette::AlternateBase, background);
auto image = [&] {
QImage result(option.rect.size(), QImage::Format_RGB32); result.fill(background);
QPainter painter(&result); delegate.paint(&painter, option, header); painter.end(); return result;
};
auto colors = [](const QImage& image, int rightOffset, const QColor& color) {
int count = 0;
for (int y = 0; y < image.height(); ++y)
for (int x = image.width() - rightOffset - 1; x < image.width() - rightOffset + 27; ++x)
count += image.pixelColor(x, y) == color;
return count;
};
const auto activeImage = image();
QVERIFY(colors(activeImage, 95, QColor("#4CAF50")) > 0);
QVERIFY(colors(activeImage, 63, QColor("#E53935")) > 0);
QVERIFY(colors(activeImage, 31, QColor("#9E9E9E")) > 0);
QSignalSpy calls(&delegate, &AccountDelegate::callRequested), toggles(&delegate, &AccountDelegate::groupToggleRequested);
for (int offset : {81, 49, 17}) {
const QPoint point(option.rect.right() - offset, option.rect.center().y());
QMouseEvent press(QEvent::MouseButtonPress, point, point, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier);
QMouseEvent release(QEvent::MouseButtonRelease, point, point, Qt::LeftButton, Qt::NoButton, Qt::NoModifier);
delegate.editorEvent(&press, &grouped, option, header); delegate.editorEvent(&release, &grouped, option, header);
}
QCOMPARE(calls.size(), 1); QCOMPARE(toggles.size(), 0); // Заголовок вызывает другие свои устройства; mute self не действует.
busy[10] = CHAT_MEMBER_FLAG_PHONE_KNOWN;
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(busy.constData()));
QCOMPARE(header.data(Qt::UserRole + 3).toInt(), int(CHAT_MEMBER_FLAG_PHONE_KNOWN));
QCOMPARE(colors(image(), 63, QColor("#E53935")), 0); // Удалённый busy-аккаунт не влияет на группу.
QCOMPARE(colors(image(), 95, QColor("#4CAF50")), 0); // self не окрашивает выключенную рацию в зелёный.
self[8] = 0; busy[8] = 0;
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(self.constData()));
source.onMemberUpdated("123", reinterpret_cast<const uint8_t*>(busy.constData()));
QVERIFY(!header.data(MemberListModel::OnlineRole).toBool());
const auto offlineImage = image();
for (int offset : {95, 63}) QCOMPARE(colors(offlineImage, offset, background), 28 * offlineImage.height());
}
void flatModelPreservesRowsSnapshotsAndPersistentIndexes() {
MemberListModel model;
QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::QtTest);
@@ -427,3 +703,5 @@ private slots:
QTEST_MAIN(MemberDetailTest)
#include "test_member_detail.moc"
void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn cb) { devgroupCallback = cb; }
@@ -15,6 +15,7 @@
extern "C" {
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_devgroup.h"
#include "debug_config.h"
#include "mem.h"
}
@@ -138,6 +139,23 @@ private slots:
}
void init() { allocationBaseline = u_get_allocated_count(); }
void cleanup() { QCoreApplication::processEvents(); QCOMPARE(u_get_allocated_count(), allocationBaseline); }
void deviceActionMatchesSelfAndDisconnectsCommittedGroup() {
MemberCore core; QVERIFY(core.start());
QVERIFY(!core.run([&] { return chat_devgroup_rename(core.instance, "Devices"); }));
auto dialog = makeDialog(core, true);
auto* button = dialog->findChild<QPushButton*>("memberAddMyDevice"); QVERIFY(button && button->isEnabled());
QCOMPARE(button->text(), QString("Disconnect from my devices")); button->click(); QVERIFY(!button->isEnabled());
QCOMPARE(core.run([&] {
devgroup_record record; uint8_t ed[32], x[32]; return devgroup_load_local(core.instance, &record, ed, x);
}), 0);
QCoreApplication::processEvents();
auto ungrouped = makeDialog(core, true);
QVERIFY(!ungrouped->findChild<QPushButton*>("memberAddMyDevice")->isEnabled());
core.db.setMyNodeId(0); auto other = makeDialog(core, true);
button = other->findChild<QPushButton*>("memberAddMyDevice"); QVERIFY(button && button->isEnabled());
QCOMPARE(button->text(), QString("Add device"));
}
void saves_data() {
QTest::addColumn<bool>("properties"); QTest::addColumn<QString>("failure");
for (bool properties : {false, true}) for (const char* failure : {"none", "prepare", "step", "commit", "missing"})
@@ -150,14 +150,14 @@ private slots:
connect(&delegate, &AccountDelegate::radioMuteRequested, &view, [&](const QModelIndex&) {
accepted = gui_bridge_toggle_member_radio_mute(probe.channel.toUtf8().constData(), 42);
});
const QRect row = view.visualRect(source); const QPoint button(row.right() - 82, row.center().y());
const QRect row = view.visualRect(source); const QPoint button(row.right() - 18, row.center().y());
auto iconImage = [&] {
return view.viewport()->grab().toImage().copy(QRect(row.right() - 95, row.center().y() - 14, 28, 28));
return view.viewport()->grab().toImage().copy(QRect(row.right() - 31, row.center().y() - 14, 28, 28));
};
auto iconHasColor = [&](const QColor& color) {
const QImage image = view.viewport()->grab().toImage();
for (int y = row.top(); y <= row.bottom(); ++y)
for (int x = row.right() - 95; x < row.right() - 67; ++x) if (image.pixelColor(x, y) == color) return true;
for (int x = row.right() - 31; x < row.right() - 3; ++x) if (image.pixelColor(x, y) == color) return true;
return false;
};
QVERIFY(iconHasColor(QColor("#9E9E9E"))); QVERIFY(!iconHasColor(QColor("#4CAF50")));
+24
View File
@@ -3,6 +3,10 @@
<TS version="2.1" language="en_US" sourcelanguage="en_US">
<context>
<name>ChatGui</name>
<message><source>Expand devices</source><translation>Expand devices</translation></message>
<message><source>Collapse devices</source><translation>Collapse devices</translation></message>
<message><source>Disconnect from my devices</source><translation>Disconnect from my devices</translation></message>
<message><source>Add device</source><translation>Add device</translation></message>
<message>
<source>Lookback time:</source>
<translation>Lookback time:</translation>
@@ -2858,4 +2862,24 @@ See log:
<translation>PNG preserves the original pixels and transparency.</translation>
</message>
</context>
<context>
<name>DevgroupDialog</name>
<message><source>Disconnect from my devices</source><translation>Disconnect from my devices</translation></message>
<message><source>Add device</source><translation>Add device</translation></message>
<message><source>PIN was not accepted. Try again.</source><translation>PIN was not accepted. Try again.</translation></message>
<message><source>PIN shown on the inviting device</source><translation>PIN shown on the inviting device</translation></message>
<message><source>Operation failed. Check connection and invitation status.</source><translation>Operation failed. Check connection and invitation status.</translation></message>
<message><source>PIN: %1 — enter it on the other device within 2 minutes.</source><translation>PIN: %1 — enter it on the other device within 2 minutes.</translation></message>
<message><source>Waiting for PIN</source><translation>Waiting for PIN</translation></message>
<message><source>Checking PIN</source><translation>Checking PIN</translation></message>
<message><source>Transferring keys</source><translation>Transferring keys</translation></message>
<message><source>Joined</source><translation>Joined</translation></message>
<message><source>Rejected</source><translation>Rejected</translation></message>
<message><source>Expired</source><translation>Expired</translation></message>
<message><source>Result unknown</source><translation>Result unknown</translation></message>
<message><source>Core unavailable</source><translation>Core unavailable</translation></message>
</context>
<context>
<name>AccountList</name>
</context>
</TS>
+24
View File
@@ -3,6 +3,10 @@
<TS version="2.1" language="ru_RU" sourcelanguage="en_US">
<context>
<name>ChatGui</name>
<message><source>Expand devices</source><translation>Развернуть устройства</translation></message>
<message><source>Collapse devices</source><translation>Свернуть устройства</translation></message>
<message><source>Disconnect from my devices</source><translation>Отключить от моих устройств</translation></message>
<message><source>Add device</source><translation>Добавить устройство</translation></message>
<message>
<source>Lookback time:</source>
<translation>Время анализа:</translation>
@@ -2868,4 +2872,24 @@ See log:
<translation>PNG сохраняет исходные пиксели и прозрачность.</translation>
</message>
</context>
<context>
<name>DevgroupDialog</name>
<message><source>Disconnect from my devices</source><translation>Отключить от моих устройств</translation></message>
<message><source>Add device</source><translation>Добавить устройство</translation></message>
<message><source>PIN was not accepted. Try again.</source><translation>PIN не принят. Попробуйте ещё раз.</translation></message>
<message><source>PIN shown on the inviting device</source><translation>PIN с экрана приглашающего устройства</translation></message>
<message><source>Operation failed. Check connection and invitation status.</source><translation>Операция не выполнена. Проверьте соединение и состояние приглашения.</translation></message>
<message><source>PIN: %1 — enter it on the other device within 2 minutes.</source><translation>PIN: %1 — введите его на другом устройстве в течение 2 минут.</translation></message>
<message><source>Waiting for PIN</source><translation>Ожидается PIN</translation></message>
<message><source>Checking PIN</source><translation>Проверка PIN</translation></message>
<message><source>Transferring keys</source><translation>Передача ключей</translation></message>
<message><source>Joined</source><translation>Устройство добавлено</translation></message>
<message><source>Rejected</source><translation>Отказ</translation></message>
<message><source>Expired</source><translation>Истёк срок</translation></message>
<message><source>Result unknown</source><translation>Результат неизвестен</translation></message>
<message><source>Core unavailable</source><translation>Ядро недоступно</translation></message>
</context>
<context>
<name>AccountList</name>
</context>
</TS>
+5 -2
View File
@@ -107,6 +107,7 @@ int gui_bridge_download_attachment(const char* channel, int64_t msg_id);
* 0 — принято, результат приходит штатными CHAT_EVT_CALL_*; -1 — отказ с логом, память не остаётся за вызывающим.
* Detach до исполнения отменяет команду и освобождает запрос, не вызывая native API. */
enum gui_call_action { GUI_CALL_ACCEPT, GUI_CALL_DECLINE, GUI_CALL_HANGUP, GUI_CALL_CONTINUE };
int gui_bridge_call_start_devices(const char* channel, uint64_t peer, uint64_t call_id);
int gui_bridge_call_start(const char* channel, uint64_t peer, uint64_t call_id); // NUL-строка 1..63 байта, peer/id ненулевые.
int gui_bridge_call_action(enum gui_call_action action, uint64_t call_id); // Только перечисленные действия, call_id ненулевой.
@@ -117,7 +118,7 @@ int gui_bridge_call_action(enum gui_call_action action, uint64_t call_id); // Т
* Detach до исполнения отменяет команду и освобождает её, не вызывая native API. */
enum gui_radio_call_action {
GUI_RADIO_CALL_START, GUI_RADIO_CALL_READY, GUI_RADIO_CALL_ACCEPT, GUI_RADIO_CALL_DECLINE,
GUI_RADIO_CALL_CANCEL, GUI_RADIO_CALL_FAIL, GUI_RADIO_CALL_REJECT_BUSY, GUI_RADIO_CALL_CONTINUE
GUI_RADIO_CALL_CANCEL, GUI_RADIO_CALL_FAIL, GUI_RADIO_CALL_REJECT_BUSY, GUI_RADIO_CALL_CONTINUE, GUI_RADIO_CALL_ANSWER, GUI_RADIO_CALL_START_DEVICES
};
int gui_bridge_radio_call(enum gui_radio_call_action action, uint64_t id, uint64_t group, uint64_t peer);
@@ -264,7 +265,8 @@ void gui_bridge_set_call_incoming_cb(gui_call_incoming_fn cb);
typedef void (*gui_call_id_fn)(uint64_t call_id);
void gui_bridge_set_call_ringing_cb(gui_call_id_fn cb);
void gui_bridge_set_call_phone_busy_cb(gui_call_id_fn cb);
void gui_bridge_set_call_accepted_cb(gui_call_id_fn cb);
typedef void (*gui_call_peer_fn)(uint64_t call_id, uint64_t peer);
void gui_bridge_set_call_accepted_cb(gui_call_peer_fn cb);
/* Callback для завершения/отклонения звонка (вызывается из GUI-потока) */
typedef void (*gui_call_ended_fn)(uint64_t call_id, uint8_t reason);
@@ -341,6 +343,7 @@ void gui_bridge_set_group_avatar_result_cb(gui_dm_messages_fn cb);
void gui_bridge_set_feedback_changed_cb(gui_dm_messages_fn cb);
/* Изменение локальных запросов/ключей вложения, без секретных данных. */
void gui_bridge_set_attachment_keys_cb(gui_dm_messages_fn cb);
void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn cb);
void gui_bridge_set_feedback_result_cb(gui_dm_messages_fn cb);
void gui_bridge_set_msg_submitted_cb(gui_dm_messages_fn cb);
void gui_bridge_set_msg_updated_cb(gui_dm_messages_fn cb);
+23 -8
View File
@@ -74,7 +74,7 @@ static gui_local_event_fn g_local_delivery_cb = nullptr;
static gui_call_incoming_fn g_call_incoming_cb = nullptr;
static gui_call_id_fn g_call_ringing_cb = nullptr;
static gui_call_id_fn g_call_phone_busy_cb = nullptr;
static gui_call_id_fn g_call_accepted_cb = nullptr;
static gui_call_peer_fn g_call_accepted_cb = nullptr;
static gui_call_ended_fn g_call_ended_cb = nullptr;
static gui_call_ended_fn g_call_declined_cb = nullptr;
static gui_call_error_fn g_call_error_cb = nullptr;
@@ -95,6 +95,7 @@ static gui_dm_messages_fn g_avatar_result_cb = nullptr;
static gui_dm_messages_fn g_group_avatar_result_cb = nullptr;
static gui_dm_messages_fn g_feedback_changed_cb = nullptr;
static gui_dm_messages_fn g_attachment_keys_cb = nullptr;
static gui_dm_messages_fn g_devgroup_result_cb = nullptr;
static gui_dm_messages_fn g_feedback_result_cb = nullptr;
static gui_dm_messages_fn g_msg_submitted_cb = nullptr;
static gui_dm_messages_fn g_msg_updated_cb = nullptr;
@@ -351,6 +352,12 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
case CHAT_EVT_ATTACHMENT_KEYS_CHANGED:
if (g_attachment_keys_cb) g_attachment_keys_cb(d, dlen);
break;
case CHAT_EVT_DEVGROUP_CHANGED:
if (g_devgroup_result_cb) g_devgroup_result_cb(nullptr, 0);
break;
case CHAT_EVT_DEVGROUP_RESULT:
if (g_devgroup_result_cb) g_devgroup_result_cb(d, dlen);
break;
case CHAT_EVT_FEEDBACK_RESULT:
if (g_feedback_result_cb) g_feedback_result_cb(d, dlen);
break;
@@ -618,9 +625,9 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) {
} else DEBUG_WARN(DEBUG_CATEGORY_CALL, "gui_bridge: invalid PHONE_BUSY bytes=%d", dlen);
break;
case GUI_EVT_CALL_ACCEPTED:
if (dlen >= 8) {
uint64_t callId; memcpy(&callId, d, 8);
if (g_call_accepted_cb) g_call_accepted_cb(callId);
if (dlen == 17) {
uint64_t callId, peer; memcpy(&callId, d, 8); memcpy(&peer, d + 9, 8);
if (g_call_accepted_cb) g_call_accepted_cb(callId, peer);
} else DEBUG_WARN(DEBUG_CATEGORY_CALL, "gui_bridge: truncated CALL_ACCEPTED bytes=%d", dlen);
break;
case GUI_EVT_CALL_ENDED:
@@ -830,7 +837,7 @@ int gui_bridge_download_attachment(const char* channel, int64_t msg_id) {
}
/* Создание payload и enqueue атомарны относительно detach; GUI не читает заимствованный instance. */
int gui_bridge_call_start(const char* channel, uint64_t peer, uint64_t call_id) {
static int bridge_call_start(const char* channel, uint64_t peer, uint64_t call_id, bool devices) {
const size_t length = channel ? strnlen(channel, sizeof(call_start_arg::ch_id)) : 0;
if (!length || length == sizeof(call_start_arg::ch_id) || !peer || !call_id) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call bridge: invalid start channel_bytes=%zu peer=%llu id=%llu",
@@ -844,7 +851,7 @@ int gui_bridge_call_start(const char* channel, uint64_t peer, uint64_t call_id)
if (!request) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call bridge: start allocation failed id=%llu", (unsigned long long)call_id); return -1;
}
request->inst = g_inst; request->peer_node_id = peer; request->call_id = call_id;
request->inst = g_inst; request->peer_node_id = peer; request->call_id = call_id; request->devices = devices;
memcpy(request->ch_id, channel, length + 1);
if (post_command(call_start_trampoline, request, true) < 0) {
u_free(request); DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call bridge: start queue rejected id=%llu", (unsigned long long)call_id); return -1;
@@ -854,6 +861,9 @@ int gui_bridge_call_start(const char* channel, uint64_t peer, uint64_t call_id)
return 0;
}
int gui_bridge_call_start(const char* channel, uint64_t peer, uint64_t id) { return bridge_call_start(channel, peer, id, false); }
int gui_bridge_call_start_devices(const char* channel, uint64_t peer, uint64_t id) { return bridge_call_start(channel, peer, id, true); }
/* Однородный call_id_arg освобождает native trampoline либо очередь при отмене. */
int gui_bridge_call_action(enum gui_call_action action, uint64_t call_id) {
void (*callback)(void*) = nullptr;
@@ -890,6 +900,8 @@ int gui_bridge_radio_call(enum gui_radio_call_action action, uint64_t id, uint64
int nativeAction = 0;
const char* name = nullptr;
switch (action) {
case GUI_RADIO_CALL_START_DEVICES: nativeAction = RADIO_CALL_START; name = "start_devices"; break;
case GUI_RADIO_CALL_ANSWER: nativeAction = RADIO_CALL_ANSWER; name = "answer"; break;
case GUI_RADIO_CALL_START: nativeAction = RADIO_CALL_START; name = "start"; break;
case GUI_RADIO_CALL_READY: nativeAction = RADIO_CALL_READY; name = "ready"; break;
case GUI_RADIO_CALL_ACCEPT: nativeAction = RADIO_CALL_ACCEPT; name = "accept"; break;
@@ -899,7 +911,7 @@ int gui_bridge_radio_call(enum gui_radio_call_action action, uint64_t id, uint64
case GUI_RADIO_CALL_REJECT_BUSY: nativeAction = RADIO_CALL_REJECT_BUSY; name = "reject_busy"; break;
case GUI_RADIO_CALL_CONTINUE: nativeAction = RADIO_CALL_CONTINUE; name = "continue"; break;
}
if (!name || !id || (action == GUI_RADIO_CALL_START ? !group || !peer : group || peer)) {
if (!name || !id || (nativeAction == RADIO_CALL_START ? !group || !peer : group || peer)) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call bridge: invalid command action=%d id=%llu group=%llu peer=%llu",
action, (unsigned long long)id, (unsigned long long)group, (unsigned long long)peer); return -1;
}
@@ -913,6 +925,7 @@ int gui_bridge_radio_call(enum gui_radio_call_action action, uint64_t id, uint64
return -1;
}
request->inst = g_inst; request->id = id; request->group = group; request->peer = peer; request->action = nativeAction;
request->devices = action == GUI_RADIO_CALL_START_DEVICES;
if (post_command(radio_call_trampoline, request, true) < 0) {
u_free(request);
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call bridge: queue rejected action=%s id=%llu", name, (unsigned long long)id); return -1;
@@ -1252,7 +1265,7 @@ void gui_bridge_set_call_ringing_cb(gui_call_id_fn cb) {
}
void gui_bridge_set_call_phone_busy_cb(gui_call_id_fn cb) { g_call_phone_busy_cb = cb; }
void gui_bridge_set_call_accepted_cb(gui_call_id_fn cb) {
void gui_bridge_set_call_accepted_cb(gui_call_peer_fn cb) {
g_call_accepted_cb = cb;
}
@@ -1362,3 +1375,5 @@ extern "C" void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP
#include "gui_bridge_impl.moc"
void gui_bridge_set_call_stats_cb(gui_call_stats_fn cb) { g_call_stats_cb = cb; }
void gui_bridge_set_devgroup_result_cb(gui_dm_messages_fn cb) { g_devgroup_result_cb = cb; }