Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
8a0654e57b
13 files changed
+547
-144
No files matched your search
+77
-12
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
+91
-44
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-22
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user