Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7062905d48 | ||
|
|
24cb0eb463 | ||
|
|
03fea5eb74 | ||
|
|
d7006aecb5 |
No files matched your search
+59
-48
@@ -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 и секреты.
|
||||
@@ -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, отказ всей подгруппе,
|
||||
уже включённая рация и отбой до завершения активации).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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, /* автоматический отказ: абонент разговаривает по телефону */
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;не отдельное соединение БД. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
// ── Подключение получателя событий ──
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
{
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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 аргумент. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-сведения
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, ×tamp) && 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
@@ -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",
|
||||
|
||||
@@ -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, ©, 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);
|
||||
|
||||
@@ -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>"),))
|
||||
|
||||
@@ -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)) } })
|
||||
}
|
||||
+129
-38
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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/отмене жеста
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 время в минуты:секунды.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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); // Молчащий участник серый; зелёный означает передачу.
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user