Merge remote-tracking branch 'origin/master'

This commit is contained in:
evgeny committed 2026-10-27 01:23:25 +01:00
commit 8a0654e57b
13 files changed
+547 -144

No files matched your search

+77 -12
View File
@@ -44,7 +44,10 @@ AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная пор
Автор подписывает все поля до sig. conv связывает автора с получателем.
Для `ct=file` перед подписью добавляется descriptor `[UUID:16][plaintext_size:8][SHA256(ciphertext):32]`.
В зашифрованном теле находится `attachment_info`; пути хранения строятся только из UUID.
Размер plaintext сообщения ограничен 1024 байтами. seq — положительное int64.
Размер data в API отправки ограничен 1024 байтами; для file сюда входит открытый
descriptor (56 байт), поэтому на зашифрованные метаданные остаётся до 968 байт.
Парсер входящего wire допускает до 1024 байт ciphertext plaintext отдельно от descriptor.
seq — положительное int64, ts — миллисекунды NTP-времени instance.
```text
[kind:1][name_len:2][duration_ms:4][width:2][height:2][UTF8_name:name_len][waveform:100, только voice]
@@ -74,6 +77,7 @@ tags, точный размер и хеш; владелец публикует
## Владение и данные
- `dm_core`: беседы, история, outbox и подтверждение получателя.
- `dm_history`: поколения, hidden, точные отпечатки удалённых сообщений и манифест локальных путей.
- `dm_mailbox`: промежуточные сообщения суперузла и квитанции.
- `dm_crypto`: вывод conv_id, content_key и nonce сообщений без БД/сети.
- `media_crypto`: общие CCM и потоковые файловые примитивы для DM и групповых вложений.
@@ -91,6 +95,9 @@ dm_messages(conv_id, dir, seq, ts, author, ct, data, sig)
dm_outbox(conv_id, seq, body, retry_at)
dm_pending_messages(recipient, sender, seq, msg, group_id, retry_at)
dm_mail_receipts(recipient, sender, seq, receipt, group_id)
dm_history(conv_id, generation, hidden)
dm_deleted_messages(conv_id, dir, seq, body_hash, media_id, media_receipt)
dm_local_files(path, conv_id, generation, busy, retry_at)
```
Запись исходящего сообщения, увеличение seq и запись неизменяемого body в outbox —
@@ -121,10 +128,21 @@ Dedup — точный ключ (conv,dir,seq) с проверкой сохра
беседы и сброса счётчиков. Полный снимок выдаётся только после успешной загрузки.
Исправление и отказные проверки описаны в [dm_audit_findings.txt](dm_audit_findings.txt).
У каждой локальной очереди таймер 1с; порция обработки до 32 задач, повтор задачи
через 5с. retry_at предотвращает голодание хвоста очереди. Перед отправкой проверяется
backpressure router. Потеря пути сохраняет durable задачу; остановка отменяет таймеры,
но сохраняет БД. При старте очередь продолжает работу.
Таймеры работают раз в 1с: outbox A и текстовая очередь S обрабатывают до 32 задач;
локальные файлы endpoint, custody M и media jobs S — до 8 задач каждый.
Обычный retry_at — через 5с; исходящий файл с квитанцией проверяется через 3600с,
неудачная уборка локального пути повторяется через 30с. Это время допуска следующей
попытки, не гарантия доставки за указанный срок. Перед отправкой проверяется backpressure router.
Потеря пути сохраняет durable задачу; stop отменяет таймеры, но сохраняет БД.
При старте медиа сбрасывает retry_at/downloading; текстовые очереди используют сохранённый retry_at.
Все DM API вызываются в uasync-потоке. Порядок запуска в `chat_service_start`:
mailbox (включая media coordinator), core/history, media/file_transfer; к этому времени
SQLite, router и media_async уже живы. Порядок остановки: media_quiesce прекращает
новые задачи и закрывает transfer/CM handles; media_async_destroy завершает workers
и callbacks с живыми owners; затем уничтожаются media, core и mailbox, после них группы/чат.
SQLite принадлежит ядру и остановкой DM не закрывается. `dm_media_destroy` при живой
работе логирует ошибку и оставляет owner; заменять drain таким вызовом нельзя.
## Подтверждения и протокол
@@ -200,16 +218,44 @@ TTL текстовых сообщений не введён; недельный
Текстовая история, подписи и сроки хранения M этим переходом не меняются.
Подробности и регрессии: [dm_audit_findings.txt](dm_audit_findings.txt), раздел11.
Control `ETCP_RT_ID_DM_MEDIA=0x38`: OFFER, STORE, STORED, RECEIPT, DELETE,
DELETE_ACK, EXPIRED, REJECT; точные форматы заданы в `dm_media.h` и реализациях owners.
Control `ETCP_RT_ID_DM_MEDIA=0x38`: перед body идёт один байт команды.
Все многобайтные поля DM/control имеют native-endian порядок; исключение —
зашифрованный attachment_info с big-endian полями, описанный выше.
| Команда | Body |
| --- | --- |
| OFFER=1 | recipient:8, holder:8, expires:8, file-message |
| STORE=2 | recipient:8, file-message |
| STORED=3 | UUID:16, expires:8 |
| RECEIPT=4 / DELETE=5 | файловая квитанция:152 |
| DELETE_ACK=6 / EXPIRED=7 | UUID:16 |
| REJECT=8 | UUID:16, reason:1 (1 — недостаток квоты) |
Подпись router обязательна. Media receipt содержит `DMFILE01`, conv, seq, author,
recipient, UUID и SHA256 полного подписанного сообщения; B подписывает первые 88 байт.
Отдельные таблицы `dm_files`, `dm_custody`, `dm_custody_done`, `dm_media_jobs`,
`dm_media_holders` сохраняют состояния и tombstones. Все выданные grants M
учитываются при удалении; повторный STORE не продлевает TTL.
Qt и Android получают историю через события ядра: 41 — conv + JSON сообщений,
45 — JSON бесед. Снимки ограничены 200 записями. Отправка вложения вызывает
A предлагает собственный файл с holder=A и expires=0. S выбирает M с storage=yes
в общей группе A/B, исключая A/B и все прежние назначения этого UUID. Назначение
фиксируется до STORE; S/M могут быть одним узлом, local control обрабатывается синхронно.
Успешная передача control в router или локальный обработчик не означает durable успех.
EXPIRED относится к промежуточной копии. B помечает ещё не готовый файл expired,
но продолжает попытки получить оригинал у A. После проверки исходного ciphertext,
публикации и сохранения квитанции файл снова ready; поздний EXPIRED его не меняет.
Готовый файл A/B остаётся в локальной истории до явного удаления беседы.
При удалении busy-счётчик каждого пути удерживает его до callback worker/transfer;
уборщик работает по старым поколениям, использует busy=-1 для своего захвата
и синхронизирует каталог после удаления. Пользовательский исходник не входит в уборку,
если он не был явно передан как временный файл приложения.
Qt и Android получают историю через события ядра: 41 —
`[conv_len:1][conv:decimal][generation:8][JSON сообщений]`, 45 — JSON бесед.
Запрос истории с ненулевым generation читается только для текущей видимой беседы;
0 захватывает текущее поколение в uasync. UI отбрасывает снимок другого поколения.
Снимки ограничены 200 записями. Отправка вложения вызывает
`dm_send_file` в uasync; длительная обработка выполняется в `media_async`.
Android копирует выбранный URI и сохраняет полученный файл через `Dispatchers.IO`;
Qt сохраняет файл из контекстного меню в отдельном потоке. Выбор «как файл» сохраняет
@@ -239,14 +285,16 @@ Android использует общие `ConversationComposer`/`ConversationMess
## Проверки
Аудит 26.10.2026 частичный: полностью сверены `dm_crypto.c/.h`, прочитаны `dm_core.c`
и `dm_history.c`; контракты остальных owners и платформенные callers ещё не проверены полностью.
Аудит контрактов DM завершён 27.10.2026: все семь заголовков `src/dm/`, этот документ,
`pm.txt` и `pm_invites.md` сверены с реализациями owners и относящимися к ним callers.
Это проверка соответствия документации; свежая интерактивная проверка клиентов и
новые платформенные сборки не выполнялись. Подробности и границы проверки —
[dm_audit_findings.txt](dm_audit_findings.txt), раздел 12.
Первоначальная проверка отказа SQLite выявила сброс счётчиков; дефект исправлен.
`test_dm_load` проверяет 79 отказных сценариев: чтение, bind/step/finalize, короткие
ключи и X25519; открытие/приём/отправку/JSON/invite до и после удаления; отдельные
отказы подготовки файла, отсутствие беседы, restart и продолжение seq. При отказе
сравниваются все поля DM-таблиц и отсутствие SQL-записей/событий успеха.
Исправление не означает завершения аудита остальных owners.
`test_dm_names`: 68 случаев с точным сравнением имени в SQL и JSON при создании,
загрузке, штатной записи, fallback из nodes и перезапуске. ASCII и 2/3/4-байтовые
@@ -278,17 +326,34 @@ SQL rollback регистрации, состояния JSON и отмена о
обоих UPDATE и commit, точный rollback назначения/job, успешный повтор, дубликат
и поздний STORED. Сетевой сценарий теряет EXPIRED, блокирует SQL-сброс, перезапускает S
и проверяет восстановление из сохранённого expires, состояние M и отсутствие ложной квитанции.
На M проверяются 11 SQL-отказов retirement: rollback сохраняет custody и не выдаёт ACK;
после restart M/S потерянный DELETE_ACK восстанавливается по tombstone и повтору DELETE.
После EXPIRED и возврата A проверяются точный оригинал на B, настоящая файловая
квитанция, очистка S и невлияние позднего EXPIRED на готовый файл.
`test_dm_worker`: повторное шифрование 8 МиБ не менее 0,5 с и проверка расшифровки;
work выполняется вне uasync, done в его потоке, heartbeat продолжает работать.
Destroy отменяет уже posted completion, callback вызывается ровно один раз.
Linux `test_dm_media_cleanup`: 13 отказов SQL при чтении назначений, записи ACK
и очистке body; сохранность квитанции, повтор, restart S, потерянный/повторный/чужой ACK,
несколько M и поздний STORED. Сеть в этом component-тесте перехвачена; подпись B и
реальное удаление M проверяет `test_dm_media`. Ошибка finalize после успешного
autocommit не означает rollback уже выполненной одношаговой записи.
`test_attachment`: строгий формат, повреждения и граничные длины, реальное
кодирование/декодирование 50 Opus-кадров, длительность и waveform.
`test_attachment_send`: выбранная беседа после смены текущего target, освобождение
PCM в worker, остановка до completion без регистрации и удаление отклонённого cache.
Qt `test_voice_file`: реальные PCM/Opus, повреждённые файлы и отмена async decoder.
Заключительный прогон 27.10.2026: штатные `test_dm`, `test_dm_history`, `test_dm_names`,
`test_dm_load`, `test_dm_e2e`, `test_dm_invite`, `test_dm_media`, `test_dm_worker`
и Linux `test_dm_media_cleanup` — 9/9 PASS.
Отдельный строгий JSON-парсер прочитал все 273 снимка имён. Заголовки проверены в C99/C++20;
в заключительном этапе менялись только документация, комментарии и форматирование деклараций.
Результаты старых сборок ниже относятся к указанной дате, а не к этому прогону.
Проверка 01.10.2026: чистые сборки Qt и Android APK; полный `check.sh` —
117 PASS, 0 FAIL, 1 SKIP (auto_socket_dynamic требует root), proxy/burst/load PASS.
Qt CTest: 7 PASS, 0 FAIL; Android APK собран после `clean`.
+102 -1
View File
@@ -1,8 +1,11 @@
DM — аудит документации и контрактов, 26.10.2026
DM — аудит документации и контрактов, 26–27.10.2026
База: /home/user/utun2, HEAD 03b050949cf36cc4868324be94a54eaddfdd494c.
Задание: /home/user/utun-doc-audit-checklist.txt, раздел src/dm/ и связанные документы.
Текущий статус на 27.10.2026: аудит контрактов DM завершён, 10/10 файлов [V].
Итог — раздел12; разделы1–11 сохраняют историю промежуточных проверок и исправлений.
1. Объём и статус
Полностью прочитаны dm_core.c/.h, dm_crypto.c/.h и dm_history.c/.h, doc/dm_arch.md,
@@ -506,3 +509,101 @@ __real_sqlite3_bind_blob. После окончания правок повто
/tmp/dm-expiry-fix-{sanitizers-build,sanitizers-results,sanitizers-test_dm*,full-check,netns}.log.
Задача завершения TTL закрыта. Общий аудит DM частичный;прежние отметки сохраняются.
GUI/Android/Windows/FreeBSD не проверялись;DELETE_ACK fault matrix отдельно не добавлялась.
12. Завершение аудита контрактов DM — 27.10.2026
База заключительного этапа: /home/user/utun4, master, исходный HEAD07f63c3047b4a9bef6c568ca590d3fefde95816f.
Полностью сверены все семь заголовков src/dm/ с реализациями core, crypto, history,
mailbox, mailbox_media и media; doc/dm_arch.md, doc/pm.txt и doc/pm_invites.md.
Это завершение проверки описаний/API, не утверждение об отсутствии всех возможных
ошибок программы. Новых подтверждённых дефектов выполнения в этом этапе нет.
В заголовках уточнены:
- Заимствование аргументов, один освобождаемый блок запросов UI, жизнь instance,
поток uasync и запрет доступа workers к SQLite/сети.
- Проверка тела/подписи отдельно от области router/ролей, точные повторы и конфликты,
границы транзакций при send/accept, подпись квитанции после commit входящего сообщения.
- Независимые квитанции сообщения и файла; STORED не удаляет исходящий outbox.
- Форматы DM/mailbox/media control, native-endian чисел и big-endian attachment_info,
лимиты send data с descriptor и размеры выходных буферов квитанций.
- Поколения/hidden, сохранение ключей и seq, отпечатки удалённых тел, отмена старых
invite/transfer и отбрасывание завершения worker старого поколения.
- Роли S/M, выбор нового storage без прежних назначений, фиксация назначения до STORE,
local S/M control и отличие передачи пакета от durable приёма.
- File holds, манифест путей, busy=-1 уборщика, повтор удаления и fsync каталога;
пользовательский исходник не передаётся уборке без явного remove_source.
- Запуск mailbox/core/media и остановка quiesce -> media_async drain -> destroy
с живыми SQLite/группами, сохранение данных между stop/restart.
dm_arch согласован с порциями таймеров32/8, обычным retry5с и отдельными retry3600с/30с,
сохранённым retry_at текстовых очередей и сбросом медиа при restart. Добавлены wire
таблица, порядок владельцев и восстановление оригинала после EXPIRED: срок копии M
не запрещает B получить исходный файл A; поздний EXPIRED не портит готовый файл.
pm.txt дополнен привязкой требований к текущим owners/ролям/настройкам, dm_arch
больше не называет этот аудит частичным. Исторические результаты сборок помечены датой.
Сверены относящиеся к контрактам callers: chat_service_start/stop, attachment_send,
file_transfer lookup/cancel, Qt DmInviteDialog/MainWindow/MessageList и bridge,
Android JNI/ViewModel/PmInviteDialog, headless DM handlers, регистрация JOIN-ключей
и планирование member_sync после JOIN_READY/принятия участника.
Клиентский код просмотрен для описанных DM-событий/выбора адресата/подготовки файла;
полный аудит Qt/Android и остальных общих модулей этим не заявляется.
pm_invites описывает владение запросом, поколение до регистрации и отсутствие
отката открытой беседы при последующей ошибке; отмена PM не отзывает JOIN-ключ.
Проверки текущего этапа:
- make -C src -j4 и сборка всех девяти существующих DM-целей: PASS.
- Native test_dm/history/names/load/e2e/invite/media/worker/media_cleanup:9/9 PASS.
test_dm_load:79 проверок; names:68 случаев и273 снимка, независимый Python JSON
parser прочитал каждый полный снимок. Worker:25 запусков,50 heartbeat,max_gap11.3мс.
Media:phase12,6266 heartbeat,allocations0->0; проверены11 retirement SQL faults,
18 release faults, потерянные control/restart и возврат точного оригинала после TTL.
- Linux cleanup component:13 SQL faults для scan/ACK/body, повтор после restart,
несколько M, потерянный/повторный/чужой ACK и поздний STORED; exit0.
Сеть перехвачена, receipt задан фикстурой; криптографию и физическое удаление M
проверяет отдельный test_dm_media. Одношаговый autocommit может уже завершиться
до искусственного finalize failure; тест проверяет это состояние и идемпотентный повтор.
- Каждый из семи заголовков: C99 и C++20 с -Wall -Wextra -Werror,14/14 PASS.
Для private/media C API C++ использует extern "C", как существующие клиенты;
include path lib/ соответствует проекту и предоставляет sqlite3.h.
- Все C-файлы DM побайтно совпадают с начальным снимком; C-токены всех заголовков
совпадают после исключения комментариев/пробелов. Алгоритмы, API/ABI, wire и SQL
этим этапом не менялись. git diff --check PASS; новые тесты не добавлялись.
Логи и снимки: /tmp/dm-contract-finish-before/sha256.json,
/tmp/dm-contract-finish-{build,final-core-build,final-tests-build,headers,validation}.log,
/tmp/dm-contract-finish-native-{results,test_dm*}.log; исходные тела/JSON — в test artifacts.
Сетевой профиль выполнен вне sandbox. Первый sandbox-запуск cleanup вернул exit0,
но uasync сообщил Operation not permitted при создании служебного fd; окончательный
повтор вне sandbox прошёл без этих сообщений. Сетевые настройки хоста не менялись.
Новые full check.sh, ASAN/UBSan/LSan, сборки/интерактив Qt/Android/Windows/FreeBSD
не выполнялись; прежние результаты не приписываются заключительному прогону.
Изменения radio и doc/tasks.md сохранены отдельно от DM-коммитов; push не выполнялся.
13. Rebase перед push — 27.10.2026
После отказа push (origin содержал новые коммиты) пользователь запросил rebase.
Получен origin/master492f6adc; все шесть DM-коммитов перенесены на эту базу.
Единственный конфликт — dm_messages_req в dm_core.h: сохранены generation из origin
и проверенные комментарии. Остальные пять патчей перенесены без изменения по range-diff.
Старые хеши и результаты раздела12 относятся к состоянию до переноса.
В origin появился generation в запросе истории и CHAT_EVT_DM_MESSAGES:
[conv_len:1][conv][generation:8][JSON]. Сверены полный trampoline, проверка current,
новая регрессия test_dm_history и получение снимка Qt/Android. dm_arch уточнён под
этот формат. Все C-файлы DM побайтно совпадают с новым origin; токены семи заголовков
совпадают после исключения комментариев/пробелов. C99/C++20:14/14 PASS.
Перед rebase сохранены21 незакоммиченных radio-файл, включая untracked, в stash
и отдельной резервной копии с SHA256/режимами. После переноса восстановлены все21:
18 файлов побайтно, doc/tasks.md/tests/Makefile.am/tools/chatgui/CMakeLists.txt
слиты с origin; списки добавленных/удалённых строк radio точно совпали с сохранёнными.
Повторная проверка после сборки также PASS; radio не включается в DM-коммиты.
Свежий ./build.sh --no-gui -j4:PASS. После пересборки native DM9/9 PASS,
включая скрытые/устаревшие запросы истории и точное поколение опубликованного снимка.
Дополнительно radio/radio_mesh/radio_headless/radio_headless_queue4/4 PASS.
Это профиль13 бинарников, не полный check.sh; новые GUI/Android/sanitizer прогоны
не выполнялись. Радио EOS C++-профиль не повторялся, его исходники сохранены побайтно.
Логи:/tmp/utun4-dm-rebase-{build,results,radio-preservation,test_*}.log.
Резервная копия указана в /tmp/utun4-dm-rebase-backup-path.txt.
+23 -2
View File
@@ -1,4 +1,4 @@
Работа лички
Работа лички — требования и реализация
1. отдели логику лички от логики групповых чатов. не смешивай.
@@ -9,4 +9,25 @@
время хранения медиафайлов на промежуточных узлах ограничено - 1 неделя по умолчанию (задачется в конфиге этих узлов).
для доставки медиа поднимаем линк (conn_mgr).
и посмотри реализацию медиа для групповых чатов.
Контракты реализации проверены 27.10.2026: src/dm/ и doc/dm_arch.md.
«Напрямую» означает доставку endpoint через живой маршрут общей CHAT-группы,
в том числе с BGP-транзитом; физический линк между A и B не обязателен.
Беседа имеет собственные историю/seq/outbox и не создаёт отдельную TOPO_GROUP.
Общие с групповыми вложениями примитивы: media_async, шифрование потоков,
attachment_info, voice_file; DM не использует групповой HAVE_BLOCK и его хранилище.
S выбирается по подписанному adm_tags supernode=yes, M — storage=yes в общей группе A/B.
Роли независимы и могут совмещаться на одном узле. A сохраняет исходящую задачу
до подписанной квитанции B; STORED от S/M не означает доставку получателю.
Текстовая квитанция подтверждает commit сообщения, файловая — проверку ciphertext,
публикацию файла и сохранение результата. S повторяет удаление у M до DELETE_ACK
либо завершения подтверждённого срока назначения; сохраняются служебные tombstones.
На M настройки чата dm_media_ttl_sec=604800 и dm_media_storage_mb=1024 задают TTL
в секундах и квоту в МиБ. Повтор STORE не продлевает TTL; при нехватке квоты новое
размещение отклоняется, подтверждённая копия не вытесняется до срока.
Текстовые pending не имеют TTL. Истечение копии M не удаляет оригинал A и не мешает
B получить его после возвращения A; локальное удаление истории не отзывает копии S/M.
Подробные wire-форматы, жизненный цикл и проверки: doc/dm_arch.md и src/dm/*.h.
+16
View File
@@ -40,6 +40,17 @@ ID получателя, выбранную группу и исходную о
Повтор во время ожидания возвращает BUSY. Закрытие диалога отменяет только
соответствующий `request_id`; уже отправленное сообщение не отзывается.
Отмена и остановка DM завершают принятый запрос ровно один раз.
Отмена уже завершённого request_id ничего не отправляет и не создаёт повторный результат.
Отмена касается ожидания PM, уже зарегистрированный JOIN-ключ живёт по своему TTL.
Запрос — один обнулённый блок `u_calloc(dm_invite_req)` с NUL-строками;
после передачи в uasync им владеет обработчик до финального callback.
Instance, chat_sync и SQLite должны оставаться живыми до завершения отмены.
`dm_core_destroy` отменяет ожидающий invite до освобождения owner.
Поколение беседы захватывается перед регистрацией и проверяется перед `dm_send`:
удалённая и повторно открытая PM не принимает старый результат.
Создание/открытие беседы происходит до регистрации; ошибка регистрации или отправки
не откатывает это открытие, но не создаёт invite-сообщение.
`CHAT_EVT_DM_INVITE_RESULT` содержит 36 байт:
`[request_id:u64][result:i32][peer:u64][conv_id:u64][generation:u64]` в порядке байтов ядра.
@@ -53,6 +64,11 @@ JOIN во вторую группу может использовать уже
## Проверка и диагностика
Контракт сверён 27.10.2026 с DM, регистрацией JOIN-ключей и обработчиками результата
Qt/Android. Свежий `test_dm_invite` прошёл вместе с остальными восемью DM-тестами.
Приведённые ниже GUI-тесты описывают существующее покрытие; в этом этапе новые
сборки Qt/Android и интерактивная проверка интерфейса не выполнялись.
- `tests/test_dm_invite`: настоящая UDP-доставка ссылки в другую группу, JOIN,
репликация участника и отправка группового сообщения после вступления;
отмена, BUSY, неверная группа, rollback при ошибке commit и остановка DM;
+59 -31
View File
@@ -9,7 +9,11 @@
* при недоступности пира — через dm_mailbox (суперузлы).
* - содержимое шифруется E2E (AES-256-CCM), запись подписывается Ed25519.
*
* Два независимых направленных потока (я→пир и пир→я) с монотонным seq.
* Два независимых направленных потока (я→пир и пир→я) с монотонным исходящим seq.
* Все API и трамплины выполняются в uasync-потоке с живым instance/SQLite/router.
* Входные строки/тела заимствуются на время вызова; функции не забирают их владение.
* Запросы трамплинов — один блок u_malloc/u_calloc с NUL-строками; обработчик освобождает блок.
* Instance должен пережить запрос и его отложенное завершение; остановка сериализуется через uasync.
*/
#ifndef DM_CORE_H
@@ -29,7 +33,9 @@ extern "C" {
/* ── Каноническое тело DM-сообщения (общий wire-формат dm_core и dm_mailbox) ──
* [conv_id:8][seq:8][ts:8][author:8][ct_len:1][ct:ct_len][data_len:2][data_enc:data_len][media?:56][sig:64]
* media присутствует только для ct="file", подписан вместе с сообщением.
* data_enc — AES-256-CCM (шифротекст + tag), sig — Ed25519 автора по всем полям до sig. */
* data_enc — AES-256-CCM (шифротекст + tag), sig — Ed25519 автора по всем полям до sig.
* Числа native-endian; seq=1..INT64_MAX, ts — миллисекунды NTP-времени instance.
* ETCP_RT_ID_DM=0x34: [MSG=1][тело] либо [ACK=2][квитанция:136]; router-пакет подписан. */
#define DM_MSG_FIXED_HDR 33 /* conv_id+seq+ts+author(32) + ct_len(1) — до ct */
#define DM_MSG_SIG_SIZE 64
#define DM_DATA_MAX 1024
@@ -54,22 +60,29 @@ static inline int dm_msg_len(const uint8_t* msg, size_t avail, size_t* out_len)
return 1;
}
/* Общие операции протокола DM; все вызовы в uasync-потоке.
* group_id — только область маршрутизации, идентичность беседы от неё не зависит.
* route_group выбирает общую группу с живым путём, возвращает 1/0.
* verify_message проверяет каноническое тело и подпись автора для recipient.
* accept_message возвращает 0 только после commit (включая точный повтор).
* Квитанция: ["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(msg):32][sig:64].
* verify_receipt проверяет подпись получателя и при msg!=NULL точное соответствие телу.
* accept_receipt удаляет только соответствующее исходящее pending-сообщение. */
/* Выбрать общую CHAT-группу с путём router к peer, сначала preferred (0=без предпочтения).
* 1 и *group_id при успехе; 0 при отсутствии пути/ошибке, выход не меняется.
* group_id обязателен; наличие пути не гарантирует успешной последующей отправки. */
int dm_route_group(struct UTUN_INSTANCE* inst, uint64_t peer, uint64_t preferred, uint64_t* group_id);
/* Проверить точную длину msg/len, conv/author/recipient/seq, descriptor и подпись автора из nodes.
* 0=корректно, -1=отказ. Нужна SQLite, владелец dm_core не требуется.
* Не расшифровывает тело и не проверяет членство/роль router-отправителя: это обязанность вызывающего. */
int dm_verify_message(struct UTUN_INSTANCE* inst, uint64_t recipient, const uint8_t* msg, size_t len);
/* Проверить и расшифровать входящее тело для локального узла, атомарно сохранить историю/беседу/media.
* 0 и receipt после commit либо точного повтора; -1 при отказе. Для file нужен живой dm_media.
* Точный удалённый повтор подтверждается по отпечатку без восстановления истории; конфликт отклоняется.
* receipt — обязательные 136 байт; при -1 буфер не использовать. Ошибка подписи квитанции возможна после commit. */
int dm_accept_message(struct UTUN_INSTANCE* inst, const uint8_t* msg, size_t len, uint8_t receipt[DM_RECEIPT_SIZE]);
/* receipt — ровно 136 доступных байт: ["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(msg):32][sig:64].
* Проверяет идентичность и подпись B по первым 72 байтам; при msg!=NULL также точное тело msg/len.
* 0=корректно, -1=отказ. Не проверяет наличие outbox и ничего не записывает. */
int dm_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[DM_RECEIPT_SIZE], const uint8_t* msg, size_t len);
/* Для локального автора проверить receipt и удалить только точный outbox; история/file receipt сохраняются.
* 0=удалено либо outbox уже отсутствует, -1=ошибка/конфликт. Требуется запущенный dm_core. */
int dm_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t receipt[DM_RECEIPT_SIZE]);
/* Жизненный цикл в uasync: init после chat_core_init с SQLite/router/ua ядра; 0=успех, -1=ошибка.
* Повторный init запущенного владельца возвращает 0. Для send/accept нужен также dm_media_init.
* Повторный init запущенного владельца возвращает 0. Для file-send/accept нужен также dm_media_init.
* destroy отменяет invite/таймер и снимает bind; БД/outbox сохраняются. Вызывать до chat_core_destroy. */
int dm_core_init(struct UTUN_INSTANCE* inst);
void dm_core_destroy(struct UTUN_INSTANCE* inst);
@@ -99,9 +112,16 @@ int dm_start(struct UTUN_INSTANCE* inst, uint64_t peer_node_id,
int dm_send(struct UTUN_INSTANCE* inst, const char* conv_id, const char* content_type,
const uint8_t* data, uint32_t data_len);
/* Локальное удаление; generation защищает повторно открытую беседу от старого запроса. */
struct dm_delete_req { struct UTUN_INSTANCE* inst; char conv_id[64]; uint64_t generation, request_id; };
/* Локальное удаление по dm_history_delete; generation защищает повторно открытую беседу.
* CHAT_EVT_DM_DELETE_RESULT: [request_id:8][conv:u64][generation:8][success:u8], всего 25 байт native-endian. */
struct dm_delete_req {
struct UTUN_INSTANCE* inst; /* Заимствованное ядро до завершения запроса. */
char conv_id[64]; /* Десятичный ID беседы с NUL. */
uint64_t generation, /* Ненулевое ожидаемое поколение. */
request_id; /* Корреляция результата с запросом UI. */
};
void dm_delete_trampoline(void* arg);
/* После commit удаления отменить ожидающий invite этой беседы; квитанции/другие беседы не меняет. */
void dm_cancel_pending_history(struct UTUN_INSTANCE* inst, const char* conv);
/* До 200 видимых бесед по last_ts DESC: [{conv_id,peer_node_id,peer_name,last_ts,out_seq,in_seq,generation}].
@@ -113,7 +133,9 @@ int dm_list_conversations_json(struct UTUN_INSTANCE* inst, char* buf, size_t buf
* count=1..200 задаёт предел; count<=0 или count>200 означает 200.
* offset>=0 отсчитывается от новых сообщений; offset=0 выбирает последние count.
* Выбранная страница возвращается по возрастанию ts, seq, dir.
* buf — выходной буфер с NUL; 0 и *out_len байт без NUL при успехе, -1 при ошибке. */
* buf — выходной буфер с NUL; 0 и *out_len байт без NUL при успехе, -1 при ошибке.
* pending относится к квитанции сообщения. media_status: delivered/ready/downloading/expired/pending.
* hidden здесь не фильтруется; интерфейс выбирает видимую беседу по списку/поколению. */
int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int count, int offset,
char* buf, size_t buf_size, size_t* out_len);
@@ -123,20 +145,20 @@ int dm_list_messages_json(struct UTUN_INSTANCE* inst, const char* conv_id, int c
* пира в таблице nodes (как headless hc_handle_dm_start), после чего вызывает
* dm_start(). Результат — событие CHAT_EVT_DM_CONV_UPDATED. */
struct dm_start_req {
struct UTUN_INSTANCE* inst;
uint64_t peer_node_id;
char source_ch_id[64];
char peer_name[128];
struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */
uint64_t peer_node_id; /* Другой участник общей группы; ключи читаются из nodes. */
char source_ch_id[64]; /* Десятичный ID общей группы либо пустая строка. */
char peer_name[128]; /* UTF-8 имя с NUL; dm_start ограничит до 63 байт. */
};
void dm_start_trampoline(void* arg);
/* Отправить текстовое сообщение в беседу (content_type="text"). */
struct dm_send_req {
struct UTUN_INSTANCE* inst;
char conv_id[64];
struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */
char conv_id[64]; /* Десятичный ID беседы с NUL. */
uint64_t generation; /* 0: захватить при входе в uasync, до любой фоновой работы. */
uint32_t data_len;
uint8_t* data; /* data_len байт (выделяется в хвосте структуры) */
uint32_t data_len; /* Число байт текста, до DM_DATA_MAX. */
uint8_t* data; /* Указывает на data_len байт в том же блоке после структуры. */
};
void dm_send_trampoline(void* arg);
@@ -151,12 +173,15 @@ enum dm_invite_result {
DM_INVITE_SEND_ERROR = -3, DM_INVITE_CANCELLED = -4, DM_INVITE_BUSY = -5
};
struct dm_invite_req {
struct UTUN_INSTANCE* inst;
uint64_t request_id, peer_node_id, channel_id, generation;
struct UTUN_INSTANCE* inst; /* Заимствованное ядро до итогового callback. */
uint64_t request_id, /* Ненулевой ID; cancel использует тот же ID. */
peer_node_id, /* Фиксированный получатель, не локальный узел. */
channel_id, /* Выбранная активная CHAT-группа отправителя. */
generation; /* Заполняет uasync до регистрации; проверяет callback. */
char source_ch_id[64]; /* общая группа для новой PM; из шапки PM может быть пустой */
char peer_name[128];
int cancel;
struct chat_invite_build* pending; /* только uasync */
char peer_name[128]; /* UTF-8 имя для новой беседы с NUL. */
int cancel; /* 0=создать, 1=отменить ожидающий request_id. */
struct chat_invite_build* pending; /* Внутренний handle; запрос создаётся обнулённым. */
};
void dm_invite_trampoline(void* arg);
@@ -164,15 +189,18 @@ void dm_invite_trampoline(void* arg);
* [conv_id_len:1][conv_id:var][generation:8][JSON:utf8]. Все чтения в uasync.
* Ненулевое generation проверяется до чтения; 0 захватывает текущее поколение. GUI отбрасывает старый снимок. */
struct dm_messages_req {
struct UTUN_INSTANCE* inst;
char conv_id[64];
struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */
char conv_id[64]; /* Десятичный ID с NUL, также для запроса маршрута звонка. */
uint64_t generation; /* Поколение выбранного PM; для запроса маршрута не используется. */
};
void dm_messages_trampoline(void* arg);
/* Живой BGP-маршрут PM; group_id=0 в снимке означает недоступность. Все вызовы в uasync. */
/* 1 при живом BGP-маршруте общей группы, 0 при недоступности/ошибке. peer/group_id обязательны.
* При корректных аргументах выходы сначала обнуляются; peer может быть известен и без маршрута.
* Трамплин выдаёт CHAT_EVT_DM_CALL_ROUTE: [conv_len:1][conv][peer:8][group:8]; group=0 без маршрута. */
int dm_call_route(struct UTUN_INSTANCE* inst, const char* conv_id, uint64_t* peer, uint64_t* group_id);
void dm_call_route_trampoline(void* arg); /* аргумент: dm_messages_req */
struct dm_conversations_req { struct UTUN_INSTANCE* inst; };
/* Снимок бесед публикуется как CHAT_EVT_DM_CONVERSATIONS с JSON без NUL. */
struct dm_conversations_req { struct UTUN_INSTANCE* inst; /* Заимствованное ядро. */ };
void dm_conversations_trampoline(void* arg);
#ifdef __cplusplus
+10 -4
View File
@@ -1,7 +1,9 @@
/* Локальная история DM: поколения, скрытые беседы и точные отпечатки удалённых сообщений.
* Все операции выполняются в uasync с SQLite ядра. init вызывается после создания DM-таблиц.
* Удаление скрывает беседу и сохраняет её ключи/счётчики в dm_conversations; show открывает её снова.
* Поколение передавать в отложенные операции и проверять current перед применением результата. */
* Поколение передавать в отложенные операции и проверять current перед применением результата.
* Беседой/seq владеет dm_core, файлами/загрузками — dm_media; этот модуль связывает их удаление.
* Аргументы заимствованы на время вызова; самостоятельные транзакции выполняются без внешней транзакции. */
#ifndef DM_HISTORY_H
#define DM_HISTORY_H
#include <stdint.h>
@@ -10,7 +12,8 @@
extern "C" {
#endif
struct UTUN_INSTANCE;
/* Создать служебные таблицы, сбросить busy файлов после restart. 0=успех, -1=ошибка.
/* Создать dm_history, dm_deleted_messages и dm_local_files; сбросить busy после restart.
* 0=успех, -1=ошибка. Вызывает dm_core_init; повторять при живых workers нельзя: они ещё держат пути.
* inst и его topo_sqlite_db обязательны; частично созданные таблицы при ошибке остаются. */
int dm_history_init(struct UTUN_INSTANCE* inst);
/* Поколение существующей беседы: 1 до первого удаления; 0 при отсутствии/ошибке.
@@ -28,8 +31,11 @@ int dm_history_show(struct UTUN_INSTANCE* inst, const char* conv);
int dm_history_deleted(struct UTUN_INSTANCE* inst, const uint8_t* body, size_t len, int dir, uint8_t* media_receipt);
/* Удалить видимую беседу текущего generation<INT64_MAX; DM/core/media должны быть запущены.
* Самостоятельная транзакция: отпечатки, удаление истории/outbox/медиа, hidden=1, generation+1.
* 0 после commit и синхронной отмены операций; -1 при отказе с rollback SQL-изменений.
* Файлы убираются отдельно владельцем media; встречный узел и его историю вызов не меняет. */
* 0 после commit, отмены invite/transfer и запуска уборки; -1 при отказе с rollback SQL-изменений.
* Это не ожидание workers: они сохраняют busy пути и отбрасывают результат старого поколения.
* Файлы убираются отдельно по durable манифесту после release; ошибку удаления повторяет media.
* Квитанция файла сохраняется только если уже была: удаление не подтверждает скачивание.
* Встречный узел, его история и промежуточные S/M-копии этим вызовом не меняются. */
int dm_history_delete(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation);
#ifdef __cplusplus
}
+11 -3
View File
@@ -6,7 +6,13 @@
* Копия удаляется только по квитанции получателя после commit в dm_core.
* Квитанция сохраняется отдельно для повторов PUT и доставки отправителю.
*
* Сервис ETCP_RT_ID_DM_MAILBOX (0x35); контрольные пакеты подписаны router.
* Роль S — supernode=yes в подписанных adm_tags общей CHAT-группы A/B; storage её не заменяет.
* При PUT от A в одной транзакции фиксируются сообщение и, для file, задание dm_mailbox_media.
* Сервис ETCP_RT_ID_DM_MAILBOX (0x35); все пакеты подписаны router, числа native-endian:
* PUT=1 [recipient:8][message]; DELIVER=2 [message]; RECEIPT=3 [receipt:136]; STORED=4 [conv:8][seq:8].
* STORED подтверждает запись на S, только подписанная B квитанция подтверждает доставку сообщения.
* Текст не имеет TTL. Таймер 1с обрабатывает до 32 задач с retry_at через 5с.
* Входные буферы заимствуются на время вызова; их копией в пакете/SQLite владеет соответствующий owner.
*/
#ifndef DM_MAILBOX_H
@@ -37,10 +43,12 @@ void dm_mailbox_destroy(struct UTUN_INSTANCE* inst);
int dm_mailbox_put(struct UTUN_INSTANCE* inst, uint64_t recipient, uint64_t sender,
const uint8_t* msg, size_t msg_len);
/* Передать точную квитанцию всем доступным суперузлам общей группы сторон.
/* Попытаться передать точную квитанцию всем доступным суперузлам общих групп сторон.
* receipt — DM_RECEIPT_SIZE байт (см. dm_core.h), проверяется подпись получателя.
* Это попытка отправки текущим доступным S; собственную durable очередь пересылки вызов не создаёт.
* Источник продолжает повторять PUT до получения этой квитанции. */
* Источник продолжает повторять PUT до получения этой квитанции.
* На S сохранение receipt и удаление точного pending атомарны; повтор PUT возвращает receipt.
* Локальный S здесь пропускается; вызов нужен endpoint, а не для локального применения на S. */
void dm_mailbox_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt);
#ifdef __cplusplus
+11 -3
View File
@@ -2,6 +2,11 @@
* метаданных получателю и освобождение назначений по DELETE_ACK/EXPIRED либо подтверждённому TTL.
* Жизненный цикл принадлежит dm_mailbox, control принимает dm_media. Все API в uasync.
* Координатор хранится в dm_media_jobs/dm_media_holders SQLite ядра; таймер — только триггер повторов.
* Таймер 1с обрабатывает до 8 задач, retry_at через 5с; выбранный M сохраняется до STORE.
* Выбор M: storage=yes в общей группе A/B, достижимый узел по node_id, кроме A/B и прежних назначений.
* S может одновременно быть M: local control проходит тот же обработчик без router-соединения к себе.
* SQLite и группы принадлежат ядру; instance должен пережить owner. Входные тела заимствованы до возврата.
* Из C++ этот внутренний C API включать внутри extern "C".
*/
#ifndef DM_MAILBOX_MEDIA_H
#define DM_MAILBOX_MEDIA_H
@@ -17,21 +22,24 @@ struct dm_mb_media* dm_mailbox_media_create(struct UTUN_INSTANCE* inst);
/* Отменить таймер и освободить owner, сохранив задания/назначения в БД. NULL допустим. */
void dm_mailbox_media_destroy(struct dm_mb_media* media);
/* Вызвать в транзакции PUT после проверки роли S, получателя и подписи полного msg/len.
* 0=не file либо зарегистрирован точный body/UUID, -1=ошибка/конфликт. Не отправляет STORE.
* 0=не file либо зарегистрирован точный SHA256 полного body для UUID, -1=ошибка/конфликт. Не отправляет STORE.
* media обязателен для file; commit/rollback принадлежит вызывающему. */
int dm_mailbox_media_register(struct dm_mb_media* media, uint64_t group, uint64_t recipient,
const uint8_t* msg, size_t len);
/* После проверки router-подписи и размера команды в dm_media: group/source — доверенные поля router.
* body заимствован на время вызова; RECEIPT=152, STORED=24, DELETE_ACK/EXPIRED=16, REJECT=17 байт.
* RECEIPT имеет формат dm_media.h; STORED=[UUID:16][expires:8], ACK/EXPIRED=[UUID:16], REJECT=[UUID:16][reason:1].
* STORED/REJECT/ACK относятся к сохранённому назначению M; RECEIPT проверяется отдельно подписью B.
* EXPIRED/REJECT атомарно освобождают назначение и сбрасывают текущий holder; ошибка откатывает оба изменения.
* Таймер завершает stored-назначение по expires даже при offline B/потерянном EXPIRED, также после restart.
* После квитанции тело удаляется только при отсутствии неосвобождённых назначений; ошибка сканирования откладывает очистку.
* После квитанции повторяется DELETE до 16 неосвобождённым M за проход cleanup.
* Тело удаляется только при отсутствии неосвобождённых назначений; ошибка сканирования откладывает очистку.
* Для неподтверждённого STORE повтор запрашивает результат из custody/tombstone M; квитанция B не отменяется. */
void dm_mailbox_media_recv(struct dm_mb_media* media, uint64_t group, uint64_t source,
uint8_t cmd, const uint8_t* body, size_t len);
/* Проверить 152-байтовую квитанцию и попытаться отправить доступным S общей группы.
* За один вызов выбирается до64 подходящих S на группу; поздние копии также получают подтверждение. */
* За один вызов выбирается до 64 подходящих S на группу, включая локальный S; A/B исключаются.
* Это попытка отправки; durable повтор обеспечивает квитанция в dm_files/dm_media_jobs, а не сам вызов. */
void dm_mailbox_media_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt);
#endif
+59 -15
View File
@@ -1,10 +1,13 @@
/* dm_media — файлы личных бесед и ограниченное ciphertext-хранилище M.
* Шифрование/проверка/публикация файлов в media_async; БД и сеть в uasync.
* Шифрование/проверка/публикация файлов в media_async; все API ниже, БД и сеть — в uasync.
* dm_core вызывает track внутри своей транзакции. Квитанция файла независима
* от квитанции сообщения; создаётся после проверки, публикации и commit.
* от квитанции сообщения; публикуется после проверки, публикации файла и commit.
* Суперузлом и заданиями удаления владеет dm_mailbox_media.
* Квота M проверяется после успешного чтения занятого места и finalize SQL-запроса.
* Ошибки SQL логируются без REJECT, сохраняя штатный повтор STORE со стороны S.
* Instance/SQLite/группы должны пережить workers и их callbacks; остановка — quiesce, drain, destroy.
* Входные строки/буферы заимствуются до возврата, кроме явно переданного временного файла.
* Из C++ этот C API включать внутри extern "C", как в клиентах Qt/JNI.
*/
#ifndef DM_MEDIA_H
#define DM_MEDIA_H
@@ -15,10 +18,17 @@
struct UTUN_INSTANCE;
#define DM_MEDIA_DESC_SIZE 56 /* UUID:16, plaintext_size:8, SHA256(ciphertext):32 */
#define DM_MEDIA_DESC_SIZE 56 /* UUID:16, plaintext_size:8 native-endian, SHA256(ciphertext):32. */
#define DM_MEDIA_RECEIPT_SIZE 152
#define DM_MEDIA_MAX_SIZE (1024ULL * 1024 * 1024)
/* ETCP_RT_ID_DM_MEDIA=0x38: [cmd:1][body], router-подпись обязательна; числа native-endian.
* OFFER: [recipient:8][holder:8][expires:8][каноническое file-сообщение]. A предлагает себя с expires=0.
* STORE: [recipient:8][file-сообщение] от S к M; STORED: [UUID:16][expires:8].
* RECEIPT/DELETE: ["DMFILE01":8][conv:8][seq:8][author:8][recipient:8][UUID:16][SHA256(message):32][sig_B:64].
* B подписывает первые 88 байт; DELETE переносит ту же квитанцию, не создаёт новую.
* DELETE_ACK/EXPIRED: [UUID:16]; REJECT: [UUID:16][reason:1], reason=1 — недостаток квоты.
* EXPIRED завершает промежуточное хранение, а не подтверждает доставку B. */
#define DM_MEDIA_OFFER 1
#define DM_MEDIA_STORE 2
#define DM_MEDIA_STORED 3
@@ -28,47 +38,81 @@ struct UTUN_INSTANCE;
#define DM_MEDIA_EXPIRED 7
#define DM_MEDIA_REJECT 8
/* После dm_core_init с SQLite/config/router/ua и media_async ядра: таблицы, каталоги, file_transfer, bind и таймер.
* 0=успех, -1=ошибка, в том числе повторный init живого owner. При отказе созданные таблицы/каталоги остаются.
* Корень: db_path/dm_media и db_path/dm_pending; без db_path используется текущий каталог.
* После restart сбрасываются downloading/retry_at; история, custody и квитанции сохраняются. */
int dm_media_init(struct UTUN_INSTANCE* inst);
/* quiesce до media_async_destroy; destroy после workers, до групп/core/mailbox. */
/* Идемпотентно запретить новые задачи, отменить таймер/bind и file_transfer с его CM handles.
* Вызвать до media_async_destroy; callbacks отмены ещё используют живые owner/SQLite/группы. NULL допустим. */
void dm_media_quiesce(struct UTUN_INSTANCE* inst);
/* После drain/destroy media_async, до групп/core/mailbox: освободить owner, сохранить durable данные.
* При живых jobs/cleanup_active логирует ошибку и оставляет owner для повторного destroy. NULL допустим. */
void dm_media_destroy(struct UTUN_INSTANCE* inst);
/* Передать подготовку копии и enqueue file-сообщения в media_async.
* Строки conv/path и info копируются при вызове; сам файл должен быть доступен worker до завершения.
* 0=submit вызван, -1=отказ до submit. Submit может синхронно завершиться ошибкой через callback;
* готовность/ошибка подготовки сообщается CHAT_EVT_ATTACHMENT_PREPARATION, включая до возврата0.
* remove_source=1 передаёт worker собственный временный файл приложения для удаления; при -1 его убирает caller. */
* готовность/ошибка подготовки сообщается CHAT_EVT_ATTACHMENT_PREPARATION, включая до возврата 0.
* Беседа должна быть видимой; до 4 одновременных подготовок, plaintext до DM_MEDIA_MAX_SIZE.
* remove_source=1 передаёт worker собственный временный файл приложения для удаления; при -1 его убирает caller.
* Успех подготовки означает commit file-сообщения, не доставку. Пользовательский исходник: remove_source=0. */
int dm_send_file(struct UTUN_INSTANCE* inst, const char* conv, const char* path, const struct attachment_info* info,
int remove_source);
struct dm_file_req {
struct UTUN_INSTANCE* inst;
char conv[64];
uint64_t generation;
char path[1024];
struct attachment_info info;
struct UTUN_INSTANCE* inst; /* Заимствованное ядро до callback. */
char conv[64]; /* Десятичный ID беседы с NUL. */
uint64_t generation; /* Ожидаемое поколение; 0=захватить при входе в uasync. */
char path[1024]; /* Путь исходника с NUL; копируется при принятии. */
struct attachment_info info; /* Проверенные метаданные вложения. */
int remove_source; /* Только принадлежащий приложению временный файл. */
};
/* arg — один блок u_malloc/u_calloc(dm_file_req); трамплин освобождает его, также при отказе.
* Проверяет generation до submit; непринятый remove_source удаляет через worker. */
void dm_send_file_trampoline(void* arg);
/* Внутри транзакции dm_core, 0=записано/точный повтор. Для text — пустая операция. */
/* Внутри транзакции dm_core после проверки канонического msg/len; dir: 0=входящее, 1=исходящее.
* 0=записано/точный повтор UUID и тела, -1=ошибка/конфликт; rollback делает вызывающий.
* Для не-file — пустая операция. Исходящий file требует уже подготовленного .file нужного размера. */
int dm_media_track(struct UTUN_INSTANCE* inst, int dir, const uint8_t* msg, size_t len);
/* Заимствованный descriptor внутри точного канонического file-тела либо NULL.
* Проверяет только формат/длину, не подпись и не размер/хеш файла; указатель живёт вместе с msg. */
const uint8_t* dm_message_media(const uint8_t* msg, size_t len);
/* Ciphertext bytes для plain_size<=DM_MEDIA_MAX_SIZE: plaintext плюс 16 байт на порцию 32768 (для пустого — 16). */
uint64_t dm_media_cipher_size(uint64_t plain_size);
/* receipt — обязательные 152 доступных байта; подпись B/идентичность проверяются по nodes SQLite.
* При msg!=NULL проверяется соответствие каноническому file-телу msg/len, UUID и полному SHA256.
* 0=корректно, -1=отказ. Ничего не записывает, факт скачивания этим вызовом не устанавливается. */
int dm_media_verify_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt, const uint8_t* msg, size_t len);
/* Для локального A проверить 152-байтовую квитанцию и сохранить её в точном исходящем dm_files.
* 0=сохранена/повтор либо точное подтверждение уже удалённого файла; -1=ошибка/неизвестное тело.
* Историю и собственный файл A не удаляет; text outbox от этой квитанции не меняется. */
int dm_media_accept_receipt(struct UTUN_INSTANCE* inst, const uint8_t* receipt);
/* Удаление: prepare внутри транзакции, committed после commit (callbacks отмены синхронны). */
/* Внутри транзакции dm_history_delete: сохранить свои пути в манифесте поколения и удалить dm_files беседы.
* 0=успех, -1=ошибка; требует живого dm_media, commit/rollback делает вызывающий. Не удаляет файлы/transfer. */
int dm_media_forget_prepare(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation);
/* Только после commit: отменить transfer удалённых UUID и начать асинхронную уборку старых поколений.
* Callback отмены transfer синхронен; ожидания workers здесь нет, busy защищает их пути. */
void dm_media_forget_committed(struct UTUN_INSTANCE* inst, const char* conv);
/* Владение путями worker: hold до запуска, release после завершения. Уборка ждёт busy=0. */
/* Зарегистрировать принадлежащий приложению path и увеличить busy до запуска worker/transfer.
* conv/generation должны быть текущими и видимыми; 0=захват, -1=ошибка/чужое поколение пути. */
int dm_media_file_hold(struct UTUN_INSTANCE* inst, const char* conv, uint64_t generation, const char* path);
/* Освободить один успешный hold после callback; SQL/нарушение счётчика логируются.
* Уборка старого поколения ждёт освобождения всех holds; busy=-1 означает захват уборщиком. */
void dm_media_file_release(struct UTUN_INSTANCE* inst, const char* path);
/* Общие операции control для endpoint/M и mailbox/S. Все пакеты подписаны. */
/* Отправить cmd/body/len через подписанный router в group к peer; нужен живой dm_media.
* 0=пакет принят транспортом, ненулевое значение=ошибка/backpressure. Durable очередь не создаёт.
* Локальный peer обрабатывается синхронно: 0 означает вызов обработчика, не его durable успех.
* body заимствован до возврата; вызывающий соблюдает формат команды, size<=DM_MEDIA_BODY_MAX+24. */
int dm_media_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer,
uint8_t cmd, const uint8_t* body, size_t len);
/* 1, если node/author/recipient — живые мемберы group и node имеет role=yes в adm_tags.
* role=NULL проверяет только членство; 0 при отсутствии полномочий/ошибке SQL. Не проверяет сетевой путь. */
int dm_media_scope(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t node,
const char* role, uint64_t author, uint64_t recipient);
/* Опубликовать CHAT_EVT_DM_MSG_RECEIVED [conv_len:1][conv:decimal][local_node:8] для обновления снимка UI. */
void dm_media_notify(struct UTUN_INSTANCE* inst, uint64_t conv);
#endif
+11 -4
View File
@@ -1,4 +1,7 @@
/* Внутренние SQL/control операции двух владельцев DM media; снаружи не использовать. */
/* Внутренние SQL/control операции dm_media и dm_mailbox_media; снаружи не использовать.
* Все вызовы в uasync с живыми inst/topo_sqlite_db. Workers не обращаются к этим helpers.
* Соединением SQLite владеет ядро; вызовы не закрывают его и не меняют внешнее владение транзакцией.
* Из C++ включать внутри extern "C". */
#ifndef DM_MEDIA_PRIV_H
#define DM_MEDIA_PRIV_H
@@ -6,17 +9,21 @@
#include "dm_core.h"
#include <sqlite3.h>
/* Верхняя граница канонического тела с ct<=255, CCM tag, descriptor и подписью; без cmd/router-заголовка. */
#define DM_MEDIA_BODY_MAX (DM_MSG_FIXED_HDR + 255 + 2 + DM_DATA_MAX + 16 + DM_MEDIA_DESC_SIZE + DM_MSG_SIG_SIZE)
/* SQLite ядра, только uasync: prepare возвращает statement либо NULL с логом ошибки.
* inst/db/sql обязательны; вызывающий связывает параметры и освобождает statement. */
* inst/db/sql обязательны; вызывающий проверяет bind/step для чтения и всегда освобождает statement.
* Для записи владение можно передать dm_media_step; prepare сам транзакцию не начинает. */
sqlite3_stmt* dm_media_prepare(struct UTUN_INSTANCE* inst, const char* sql);
/* Шаг записи, затем finalize (включая ошибку): statement передаётся во владение helper.
* 0 только при SQLITE_DONE и успешном finalize, иначе -1 с логом; внутри транзакции rollback делает вызывающий. */
* 0 только при SQLITE_DONE и успешном finalize, иначе -1 с логом; NULL даёт -1 после ошибки prepare.
* Не предназначен для SELECT; bind проверяет вызывающий, внутри транзакции rollback делает вызывающий. */
int dm_media_step(struct UTUN_INSTANCE* inst, sqlite3_stmt* st);
/* Выполнить SQL без результата: 0/-1 с логом. Владение транзакцией определяется SQL вызывающего. */
int dm_media_exec(struct UTUN_INSTANCE* inst, const char* sql);
/* Заимствованный координатор mailbox либо NULL при остановленном/отсутствующем inst. */
/* Заимствованный координатор mailbox либо NULL при остановленном/отсутствующем inst.
* Не освобождать; действителен до dm_mailbox_destroy в том же uasync-потоке. */
struct dm_mb_media* dm_mailbox_get_media(struct UTUN_INSTANCE* inst);
#endif
@@ -1,8 +1,9 @@
package com.utun.chat.ui.components
import android.graphics.Bitmap
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
@@ -16,14 +17,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication
import com.utun.chat.R
import com.utun.chat.ui.screens.AvatarViewerScreen
/** Короткое нажатие открывает просмотр; удержание можно передать меню содержащей строки. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserAvatar(group: String?, owner: Long, name: String, modifier: Modifier = Modifier,
size: Dp = 40.dp, profile: Boolean = false) {
size: Dp = 40.dp, profile: Boolean = false, enabled: Boolean = true, onLongClick: (() -> Unit)? = null) {
val repository = ChatApplication.instance.avatars
val revision by repository.revision.collectAsState()
var viewing by rememberSaveable(group,owner) { mutableStateOf(false) }
@@ -31,7 +36,8 @@ fun UserAvatar(group: String?, owner: Long, name: String, modifier: Modifier = M
if (profile) repository.ensureProfile() else repository.ensureUser(group, owner)
}
AvatarImage(if (profile) repository.profileBitmap() else repository.userBitmap(group, owner), name,
modifier.clickable(enabled=!profile) { viewing=true }, size)
modifier.combinedClickable(enabled = enabled && !profile, onLongClick = onLongClick,
onLongClickLabel = if (onLongClick != null) stringResource(R.string.options) else null, onClick = { viewing = true }), size)
if (viewing) AvatarViewerScreen(group,owner,name,onClose={ viewing=false })
}
@@ -97,55 +97,102 @@ fun AvatarViewerScreen(group: String?, owner: Long, name: String, onClose: () ->
} } }
}
fun move(delta: Int) { val next=index+delta; if (next in files.indices) selected=files[next] }
Dialog(onDismissRequest=onClose,properties=DialogProperties(decorFitsSystemWindows=false)) {
AvatarViewerDialog(name, onClose) {
val view=LocalView.current
val window=(view.parent as DialogWindowProvider).window
DisposableEffect(window) {
// Compose 1.6 с usePlatformDefaultWidth=false измеряет по дисплею, а не по доступной области окна.
window.setLayout(WindowManager.LayoutParams.MATCH_PARENT,WindowManager.LayoutParams.MATCH_PARENT)
onDispose { }
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds(),contentAlignment=Alignment.Center) {
val image=original ?: previews[selected]
if (image!=null) ZoomableImage(image,Modifier.fillMaxSize(),onSwipe={ move(it) })
else if (source!=null && !failed) CircularProgressIndicator()
else Text(stringResource(if (source==null) R.string.avatar_none else R.string.avatar_load_failed),color=Color.White)
}
Column(Modifier.fillMaxSize().background(Color.Black).safeDrawingPadding().onSizeChanged {
LogManager.addLog("DEBUG","Avatar","Viewer content=${it.width}x${it.height}")
}) {
Row(Modifier.fillMaxWidth(),verticalAlignment=Alignment.CenterVertically) {
Text(name,color=Color.White,modifier=Modifier.weight(1f).padding(12.dp))
TextButton(onClick=onClose) { Text(stringResource(R.string.close)) }
}
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds(),contentAlignment=Alignment.Center) {
val image=original ?: previews[selected]
if (image!=null) ZoomableImage(image,Modifier.fillMaxSize(),onSwipe={ move(it) })
else if (source!=null && !failed) CircularProgressIndicator()
else Text(stringResource(if (source==null) R.string.avatar_none else R.string.avatar_load_failed),color=Color.White)
}
if (failed || catalogFailed || failedPreviews.isNotEmpty()) TextButton(onClick={ retry++ },modifier=Modifier.align(Alignment.CenterHorizontally)) {
Text(stringResource(R.string.avatar_retry))
} else if (original==null && source!=null) LinearProgressIndicator(Modifier.fillMaxWidth())
if (index>=0) Text("${index+1} / ${files.size}",color=Color.White,modifier=Modifier.align(Alignment.CenterHorizontally))
Row(Modifier.fillMaxWidth().padding(vertical=8.dp).onGloballyPositioned {
val insets=ViewCompat.getRootWindowInsets(view)?.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
LogManager.addLog("DEBUG","Avatar","Viewer thumbnails position=${it.positionInWindow()} size=${it.size} " +
"window=${view.width}x${view.height} insets=$insets")
},verticalAlignment=Alignment.CenterVertically,
horizontalArrangement=Arrangement.Center) {
TextButton(enabled=index>0,onClick={ move(-1) },contentPadding=PaddingValues(4.dp),modifier=Modifier.width(32.dp)) { Text("←") }
repeat(5) { slot ->
val item=index+slot-2; val file=files.getOrNull(item)
Box(Modifier.weight(1f).aspectRatio(1f).padding(2.dp)
.border(if (slot==2) 2.dp else 0.dp,if (slot==2) Color(0xFF77AAFF) else Color.Transparent)
.clickable(enabled=file!=null) { if (file!=null) selected=file },contentAlignment=Alignment.Center) {
previews[file]?.let { Image(it.asImageBitmap(),contentDescription=null,contentScale=ContentScale.Crop,
modifier=Modifier.fillMaxSize()) }
if (file!=null && file !in previews) {
if (file in failedPreviews) Text("!",color=Color.White)
else CircularProgressIndicator(Modifier.size(18.dp),strokeWidth=2.dp)
}
if (failed || catalogFailed || failedPreviews.isNotEmpty()) TextButton(onClick={ retry++ },modifier=Modifier.align(Alignment.CenterHorizontally)) {
Text(stringResource(R.string.avatar_retry))
} else if (original==null && source!=null) LinearProgressIndicator(Modifier.fillMaxWidth())
if (index>=0) Text("${index+1} / ${files.size}",color=Color.White,modifier=Modifier.align(Alignment.CenterHorizontally))
Row(Modifier.fillMaxWidth().padding(vertical=8.dp).onGloballyPositioned {
val insets=ViewCompat.getRootWindowInsets(view)?.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
LogManager.addLog("DEBUG","Avatar","Viewer thumbnails position=${it.positionInWindow()} size=${it.size} " +
"window=${view.width}x${view.height} insets=$insets")
},verticalAlignment=Alignment.CenterVertically,
horizontalArrangement=Arrangement.Center) {
TextButton(enabled=index>0,onClick={ move(-1) },contentPadding=PaddingValues(4.dp),modifier=Modifier.width(32.dp)) { Text("←") }
repeat(5) { slot ->
val item=index+slot-2; val file=files.getOrNull(item)
Box(Modifier.weight(1f).aspectRatio(1f).padding(2.dp)
.border(if (slot==2) 2.dp else 0.dp,if (slot==2) Color(0xFF77AAFF) else Color.Transparent)
.clickable(enabled=file!=null) { if (file!=null) selected=file },contentAlignment=Alignment.Center) {
previews[file]?.let { Image(it.asImageBitmap(),contentDescription=null,contentScale=ContentScale.Crop,
modifier=Modifier.fillMaxSize()) }
if (file!=null && file !in previews) {
if (file in failedPreviews) Text("!",color=Color.White)
else CircularProgressIndicator(Modifier.size(18.dp),strokeWidth=2.dp)
}
}
TextButton(enabled=index>=0 && index+1<files.size,onClick={ move(1) },contentPadding=PaddingValues(4.dp),
modifier=Modifier.width(32.dp)) { Text("→") }
}
TextButton(enabled=index>=0 && index+1<files.size,onClick={ move(1) },contentPadding=PaddingValues(4.dp),
modifier=Modifier.width(32.dp)) { Text("→") }
}
}
}
/** Аватарка канала хранится одним изображением, без каталога пользовательских оригиналов. */
@Composable
fun GroupAvatarViewerScreen(group: String, name: String, onClose: () -> Unit) {
val repository = ChatApplication.instance.avatars
val groups by repository.groups.collectAsState()
val revision by repository.revision.collectAsState()
val account = repository.accountId
val state = groups[group]
val image = remember(group, state, revision) { repository.groupBitmap(group) }
DisposableEffect(group, account) {
LogManager.addLog("INFO", "Avatar", "Group viewer opened group=$group account=$account")
onDispose { LogManager.addLog("INFO", "Avatar", "Group viewer closed group=$group") }
}
LaunchedEffect(group, account, revision) {
if (account == 0L) onClose() else repository.ensureGroup(group)
}
LaunchedEffect(group, state, image) {
LogManager.addLog("DEBUG", "Avatar", "Group viewer group=$group version=${state?.version} loading=${state?.loading} " +
"error=${state?.error} hasImage=${state?.hasImage} bitmap=${image?.width}x${image?.height}")
}
AvatarViewerDialog(name, onClose) {
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds(), contentAlignment = Alignment.Center) {
when {
image != null -> ZoomableImage(image, Modifier.fillMaxSize())
state?.error == true -> Text(stringResource(R.string.avatar_load_failed), color = Color.White)
state == null || state.loading -> CircularProgressIndicator()
else -> Text(stringResource(R.string.avatar_none), color = Color.White)
}
}
if (state?.error == true) TextButton(onClick = {
LogManager.addLog("INFO", "Avatar", "Group viewer retry group=$group")
repository.ensureGroup(group, true)
}, modifier = Modifier.align(Alignment.CenterHorizontally)) {
Text(stringResource(R.string.avatar_retry))
} else if (state?.loading == true && image != null) LinearProgressIndicator(Modifier.fillMaxWidth())
}
}
/** Общие размеры окна, безопасные отступы и закрытие для обоих видов аватарок. */
@Composable
private fun AvatarViewerDialog(name: String, onClose: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
Dialog(onDismissRequest = onClose, properties = DialogProperties(decorFitsSystemWindows = false)) {
val view = LocalView.current
val window = (view.parent as DialogWindowProvider).window
DisposableEffect(window) {
// Compose 1.6 с usePlatformDefaultWidth=false измеряет по дисплею, а не по доступной области окна.
window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
onDispose { }
}
Column(Modifier.fillMaxSize().background(Color.Black).safeDrawingPadding().onSizeChanged {
LogManager.addLog("DEBUG", "Avatar", "Viewer content=${it.width}x${it.height}")
}) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(name, color = Color.White, modifier = Modifier.weight(1f).padding(12.dp))
TextButton(onClick = onClose) { Text(stringResource(R.string.close)) }
}
content()
}
}
}
@@ -3,13 +3,13 @@ package com.utun.chat.ui.screens
import com.utun.chat.R
import com.utun.chat.BuildConfig
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.QrCode
@@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import com.utun.chat.ui.components.PeerStatusDot
import com.utun.chat.ui.components.CpuLoadIndicator
import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -34,12 +35,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
import com.utun.chat.data.DmConversation
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.LogManager
import com.utun.chat.ChatApplication
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ChannelListScreen(
channels: List<Channel>,
@@ -73,6 +71,20 @@ fun ChannelListScreen(
var deleteConfirmChannel by remember { mutableStateOf<Channel?>(null) }
var menuDmId by remember { mutableStateOf<String?>(null) }
var deleteConfirmDm by remember { mutableStateOf<DmConversation?>(null) }
var avatarChannelId by rememberSaveable { mutableStateOf<String?>(null) }
val optionsLabel = stringResource(R.string.options)
LaunchedEffect(channels, dmConversations, deletingDm) {
if (channels.none { it.id == menuChannelId }) menuChannelId = null
if (channels.none { it.id == avatarChannelId }) avatarChannelId = null
if (dmConversations.none { it.convId == menuDmId && it.convId !in deletingDm }) menuDmId = null
deleteConfirmDm?.let { selected ->
if (dmConversations.none { it.convId == selected.convId && it.generation == selected.generation && it.convId !in deletingDm }) {
LogManager.addLog("DEBUG", "UI", "Dismiss stale PM deletion conv=${selected.convId} generation=${selected.generation}")
deleteConfirmDm = null
}
}
}
LaunchedEffect(Unit) {
LogManager.addLog("INFO", "UI", "Channel list: version=${BuildConfig.VERSION_NAME} code=${BuildConfig.VERSION_CODE}")
@@ -122,7 +134,7 @@ fun ChannelListScreen(
}
} else {
LazyColumn {
items(channels) { ch ->
items(channels, key = { "channel:${it.id}" }) { ch ->
val barColor = if (ch.peersOnline > 0) Color(0xFF81C784) else Color(0xFFAEBCC8)
val badgeText = when {
ch.peersOnline > 99 -> "99+"
@@ -130,29 +142,47 @@ fun ChannelListScreen(
else -> ""
}
Surface(modifier = Modifier.clickable { onSelectChannel(ch) }) {
val openMenu: (String) -> Unit = { source ->
LogManager.addLog("DEBUG", "UI", "Channel menu opened channel=${ch.id} source=$source")
menuDmId = null
menuChannelId = ch.id
}
Surface(modifier = Modifier.combinedClickable(onLongClickLabel = optionsLabel,
onLongClick = { openMenu("row") }, onClick = {
LogManager.addLog("INFO", "UI", "Channel opened channel=${ch.id}")
onSelectChannel(ch)
})) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
Box(Modifier.width(4.dp).fillMaxHeight().background(barColor))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
IconButton(onClick = { menuChannelId = ch.id }, modifier = Modifier.size(44.dp)) {
GroupAvatar(ch.id, ch.name)
}
Box(Modifier.size(44.dp).combinedClickable(onLongClickLabel = optionsLabel,
onLongClick = { openMenu("avatar") }, onClick = { avatarChannelId = ch.id }),
contentAlignment = Alignment.Center) {
GroupAvatar(ch.id, ch.name)
DropdownMenu(
expanded = menuChannelId == ch.id,
onDismissRequest = { menuChannelId = null }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.invite_link)) },
onClick = { menuChannelId = null; onInviteClick(ch) }
onClick = {
LogManager.addLog("INFO", "UI", "Channel menu invite channel=${ch.id}")
menuChannelId = null; onInviteClick(ch)
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.group_properties)) },
onClick = { menuChannelId = null; optionsChannel = ch }
onClick = {
LogManager.addLog("INFO", "UI", "Channel menu properties channel=${ch.id}")
menuChannelId = null; optionsChannel = ch
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.delete_channel), color = MaterialTheme.colorScheme.error) },
onClick = { menuChannelId = null; deleteConfirmChannel = ch }
onClick = {
LogManager.addLog("INFO", "UI", "Channel menu delete channel=${ch.id}")
menuChannelId = null; deleteConfirmChannel = ch
}
)
}
if (badgeText.isNotEmpty()) {
@@ -198,20 +228,32 @@ fun ChannelListScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
items(dmConversations) { dm ->
Surface(modifier = Modifier.clickable(enabled = dm.convId !in deletingDm) { onSelectDm(dm) }) {
items(dmConversations, key = { "dm:${it.convId}:${it.generation}" }) { dm ->
val enabled = dm.convId !in deletingDm
val openMenu: (String) -> Unit = { source ->
LogManager.addLog("DEBUG", "UI", "PM menu opened conv=${dm.convId} generation=${dm.generation} source=$source")
menuChannelId = null
menuDmId = dm.convId
}
Surface(modifier = Modifier.combinedClickable(enabled = enabled, onLongClickLabel = optionsLabel,
onLongClick = { openMenu("row") }, onClick = {
LogManager.addLog("INFO", "UI", "PM opened conv=${dm.convId} generation=${dm.generation}")
onSelectDm(dm)
})) {
Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) {
Box(Modifier.width(4.dp).fillMaxHeight().background(Color(0xFFD2A8FF)))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
IconButton(onClick = { menuDmId = dm.convId }, modifier = Modifier.size(44.dp)) {
UserAvatar(null, dm.peerNodeId, dm.displayName, size = 44.dp)
}
UserAvatar(null, dm.peerNodeId, dm.displayName, size = 44.dp,
enabled = enabled, onLongClick = { openMenu("avatar") })
DropdownMenu(expanded = menuDmId == dm.convId, onDismissRequest = { menuDmId = null }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.delete_conversation), color = MaterialTheme.colorScheme.error) },
enabled = dm.convId !in deletingDm,
onClick = { menuDmId = null; deleteConfirmDm = dm }
enabled = enabled,
onClick = {
LogManager.addLog("INFO", "UI", "PM menu delete conv=${dm.convId} generation=${dm.generation}")
menuDmId = null; deleteConfirmDm = dm
}
)
}
PeerStatusDot(dm.online, Modifier.align(Alignment.TopStart).offset(x = (-4).dp, y = (-6).dp))
@@ -231,6 +273,10 @@ fun ChannelListScreen(
}
}
channels.firstOrNull { it.id == avatarChannelId }?.let { ch ->
GroupAvatarViewerScreen(ch.id, ch.name, onClose = { avatarChannelId = null })
}
deleteConfirmDm?.let { dm ->
AlertDialog(
onDismissRequest = { deleteConfirmDm = null },