From e018be346ad70fc95129ecbfde3f57193a461907 Mon Sep 17 00:00:00 2001 From: wt Date: Wed, 14 Oct 2026 17:56:31 +0700 Subject: [PATCH] added Docs/UIKIT.md uikit_clear.md tz.md tz_clear.md --- Docs/UIKIT.md | 614 ++++++++++++++ README.md | 8 + tz.md | 2151 ++++++++++++++++++++++++++++++++++++++++++++++++ tz_clear.md | 793 ++++++++++++++++++ uikit_clear.md | 613 ++++++++++++++ 5 files changed, 4179 insertions(+) create mode 100644 Docs/UIKIT.md create mode 100644 tz.md create mode 100644 tz_clear.md create mode 100644 uikit_clear.md diff --git a/Docs/UIKIT.md b/Docs/UIKIT.md new file mode 100644 index 0000000..b4c9794 --- /dev/null +++ b/Docs/UIKIT.md @@ -0,0 +1,614 @@ +# Luma UI Kit — стандарт интерфейса + +Версия документа: 1.0. Дата фиксации: 14 октября 2026 года. + +Документ фиксирует существующий дизайн Luma для повторного использования в новых экранах и при сопровождении. Здесь UI Kit означает библиотеку визуальных правил и компонентов продукта; приложение написано преимущественно на SwiftUI, а не на одноимённом UIKit framework. + +Основание: текущие `Sources/App`, `Sources/Shared/UI`, UI policies в `Sources/Shared/Models`, `Sources/Watch/WatchViews.swift`, ресурсы цвета и `project.yml`. Исходный коммит: `6969863d1e4db434004f8e5e032ada270a4b948f`. Документ составлен по коду; screenshots и фактический рендеринг на устройствах в этом сеансе не получены. Системные материалы, SF Symbols и размеры semantic fonts зависят от платформы, версии ОС, настроек и accessibility. + +Функциональное поведение подробнее описано в [обратном ТЗ](../tz.md). При расхождении старого описания с этим документом для текущего дизайна приоритет имеет исполняемый компонент, на который дана ссылка. + +## Содержание + +- [1. Правила применения](#1-правила-применения) +- [2. Визуальный характер](#2-визуальный-характер) +- [3. Палитра](#3-палитра) +- [4. Типографика](#4-типографика) +- [5. Геометрия и поверхности](#5-геометрия-и-поверхности) +- [6. Иконки](#6-иконки) +- [7. Каркас экранов](#7-каркас-экранов) +- [8. Avatar](#8-avatar) +- [9. ConversationRow и контакт](#9-conversationrow-и-контакт) +- [10. Поиск](#10-поиск) +- [11. Лента чата](#11-лента-чата) +- [12. Message bubble](#12-message-bubble) +- [13. Replies и reactions](#13-replies-и-reactions) +- [14. Composer](#14-composer) +- [15. Медиа](#15-медиа) +- [16. Call UI](#16-call-ui) +- [17. Формы и системные листы](#17-формы-и-системные-листы) +- [18. Feedback и overlays](#18-feedback-и-overlays) +- [19. Анимации и жесты](#19-анимации-и-жесты) +- [20. Watch UI](#20-watch-ui) +- [21. Состояния компонента](#21-состояния-компонента) +- [22. Текст и доступность](#22-текст-и-доступность) +- [23. Известные неоднородности](#23-известные-неоднородности) +- [24. Порядок добавления нового UI](#24-порядок-добавления-нового-ui) +- [25. Каталог компонентов и источников](#25-каталог-компонентов-и-источников) + +## 1. Правила применения + +- Новые экраны продолжают существующий визуальный язык: системные навигация и формы, синий accent, полупрозрачные поверхности, скруглённые элементы, SF Symbols. +- Сначала переиспользуется имеющийся компонент, затем существующий вариант, и только затем создаётся новый. +- Числа в таблицах — значения SwiftUI в points, если не указана другая единица. RGB — нормализованные значения 0…1. +- Semantic colors и font styles сохраняются как semantic, без подмены случайными HEX и фиксированным размером. +- Имена токенов в этом документе — документальные обозначения. Единого исполняемого `LumaTheme`/`DesignTokens` в проекте сейчас нет. +- «Стандарт» — правило для будущих экранов на основе текущих компонентов. «Факт/исключение» — существующая неоднородность, которая не должна автоматически распространяться на новые компоненты. +- Изменение документа само по себе не изменяет UI. Унификация разных текущих значений требует отдельного изменения приложения. + +## 2. Визуальный характер + +Luma сочетает нативную Apple-навигацию, Telegram-подобные списки/компоновку чата и Messages-подобную подачу ответов/анимаций. Основной контент спокойный: имя, сообщение и действие важнее декоративной рамки. + +| Принцип | Применение | +| --- | --- | +| Нативность | TabView, NavigationStack, NavigationSplitView, Form, List, системные sheets/alerts/pickers | +| Акцент | Синий для действий и выбора; сине-голубой градиент исходящих | +| Полупрозрачность | Glass/material для управления и вторичных поверхностей | +| Иерархия | Primary → secondary → caption; текст не дублируется крупными декоративными заголовками | +| Разделение | Отступы и скруглённые поверхности; divider там, где меняется зона работы | +| Состояние | Иконка, текст, progress и доступность действия; цвет дополняет их | +| Контекст | Действия над сообщением — context menu, массовые — selection mode | +| Адаптация | Общие роли компонентов при разной навигации iOS/Mac/Watch | + +## 3. Палитра + +### 3.1. Основные цвета + +| Документальный токен | Значение | Источник / использование | +| --- | --- | --- | +| `accent.root` | RGB 0.14, 0.56, 0.96 ≈ #248FF5 | `LumaApp`: `.tint(...)` | +| `accent.asset` | RGB 0.145, 0.565, 0.965 ≈ #2590F6 | `Resources/Shared.xcassets/AccentColor.colorset` | +| `text.primary` | `.primary` | Основной текст на адаптивных светлых поверхностях | +| `text.secondary` | `.secondary` | Подписи, время, вторичная информация | +| `text.onOutgoing` | `.white` | Текст исходящего bubble | +| `surface.incoming` | `.secondary.opacity(0.13)` | Входящий bubble | +| `surface.timeline` | `.secondary.opacity(0.035)` | Фон ChatView | +| `surface.photoPlaceholder` | `.secondary.opacity(0.12)` | Фотоостровок/placeholder | +| `border.media` | `.secondary.opacity(0.18)` | Рамка фото/видео, 1 pt | +| `state.success` | `.green` | Online, verified, completed call | +| `state.warning` | `.orange` | Plaintext и предупреждение | +| `state.error` | `.red` | Ошибка, destructive, неудачный звонок | +| `overlay.dark` | `.black` с локальной opacity | Viewer, подписи на media, звонок | + +**Различие accent:** `.tint` на уровне корня и `Color.accentColor` из assets не являются одним числом. В новых компонентах переиспользовать `.tint`/`Color.accentColor` по роли, не добавлять третий близкий синий. Не считать таблицу результатом уже выполненной унификации. + +### 3.2. Градиенты + +Все перечисленные линейные градиенты направлены topLeading → bottomTrailing, кроме явно заданного источником другого направления. + +| Название | RGB stops | Применение | +| --- | --- | --- | +| `gradient.auth` | (0.08, 0.38, 0.78) → (0.12, 0.62, 0.96) → (0.44, 0.84, 0.96) | LoginView, AppLockView | +| `gradient.outgoing` | (0.13, 0.51, 0.94) → (0.12, 0.63, 0.94) | Исходящее сообщение | +| `gradient.videoPlaceholder` | (0.06, 0.17, 0.29) → (0.09, 0.43, 0.64) | Видеоостровок | +| `gradient.videoNote` | (0.08, 0.30, 0.56) → (0.07, 0.64, 0.83) | Круглое видеосообщение | +| `gradient.call` | (0.11, 0.17, 0.24) → black | Экран звонка без remote video | +| `gradient.avatar` | baseColor → baseColor.opacity(0.68) | Fallback аватара | + +### 3.3. Детерминированные цвета участников + +Avatar fallback: hue = `colorSeed % 360 / 360`, saturation 0.68, brightness 0.88. Sender name в группе: отдельный hash JID, saturation 0.72, brightness 0.78. Цвет участника стабилен для идентичности, но avatar color и sender color не обязаны совпадать. + +### 3.4. Тёмная тема + +Большинство основных поверхностей использует semantic colors/materials и наследует системный color scheme. Отдельного переключателя темы или самостоятельной полной dark palette в текущей реализации нет. Auth gradients, белый текст исходящих и тёмный call/viewer сохраняют собственные цвета. + +Для новых компонентов нужны light/dark проверки; документ не утверждает уже проведённый аудит контраста. + +## 4. Типографика + +Основной шрифт — системный SwiftUI. Название конкретного установленного семейства не закрепляется как файл шрифта. + +| Роль | Стиль | Пример | +| --- | --- | --- | +| Имя чата/контакта | `.headline` | ConversationRow | +| Превью последнего сообщения | `.subheadline` | ConversationRow | +| Текст сообщения | унаследованный стандартный Text font | MessageBubble | +| Название call card | `.subheadline.weight(.semibold)` | MessageBubble | +| Подпись call card | `.caption`, opacity 0.78 | Длительность/результат | +| Время списка | `.caption`, secondary | ConversationRow | +| Метаданные bubble | `.caption2`, secondary | Время, delivery, «изм.» | +| Имя автора MUC | `.caption.weight(.semibold)` | Цветной sender name | +| Badge unread | `.caption2.bold()` | Белый текст capsule | +| Compact reply | `.callout.weight(.medium)` | Однострочная цитата | +| Пояснение формы | `.caption` / `.footnote` по экрану | Login, настройки, группа | +| Fingerprint | `.system(.caption, design: .monospaced)` | Шифрование | +| Таймер/длительность | `.monospacedDigit()` | Запись, player, звонок | +| Бренд login | system 42, bold, rounded | «Luma» | +| Имя в active call | system 30, semibold, rounded | До двух строк | +| Lock heading | `.title2.weight(.bold)` | «Luma заблокирован» | + +Не превращать `.headline` в произвольные «17 px» в переносимом стандарте: semantic style может меняться платформой и Dynamic Type. Фиксированные sizes из таблицы применяются именно к указанным special components. + +Обрезка: имя/preview списка — одна строка; имя звонящего — две; toast — две; compact reply — одна. Основной message body не обрезается правилом preview. + +## 5. Геометрия и поверхности + +### 5.1. Повторяющиеся интервалы + +В реализации встречаются интервалы 3/4/5/6/7/8/10/12/13/14/16/18/22/24/28. Это не строго реализованная сетка кратности четырём. При повторении существующего компонента использовать его реальные значения. + +| Роль | Значение | +| --- | --- | +| Между avatar и текстом списка | 12 | +| Между строками текста списка | 5 | +| Horizontal row insets списка чатов | 12 | +| Vertical listRowInsets чатов | 6 | +| Дополнительный vertical padding ConversationRow | 2 | +| Horizontal padding message row | 12 | +| Text/file bubble внутренний padding | horizontal 13, vertical 9 | +| Audio/voice bubble padding | horizontal 13, vertical 10 | +| Composer основной gap | 8 | +| Composer horizontal padding | 10 | +| Composer bottom / top | 8 / 6 | +| Input leading / trailing / vertical | 14 / 12 / 9 | +| Auth screen horizontal / vertical padding | 24 / 48 | + +### 5.2. Формы + +| Роль | Форма | +| --- | --- | +| Avatar, online dot, call control | Circle | +| Unread badge, media caption, toast, reactions | Capsule | +| Auth/lock card | RoundedRectangle continuous, radius 28 | +| Composer input | Glass rect, radius 21 | +| Photo/video island | RoundedRectangle continuous, radius 20 | +| Compact reply | RoundedRectangle continuous, radius 17 | +| Reply thumbnail | RoundedRectangle continuous, radius 6 | +| Attachment caption field | RoundedRectangle, radius 16 | +| Watch message | RoundedRectangle, radius 11 | +| Call local-video preview | RoundedRectangle continuous, radius 22 | +| Основной message bubble | Собственный ChatBubbleShape | + +**Фактическая форма bubble:** `MessageBubble.messageBubbleShape` возвращает `ChatBubbleShape(isOutgoing:)`. Его path содержит нижний хвостик/асимметрию на стороне сообщения и опорные скругления около 20 pt. Не заменять его обычным RoundedRectangle при реализации «такого же» текстового bubble. Старые формулировки README/tz об отсутствии хвостиков расходятся с этим исполняемым shape. Фото/видео имеют независимую островковую рамку. + +### 5.3. Материалы + +- `.glassEffect()` — search field общего компонента, login inputs, composer controls, часть новых/платформенных действий. +- `.glassEffect(in: .rect(cornerRadius: 21))` — composer input. +- `.regularMaterial` — login/lock card и toast. +- `.ultraThinMaterial` — selection panel, нижняя панель предпросмотра вложений и другие явно заданные контекстом поверхности. +- `.buttonStyle(.glassProminent)` — основное действие login и ряд Mac forms. +- `.buttonStyle(.borderedProminent)` — часть системных forms, lock, attachment send, Watch. +- `.buttonStyle(.plain)` — управление внутри списка/bubble/player, где оформление рисует компонент. + +Это несколько существующих контекстных вариантов, не требование заменить каждую кнопку на glass. Внешний вид материала не закрепляется статическим цветом. + +### 5.4. Тени и границы + +| Компонент | Значение | +| --- | --- | +| Login card | black opacity 0.14, radius 24, y 12 | +| Login logo tile | black opacity 0.16, radius 22, y 10 | +| Toast | black opacity 0.18, radius 12, y 4; border primary opacity 0.06 | +| Avatar звонящего | black opacity 0.32, radius 28, y 14; white border opacity 0.14, 1 pt | +| Local video | black opacity 0.38, radius 16, y 8; white border opacity 0.24, 1 pt | +| Media island | secondary border opacity 0.18, 1 pt | +| Reaction | 0.8 pt border; цвет зависит от own state | + +На обычную строку чата не добавляется карточная тень. Использовать тень как отделение overlay/крупного элемента, а не универсальный декор. + +## 6. Иконки + +Основной набор — SF Symbols. Размер и weight задаются по компоненту; пользовательское действие имеет accessibility label, если состоит только из icon. + +| Роль | Symbol | +| --- | --- | +| Контакты / звонки / чаты / настройки | `person.circle.fill` / `phone.fill` / `bubble.left.and.bubble.right.fill` / `gearshape.fill` | +| Поиск / очистить | `magnifyingglass` / `xmark.circle.fill` | +| Новый личный / групповой | `square.and.pencil` / `person.3.fill` | +| Вложение | Знак `+` в composer; набор файлов — `doc.on.doc` | +| Отправка | `arrow.up`; в preview — `arrow.up.circle.fill` | +| Emoji / voice / videoNote | `face.smiling` / `mic.fill` / `video.circle` | +| Location | `location.fill` | +| Ответ / пересылка | `arrowshape.turn.up.left` / `arrowshape.turn.up.right` | +| Редактирование / сохранить файл | `pencil` / `square.and.arrow.down` | +| Удалить / retract | `trash` / `trash.slash` | +| OMEMO / plaintext / decrypt error | `lock.fill` или `lock.shield.fill` по контексту / `lock.open` / `exclamationmark.shield` | +| Play / pause | `play.fill` / `pause.fill` | +| Раскрыть media | `arrow.up.left.and.arrow.down.right` | +| Звонок завершить | `phone.down.fill` | +| Switch camera | `camera.rotate.fill` | +| Toast success | `checkmark.circle.fill` | + +Не заменять SF Symbols emoji в функциональных icon buttons. Emoji допустимы как пользовательские реакции и текстовый preview типов сообщений. + +## 7. Каркас экранов + +### 7.1. iOS/iPadOS + +Native TabView: **Контакты → Звонки → Чаты → Настройки**. Начальный выбор — Чаты. Иконки selected/unselected сейчас одинаковые по SF name; выделение даёт системный tab UI. + +Каждый сценарий использует NavigationStack соответствующего контекста. В pushed ChatView tab bar скрывается и восстанавливается при возврате. Верхний поиск списка остаётся над строками; `.searchable` и общий SearchField — существующие разные реализации, их итоговое расположение проверяется UI tests. + +iPhone: portrait и обе landscape. iPad: все четыре orientation. Отдельная фиксированная desktop-sidebar ширина не переносится автоматически на iPad. + +### 7.2. macOS + +NavigationSplitView: sidebar + detail. Sidebar width min 240, ideal 290, max 380. Минимальный общий размер 840 × 560, default app window 1080 × 720. + +Слева переключение режимов, поиск и список. Справа выбранный чат/контакт/звонок либо соответствующий empty state, настройки в своём контексте. Режимы: Чаты, Контакты, Звонки, Настройки. Новые chat/group actions — toolbar с `.help`. + +Используются нативные SavePanel, QuickLook и AppKit интеграции; iPhone fullscreen не следует буквально имитировать отдельным окном Mac. + +### 7.3. Watch + +NavigationStack → List чатов → List истории/ответов. Нет нижней панели четырёх вкладок. Компактные semantic fonts и системные кнопки, самостоятельные упрощённые message bubbles. Не переносить размеры 268 pt media cards на экран часов. + +## 8. Avatar + +Источник: `Sources/Shared/UI/Components/AvatarView.swift`. + +| Параметр | Значение | +| --- | --- | +| Default size | 50 × 50 | +| В профиле settings | 64 × 64 | +| В active call | 132 × 132 | +| Image | scaledToFill, clip Circle | +| Инициалы | size ×0.35, semibold, rounded, white | +| Group fallback icon | `person.3.fill`, size ×0.31, semibold, white | +| Online/joined dot | size ×0.25, green, bottomTrailing | +| Dot border | background, 2.5 pt | + +Для direct dot означает online, для group — joined. Он не обозначает новое сообщение. Fallback color детерминирован по JID. Accessibility label включает имя и статус. + +## 9. ConversationRow и контакт + +Источник: `ConversationRow.swift`, `MainTabView.swift`. + +Композиция: avatar слева, две строки текста, справа время сверху и unread снизу. Верхняя строка — имя headline и время caption secondary. Нижняя — group/encryption indicator, preview subheadline secondary, optional group count и unread badge. + +- Preview заменяется JID, если lastMessage пуст. +- Время не показывается для distantPast. +- Direct lock icon показывается при непустом lastMessage; plaintext icon orange. +- Unread badge только при count >0; текст `min(count,99)`, именно «99», без «99+». +- Badge horizontal padding 7, vertical 3, white text, tint capsule. +- Имя и preview lineLimit 1. +- При нескольких аккаунтах подпись account JID caption2 secondary, leading padding 52. +- Contact rows используют имя, JID/статус и group subtitle; не вся строка contact обязана содержать preview последнего сообщения. + +Пустые списки используют ContentUnavailableView с русским заголовком, SF Symbol и пояснением. Search-empty и data-empty различаются текстом. + +## 10. Поиск + +Общий SearchField: + +- HStack gap 6; +- magnifyingglass и clear icon — 13 pt; +- input plain, autocorrection off, iOS autocapitalization never; +- clear button только при непустом вводе; +- внутренние отступы horizontal 10, vertical 7; +- glassEffect; +- внешние horizontal 12, vertical 6. + +Существующие `.searchable` поля сохраняют нативную геометрию. Документ не предписывает эти padding нативному SearchField ОС. Главное правило размещения — над списком и в его контексте. + +## 11. Лента чата + +Лента занимает доступную ширину; incoming слева, outgoing справа. Message row HStack gap 8, horizontal padding 12. Противоположный spacer: минимум 54 для немедийных сообщений и 16 для media. + +Внутри message stack gap 4. Group incoming имеет имя автора над содержимым с horizontal padding 8. Метаданные и reactions располагаются рядом с сообщением в его выравнивании, не в отдельной колонке списка. + +Outer vertical padding MessageBubble: 7 для photo/video, 2 для остальных. Photo/video компонент добавляет собственные 5 vertical; не заменять эти два уровня одним случайным gap. + +Даты показаны разделителями с локализованной датой. Фон timeline — secondary opacity 0.035. Подгрузка сверху должна сохранять позицию чтения. Кнопка вниз — 46 ×46, trailing 14, bottom 12. + +## 12. Message bubble + +### 12.1. Текст и файл + +Incoming: semantic primary foreground + secondary opacity 0.13 background. Outgoing: white foreground + синий градиент. Text/file padding 13×9; shape — ChatBubbleShape. Audio/voice используют тот же background/shape с padding 13×10. + +Обычный body — Text, на Mac доступно textSelection. Файл — кнопка с mediaContent и системной иконкой. System не означает всегда одинаковый серый центрированный label: call card и tombstone имеют свои варианты. + +### 12.2. Metadata + +HStack gap 4, font caption2, secondary, horizontal padding 4. Содержит security warning при необходимости, «изм.», время hour/minute и для outgoing delivery icon/text. У call card delivery indicator не показывается. + +| Delivery | Icon | Текущий текст | +| --- | --- | --- | +| sending | `clock` | `Sending...` | +| sent | `checkmark` | `Sended` | +| delivered | `checkmark.circle.dotted` | `Delivered` | +| failed | `exclamationmark.circle.fill`, red | `Error` | +| read | `checkmark.circle.fill` | `Read` | + +Это зафиксированные исключения русской локализации, включая опечатку `Sended`. Для новых строк стандарт — русский; автоматическая замена перечисленных строк этим документом не выполнена. + +### 12.3. Удалённое сообщение + +`trash.slash` + «Сообщение удалено», subheadline italic, gap 7, padding 13×9, прежнее directional background/shape. Outgoing foreground white opacity 0.86, incoming secondary. Реакции, edit и прежние вложения не должны оставаться видимыми. + +### 12.4. Context menu и selection + +Меню включает только доступные для конкретного сообщения действия: реакция, ответ, пересылка, копирование, сохранение, повтор, редактирование, выбор, удаление у меня/у всех. Destructive actions отмечены role destructive и требуют предусмотренного сценарием подтверждения. + +Selection mode показывает count и панель массовых действий, отключает swipe-reply и обычное context menu. Accessibility сообщает selected/not selected. Нельзя рисовать активную кнопку действия, которое запрещено модельным predicate. + +## 13. Replies и reactions + +### 13.1. Compact reply + +- весь reference opacity 0.56; +- ширина max 230, высота min 34; +- callout medium, lineLimit 1; +- radius 17, vertical padding 7; +- leading/trailing 10/12 по направлению; +- background primary opacity 0.018; +- border replyColor opacity 0.75, width 1.25; +- optional thumbnail 27×27, radius 6; +- дуга 58×34, stroke 2.4, round caps, offset ±18; +- направление отражает исходное сообщение, а не только новый ответ. + +При наличии target нажатие открывает фокус ветки; legacy preview без id выглядит цитатой, но не притворяется существующей ссылкой. ReplyThreadOverlay — отдельный затемнённый focus context с root/ответами и bracket; не является новым экраном полноценного канала. + +### 13.2. Reaction chip + +Strip gap 5. Chip: emoji + count, внутренний gap 3, padding horizontal 8/vertical 4, Capsule. Count caption2 semibold. + +| Состояние | Background | Border | +| --- | --- | --- | +| Есть own reaction | accent opacity 0.20 | accent opacity 0.55, 0.8 pt | +| Только чужие | secondary opacity 0.12 | secondary opacity 0.16, 0.8 pt | + +Тап по chip переключает собственную реакцию. Quick choices: 👍 ❤️ 😂 😮 😢 🔥. + +### 13.3. EmojiPicker + +Заголовок «Выберите эмодзи», кнопка «Готово», grid и горизонтальные category icons снизу. Adaptive grid min 44, column gap 4, row gap 6; emoji font 28, cell 46×46. Selected category — accent background opacity 0.15, radius 10, icon 18 medium, button 44×44. Minimum height 360, macOS min width 380. Текстового поиска сейчас нет. + +## 14. Composer + +Основная горизонталь: attachment button → input → send/record. HStack выровнен bottom. Composer прозрачен; input/control имеют собственную glass surface. + +Input: min height 42, radius 21, padding leading 14/trailing 12/vertical 9. TextField vertical axis, 1…6 строк. Placeholder «Сообщение» либо «Изменить сообщение». Emoji icon 20 regular, secondary. + +Send: 42×42, arrow.up 18 bold, accent при доступности, secondary при disabled, glassEffect. При пустом draft вместо send — record control. Attachment control также 42×42. + +Reply/edit banner располагается внутри input над полем, с цветной вертикалью и явной отменой. Selection mode заменяет обычную зону ввода панелью выбранных сообщений. Во время capture вместо поля показываются recording strip/timer/cancel/lock hints. + +Для нового composer-действия: сохранять текущую высоту соседних controls, учитывать multiline expansion и не перекрывать safe area/клавиатуру. + +## 15. Медиа + +### 15.1. Фото/видеоостровок + +| Параметр | Значение | +| --- | --- | +| Width | 268 | +| Photo height | natural по ratio, clamp 164…328 | +| Photo ratio clamp | 0.45…2.2, fallback 4:3 | +| Внутренний inset thumbnail | 6 | +| Radius / border | 20 / 1 pt secondary opacity 0.18 | +| Thumbnail mode | scaledToFit | +| Верхний expand icon | caption bold, padding 7, black opacity 0.42 circle | +| Нижняя подпись | caption2 semibold, white, padding 8×5, black opacity 0.48 capsule | +| Overlay inset | 9 | + +Видео использует аналогичную рамку и свой расчёт islandHeight по thumbnail. Центральный play 22 bold, padding 15, black opacity 0.38. Loading заменяет/сопровождает соответствующий control. Не растягивать bitmap с нарушением ratio и не заменять fit на crop для обычного media island. + +### 15.2. Voice/audio + +Player content width 252. Play/pause 44×44, symbol 18 bold. Voice waveform height 30; seek track — 3 pt с knob 9×9 в соответствующем компоненте. Duration caption2 monospacedDigit. Speed chip caption bold, padding 6×3, accent opacity 0.14. Скорости 1×/1.5×/2×. Audio filename subheadline semibold. + +Цвета управления учитывают incoming/outgoing: на синем bubble белые/полупрозрачные элементы, на incoming semantic/accent. Player должен показывать loading, paused, playing и progress. + +### 15.3. VideoNote + +Circle diameter 144 в покое и 176 при playback. Play symbol 24 bold с padding 17 и black opacity 0.34. Duration badge caption2 monospacedDigit semibold, padding 7×3, black opacity 0.48; container inset 12. Border white opacity 0.18, 1 pt. + +Увеличение — spring response 0.32, damping 0.82. После завершения/исчезновения/запуска другого exclusive media возвращается к stopped presentation. + +### 15.4. Attachment preview перед отправкой + +NavigationStack, title «Перед отправкой», scroll grid, divider, нижняя material panel. Grid adaptive min 128/max 190, column gap 18, row gap 22, outer padding 16. Нижняя панель gap 10/padding 14, ultraThinMaterial. + +Caption 1…4 строки, padding 13×10, secondary opacity 0.10, radius 16. Кнопка «Отправить»/«Отправка…», borderedProminent, disabled при пустом наборе/отправке. Неудача — красная caption с retry icon; failed drafts остаются видимыми. На Mac minimum 560×560. + +### 15.5. Fullscreen viewer + +MediaViewer поверх всего основного окна. Тёмный фон, исходное фото с zoom/pan либо системное видео. Свайп вертикально закрывает; увеличенное фото продолжает pan. Отдельный обязательный крестик не является текущим паттерном. Для обычных файлов используется системный QuickLook, не imitation media viewer. + +## 16. Call UI + +Full-window тёмная сцена, white foreground, крупное имя, статус и controls снизу. Remote video занимает фон; локальное — верхняя правая карточка 118×168, radius 22, top 92/trailing 18. + +Без remote video: тёмный градиент, декоративный accent circle 440, opacity 0.24. Avatar 132, имя 30 semibold rounded, два ряда максимум. Подпись типа звонка subheadline white opacity 0.66; статус subheadline monospacedDigit white opacity 0.72. + +Call control: круг 58×58, symbol 24 semibold, caption ниже. Incoming: decline red и answer green, horizontal gap 52. Active controls: полупрозрачные белые круги opacity 0.18/0.34 по состоянию, mic/camera/speaker/rotate; hangup red отдельно. Speaker control платформенно iOS. + +History card в чате: icon circle 42, направление badge, content gap 12, title subheadline semibold, subtitle caption opacity 0.78, padding 14×10, max width 300. Completed accent green; declined/missed/unanswered/failed red; cancelled/answeredElsewhere secondary. Outgoing foreground white. + +## 17. Формы и системные листы + +### 17.1. Login + +Полноэкранный auth gradient; контент max width 460, horizontal padding 24, vertical 48. Brand block: белая tile 88×88, radius 26, symbol 39 semibold, название 42 bold rounded. + +Карточка regularMaterial, radius 28, padding 26, main spacing 18. Поля и дополнительный DisclosureGroup. Main action glassProminent, large control size, inner content height 34; spinner при подключении. Кнопка видимости пароля имеет label и не заменяет SecureField молча. + +### 17.2. Settings / new chat / group / encryption + +Переиспользуются Form/Section, Toggle switch, NavigationLink и нативная toolbar. Заголовки и объяснения по-русски. Main/cancel actions в стандартных toolbar placements на iOS; на Mac формы могут иметь нижнюю action row. + +Ключевые settings sections: аккаунты, профиль, аккаунт, соединение, шифрование, уведомления, блокировка, приватность чата. Profile avatar 64; fingerprint monospaced caption selectable. Device trust: verified green, trusted blue, undecided orange, compromised red; protocol badge различает OMEMO 2 и legacy. + +### 17.3. App lock + +Auth gradient + material card radius 28/padding 28, outer padding 24; vertical spacing 22. Lock symbol 44 semibold, title2 bold white, password field max width 260, centered, roundedBorder. Кнопка «Разблокировать» borderedProminent, inner content max width 260/height 34. Error «Неверный пароль» caption medium white opacity 0.95. Биометрия — отдельная подписанная кнопка. + +Lock должен скрывать underlying chat/media/call content, а не только накладывать полупрозрачный scrim на читаемую переписку. + +## 18. Feedback и overlays + +| Вид | Паттерн | +| --- | --- | +| Ошибка | System alert «Luma», сообщение, OK | +| Подтверждение destructive | Alert/confirmation по контексту, destructive button, Отмена | +| Короткий success | Toast capsule regularMaterial | +| Долгая операция | ProgressView и контекстный label, disabled controls | +| Empty state | ContentUnavailableView с SF Symbol и пояснением | +| Connection status | ConnectionBanner в контексте заголовка | + +Toast: gap 8, subheadline medium, horizontal padding 16/vertical 10, top 8, максимум две строки, auto dismiss 2.5 с, checkmark.circle.fill accent. + +ConnectionBanner фактически использует caption white и mini white ProgressView в состояниях «Подключение», «Ожидание сети», «Обновление». В обычном ready state — переданный заголовок headline. Контраст этого white banner на любой возможной светлой поверхности не подтверждён; не считать его универсальной готовой плашкой для любого фона. + +Root layering: main → media viewer z100 → call z200 → toast z300. Lock выбирается вместо основного набора слоёв. QuickLook — отдельная системная презентация. + +## 19. Анимации и жесты + +| Эффект | Значение | +| --- | --- | +| Открытие timeline | Последние 12 rows; stagger 35 мс; rise 16; scale 0.94 | +| Lifetime entrance | 0.75 с | +| Outgoing launch | scale 0.2, rise 12 | +| Incoming launch | scale 0.4, rise 10 | +| Append spring | response 0.44, damping 0.68 | +| History burst | Более 4 appended rows не анимируются как отдельные fresh sends | +| Root account change | easeInOut 0.22 с | +| Root media/call | easeInOut 0.2 с | +| Toast | spring response 0.3, damping 0.85 | +| Composer capture switch | easeInOut 0.18 с | +| Reply swipe return | spring response 0.24, damping 0.82 | + +Reply swipe: горизонтальный trigger 64; activation 24; maximum visual offset 76; lock dominance 1.6/follow 1.05. Gesture starts на bubble, не на пустом пространстве/metadata. При достижении порога — light haptic на iOS. Вертикальная прокрутка должна оставаться приоритетной. + +Media dismiss: vertical минимум 8, dominance 1.15; distance clamp 88…140 в зависимости от 14% height либо predicted endpoint 1.35×threshold. Не смешивать этот gesture с pan zoomed photo. + +Record: long press около 0.22–0.24 с, cancel left 92, lock up 78. UI отражает recording/finalizing и не обещает отправку до завершения recorder. + +Анимационные значения отражают код; независимый тест Reduce Motion не выполнен. Для новых анимаций требуется проверить accessibility settings и отсутствие повторной анимации при archive updates. + +## 20. Watch UI + +| Компонент | Стандарт | +| --- | --- | +| Chat list | Native List, имя headline, preview caption secondary, one line | +| Unread | caption2 bold, padding 5, blue Circle, исходное число | +| Message | padding 7, radius 11, outgoing blue, incoming secondary opacity 0.25 | +| Time | caption2, opacity 0.7 | +| Message spacing | inner VStack 3, opposite spacer min 16 | +| Reply | TextField «Ответ», кнопка «Отправить», disabled при пустом тексте | +| Quick replies | «Хорошо», «Спасибо!», «Отвечу позже» | +| Voice idle | prominent red «Записать голосовое» | +| Voice recording | red timer/progress, «Отмена» и «Готово» | +| Voice preview | play/pause, duration, «Отправить голосовое», destructive «Удалить запись» | +| Transfer status | caption2 secondary label с iPhone symbol | + +Watch outgoing использует Color.blue, не gradient.outgoing телефона. Это намеренно зафиксированный платформенный вариант. На Watch unread сейчас не ограничивается 99, в отличие от ConversationRow. + +## 21. Состояния компонента + +Для каждого нового интерактивного компонента определять применимые состояния: + +| Состояние | Отображение / действие | +| --- | --- | +| Default | Обычная поверхность и основное содержимое | +| Pressed | Нативный button style либо существующая feedback-анимация | +| Disabled | `.disabled`, secondary/системное приглушение; действие недоступно | +| Loading | ProgressView вместо/рядом с control, без ложного success | +| Empty | Объяснение и применимое next action | +| Error | Контекстный текст/alert, явная возможность retry где предусмотрено | +| Selected | Существующий accent/outline/check и accessibility state | +| Incoming/outgoing | Выравнивание, цвет, metadata и shape по направлению | +| Light/dark | Semantic foreground/background либо explicit inverse scene | +| Focused | Системный keyboard/focus behavior; поле не скрыто overlay | + +Не всем компонентам нужны все состояния. Сеть/OMEMO predicates определяют доступность; UI Kit не отменяет функциональные ограничения `tz.md`. + +## 22. Текст и доступность + +- Основной язык новых UI строк — русский, короткие глаголы: «Отправить», «Сохранить», «Отмена», «Готово». +- Progress label обозначает действие: «Подключение…», «Отправка…», «Соединение…». +- Ошибка объясняет доступное пользователю действие, а не только внутренний enum. +- Destructive действия должны различать «Удалить у меня» и «Удалить у всех». +- Иконки без текста имеют accessibilityLabel; медиа — label/hint; selected state сообщается текстом. +- Время и меняющиеся числа используют monospacedDigit в соответствующих компактных controls. +- Существуют identifiers `chat-timeline`, `bubble-`, `reply-banner`, `info-toast`; сохранять их при визуальном рефакторинге. +- Не заявлять аудит VoiceOver/Dynamic Type/contrast только по наличию modifiers. Это отдельная проверка на устройствах. +- Контрольная рамка composer 42×42 — факт текущего кода, а не гарантия минимального 44×44 hit area у каждой кнопки. При расширении доступности проверять effective hit target отдельно. + +## 23. Известные неоднородности + +| Область | Факт | Как использовать дальше | +| --- | --- | --- | +| Accent | root и asset отличаются | Переиспользовать semantic accent; унификация отдельно | +| Bubble | Custom shape с хвостиком, старые тексты говорят обратное | Текущий shape — эталон существующего bubble | +| Delivery labels | Английские строки и `Sended` | Не распространять на новые тексты; исправление отдельно | +| Search | Shared custom и native searchable | Сохранять верхнее расположение, не считать padding общим для native | +| Buttons | Glass и borderedProminent | Выбирать по существующему контексту | +| Spacing | Нет единой 4-pt системы | Копировать конкретный pattern, не выдумывать новую сетку | +| Watch accent/badge | Color.blue, число без clamp99 | Платформенный вариант | +| Connection banner | White foreground на прозрачной поверхности | Проверить контраст в месте использования | +| Responsive media | Фиксированная ширина 268 | Проверять узкую ширину и большой Dynamic Type | +| Dark/accessibility | Semantic база, без полного аудита | Проверять каждое изменение на Apple runtimes | + +## 24. Порядок добавления нового UI + +1. Выбрать ближайший существующий экран и компонент из каталога ниже. +2. Описать цель, primary action и применимые состояния. +3. Сохранить navigation pattern платформы. +4. Использовать semantic text/colors; специальные значения взять из соответствующей таблицы. +5. Переиспользовать AvatarView, player, media preview, toast, форму или bubble вместо копирования логики. +6. Проверить loading/error/disabled/empty, keyboard, selection и доступность действия. +7. Проверить light/dark, маленькое/большое окно, iPhone landscape, Dynamic Type и VoiceOver, если затронуты. +8. При изменении существующего pattern обновить этот документ и связанные previews/UI tests; при изменении функциональной политики — также tz.md/tests/verify guard. + +### Чек-лист визуальной приёмки + +- [ ] Экран выглядит частью Luma и использует подходящий platform shell. +- [ ] Accent, typography и material роли совпадают с соседними экранами. +- [ ] Нет нового близкого синего или случайного radius вместо имеющегося компонента. +- [ ] Long names/body, empty state и ошибки не ломают layout. +- [ ] Media не обрезается неожиданно и не выходит за рамку. +- [ ] Incoming/outgoing/selected/disabled различимы. +- [ ] Scroll/reply swipe/media pan не конфликтуют. +- [ ] Loading не оставляет вечный блокирующий spinner. +- [ ] Controls доступны клавиатуре/VoiceOver по применимости. +- [ ] Системные safe areas, toolbar/tab bar и overlays не перекрывают важный контент. +- [ ] Проверены платформы, затронутые изменением. + +## 25. Каталог компонентов и источников + +Пути относительно корня репозитория. Исходник компонента содержит окончательные значения локальной геометрии и платформенные ветви. + +| Область | Эталон | +| --- | --- | +| App tint / window | `Sources/App/LumaApp.swift` | +| Layering / toast / alert | `Sources/Shared/UI/RootView.swift` | +| Navigation / search / lists | `Sources/Shared/UI/MainTabView.swift` | +| List row / avatar / connection | `Sources/Shared/UI/Components/ConversationRow.swift`, `AvatarView.swift`, `ConnectionBanner.swift` | +| Timeline / composer / selection | `Sources/Shared/UI/ChatView.swift` | +| Message / reaction / metadata | `Sources/Shared/UI/Components/MessageBubble.swift` | +| Bubble shape | `Sources/Shared/UI/Components/ChatBubbleShape.swift` | +| Reply curves / focus | `Sources/Shared/UI/Components/BubbleShapes.swift`, `ReplyThreadOverlay.swift` | +| Photo / video / videoNote | `Sources/Shared/UI/Components/PhotoAttachmentPreview.swift`, `VideoAttachmentPreview.swift`, `VideoNotePreview.swift` | +| Voice / audio | `Sources/Shared/UI/Components/AudioMessagePlayer.swift` | +| Viewer | `Sources/Shared/UI/Components/MediaViewer.swift` | +| Emoji | `Sources/Shared/UI/Components/EmojiPickerView.swift` | +| Attachment preparation UI | `Sources/Shared/UI/AttachmentPreviewView.swift` | +| Calls | `Sources/Shared/UI/CallView.swift`, `Components/RTCVideoRendererView.swift` | +| Login / lock | `Sources/Shared/UI/LoginView.swift`, `AppLockView.swift` | +| Settings / encryption | `Sources/Shared/UI/SettingsView.swift`, `EncryptionDevicesView.swift` | +| New chat/group / group info | `Sources/Shared/UI/NewChatView.swift`, `NewGroupView.swift`, `GroupInfoView.swift` | +| Forward / location | `Sources/Shared/UI/ForwardMessageView.swift`, `LocationPickerView.swift`, `Components/LocationMessagePreview.swift` | +| Camera capture | `Sources/Shared/UI/SystemPhotoCameraView.swift`, `VideoNoteCaptureView.swift` | +| Server diagnostics | `Sources/Shared/UI/ServerInfoView.swift` | +| Preview fixtures | `Sources/Shared/UI/PreviewSupport.swift` и локальные `#Preview` | +| Animation / gestures | `Sources/Shared/Models/ChatMessageEntrancePolicy.swift`, `MessageReplySwipePolicy.swift`, `ChatScrollPositionPolicy.swift` | +| Watch | `Sources/Watch/WatchViews.swift` | +| Visual regression tests | `UITests/LayoutCheckUITests.swift`, `UITests/TimelineUITests.swift` | + +Документ является baseline текущего UI. Он не объявляет новый дизайн уже реализованным и не подменяет screenshots живого приложения иллюстрациями. diff --git a/README.md b/README.md index 1255f8a..af1b2ac 100644 --- a/README.md +++ b/README.md @@ -179,3 +179,11 @@ OMEMO, и для XEP-0084 аватаров. Для медиа и вложени Архитектура описана в [`Docs/ARCHITECTURE.md`](Docs/ARCHITECTURE.md), модель угроз и ограничения хранения — в [`Docs/SECURITY.md`](Docs/SECURITY.md). + +## Стандарт интерфейса + +Текущий дизайн, компоненты, размеры, цвета, состояния и платформенные правила зафиксированы в [Luma UI Kit](Docs/UIKIT.md). Используйте его как основу для новых экранов и визуальных изменений. + +## Требования для Web, Desktop и Android + +Для разработки новых клиентов используйте [чистое техническое задание](tz_clear.md) и [единый UI Kit](uikit_clear.md). Эти документы задают целевой продукт и не зависят от реализации Apple-клиента. diff --git a/tz.md b/tz.md new file mode 100644 index 0000000..cf3e1b1 --- /dev/null +++ b/tz.md @@ -0,0 +1,2151 @@ +# Luma — обратное техническое задание + +Подробная спецификация фактически реализованной системы. + +- Дата анализа: 14 октября 2026 года. +- Исходный коммит: `6969863d1e4db434004f8e5e032ada270a4b948f`. +- Источник истины: исходники, `project.yml`, конфигурация, тесты и скрипты этого репозитория. +- Язык документа и пользовательского интерфейса: русский. +- Назначение: восстановить требования по реализации, дать основу для сопровождения, повторной реализации и приёмки. +- Статус проверки: статический анализ и успешный `make verify`; сборка Apple-targets, XCTest и сетевые сценарии в этом сеансе не выполнялись. + +## Содержание + +- [1. Как читать документ](#1-как-читать-документ) +- [2. Назначение и границы продукта](#2-назначение-и-границы-продукта) +- [3. Платформы, версии и сборочные продукты](#3-платформы-версии-и-сборочные-продукты) +- [4. Архитектура и ответственность компонентов](#4-архитектура-и-ответственность-компонентов) +- [5. Словарь данных и идентичностей](#5-словарь-данных-и-идентичностей) +- [6. Запуск, вход и жизненный цикл аккаунта](#6-запуск-вход-и-жизненный-цикл-аккаунта) +- [7. Соединение, TLS и аутентификация](#7-соединение-tls-и-аутентификация) +- [8. Главный интерфейс и навигация](#8-главный-интерфейс-и-навигация) +- [9. Личный чат и жизненный цикл сообщения](#9-личный-чат-и-жизненный-цикл-сообщения) +- [10. Действия над сообщениями](#10-действия-над-сообщениями) +- [11. Реакции и статус набора](#11-реакции-и-статус-набора) +- [12. Групповые чаты MUC](#12-групповые-чаты-muc) +- [13. Шифрование OMEMO](#13-шифрование-omemo) +- [14. Вложения и HTTP File Upload](#14-вложения-и-http-file-upload) +- [15. Превью, воспроизведение и сохранение медиа](#15-превью-воспроизведение-и-сохранение-медиа) +- [16. Голосовые и круглые видеосообщения](#16-голосовые-и-круглые-видеосообщения) +- [17. Геопозиция](#17-геопозиция) +- [18. История, MAM и восстановление](#18-история-mam-и-восстановление) +- [19. Прочтение между собственными устройствами](#19-прочтение-между-собственными-устройствами) +- [20. Аудио- и видеозвонки](#20-аудио--и-видеозвонки) +- [21. Аватары](#21-аватары) +- [22. Локальные уведомления](#22-локальные-уведомления) +- [23. Блокировка приложения](#23-блокировка-приложения) +- [24. Apple Watch](#24-apple-watch) +- [25. Постоянное хранение и кэши](#25-постоянное-хранение-и-кэши) +- [26. Информация о сервере и диагностика](#26-информация-о-сервере-и-диагностика) +- [27. Серверные требования](#27-серверные-требования) +- [28. Безопасность и модель угроз](#28-безопасность-и-модель-угроз) +- [29. Ошибки, ограничения и поведение при сбоях](#29-ошибки-ограничения-и-поведение-при-сбоях) +- [30. Визуальные и нефункциональные требования](#30-визуальные-и-нефункциональные-требования) +- [31. Сборка, проверки и CI](#31-сборка-проверки-и-ci) +- [32. Приёмочные сценарии](#32-приёмочные-сценарии) +- [33. Обнаруженные расхождения и вопросы дальнейшего развития](#33-обнаруженные-расхождения-и-вопросы-дальнейшего-развития) +- [Приложение A. Реестр исходников собственной реализации](#приложение-a-реестр-исходников-собственной-реализации) +- [Приложение B. Полный индекс тестовых сценариев](#приложение-b-полный-индекс-тестовых-сценариев) +- [Приложение C. Реестр именованных констант](#приложение-c-реестр-именованных-констант) +- [Приложение D. Протоколы и назначение](#приложение-d-протоколы-и-назначение) +- [Приложение E. Трассировка основных требований к реализации](#приложение-e-трассировка-основных-требований-к-реализации) +- [Приложение F. Дополнительные точные особенности](#приложение-f-дополнительные-точные-особенности) +- [Приложение G. Правила актуализации](#приложение-g-правила-актуализации) + +## 1. Как читать документ + +Это «ТЗ наоборот»: описывается существующий продукт, включая ограничения, пограничные случаи и технические решения. Формулировка «должен» означает требование сохранения наблюдаемого контракта при сопровождении, а не обещание будущей разработки. + +Различаются следующие виды сведений: + +1. **Реализовано** — имеется исполняемый путь в исходном коде. +2. **Заявлено тестом** — существует тест соответствующего поведения; наличие теста само по себе не доказывает его успешное выполнение на текущей сборке. +3. **Зависит от среды** — результат требует поддержки сервера, другого клиента, разрешения ОС или доступной сети. +4. **Ограничение** — отсутствующая функция, неполная гарантия либо существенная особенность реализации. +5. **Расхождение** — комментарий или прежняя документация не полностью соответствует коду. + +Имена файлов в документе указаны относительно корня репозитория. Приложения содержат полный перечень файлов собственной реализации, тестовых сценариев и извлечённых констант. Точные визуальные размеры, таймеры и внутренние политики следует сверять с этим перечнем и указанными исходниками: они не являются независимо измеренными характеристиками устройства. + +Без отдельной проверки не заявляются полная совместимость со всеми XMPP-клиентами, криптографический аудит, успешность production-развёртывания, гарантия фоновой доставки на iOS или гарантированное удаление копий сообщения у других участников. + +## 2. Назначение и границы продукта + +Luma — нативный XMPP-мессенджер для iPhone, iPad и Mac с приложением-компаньоном Apple Watch. Клиент работает с существующим аккаунтом формата `user@example.org` и не привязан к конкретному провайдеру. В документации сервера в качестве основной конфигурации рассматривается Prosody. + +Основные возможности: + +- вход, сохранение нескольких аккаунтов и переключение активного аккаунта; +- личные диалоги, чат с собой, контакты roster и групповые MUC-комнаты; +- отправка текста, ответов, реакций, геопозиции и медиа; +- редактирование и отзыв собственных сообщений в личных чатах; +- локальное удаление сообщений и групповая обработка выбранных сообщений; +- шифрование OMEMO, просмотр устройств и ручная проверка отпечатков; +- серверная история MAM, локальная история SwiftData и синхронизация через Carbons; +- фото, видео, файлы, музыка, голосовые и круглые видеосообщения; +- личные аудио- и видеозвонки, история звонков; +- локальные уведомления; +- защита входа в интерфейс паролем и биометрией; +- последние чаты, текстовые ответы и голосовые сообщения с часов через iPhone. + +В границы текущего продукта не входят регистрация аккаунта на сервере, восстановление серверного пароля, самостоятельный сервер Luma, административная панель Prosody, собственная APNs-инфраструктура, групповые звонки, самостоятельный XMPP-клиент часов, реализация MIX, SQLCipher и share extension. + +## 3. Платформы, версии и сборочные продукты + +| Параметр | Реализация | +| --- | --- | +| Язык | Swift 5.9 | +| UI | SwiftUI с платформенными UIKit/AppKit/AVFoundation-компонентами | +| Минимум iOS/iPadOS | 26.0 | +| Минимум macOS | 26.0 | +| Минимум watchOS | 26.0 | +| Версия маркетинга | `20261013.0` | +| Номер сборки | `31` | +| Язык разработки Xcode | `ru` | +| Конфигурации | Debug, Release | +| Отступы | 4 пробела | +| iOS bundle ID | `app.luma.chat` через `COMPANION_BUNDLE_IDENTIFIER` | +| macOS bundle ID | `app.luma.chat.macos` | +| watchOS bundle ID | `app.luma.chat.watchkitapp` | +| Unit tests bundle ID | `app.luma.chat.tests` | +| UI tests bundle ID | `app.luma.chat.uitests` | +| Основные schemes | `Luma`, `LumaMac`, `LumaWatch` | +| Mac Catalyst | отключён | +| Общий accent | RGB 0.14 / 0.56 / 0.96 на уровне App | + +`Luma` собирает `Sources/App` и `Sources/Shared` для iPhone/iPad. `LumaMac` использует те же директории для нативного Mac-приложения. `LumaWatch` собирает только `Sources/Watch`, не подключает Martin/WebRTC и не создаёт собственную XMPP-сессию. + +watchOS-продукт является современным single-target `application`. Имя файла продукта — `LumaWatch`, пользовательское название — Luma. Устаревший контейнер `application.watchapp2` не допускается: он конфликтует с компиляцией SwiftUI `@main` в том же target. + +Проект генерируется XcodeGen из `project.yml`; редактировать `Luma.xcodeproj` вручную запрещено правилами репозитория. В `project.yml` указан `xcodeVersion: 16.0`, но это не доказательство сборки API ОС 26 средствами Xcode 16. Фактическая CI-конфигурация выбирает Xcode 26.6. Требуется SDK, включающий использованные API ОС 26, в том числе `.glassEffect()`. + +### 3.1. Внешние зависимости + +| Зависимость | Источник | Версионирование / назначение | +| --- | --- | --- | +| Martin | `tigase/Martin` | точная 3.2.4; XMPP, модули и транспортные абстракции | +| MartinOMEMO | `ThirdParty/MartinOMEMO` | локальный Swift package; legacy OMEMO и Signal-адаптеры | +| WebRTC | `stasel/WebRTC` | точная 150.0.0; RTP, ICE, аудио/видео | +| OpenSSL | `krzyzanowskim/OpenSSL` | `from: 3.4.0`, не exact; TLS и channel binding | +| libsignal | транзитивно через MartinOMEMO | `from: 1.0.0` в локальном Package.swift | +| tigase-logging | транзитивно через Martin | логирование | + +SwiftChat закомментирован в `project.yml`. Наличие `SwiftChatView.swift` не означает использование внешнего пакета SwiftChat в текущей сборке. + +`THIRD_PARTY_NOTICES.md` перечисляет AGPL-3.0 для Martin и логирования, GPL-3.0 для MartinOMEMO/libsignal, BSD-подобную лицензию WebRTC и Apache-2.0 для OpenSSL. Этот перечень является состоянием репозитория, а не отдельным юридическим заключением. Утверждение файла об отсутствии vendored-кода устарело: локальная копия MartinOMEMO существует. + +### 3.2. Ресурсы и системные настройки + +Все три app-target подключают `Resources/Shared.xcassets` и `Resources/AppAssets.xcassets`. Общий `AppIcon` содержит размеры для iPhone, iPad, Mac и Watch; имя закреплено в build settings и Info.plist. Отдельные каталоги iOS/macOS/watchOS также присутствуют и проверяются скриптом на целостность файлов, но источником ресурсов текущих app-target является общий каталог. + +iPhone поддерживает portrait, landscape left/right; portrait upside down не включён. iPad поддерживает все четыре ориентации. + +iOS Info.plist содержит background modes `audio`, `fetch`, `remote-notification`. Их наличие не означает реализованной APNs-регистрации или возможности постоянно держать XMPP-сокет в фоне. `UISupportsDocumentBrowser` отключён, indirect input включён. + +macOS sandbox разрешает сетевой клиент и сервер, камеру, микрофон, геопозицию и доступ к выбранным пользователем файлам. Mac-окно по умолчанию 1080 × 720; основной split view задаёт минимум 840 × 560. + +## 4. Архитектура и ответственность компонентов + +```text +SwiftUI / UIKit / AppKit + ↓ действия ↑ наблюдаемое состояние / @Query +AppModel (@MainActor) ←→ ArchiveStore / SwiftData + ↓ CredentialVault / AccountPreferences +XMPPService (@MainActor) + ├─ Martin: соединение, roster, presence, MUC, MAM, PEP, upload + ├─ LumaTLSNetworkProcessor / OpenSSL / SASL + ├─ legacy OMEMO + LumaOMEMO2Module → LumaOMEMOStore → libsignal + └─ LumaCallEngine → Martin Jingle + WebRTC + +AppModel → PhoneWatchBridge → WatchConnectivity → WatchSessionModel +AppModel → сервисы записи / файлов / превью / воспроизведения / уведомлений +``` + +### 4.1. Основные слои + +- `LumaApp`: создание единственного наблюдаемого `AppModel`, RootView, обработка scene phase. +- `AppModel`: аккаунт, UI-состояние, обработка сетевых событий, optimistic updates, индексы сообщений, политика действий, сохранение, уведомления и часы. +- `XMPPService`: настройка клиента Martin, соединение, SASL/TLS, кодирование/декодирование stanza, MAM, MUC, HTTP Upload, аватары, шлюз к движку звонков. +- `LumaCallEngine`: очереди и состояния звонка, выбор full JID, сигналинг, SDP, ICE, WebRTC tracks, история завершения. +- `LumaOMEMOStore`: адаптеры identity/session/prekey/signed-prekey/sender-key и постоянный JSON состояния. +- `LumaOMEMO2Module`: новый wire format OMEMO 2, PEP-узлы и SCE. +- `ArchiveStore`: отдельный SwiftData ModelContainer/ModelContext на аккаунт, миграция прежнего JSON, метаданные MAM, purge локально удалённых записей. +- `Models`: сохраняемые сущности и чистые policy/value types. +- `UI`: экраны, панели, списки и компоненты сообщений. +- `Services`: медиа, файлы, геолокация, аватары, уведомления и WatchConnectivity. +- `Security`: Keychain для серверных паролей и пароля блокировки, доверие TLS. + +### 4.2. Конкурентность и границы потоков + +`AppModel` и `XMPPService` изолированы main actor. `ArchiveStore` также `@MainActor`; это существенная особенность: наличие SwiftData не означает вынесения всей работы с историей на фоновый actor. + +OMEMO-декодирование выполняется на отдельной последовательной dispatch queue. Parser queue принимает MAM stanza в ограниченный потокобезопасный inbox; передача на main actor выполняется для страницы, а не по одному callback на каждую stanza. Декодирование делится на небольшие срезы с yield. + +Медиаподготовка выполняется через отдельные сервисы/actors; capture session обслуживается собственной очередью. Signal store защищает состояние `NSLock`, запись JSON — отдельной serial utility queue с debounce. Состояние звонков обслуживает собственная очередь движка. + +SwiftUI читает списки и сообщения через `@Query` и `ModelContext`, но `AppModel` также хранит массивы и словарные индексы. В ряде списков модельные массивы используются при наличии данных, иначе источником служит `@Query`. Это не полностью безсостоянийный UI поверх базы. + +## 5. Словарь данных и идентичностей + +### 5.1. JID и ресурс + +- Bare JID: `user@example.org`; используется для аккаунта, собеседника, имени отдельного хранилища и policy settings. +- Full JID: `user@example.org/resource`; нужен для конкретного endpoint звонка. +- MUC room JID: `room@conference.example.org`. +- Occupant JID: `room@conference.example.org/nickname`; идентифицирует автора сообщения комнаты. +- `clientID`: локальный/переданный идентификатор сообщения. +- `originID`: исходный XEP-0359 id для объединения live/Carbons/MAM. +- `stanzaID`: серверный id, особенно важный для MUC-ответов/реакций. +- MAM UID: непрозрачный курсор внутри конкретного архива; не переносится между аккаунтами или комнатами. + +Для ChatMessage bare-часть sender JID нормализуется в lowercase, resource сохраняется. Некоторые специальные пути, в частности реакции, используют дополнительную нормализацию всего значения; нельзя считать нормализацию всех типов JID полностью одинаковой. + +### 5.2. AccountConfiguration + +| Поле | Тип | Назначение / правило | +| --- | --- | --- | +| `jid` | String | введённый идентификатор, после validated нормализован | +| `displayName` | String | локальное отображаемое имя / nickname конфигурации | +| `resource` | String | желаемый ресурс | +| `manualHost` | String? | ручной адрес транспорта | +| `manualPort` | Int? | ручной порт | +| `usesDirectTLS` | Bool | direct TLS вместо STARTTLS для ручной конфигурации | +| `id` | вычисляемый String | normalizedJID | +| `domain` | вычисляемый String? | доменная часть после единственного `@` | +| `effectiveResource` | вычисляемый String | заданный либо уникальный ресурс устройства | + +Проверка JID требует ровно две части при разделении по `@`, непустые localpart/domain и отсутствие whitespace. Это прикладная проверка, не полная реализация всех правил XMPP address preparation. `normalizedJID` обрезает края и переводит строку в lowercase. + +Порт должен быть 1…65535. Пустой/legacy-ресурс `Luma` заменяется `DeviceResource.default`. Ресурс имеет формат `Luma-<8 lowercase hex-символов UUID>`, сохранён в UserDefaults `app.luma.xmpp.resource`, стабилен для установки и снижает конфликты iPhone/Mac одного аккаунта. + +### 5.3. Conversation + +`@Model`, unique `jid`, cascade relationship к сообщениям. + +| Поле | Смысл | +| --- | --- | +| `jid` | нормализованный JID чата | +| `displayName` | имя либо JID при пустом имени | +| `lastMessage` | текст предпросмотра последнего сообщения | +| `lastActivity` | дата сортировки; по умолчанию distantPast | +| `unreadCount` | локальный счётчик непрочитанных | +| `isOnline` | presence собеседника | +| `isPinned` | признак приоритетной сортировки; само поле не доказывает наличие UI закрепления | +| `encryptionPreference` | inheritGlobal / enabled / disabled | +| `kind` | direct / group | +| `groupNickname` | псевдоним текущего участника | +| `isGroupJoined` | состояние подключения к комнате | +| `shouldAutojoin` | локальная политика автоматического входа | +| `occupantCount` | неотрицательное число участников | +| `invitedBy` | отправитель приглашения | +| `messages` | relationship к ChatMessage | + +Инициалы строятся из максимум двух слов имени, разделённых whitespace/дефисом/подчёркиванием; при отсутствии имени — из localpart JID. Цвет аватара детерминирован по FNV-подобному хешу JID, а не случаен при каждом запуске. + +`AccountConversation.id` равен `|`. Это предотвращает совпадение идентичностей строк между аккаунтами, но текущий экран не является объединённым inbox всех одновременно подключённых аккаунтов. + +### 5.4. ChatMessage + +`@Model`, unique `clientID`, ссылка на Conversation. + +| Поле | Тип / роль | +| --- | --- | +| `clientID` | String, основной уникальный id | +| `conversationID` | String, lowercased JID чата | +| `senderJID` | String, JID автора | +| `body` | String, текст / geo URI / отображаемое содержимое | +| `timestamp` | Date | +| `direction` | incoming / outgoing | +| `delivery` | sending / sent / delivered / failed / read | +| `security` | omemo / plaintext / decryptionFailed | +| `kind` | text / attachment / photo / video / audio / voice / videoNote / location / system | +| `remoteAttachmentURL` | String?, HTTPS или aesgcm URI | +| `localFilename` | String?, имя файла | +| `mimeType` | String? | +| `duration` | TimeInterval? | +| `byteCount` | Int? | +| `encryptionFingerprint` | String? | +| `editedAt` | Date? | +| `replyToID`, `replyToJID`, `replyPreview` | String?, ссылка и локальная цитата | +| `forwardedFrom` | String?, модельное поле; реальный путь пересылки также формирует обычный текст атрибуции | +| `retractedAt` | Date?, tombstone | +| `originID`, `stanzaID` | String?, сетевые идентификаторы | +| `senderDisplayName` | String?, в частности nickname MUC | +| `isGroupMessage` | Bool | +| `callHistory` | CallHistoryMetadata? | +| `reactions` | массив MessageReaction | +| `conversation` | Conversation? | + +У исходящего сообщения `originID` по умолчанию равен id. `isMedia` истинно только для attachment/photo/video/audio/voice/videoNote. Location и system не считаются файлами. + +Предпросмотры типов: «📎 имя», «📷 Фото», «🎬 Видео», «🎵 имя», «🎙 Голосовое сообщение», «⭕️ Видеосообщение», «📍 Геопозиция». Отозванное сообщение отображается как удалённое независимо от прежнего kind. + +Цитата удаляет переводы строк, обрезает whitespace и при длине больше 140 символов оставляет первые 137 плюс многоточие. Это ограничение предпросмотра, не максимальная длина сообщения. + +### 5.5. ArchiveMetadata + +Отдельный `@Model` с unique accountJID: + +- `locallyDeletedMessageIDs`; +- `rosterContactJIDs`; +- `lastSuccessfulMAMSync`; +- `lastSuccessfulMAMCursor`; +- массив `MAMCheckpointEntry` для архивов account/MUC. + +`MAMArchiveKey` состоит из kind (`account`, `muc`) и lowercased JID. Checkpoint хранит timestamp и optional cursor. Пустой курсор после trim становится nil. + +## 6. Запуск, вход и жизненный цикл аккаунта + +### 6.1. Начальный запуск + +RootView выбирает экран в следующем порядке: + +1. Если `isAppLocked`, показать AppLockView поверх основного содержимого. +2. Если аккаунта нет — LoginView. +3. Если аккаунт есть — MainTabView с modelContext активного хранилища. +4. Поверх разблокированного основного UI могут появляться media viewer, звонок и toast. + +При обычном запуске AppModel выполняет bootstrap. Во время unit tests и previews реальное автоподключение подавлено. В Debug доступен отдельный seed-режим UI-тестов. + +Bootstrap загружает список конфигураций, активную конфигурацию либо первый аккаунт, открывает его архив и настройки, затем получает пароль из Keychain. При отсутствующем пароле сбрасывает активный экран аккаунта и сообщает: «Введите пароль заново: запись Keychain не найдена». Ошибка чтения Keychain также возвращает к состоянию без активного аккаунта. + +После успешного соединения запрашивается разрешение локальных уведомлений. Наличие настроенного аккаунта и наличие живого соединения — разные состояния: сохранённая история может отображаться при ошибке подключения. + +### 6.2. Экран входа + +Экран содержит: + +- бренд Luma и подпись о независимости от сервера; +- JID; +- пароль с отдельной кнопкой показать/скрыть; +- раскрываемые дополнительные настройки: имя, ресурс, ручной host, порт, Direct TLS; +- кнопку «Войти», переходящую в «Подключение…» и блокируемую на время sign-in; +- пояснение о Keychain и OMEMO. + +У JID выключены автокоррекция и автокапитализация; на iOS используется email keyboard. Пустой пароль отвергается. Пароль не следует автоматически trim: пробелы могут быть частью credentials. + +Особенность текущего UI: порт разбирается через `Int(trimmedString)`. Нечисловой ввод становится nil и может трактоваться как отсутствие ручного порта, тогда как числовой порт вне диапазона вызывает validation error. Строгую ошибку для любого нечислового значения текущий код не гарантирует. + +Карточка входа имеет max width 460, padding 26 и corner radius 28; фон — сине-голубой градиент. Эти параметры относятся к LoginView, а не к универсальной дизайн-системе. + +### 6.3. Успешный и неуспешный вход + +Sign-in: + +1. валидирует конфигурацию и непустой пароль; +2. очищает временные индексы/кэши/состояния предыдущего активного аккаунта; +3. выбирает per-account хранилище и загружает локальную историю; +4. устанавливает настройки OMEMO и chat states этого JID; +5. подключает XMPP; +6. сохраняет пароль в Keychain только после успешного connect; +7. добавляет/заменяет конфигурацию в списке и сохраняет активный аккаунт; +8. запрашивает notifications authorization; +9. закрывает LoginView, если он был открыт sheet из настроек и вход завершился успешно. + +При ошибке sign-in сервис disconnect вызывается явно, активный аккаунт и модельные массивы очищаются, ошибка показывается пользователю. Добавление аккаунта использует тот же sign-in путь: это не независимое параллельное соединение. + +### 6.4. Несколько аккаунтов + +Сохраняется несколько конфигураций и паролей. Дубликаты нормализованного JID объединяются; AccountPreferences сохраняет список, отсортированный по normalizedJID. + +Одновременно AppModel содержит один XMPPService и один активный store. При переключении старый клиент отключается, открывается база целевого аккаунта и выполняется новый connect с его паролем и checkpoint. При отсутствующем пароле целевого аккаунта переключение отменяется с сообщением. + +В списке чатов при нескольких сохранённых аккаунтах может выводиться подпись аккаунта. Текущий источник строк относится к активному аккаунту; нельзя специфицировать одновременную фоновую доставку на все сохранённые аккаунты. + +### 6.5. Выход + +Выход отключает XMPP, удаляет текущую конфигурацию из списка и пароль этого JID из Keychain. По умолчанию история сохраняется. При `forgetHistory = true` удаляются сущности Conversation/ChatMessage/ArchiveMetadata в текущем store. + +После выхода очищаются UI-состояние, выбор чата, медиа, typing, pending mutations и active call. Если остались аккаунты, выполняется переключение на следующий. + +Существенное ограничение: `ArchiveStore.erase()` удаляет записи текущей базы. Этот путь сам по себе не является гарантией физического secure erase SQLite, удаления файлов OMEMO identity, всех аватаров, резервных копий ОС, экспорта файлов или серверного MAM. + +### 6.6. Фаза приложения + +- `.active`: пометить приложение активным; при disconnected инициировать reconnect. +- `.background`: снять активность, очистить typing, синхронизировать snapshot часов, включить app lock при соответствующей настройке; сервис приостанавливает/управляет архивной работой. +- `.inactive`: не блокировать интерфейс. Control Center, переключатель приложений и системный picker не должны подменяться экраном разблокировки посреди операции. + +Смена scene phase не является реализацией гарантированной фоновой доставки ОС. + +## 7. Соединение, TLS и аутентификация + +### 7.1. Адресация + +Без ручного host используется DNS SRV. Предусмотрены STARTTLS и direct TLS. Для ручной конфигурации стандартный порт — 5222 или 5223 соответственно. XMPP service domain остаётся доменом JID: ручной транспортный адрес не должен отключать проверку сертификата на ожидаемый service domain. + +Connect начинает с disconnect предыдущего клиента, сбрасывает служебное состояние MAM/TLS/SASL/аватаров/комнат, создаёт новый XMPPClient, OMEMO store и SignalContext, регистрирует модули, задаёт connection options, подписывает обработчики и вызывает loginAndWait. + +Timeout SocketConnector — 20 секунд. Создание SignalContext может завершиться отдельной ошибкой инициализации OMEMO. + +### 7.2. TLS + +Собственный `LumaTLSNetworkProcessor` использует OpenSSL; имеется `TLSVersionProbe` для диагностического определения параметров, если основной путь не предоставил их. Параллельный probe не следует считать доказательством, что все показанные параметры получены от того же живого сокета. + +CertificateTrustEvaluator создаёт `SecPolicyCreateSSL(true, expectedDomain)` и выполняет `SecTrustEvaluateWithError`. Самоподписанный/невалидный сертификат не принимается автоматически; пользовательского режима «доверять любому» в рассмотренной реализации нет. + +`LumaTLSState`: версия, cipher, DER leaf certificate, optional 32-byte exporter. Thread-safe holder сохраняет также объявленные server channel binding types, порядок mechanisms и признак downgrade protection. + +Предпочтение channel binding: `tls-exporter`, если объявлен и доступен, затем `tls-server-end-point`. Для PLUS недостаточно одного имени механизма от сервера: нужны пригодные binding data. + +### 7.3. SASL + +Порядок предпочтения: + +1. SCRAM-SHA-512-PLUS. +2. SCRAM-SHA-256-PLUS. +3. SCRAM-SHA-1-PLUS. +4. SCRAM-SHA-512. +5. SCRAM-SHA-256. +6. SCRAM-SHA-1. +7. PLAIN. + +Выбирается только объявленный сервером механизм. Собственные реализации расширяют Martin SHA-512 и PLUS-вариантами. `SASLprep` реализует подготовку строки по RFC 4013, включая mapping, normalization и проверки запрещённых/двунаправленных символов; точные отрицательные сценарии перечислены в тестах. + +SCRAM проверяет структуру server-first/server-final, nonce, salt/iterations, proof/signature; XEP-0474 использует объявленные списки в исходном порядке. Наличие `h=` в challenge наблюдается дополнительным модулем. UI выделяет слабые варианты (`PLAIN`, `OAUTHBEARER` в policy), но упоминание OAUTHBEARER в диагностической политике не означает реализованного OAuth login. + +SASL failure observer сохраняет исходное условие RFC 6120, чтобы пользователь получил более конкретную причину вместо одной общей ошибки библиотеки. + +### 7.4. Зарегистрированные модули + +Auth, stream features, connection statistics, Stream Management, SASL failure/challenge/SASL, resource bind, session establishment, discovery, software version, ping, client state indication, roster, presence, capabilities, pubsub, user avatar, vCard-temp, message, chat states, MUC, carbons, MAM, delivery receipts, chat markers, HTTP File Upload, Jingle, external service discovery, legacy OMEMO и OMEMO 2. + +Discovery identity: category client, type phone, name Luma; platformName используется отдельно в software version. Отдельная category/type для Mac в configureModules не выбирается. + +Клиент объявляет correction/reply/fallback/retraction/reactions/chat states/stanza-id capabilities. Receipts настроены на автоматическую отправку received. + +## 8. Главный интерфейс и навигация + +### 8.1. iPhone и iPad + +Корень — native TabView: «Контакты», «Звонки», «Чаты», «Настройки». Начальная выбранная вкладка — «Чаты». На ОС 26 используется системное полупрозрачное оформление. + +У чатов и контактов есть поиск над списком; код содержит как общий SearchField, так и платформенные `.searchable` ветви. Требование к видимому результату — поиск находится сверху, а не у нижнего края. Это закреплено UI-тестом. + +Переход в ChatView скрывает tab bar; возврат в список восстанавливает её. У каждого списка предусмотрено состояние отсутствия данных/совпадений. + +### 8.2. Mac + +MainSplitView содержит sidebar и detail pane. Режимы: чаты, контакты, звонки, настройки. Меню и поле поиска находятся слева; выбранный чат открывается справа. При переключении режима поиск и selection сбрасываются; в режиме чатов может автоматически выбираться первый отсортированный чат. + +### 8.3. Сортировка и поиск + +Чаты сортируются: + +1. `isPinned = true` перед остальными; +2. `lastActivity` по убыванию; +3. displayName через localized case-insensitive comparison. + +Поиск чатов проверяет имя, JID чата и JID аккаунта. Контакты сортируются по имени. Поиск звонков проверяет conversationID и имя контакта. + +Это локальная фильтрация списков. Полнотекстовый поиск по сообщениям, серверный поиск и глобальный индекс содержимого вложений не реализованы соответствующим полем поиска. + +### 8.4. Контакты + +Раздел «Люди» строится по сохранённому множеству roster JID и негрупповым Conversation. Initial roster и roster-push обновляют это множество. Удаление контакта на сервере убирает его из списка людей, но не обязано уничтожать существующий диалог и историю. + +Комнаты отображаются отдельной группой по локальным Conversation.kind. Обычный XMPP roster не является серверным каталогом членства во всех MUC. + +Новый личный чат принимает JID, optional name и возможность добавить контакт в roster. Сама возможность открыть JID без roster нужна для переписки с не добавленным контактом. + +### 8.5. Слои и общая обратная связь + +RootView задаёт порядок: основной UI, media viewer (`zIndex 100`), call view (`200`), toast (`300`). App lock выбирается до этих слоёв и скрывает их содержимое. + +Ошибка показывается alert с заголовком Luma и кнопкой OK. Informational toast — capsule с checkmark, максимум две строки, автоматическое исчезновение через 2.5 секунды; новая информация отменяет прежний dismiss task. + +QuickLook получает отдельный `previewURL` и служит для просмотра обычных файлов. Встроенный viewer фото/видео — отдельный корневой overlay, не QuickLook. + +## 9. Личный чат и жизненный цикл сообщения + +### 9.1. Отправка текста + +Входной текст trim по whitespace/newlines; пустой результат не отправляется. Нужны активный аккаунт и явный recipient либо выбранный чат. Каждая отправка получает UUID и origin-id. + +Перед сетью в модель вставляется outgoing `sending` с рассчитанным security и возможными reply fields. После успешной записи stanza delivery продвигается до `sent`; receipt — до `delivered`; displayed — до `read`. Ошибка переводит запись в `failed` и выводит alert. + +`sent` означает успешную отправку на уровне клиентского пути, не доказательство прочтения или долговременного сохранения собеседником. Для self-chat успешное исходящее отдельно помечается прочитанным. + +### 9.2. Слияние delivery + +Порядок монотонного повышения: sending < sent < delivered < read. Повтор из архива с `.sent` не понижает `.delivered` или `.read`. + +Если любая из объединяемых сторон `.failed`, результат `.failed`. Архивный replay не должен молча «исправить» неудачную отправку. Явный retry является отдельным действием и может установить sending заново. + +### 9.3. Входящие сообщения + +Обрабатываются live, Carbons, MAM и groupchat. Сервис определяет собеседника/комнату, тип, идентификаторы, дату, автора, encryption и управляющие элементы. Envelope передаётся AppModel. + +Дедупликация использует индексы по storage key, stanza-id, origin-id и clientID. При совпадении дополняется существующая запись и серверная идентичность, а не создаётся ещё один bubble. Строки разных чатов нельзя склеивать только по случайно одинаковому id. + +Служебные receipts/chat markers и пустые технические payload не должны создавать обычные сообщения. Собственные архивные OMEMO-копии, которые невозможно расшифровать обратно, фильтруются в предусмотренных путях, чтобы не образовывать фантомные ошибки. Для реального входящего нерасшифрованного сообщения предусмотрен security `decryptionFailed`. + +### 9.4. Непрочитанные и просмотр + +Новая incoming запись может увеличить unreadCount и обновить lastMessage/lastActivity. Открытие чата сбрасывает локальный unread и для личного чата отправляет displayed для последнего подходящего входящего сообщения; одинаковый displayed не дублируется без необходимости. + +Счётчик непрочитанных, собственная синхронизация `.read` и маркер прочтения от удалённого собеседника — разные механизмы. Нельзя выводить из локального открытия комнаты, что все участники прочитали её историю. + +## 10. Действия над сообщениями + +### 10.1. Матрица доступности + +| Действие | Предусловия | +| --- | --- | +| Ответ | не retracted, не system; для MUC нужен stanzaID | +| Пересылка | не retracted, не system | +| Редактирование | outgoing text личного чата, не retracted, delivery sent/delivered/read | +| Удалить у всех | outgoing личного чата, не system/retracted, delivery sent/delivered/read | +| Удалить у меня | локальное действие над выбранными записями; не серверный отзыв | +| Реакция | не system/retracted; incoming либо outgoing не sending; для MUC нужен stanzaID | +| Копировать текст | непустой body, не system, не retracted | +| Сохранить медиа | файл доступен либо может быть скачан/расшифрован; требуется действие пользователя | +| Повторить медиа | outgoing failed, media-kind, remote URL отсутствует, локальное preview ещё доступно | + +Важная деталь reaction policy: она запрещает outgoing sending, но не формулирует общего запрета на outgoing failed. Приёмка должна опираться на реальные predicates, а не на предположение «реагировать можно только на delivered». + +### 10.2. Редактирование + +XEP-0308 correction получает новый id и `replace` с id исходного сообщения. Исходная строка остаётся одной записью с новым body и editedAt. + +При применении проверяются тип text, отсутствие decryptionFailed, отсутствие локального удаления/отзыва, conversation, автор, совпадение security и не более старая дата correction. Несовпадение шифрования исходной записи и correction отвергается. + +Если оригинал ещё не загружен, correction удерживается в pending queue; максимум 100 записей, при превышении удаляется один ключ словаря. Это не гарантированный FIFO/LRU и не бесконечная durable очередь. Live/Carbons/MAM проходят общий путь. + +UI разрешает исходящее редактирование только в direct chat. Наличие кода парсинга correction не следует расширять до обещания полноценного редактирования MUC. + +### 10.3. Ответы + +Стандартный payload: ``. Для direct используется clientID, для MUC — stanzaID комнаты и occupant JID автора. + +Тело дополнено fallback-цитатой для старых клиентов. XEP-0428 отмечает границы Unicode scalar offsets, а не UTF-8 bytes. Luma убирает fallback из отображаемого текста, оставляя reply preview. + +Цитата показана уменьшенным полупрозрачным bubble с визуальной дугой. Нажатие при наличии target открывает ReplyThreadOverlay с источником и ответами на тот же reference. Это визуальная группировка ответов, не отдельный серверный тред/канал. + +Legacy parser распознаёт начальные `>`-цитаты Conversations/Monal без XML metadata. В этом случае создаётся preview без выдуманного id и без ложной навигационной ссылки. + +### 10.4. Жест ответа + +Поддерживаются горизонтальные свайпы в обе стороны. Ранняя блокировка жеста требует малого вертикального смещения и доминирования горизонтали; обычная вертикальная прокрутка не должна начинать swipe-reply. + +Параметры: activation 24 pt, ранняя lock при горизонтали минимум 12 pt и вертикали ≤24 pt, lock dominance 1.6, follow dominance 1.05, trigger 64 pt, maximum visual offset 76 pt. Предсказанный endpoint может завершить ответ при быстром жесте. + +### 10.5. Пересылка + +Пересылка одного или нескольких сообщений выполняется в существующий чат либо на введённый валидный JID. Отбор исключает system/retracted; сортировка — timestamp, затем clientID. Порядок отправки последовательный. + +- Text: новое обычное сообщение `↪ Переслано от <имя>\n`. +- Location: сначала отдельная текстовая атрибуция, затем geo URI. +- Media: получить локальные/скачанные расшифрованные байты, отправить атрибуцию, заново загрузить файл и отправить новую ссылку. + +Шифрование определяется политикой нового получателя. Старый aesgcm URL не переиспользуется: получатели не должны получать тот же file key через обход новой политики. + +При первой неудаче серия останавливается. Если часть уже отправлена, показывается число успешных из общего количества. Это не транзакционная пересылка с откатом уже отправленного. + +### 10.6. Отзыв у всех + +Используется XEP-0424 namespace `urn:xmpp:message-retract:1`, target id и generic fallback через XEP-0428. Fallback не повторяет удаляемое содержимое. При включённом encryption данный метод использует legacy OMEMO-путь; общий факт предпочтения OMEMO 2 для sendText не означает OMEMO 2 во всех управляющих отправках. + +Применение проверяет conversation и автора. Если оригинала нет, retract удерживается в pending queue до 100 записей. Повтор с более старой датой не отменяет новое состояние. + +Tombstone: + +- body = «Сообщение удалено»; +- kind = system; +- удаляются URL, имя, MIME, duration, byteCount; +- удаляются editedAt, reply fields, forwardedFrom, callHistory и реакции; +- устанавливается retractedAt; +- очищается известный медиакэш и pending correction. + +Отзыв не гарантирует удаления серверного MAM, скриншотов, экспортов, backup или копии в несовместимом клиенте. + +### 10.7. Локальное удаление + +«Удалить у меня» физически удаляет строку локального ModelContext и запоминает suppression key формата `\u001F`. Этот набор сохраняется в ArchiveMetadata и предотвращает повторное добавление после MAM. + +При открытии базы дополнительно удаляются строки, которые старые версии лишь помечали локально удалёнными. После удаления пересчитывается preview чата и очищается связанное медиа. + +Удаление локально для конкретного аккаунта/устройства, не синхронизируется как серверный отзыв и не удаляет копию другого собственного устройства. + +### 10.8. Множественный выбор + +Команда «Выбрать» переводит ленту в selection mode. UI показывает число выбранных, действие пересылки и меню удаления. «Удалить у всех» доступно с учётом допустимости выбранных сообщений. Отмена снимает selection. Массовые действия не должны менять исходный порядок пересылки. + +## 11. Реакции и статус набора + +### 11.1. Реакции + +Используется XEP-0444, `urn:xmpp:reactions:0`. Payload содержит target id и полный текущий набор emoji автора; пустой набор снимает реакцию. + +Quick choices: 👍, ❤️, 😂, 😮, 😢, 🔥. Отдельный emoji picker даёт каталог/категории для вставки emoji; текстового поиска в текущем EmojiPickerView нет; точный состав каталога хранится в EmojiCatalog. + +Sanitization реакции: + +- trim whitespace; +- ровно один Swift Character; +- максимум 64 UTF-8 байта; +- хотя бы один Unicode scalar с `isEmoji`; +- дубликаты удаляются с сохранением первого вхождения. + +Это конкретная техническая проверка Unicode, а не утверждение, что только визуально привычные emoji могут её пройти. + +Реакции группируются по emoji, считают число авторов и отмечают собственный вклад. В direct авторы сверяются по bare JID, в group — по occupant identity. Более старая реакция не заменяет более новую. Pending reactions ограничены 200, при переполнении удаляется самая старая по timestamp. + +Существенная приватность: `sendReactions` отправляет reactions element без OMEMO-обёртки, в том числе в encrypted chat. Сервер видит emoji и target id. Это отдельно от шифрования текста. + +### 11.2. Набор текста + +Состояния: active, composing, paused, inactive, gone. Локальный переход к paused после 5 секунд; удалённое composing истекает через 8 секунд. + +В direct: «печатает…». В группе: одно имя, два имени либо «Несколько участников печатают…». Имена дедуплицируются и сортируются. + +Настройка «Показывать статус набора» сохранена отдельно для каждого аккаунта, по умолчанию true. Отключение очищает typing state. Для direct отправка standalone chat state требует известной поддержки peer; group отправка требует joined room. При уходе с экрана/из активного приложения typing tasks очищаются. + +Chat states являются незашифрованными метаданными присутствия/активности, даже когда текст защищён OMEMO. + +## 12. Групповые чаты MUC + +### 12.1. Создание и вход + +NewGroupView позволяет создать или войти в room JID, задать название, nickname и приглашённых участников. В сервисном API предусмотрен optional password, но отдельного поля пароля в этой форме нет. Адрес комнаты должен иметь room/domain; фактическая возможность создания зависит от disco и прав сервера. + +Conversation хранится как group с nickname, joined/autojoin и occupant count. Пароли входа удерживаются в session state `pendingRoomPasswords`; отдельного постоянного Keychain-контракта для room passwords нет. + +В GroupInfo доступны адрес/состояние комнаты, nickname, число участников, приглашение и выход. Не следует считать экран полной панелью MUC administration с ban/kick/role management, если соответствующих действий нет в UI. + +### 12.2. Конфигурация новой комнаты + +При создании клиент стремится установить: + +- persistent room; +- members-only; +- `muc#roomconfig_whois = anyone`; +- доступ moderator/participant к спискам участников. + +Это необходимо для предсказуемого группового OMEMO. Сервер может не предложить поле или отказать из-за прав — тогда возникает отдельная ошибка. + +### 12.3. Получатели группового OMEMO + +Перед encrypted group send: + +1. проверить joined room; +2. проверить `muc_nonanonymous`; +3. получить реальные JID online occupants; +4. получить member/admin/owner affiliation lists, включая offline участников; +5. построить полный набор bare JID; +6. получить device lists и сессии; +7. выбрать OMEMO 2 только при его наличии у всех адресатов, иначе legacy; +8. сформировать одну groupchat stanza с ключами устройств получателей. + +Если real JID скрыт, полный список недоступен, у одного участника нет устройств либо невозможно создать требуемую session, отправка завершается ошибкой. Автоматической отправки plaintext вместо неудачного group OMEMO нет. + +Если текущий участник — owner существующей комнаты, сервис может попытаться настроить whois; обычный участник не имеет таких прав. + +### 12.4. Эхо, история и действия + +Own echo комнаты должен дополнить stanzaID даже тогда, когда decoder считает encrypted payload дубликатом. Без stanzaID нельзя построить совместимый group reply/reaction. + +MUC имеет собственный архив/checkpoint. Group сообщения показывают nickname и используют общий timeline pipeline. Редактирование и «Удалить у всех» из UI ограничены direct chat, ответы и реакции в group требуют stanzaID. + +### 12.5. Приглашение, выход и удаление + +Приглашение фиксирует `invitedBy`, а чат предлагает «Принять»/«Войти». Автоподключение использует сохранённую shouldAutojoin. + +Выход оставляет чат и историю, состояние — «Не подключено». Удаление group chat предварительно выходит при необходимости, удаляет локальный разговор/историю и подавляет повторное создание событиями комнаты на время текущей сессии. + +Удаление чата не уничтожает комнату на сервере и не равно удалению истории всех участников. Session suppression не является вечным серверным запретом появления комнаты. + +## 13. Шифрование OMEMO + +### 13.1. Политика + +Глобальный переключатель аккаунта по умолчанию true. Conversation preference: inheritGlobal (наследовать глобальный), enabled (всегда шифровать), disabled (без OMEMO). Вычисление должно использовать override чата, если он есть. + +Настройки не секретны и сохранены локально: глобальная в UserDefaults по JID, per-chat — в SwiftData. Они не синхронизируются автоматически как общий серверный policy profile. + +Ошибка шифрования не вызывает plaintext fallback. Сам выбор legacy вместо OMEMO 2 является сменой encrypted wire format, а не отключением E2EE. + +### 13.2. Legacy + +Namespace `eu.siacs.conversations.axolotl`, MartinOMEMO + libsignal. AES-GCM реализован адаптером CryptoKit. Сохраняются identity keys, prekeys, signed prekeys, sessions и trust. + +Применяется TOFU: первый увиденный identity key доверен; изменившийся ключ требует нового решения и становится undecided. Ручная verification выделена отдельно от первоначального автоматического доверия. + +Исходящие учитывают устройства адресата и собственные устройства. Для encrypt-to-self предусмотрено создание собственной session и разовая очистка старой ошибочной self-session после прежних сборок. + +### 13.3. OMEMO 2 + +Модуль реализует формат, обозначенный исходниками как XEP-0384 0.8.3: + +- encrypted namespace `urn:xmpp:omemo:2`; +- device list node `urn:xmpp:omemo:2:devices`; +- bundles node `urn:xmpp:omemo:2:bundles`; +- SCE envelope namespace `urn:xmpp:sce:0`; +- payload AES-256-CBC; +- HKDF-SHA-256 с info `OMEMO Payload`; +- HMAC-SHA-256 с усечением до 16 байт; +- Signal-encrypted материал содержит 32-byte key и 16-byte MAC; +- bundle имеет `spk`, `spks`, `ik`, `prekeys/pk`; +- Double Ratchet storage общий с legacy module. + +Для direct sendText OMEMO 2 предпочитается, если модуль ready и peer публикует хотя бы одно устройство. Device list запрашивается on demand, чтобы первая отправка не зависела только от cache. Для групп наличие OMEMO 2 требуется у каждого получателя. + +Общий storage и локальные unit tests не заменяют отдельную проверку interoperabilty со всеми редакциями OMEMO/клиентами. + +### 13.4. Устройства и доверие + +Экран шифрования показывает режим чата, готовность, устройства собеседника, active state, id, protocol badge, fingerprint и trust. Собственный fingerprint показывается отдельно. Доступно выделение текста, группировка отпечатка по 8 символов и переключение verified. + +Статусы trust: verified, trusted, undecided, compromised. Пустой список сопровождается объяснением, что ключи появляются после обмена/попытки отправки. `isOMEMOReady` не гарантирует наличие исправных сессий абсолютно со всеми адресатами. + +### 13.5. Границы конфиденциальности + +OMEMO защищает payload, а не весь XML transport. Сервер знает аккаунты, время/маршрутизацию, stanza ids и ряд управляющих элементов. Upload service видит размер и имя blob. Аватары, реакции, chat states, custom read/call metadata имеют отдельные незашифрованные пути. + +Шифрование звонка — DTLS-SRTP WebRTC, не OMEMO. TLS — транспортная защита до сервера, не эквивалент E2EE. Блокировка приложения — gate интерфейса, не криптографическая защита базы. + +## 14. Вложения и HTTP File Upload + +### 14.1. Источники файлов + +Меню `+` в чате содержит платформенно доступные действия: + +- системная камера для фото на iOS; +- фото и видео из медиатеки; +- музыка через файловый выбор; +- видеосообщение; +- «Отправить геопозицию»; +- «Файлы (несколько)». + +Файловые источники читаются через MediaFileIO с обработкой security-scoped URL. Для предпросмотра создаются временные копии; наличие исходного URL не должно требовать удержания доступа к чужому файлу до конца сетевой загрузки. + +Фото/видео picker ограничен 20 выбранными элементами; обработка также использует `prefix(20)`. Live Photo с image/video representation предпочитает неподвижное фото; video-only — видео. Второй representation используется как fallback при неудаче первого. Отдельные ошибки подготовки не обязаны уничтожать успешно подготовленные элементы. + +### 14.2. AttachmentDraft и пакетная отправка + +Draft хранит идентичность, временный URL, имя, MIME, kind, размер, duration, thumbnail и признак временности. Экран предпросмотра позволяет просмотреть фото/видео, удалить элементы и добавить общую подпись. + +Отправка пакета последовательная: + +1. прочитать очередной draft; +2. отправить через sendMedia; +3. успешный временный draft удалить; +4. неуспешный сохранить в failedDrafts и продолжить остальные; +5. подпись отправлять только если failedDrafts пуст; +6. подпись — отдельное текстовое сообщение, по возможности reply к первому отправленному вложению; +7. в MUC ответ-подпись возможен только после получения подходящего stanzaID; иначе подпись обычная. + +Результат содержит sentCount, failedDrafts и captionSent. Это не единый серверный альбом и не атомарная транзакция. При частичной неудаче остаются только неотправленные элементы; уже отправленные не должны отправляться повторно при работе с оставшимися drafts. + +### 14.3. Типизация и имена + +Тип определяется UTType по расширению/провайдеру: image → photo, movie → video, audio → audio, остальное → attachment. Внутренние voice/videoNote сохраняют специальный kind. + +Для совместимости transport filename: + +- voice: `luma-voice-<округлённые миллисекунды>-`; +- videoNote: `luma-videonote-<округлённые миллисекунды>-`; +- остальные типы: исходное имя после sanitization. + +Duration кодируется только при конечном положительном значении, безопасном для Int. При приёме prefix восстанавливает voice/videoNote и duration. Исходное расширение сохраняется даже у encrypted blob; новая загрузка не добавляет `.aesgcm` к имени. Предусмотрено чтение старых собственных ссылок с таким суффиксом. + +### 14.4. Отправка одного файла + +sendMedia при отсутствии подключения сначала пытается reconnect. Значение duration проверяется на finite/positive; при отсутствии извлекается из media asset. + +Создаётся optimistic message с размером исходных байтов. Для inline media preview готовится до upload. UI activity учитывается токенами, а не одним несбалансированным Bool: параллельное завершение операций не должно навсегда оставить isSendingAttachment включённым. + +Политика шифрования вычисляется для recipient. Group flag берётся из Conversation. Если входной kind не media, используется attachment. + +### 14.5. Шифрование файла и slot + +При encryption содержимое шифруется AES-GCM до обращения к upload. Модуль legacy OMEMO используется для file crypto даже когда текстовая stanza затем может уйти через OMEMO 2. Upload Content-Type становится `application/octet-stream`; имя/расширение сохраняются. + +HTTP File Upload discovery получает компоненты. Из тех, чей maxSize не меньше размера загружаемого blob, выбирается компонент с наибольшим maxSize. Отсутствие подходящего компонента сообщает размер файла. Собственного универсального максимального размера продукта нет: ограничение определяется компонентом и доступными ресурсами; работа через Data также ограничена памятью устройства. + +Slot запрашивается с filename, размером uploadData и content type. И `putUri`, и `getUri` должны иметь scheme HTTPS. Обычный HTTP отвергается; user bypass не предусмотрен. + +### 14.6. HTTP PUT + +Используется ephemeral URLSession: + +- метод PUT; +- Content-Type по режиму шифрования; +- дополнительные headers из slot; +- request timeout 120 секунд; +- session timeout request 120 секунд; +- resource timeout 300 секунд; +- waitsForConnectivity = false; +- успех только HTTP 200…299. + +Различаются discovery failure, slot failure, transport failure, non-HTTP response, HTTP status, invalid URL и insecure URL. + +После успешного upload encrypted GET URL преобразуется в `aesgcm://...#`; fragment не отправляется upload service как часть HTTP request. Эта ссылка отправляется внутри encrypted message body. Plaintext-файл использует HTTPS body и XEP-0066 OOB. Тело файловой stanza остаётся распознаваемой ссылкой, без добавления подписи внутрь URL. + +Успех PUT и успех XMPP send — две стадии: при ошибке последней сервер может уже хранить blob. Очистка такого orphan upload клиентом не гарантирована. + +### 14.7. Автоматические и ручные повторы + +Автоматически максимум две попытки, пауза 600 мс. Повторяемые ошибки: отсутствие соединения, connection failure, discovery/slot/transport failure; HTTP 408, 429, 5xx либо отсутствие status. Не все 4xx повторяются. + +Ручной retry медиа доступен только если сообщение outgoing failed, kind media, нет remoteAttachmentURL и сохранился локальный media preview. Он использует тот же clientID и исходный security сообщения; переключение OMEMO после ошибки не должно молча изменить encryption этого retry. + +Временный файл может исчезнуть после перезапуска или очистки ОС. Durable offline outbox для любых файлов и гарантированное восстановление всех failed sends не реализованы. + +### 14.8. Скачивание + +При aesgcm scheme заменяется на HTTPS, fragment отделяется до сетевого запроса, после HTTP 2xx blob расшифровывается. Plaintext download также принимает только HTTPS. Некорректный URL, статус или file key вызывают отдельные ошибки. + +Клиент использует стандартные URLSession механизмы. Проверка первоначального scheme в коде не является самостоятельной спецификацией дополнительного контроля каждого HTTP redirect, content sniffing или антивирусной проверки. + +## 15. Превью, воспроизведение и сохранение медиа + +### 15.1. Подготовка + +MediaPreviewProcessor — actor. Для фото ImageIO строит thumbnail с transform, max pixel size 1200 и JPEG quality 0.82. Для видео применяется preferred track transform, maximum size 960 × 960 и временные допуски 0.35 секунды. + +Видео thumbnail выбирается не только строго с нулевого кадра: сначала около четверти duration, ограниченной 0.01…1 секундой, затем ранний кадр и 0. Повторяющиеся попытки по миллисекундной позиции исключаются. MIME hint передаётся в AVURLAsset для временных файлов с неточным расширением. + +Voice waveform рассчитывается по PCM, по умолчанию 48 столбцов, чтение буферами по 4096 frames. Пики нормализуются; имеется placeholder при недоступном анализе. Waveform — визуальное представление громкости, не распознавание речи. + +### 15.2. Inline-представление + +Фото и видео показаны отдельными скруглёнными островками с padding и aspectFit. Изображение не обрезается под фиксированную aspect-fill рамку. Loading state и thumbnail могут отображаться до доступности полного содержимого. + +Музыка показывает filename, duration, play/pause и seek. Voice показывает waveform, прогресс и скорости 1×, 1.5×, 2×. VideoNote воспроизводится непосредственно в круглом bubble и увеличивается на время playback. + +MediaPlaybackCoordinator обеспечивает один активный audio/voice playback. Для разных медиакомпонентов есть уведомление exclusive playback; нельзя выводить из этого абсолютную гарантию аудиофокуса всех внешних приложений ОС. + +### 15.3. Полноэкранный просмотр + +MediaViewer размещается поверх всего app window. Фото поддерживает zoom/pan, видео — системный VideoPlayer. Увеличенное фото должно перемещаться внутри viewport, не закрываясь от обычного pan. + +Закрытие вертикальным жестом: + +- движение вертикали минимум 8 pt; +- вертикаль превосходит горизонталь в 1.15 раза; +- порог расстояния = `min(140, max(88, height × 0.14))`; +- либо predicted vertical distance ≥ threshold × 1.35; +- горизонтальный drag не должен закрывать viewer; +- закрытие не требует отдельного крестика. + +### 15.4. Файлы и экспорт + +Обычный attachment открывается через QuickLook по запросу. Inline media получает общий cached local file; лишнее повторное скачивание уже подготовленного preview избегается. + +Сохранение выполняется только по явному действию: + +| Тип | iOS | macOS | +| --- | --- | --- | +| Фото, видео, videoNote | Photos library | NSSavePanel, по умолчанию Downloads | +| Attachment, audio, voice | системный экспорт в Files | NSSavePanel | +| Text, location, system | unsupportedKind | unsupportedKind | + +При Files export лист открывается после небольшой задержки 350 мс, чтобы завершилось закрытие context menu. Отмена возвращает false и не должна показывать ложный toast «Сохранено». При успехе показывается подтверждение. После экспорта расшифрованный файл покидает sandbox и не удаляется очисткой внутреннего кэша. + +## 16. Голосовые и круглые видеосообщения + +### 16.1. Голос на iPhone/Mac + +AudioMessageRecorder запрашивает microphone permission. На iOS AVAudioSession использует playAndRecord/default, defaultToSpeaker и Bluetooth HFP. Формат: MPEG-4 AAC, 44.1 kHz, mono, high quality, `.m4a` в tmp `LumaRecordings`. + +Запись короче 0.4 секунды удаляется и считается слишком короткой. Отмена останавливает recorder, очищает timer/state и удаляет файл. После завершения аудиосессия деактивируется с notifyOthersOnDeactivation. + +В отличие от WatchVoiceRecorder и VideoNoteStopPolicy, AudioMessageRecorder не задаёт явного предела 60 секунд. Нельзя переносить ограничение круглого видео/часов на голос с телефона только по тексту README. + +### 16.2. Управление записью из composer + +Telegram-подобная кнопка переключает режимы voice/video. Длительное нажатие запускает запись, движение влево отменяет, вверх фиксирует запись. Для фиксированной записи предусмотрены явные finish/cancel. + +Gesture policy: cancelDistance 92 pt, lockDistance 78 pt, hint fadeDistance 150 pt, maximum fade 0.72, longPress delay 240 мс, minimumLongPressDuration 0.22 секунды. Запуск разрешений/камеры асинхронен, поэтому отпускание до фактического старта не должно оставлять скрытую запись. + +### 16.3. Круглое видео + +VideoNoteRecorder управляет capture session, movie output, камерой, звуком, очередью и finalization. UI позволяет открыть отдельный экран съёмки и переключить камеру. Длительность ограничена 60 секундами. + +Числовой контракт VideoNoteStopPolicy: + +| Параметр | Значение | +| --- | --- | +| minimumCaptureDuration | 1.2 с | +| minimumValidFileDuration | 0.8 с | +| maximumCaptureDuration | 60 с | +| recordedDurationWaitTimeout | 5 с | +| recordedDurationPoll | 80 мс | +| startupTimeout | 6 с | +| finalizationTimeout | 12 с | +| finalizationRecoveryGrace | 1 с | + +Состояния lifecycle: idle, prepared, starting, recording, stopping. Повторный stop/finalize должен быть согласован с текущим состоянием. Finish до фактического recording запоминается, а не теряется. + +Ненулевой AVFoundation error не всегда означает потерянный файл: maximumDurationReached и `AVErrorRecordingSuccessfullyFinishedKey` могут подтверждать успешный output. При этом пригодный partial file после interruption нельзя автоматически отправлять как будто пользователь нажал finish. Отправка без explicit stop допустима при достижении лимита; tolerance при проверке duration — 0.25 с. + +### 16.4. Ориентация + +Для записанного QuickTime track: + +- portrait → 90°; +- portrait upside down → 270°; +- landscape left → 180°; +- landscape right → 0°. + +На iOS берётся ориентация активного интерфейса UIWindowScene, а не только датчик UIDevice: устройство может лежать иначе, чем отображается UI. Это предотвращает круглое видео, повёрнутое набок. + +### 16.5. Взаимодействие с MAM + +На время активной съёмки videoNote, подготовки медиа и media send сервис приостанавливает фоновые archive queries. Завершение всех activity tokens позволяет продолжить после 3-секундной задержки. Цель — ограничить конкуренцию дешифрования истории с камерой и upload; это не гарантированный FPS на любом устройстве. + +## 17. Геопозиция + +GeoLocation содержит latitude, longitude и optional uncertainty. Допустимы только finite значения: latitude −90…90, longitude −180…180, uncertainty ≥0. + +При чтении RFC 5870 допускаются две или три координаты; третья проверяется как число, но отдельная altitude в модели не сохраняется. Параметр `u=` разбирается, остальные неизвестные параметры не превращаются в дополнительные свойства. + +Сериализация: `geo:%.6f,%.6f` в POSIX locale, optional `;u=%.0f`. Координаты имеют шесть знаков после запятой; uncertainty округляется до целого. Это точность представления, не гарантия точности GPS. + +LocationPicker показывает карту, позволяет выбрать текущую или произвольную точку. Core Location вызывается только из явного сценария отправки; requestWhenInUse и one-shot requestLocation, desiredAccuracy best. При denied/restricted остаётся ручной выбор на карте и понятное сообщение. + +Сообщение проходит обычный text/OMEMO pipeline, отображается картографическим preview. Постоянного live-location tracking, подписки на перемещение и фонового геомониторинга нет. + +## 18. История, MAM и восстановление + +### 18.1. Три независимых источника + +1. Локальная база SwiftData — сохранённые модели активного аккаунта. +2. Account MAM — личный архив пользователя. +3. MUC-MAM — отдельный архив конкретной комнаты. + +Carbons и live delivery дополняют историю; не заменяют durable локальный store или server MAM. Retention и полнота сервера определяются его настройками. + +### 18.2. Первый вход + +Без checkpoint запрашивается ограниченная последняя RSM-страница, а не весь архив с самого начала. Bootstrap limit 40. Видимый баннер «Синхронизация истории…» предназначен для bootstrap; последующее догоняющее чтение работает без постоянного spinner. + +Старую историю пользователь догружает обратной прокруткой. Установка нового клиента не означает немедленную полную загрузку всех лет архива. + +### 18.3. Инкрементальная синхронизация + +Первичный anchor — сохранённый непрозрачный MAM UID через RSM after. Timestamp применяется как совместимый recovery/migration fallback с перекрытием 60 секунд. Account и каждая MUC используют разные namespace ключа checkpoint. + +Проверяется queryID и разрешённый источник результата. Inbox максимум 96 stanza, page size 40. Overflow или неожиданный отправитель отвергает страницу и не должен продвигать durable checkpoint. + +Декодирование порциями по 8 stanza, inter-slice delay 1 мс. Запрос страницы и её применение имеют отдельные timeout по 20 секунд. + +### 18.4. Пагинация и commit + +`complete = true` завершает проход. Иначе нужен непустой новый cursor, не равный requestedAfter и не встречавшийся в текущем проходе. Некорректный/repeating cursor останавливает пагинацию, отбрасывает незафиксированные mutations и выдаёт recoverable error. + +Checkpoint timestamp не должен двигаться назад и не должен выходить за queryStartedAt при учёте high watermark. При полном catch-up может быть поднят до времени начала запроса; при частичном — до ограниченного watermark. Продвижение определяется timestamp либо сменой cursor. + +Важно различать in-memory публикацию и durable сохранение: сервис отдаёт archiveBatch, AppModel применяет изменения и планирует SwiftData save с debounce. В коде нет отдельной распределённой транзакции, одновременно коммитящей server cursor, UI, диск и часы. + +### 18.5. Бюджет и продолжение + +`ArchiveSyncWorkBudget`: 2 завершённые страницы либо 2 секунды. Фактический `completeArchivePage` при достижении бюджета публикует накопленные mutations, обновляет внутренний checkpoint, сбрасывает budget и планирует следующую страницу. + +Следовательно, это граница порции публикации/работы, а не абсолютный лимит двух страниц на одно открытие приложения. Комментарий о растягивании всего catch-up по активациям не полностью описывает текущий исполняемый путь: при допустимом состоянии догрузка продолжается. + +Между страницами задержка 100 мс; foreground refresh interval 60 секунд. Синхронизация может приостанавливаться background/media work и возобновляться при допустимом состоянии. + +### 18.6. Recovery + +- Повтор одной страницы: максимум 1 дополнительный. +- Просроченный сохранённый cursor: одна попытка fallback к timestamp, только до успешных страниц и без повторного переключения стратегий. +- После неудачного catch-up: автоматический retry через 20 секунд. +- Максимум последовательных automatic retries: 3; затем ожидание следующей активации. +- Disconnect/timeout не должен коммитить незавершённую пачку как успешно завершённый архив. + +Источники live delivery могут временно удерживаться до завершения catch-up соответствующего архива и затем проигрываться, чтобы сохранить согласованный порядок применения. + +### 18.7. Прокрутка назад + +Локальное окно ChatView — 60 сообщений; по мере прокрутки оно расширяется. Это ограничение отображаемого окна, а не fetch-limit всей базы: `@Query` и загрузка AppModel могут читать больше записей. + +Interactive server query имеет timeout 15 секунд и собственный query-ID-scoped буфер. Account catch-up не должен отменять его глобальной очисткой inbox. + +Следующий RSM before берётся из `` предыдущей серверной страницы. Нельзя брать id старейшего локального bubble: страница может содержать только reactions/retractions/дубликаты без новых строк и всё равно должна продвинуть cursor. + +`hasMore` true только при `!complete` и непустом first UID. Пустая страница/отсутствие RSM не должна создавать бесконечный spinner. Позиция чтения сохраняется при вставке старых сообщений; пользователь, читающий вверх, не должен автоматически улетать вниз от каждого update. + +## 19. Прочтение между собственными устройствами + +Исходящее direct сообщение имеет XEP-0333 ``. Received receipt и displayed marker имеют разный смысл. Luma отправляет peer displayed при просмотре личного чата; в MUC такие markers сама не отправляет. + +Для собственных устройств используется custom namespace `https://luma.chat/read-marker`. Payload: + +- `conversation`: JID чата; +- `id`: origin/client id сообщения; +- optional `stanza`: server/MUC stanza id; +- `when`: ISO 8601 timestamp. + +Служебная stanza адресуется собственному bare JID, имеет origin-id, не содержит body и не шифруется OMEMO. Отсутствие body предотвращает ложный bubble self-chat у клиентов, не понимающих namespace. Carbons доставляет другим собственным ресурсам, MAM позволяет восстановление, если сервер архивирует такой payload. + +Маркер, пришедший раньше сообщения, хранится pending и применяется при upsert. Direct сопоставляется по origin/client id, group — в том числе stanzaID. Маркер участника группы может быть принят, но нельзя специфицировать массовую рассылку displayed Luma в комнату. + +Это собственное расширение Luma, не общепринятая универсальная синхронизация read state всех XMPP-клиентов. Payload виден серверу и содержит идентификаторы чата/сообщения. + +## 20. Аудио- и видеозвонки + +### 20.1. Границы + +Поддерживается один активный личный звонок. Group call отсутствует. Нужны активное XMPP-соединение, presence/capabilities peer, microphone permission и для видео camera permission. + +Экран входящего позволяет принять/отклонить. Во время звонка доступны mute, speaker на соответствующей платформе, camera enable/disable, switch camera и завершение. Remote/local video представлены RTCVideoTrack через renderer, владение PeerConnection остаётся у движка. + +### 20.2. Установка + +LumaCallEngine выбирает full JID по presence и RTP capabilities. Если доступен Jingle Message Initiation XEP-0353, propose адресуется выбранному full JID; иначе применяется прямой session-initiate IQ. + +Jingle использует RTP audio/video, ICE-UDP и DTLS. SDP преобразуется между Martin и WebRTC. `session-accept` приводится к порядку `m=` lines исходного offer: Jingle content-name и строгий порядок WebRTC SDP не должны расходиться. + +ICE candidates накапливаются до успешной начальной stanza и установки remote SDP. Full JID уже известен, поэтому ожидание bare proceed не должно навсегда блокировать trickle. Последующие proceed/accept/transport-info проверяются относительно выбранного endpoint, чтобы candidate не ушёл другому ресурсу того же аккаунта. + +### 20.3. ICE и NAT + +Сначала XEP-0215 получает STUN/TURN сервера. Записи валидируются: host/port/type/transport, обязательные TURN username/password, истёкшие credentials, согласованность secure schemes с TCP. + +Fallback: + +1. пригодные server ICE services; +2. `stun:stun.l.google.com:19302`; +3. только host candidates, если WebRTC отклонил конфигурацию. + +Публичный STUN раскрывает сетевой адрес этому сервису и не заменяет TURN. Без relay соединение через некоторые NAT/CGNAT может не установиться. + +Краткий ICE failed получает около 8 секунд recovery для поздних candidates. Ringing timeout — 60 секунд; negotiation — 90 секунд; ICE disconnected recovery — 12 секунд, ICE failed recovery — 8 секунд. Таймер не является обещанием ответа удалённого клиента за это время. + +### 20.4. CallSnapshot + +Value projection: + +- UUID; +- peerJID; +- direction incoming/outgoing; +- media Set audio/video; +- phase ringing/connecting/connected; +- connectedAt; +- isMuted, isCameraEnabled, isSpeakerEnabled; +- hasLocalVideo, hasRemoteVideo. + +Входящий ringing определяется одновременно direction и phase. Видимость local/remote track не выводится только из isVideoCall. + +### 20.5. История завершения + +Сохраняется ChatMessage.kind.system с CallHistoryMetadata и optional duration. Outcomes: completed, declined, missed, cancelled, unanswered, failed, answeredElsewhere. + +Правила классификации: + +| Событие до установления media | Итог | +| --- | --- | +| localRejected / remoteRejected | declined | +| localEnded | cancelled | +| remoteEnded / remoteCancelled при incoming ringing | missed | +| remoteEnded / remoteCancelled при outgoing ringing | declined | +| timedOut при incoming ringing | missed | +| timedOut при outgoing ringing | unanswered | +| timeout/remote termination уже в connecting | failed, если не было connectedAt | +| answeredElsewhere | answeredElsewhere | +| failed / connectionDetached | failed | + +При наличии connectedAt либо phase connected итог completed имеет приоритет над поздней transport error; длительность сохраняется. Активный звонок снимается до ответных completion callbacks, чтобы не создавать двойную карточку. + +Пропущенный входящий увеличивает unread. В чатах карточка содержит направление, тип звонка, исход и duration. Длительность округляется до секунд, формат m:ss либо h:mm:ss. + +### 20.6. Синхронизация истории звонков + +Custom namespace `https://luma.chat/call-history`, self-addressed plain message. Поля: direction, status, duration (округлённые секунды), start (ISO 8601), with (peer JID), video. Origin-id соответствует id карточки и дедуплицирует её Carbons/MAM копии. + +В отличие от read marker эта stanza содержит body с текстом истории звонка. Другой клиент без поддержки расширения может показать её как обычное сообщение в self-chat. Payload раскрывает серверу собеседника и метаданные звонка. + +### 20.7. Ограничения фоновых вызовов + +Входящий звонок обрабатывается при живой сессии. PushKit/CallKit/VoIP APNs wake-up для завершённого процесса отсутствуют. UIBackgroundModes не заменяют этот путь. + +DTLS-SRTP шифрует media между endpoints; XMPP сервер видит Jingle signaling, STUN/TURN — адреса и объём трафика. Это не обещание скрытия метаданных и не OMEMO-защита сигналинга. + +## 21. Аватары + +Обновление своего аватара выполняется через PhotosPicker и AvatarImageProcessor; изображение нормализуется в PNG до 512 px. Публикация XEP-0084 PEP, vCard-temp — best-effort compatibility fallback. + +PEP metadata notifications вызывают обновление контактов. Локальный AvatarCache показывает сохранённое изображение до подключения. При отсутствии изображения AvatarView использует инициалы и стабильный цвет. + +Аватары не защищены OMEMO. Их доступность задают PEP/roster permissions сервера. Наличие локального кэша не является механизмом удаления чужих копий после изменения аватара. + +## 22. Локальные уведомления + +NotificationCoordinator запрашивает alert/sound/badge и является delegate UNUserNotificationCenter. В foreground разрешены banner/list/sound. + +NotificationPolicy: + +- только новое incoming сообщение; +- архивный replay не должен создавать уведомление как новая доставка; +- при активном приложении и открытом том же conversation повторный banner подавляется; +- при другом открытом чате уведомление допустимо; +- outgoing не создаёт новое incoming notification. + +Это локальная реакция уже работающего процесса на полученное сетевое событие. APNs device token, XEP-0357 enable/disable, собственный push gateway и notification service extension не реализованы. Серверный disco feature push может отображаться зелёным и при этом не означать, что Luma зарегистрировалась на push. + +UI lock не следует считать гарантией скрытия текста уже созданных системных уведомлений или синхронизированного snapshot часов: это отдельные каналы отображения. + +## 23. Блокировка приложения + +AppLockVault хранит passcode в Keychain service `app.luma.chat.applock`, account `applock-passcode`. Настройки: UserDefaults `appLockEnabled`, `appLockBiometricUnlock`. + +Passcode policy проверяет только длину минимум 4 Swift Character. Цифровой PIN, обязательная сложность, hash-based database encryption, счётчик попыток и задержка brute force не являются контрактом текущей policy. Stored passcode сравнивается при verify; он не преобразуется в ключ шифрования истории. + +На iOS Keychain accessibility — WhenUnlockedThisDeviceOnly. Включение/смена требуют сохранения валидного passcode. Выключение, смена и изменение biometric preference требуют проверки текущего passcode. + +Биометрия определяется через LocalAuthentication, `.deviceOwnerAuthenticationWithBiometrics`, Face ID/Touch ID. Отказ/ошибка возвращают false и оставляют lock. Passcode остаётся альтернативой. + +Lock включается при запуске с сохранённой настройкой и после background. На inactive не включается, чтобы системный picker не прерывался. Блокировка закрывает main UI, viewer и call view, но не останавливает автоматически сеть, не стирает память, не шифрует store и не задаёт отдельную модель блокировки watchOS. + +## 24. Apple Watch + +### 24.1. Роль и интерфейс + +LumaWatchApp создаёт WatchSessionModel и показывает последние чаты. Watch не подключается к XMPP напрямую. Интерфейс содержит список, непрочитанные, историю выбранного чата, текстовый ввод с возможностями системной диктовки и запись голосового. + +Доступность iPhone отражается `phoneReachable`. Cached snapshot позволяет читать прежние данные без немедленного соединения. + +### 24.2. Snapshot + +PhoneWatchBridge выбирает первые 20 чатов из переданного отсортированного списка и максимум 20 последних сообщений каждого. Сообщения сортируются по timestamp и id, а не по порядку добавления после MAM. + +Chat: jid, name, unread, messages. Message: id, body/previewText, timestamp, outgoing, encrypted. Медиа передаются как текст предпросмотра, не как полный inline media archive. + +JSON date strategy — millisecondsSince1970. Snapshot передаётся application context и сохраняется Watch в UserDefaults `luma.watch.snapshot.v1`. Одинаковый сериализованный snapshot повторно не публикуется. Синхронизация Phone обычно debounced на 250 мс. + +Ограничение multiaccount: snapshot и reply payload адресуют чат по JID, отдельного account identity в контракте snapshot нет; следует проверять переключение аккаунтов на телефоне перед ответом на старый watch snapshot. + +### 24.3. Текстовый ответ + +Payload: action reply, jid, text. Trimmed empty text не отправляется. При isReachable используется sendMessage; иначе transferUserInfo. Ошибка немедленной передачи отображается; этот текстовый контракт не включает тот же отдельный финальный XMPP-result handshake, что voice. + +На iPhone callback создаёт разговор при необходимости и запускает обычный sendText. Encryption зависит от текущего аккаунта/чата телефона. + +### 24.4. Запись голоса + +WatchVoiceRecorder: + +- разрешение микрофона; +- AAC `.m4a`, mono, 44.1 kHz; +- bitrate 32 kbit/s, high quality; +- максимум 60 секунд; +- минимум 0.4 секунды; +- timer/progress; +- отмена; +- предпрослушивание перед отправкой. + +Перед отправкой проверяются activated WCSession, установленное companion app, finite duration и наличие файла. + +### 24.5. Передача и подтверждение + +Используется `transferFile`, а не перенос Data в application context. Metadata: action voice, transferID UUID, jid, filename, duration. Stable XMPP message id — `watch-`. + +Watch сохраняет OutgoingVoiceTransfer metadata в `luma.watch.voice-transfers.v1`. После перезапуска записи не старше 7 суток восстанавливаются как ожидающие подтверждения. + +iPhone должен скопировать входной URL внутри WCSession callback: файл принадлежит WatchConnectivity и не гарантирован после выхода из callback. Запись и metadata сохраняются в очередь телефона; по возможности применена iOS data protection. Повторный transferID не должен создавать повторную отправку в одном процессе. + +iPhone ждёт bootstrap, проверяет аккаунт, при необходимости reconnect, отправляет через обычный XEP-0363/OMEMO media pipeline, возвращает action voiceResult, transferID, success и optional error. Result отправляется немедленно либо очередью. + +Состояния UI: «Голосовое в очереди на iPhone…», «iPhone получил запись. Отправляем…», «Голосовое отправлено», «Голосовое не отправлено», «Не удалось передать запись», «Ожидает подтверждения iPhone…». + +Успешная файловая передача на телефон не равна успешному XMPP upload. Watch удаляет исходный файл после transport completion, оставляя metadata до результата; при финальном результате metadata убирается. Это не гарантия вечного повторения голосового после любой ошибки и не серверная очередь Luma. + +## 25. Постоянное хранение и кэши + +### 25.1. Карта хранилищ + +| Данные | Место / технология | Особенности | +| --- | --- | --- | +| Конфигурации аккаунтов | UserDefaults JSON | active и список отдельно | +| XMPP passwords | Keychain `app.luma.chat.xmpp` | account = lowercase JID | +| App lock passcode | Keychain `app.luma.chat.applock` | отдельный service/account | +| Global encryption | UserDefaults dictionary по JID | default true | +| Chat state setting | UserDefaults dictionary по JID | default true | +| История | Application Support `Luma/Accounts/.store` | SwiftData, по аккаунту | +| Legacy history | `Luma/Accounts/.json` | миграция | +| OMEMO identity/session | Application Support `Luma/OMEMO/.json` | по аккаунту, atomic write | +| Avatars | AvatarCache | локальный файловый кэш | +| Media drafts/preview/recordings | tmp directories | могут быть удалены ОС | +| Watch snapshot | UserDefaults часов | содержит отображаемые тексты | +| Watch voice queue | metadata + файлы | отдельный lifecycle transfer/result | + +Хеш имён файлов — стабильный 64-bit FNV-подобный lowercased JID hash; это не криптографическое скрытие аккаунта и не encryption. + +### 25.2. SwiftData + +Schema: Conversation, ChatMessage, ArchiveMetadata. Отдельный ModelContainer на аккаунт; autosaveEnabled false. AppModel планирует сохранение через 300 мс после последнего изменения. + +`save` сохраняет metadata и pending model mutations. Часть вызовов использует `try?`, поэтому код не гарантирует, что любая disk error будет показана alert пользователю. In-memory успешное отображение не следует приравнивать к подтверждённой durable записи. + +На iOS для основного store применяется completeUntilFirstUserAuthentication. Это защита файла средствами ОС, не отдельный encryption key Luma; код применения к основному `.store` не является заявлением отдельной настройки каждого SQLite sidecar. + +При ошибке создания store предусмотрены fallback/in-memory пути в модели; их нельзя считать долговременной сохранностью данных после закрытия процесса. + +### 25.3. Миграция JSON + +Если прежний JSON существует, LegacyArchiveImporter восстанавливает Conversation, messages и metadata с backward-compatible defaults. Новые модели вставляются в context, затем вызывается save. + +Legacy JSON удаляется только после успешного context.save. Если сохранение не удалось, файл остаётся durable исходной копией, а imported pending rows могут использоваться в текущей сессии. Это одноразовая миграция формата, не общий экспорт/импорт произвольной базы. + +### 25.4. OMEMO state + +JSON содержит registrationID, identityKeyPair, identities/trust, preKeys, signedPreKeys, sessions, senderKeys и optional omemo2DeviceIDs для backward compatibility. + +Изменения защищены lock, persistence queue utility, debounce 350 мс. Revision предотвращает запись устаревшего snapshot как последнего. Flush синхронно доводит persistence до текущей revision. Atomic file write не означает физический secure erase старых блоков. + +На iOS OMEMO JSON получает completeUntilFirstUserAuthentication. На macOS дополнительного SQLCipher/зашифрованного контейнера Luma нет. + +### 25.5. UserDefaults keys + +- `luma.active-account.v1`; +- `luma.accounts.v1`; +- `luma.encryption-settings.v1`; +- `luma.chat-state-settings.v1`; +- `appLockEnabled`; +- `appLockBiometricUnlock`; +- `luma.watch.snapshot.v1`; +- `luma.watch.voice-transfers.v1`. + +Наличие suffix v1 — имя ключа, не отдельный официальный versioned API контракт для внешних приложений. + +## 26. Информация о сервере и диагностика + +ServerInfoView показывает snapshot: + +- домен; +- software name/version/OS через XEP-0092; +- disco identities и features server/account; +- conference services; +- external STUN/TURN services без необходимости показывать secrets; +- last login, established Stream Management session; +- sent/acknowledged/received и неотрицательный unacknowledged; +- SASL и объявленные SASL2 mechanisms; +- channel binding types, фактически использованный type; +- обнаружение downgrade protection; +- TLS version/cipher; +- признаки SM, carbons, CSI, upload, roster versioning/preapproval. + +Capability list включает PEP, blocking, SM, ping, extdisco, roster versioning, carbons, MAM, CSI, push, upload, pre-auth roster subscription и XEP-0474. Цвета success/normal/warning/error относятся к диагностике. + +Список возможностей сервера не равен списку пользовательских функций клиента. В частности, server blocking feature не доказывает UI блокировки контакта, push feature — настроенный APNs, SASL2 advertisement — использование полноценного SASL2 flow. + +Есть явное обновление server information и состояния загрузки/ошибки. Часть сетевых данных может отсутствовать из-за недоступности IQ, прав или поддержки сервера. + +Логи отдельных TLS/MAM/call/media путей существуют. Они могут содержать домены, JID, ids или имена файлов; наличие защищённого body не является заявлением полного отсутствия метаданных в debug/system logs. + +## 27. Серверные требования + +Это требования совместимости из реализации и локального `Docs/PROSODY.md`, а не проверенная в этом сеансе конфигурация актуального внешнего сервера. + +### 27.1. Базовая инфраструктура + +Нужны существующий XMPP account, TLS certificate для service domain, доступный STARTTLS/direct TLS, DNS SRV либо ручная конфигурация. Для полного набора функций: roster, presence, disco, PEP, smacks, carbons, mam, HTTP File Upload, MUC/MUC-MAM и extdisco. + +Функции деградируют независимо: отсутствие upload блокирует вложения, отсутствие MAM — серверное восстановление, отсутствие TURN может блокировать звонки за NAT, отсутствие PEP/device bundles — encrypted send. + +### 27.2. Prosody + +Документация предлагает модули roster, pep, smacks, carbons, mam, turn_external; archive storage SQL. Пример retention 1 месяц и max archive results 100 — серверный пример, не клиентский предел pageSize. + +Upload component `http_file_share` должен выдавать HTTPS URL, быть найден через disco и иметь согласованные reverse proxy host/path/лимиты. Пример max file size 1 GiB задаётся сервером и не означает обязательную поддержку такого файла любым устройством. + +MUC component должен быть discoverable, разрешать необходимые room operations и желательно иметь MUC-MAM. Для OMEMO нужны nonanonymous room и читаемые affiliation lists. + +### 27.3. TURN + +`turn_external` выдаёт временные credentials coturn; серверный shared secret совпадает с coturn static-auth-secret. Обычные порты в примере: UDP/TCP 3478, TLS 5349 и отдельно relay UDP range. Доступность только listening port недостаточна. + +Временные credentials и endpoint должны соответствовать валидаторам Luma. Резервный Google STUN не является бесплатным TURN relay. + +### 27.4. Push как отсутствующая подсистема + +Для production APNs нужны entitlement/provisioning, device token, gateway с credentials именно bundle ID Luma и XEP-0357 enable/disable. Чужой production Monal endpoint нельзя считать универсальной инфраструктурой приложения. + +Развёртывание gateway и хранение APNs secrets не выполнены в этом репозитории. Пример конфигурации `monal-im/fpush` в Docs — ориентир будущей интеграции, не действующий backend Luma. + +## 28. Безопасность и модель угроз + +### 28.1. Реализованные меры + +- системная проверка certificate chain/hostname; +- TLS transport и SCRAM preference; +- channel binding при совместной поддержке; +- проверка SCRAM server signature и downgrade data; +- Keychain для passwords; +- OMEMO без автоматического plaintext fallback при encryption failure; +- AES-GCM blob до upload; +- HTTPS-only первоначальные upload/download URL; +- author/conversation проверки corrections/retractions; +- source/queryID проверки MAM; +- ограниченные pending queues и archive buffers; +- data protection ряда файлов на iOS; +- UI lock/biometrics; +- явное действие для location/export. + +### 28.2. Не гарантируется + +- шифрование локальной истории отдельным ключом пользователя; +- сокрытие всех metadata от XMPP/upload/STUN/TURN; +- OMEMO для реакций, typing, read-sync и call-sync; +- защита от скомпрометированного endpoint, jailbreak/root, записи экрана; +- secure erase SQLite/flash/backup; +- отзыв уже экспортированного файла; +- стирание копии peer при retraction; +- непрерывная доставка закрытому iOS-процессу; +- синхронизация локально удалённых id на все устройства; +- самостоятельная блокировка watch snapshot тем же app passcode; +- доказанная универсальная interoperability OMEMO 2. + +Тексты уже расшифрованной истории и preview media находятся локально в читаемом приложением виде. На Mac защита дополнительно зависит от системной учётной записи, sandbox и FileVault. App lock скрывает интерфейс, но не заменяет эту модель. + +## 29. Ошибки, ограничения и поведение при сбоях + +| Ситуация | Наблюдаемый результат / контракт | +| --- | --- | +| Неверный JID | validation error с примером user@example.org | +| Пустой пароль | sign-in отклонён | +| Невалидный числовой порт | ошибка диапазона 1…65535 | +| Нет Keychain password | запрос повторного входа или отказ переключения | +| TLS/cert failure | connect failure, автоматическое ослабление доверия отсутствует | +| SASL rejection | локализованное сообщение по исходному condition | +| Нет соединения при text send | optimistic message может стать failed | +| Нет соединения при media | попытка reconnect, затем обычная обработка ошибки | +| OMEMO не ready/нет devices | encrypted send отклонён | +| MUC не joined | отправка group stanza отклонена | +| MUC скрывает JID/списки | encrypted group send отклонён | +| Нет upload для размера | ошибка с размером файла | +| HTTP slot не HTTPS | upload отклонён | +| PUT 408/429/5xx | ограниченный retry | +| PUT 4xx другого типа | ошибка без общего автоматического retry | +| Частично отправлен пакет | successful удаляются из drafts, failed остаются | +| Ошибка caption | вложения не откатываются | +| Не читается media preview | loading заканчивается/ошибка; нет гарантии codec support | +| Слишком короткая запись | файл удаляется, пользовательская ошибка | +| Video interruption | пригодный partial не отправляется автоматически без допустимого завершения | +| MAM repeating cursor | stop/recovery, без бесконечной пагинации | +| MAM overflow/source mismatch | rejected page, checkpoint не признаётся успешным | +| Истёк server cursor | однократный timestamp fallback | +| Нет TURN | fallback STUN/host, звонок может не установиться | +| Второй звонок | отдельная ошибка callAlreadyActive | +| iPhone не доступен Watch | очередь WC при применимом типе операции | +| Нет аккаунта iPhone для voice | отрицательный voiceResult | +| Disk save failure | часть путей best-effort/try?, durable гарантия отсутствует | + +Ошибки отображаются по-русски. Динамические низкоуровневые descriptions могут приходить от ОС/библиотек на другом языке. Приложение не имеет единой durable централизованной очереди всех операций, promise exactly-once delivery или серверных транзакций для массовых действий. + +## 30. Визуальные и нефункциональные требования + +### 30.1. Лента + +Лента сортируется по timestamp, затем clientID, разделяется по датам. Должна сохранять стабильную идентичность bubble для replies/selection/animations и корректно обрабатывать вставку старой истории. + +Near-bottom threshold 96 pt. Если пользователь близок к низу, новые сообщения могут сопровождаться переходом к последнему; если читает старое, update не должен принудительно сбрасывать позицию. Имеется кнопка перехода к последнему. + +Анимация открытия: до 12 последних rows, stagger 35 мс, rise 16 pt, scale 0.94, lifetime 0.75 с. Новые outgoing используют sendScale 0.2 / rise 12; incoming receiveScale 0.4 / rise 10. Burst больше 4 appended rows считается историей и не анимируется как поток отдельных свежих сообщений. + +Обычные text/file bubbles — скруглённые островки без хвостов. Фото/видео должны оставаться вписанными в рамки и не слипаться. Точные локальные padding/colors/fonts определяются компонентами, а не одним универсальным token-файлом. + +### 30.2. Доступность + +Действия имеют русские labels, включая переключение видимости пароля, отмену ответа, вложения, звонки, навигацию. Для тестирования существуют accessibility identifiers `chat-timeline`, `bubble-`, `reply-banner`, `info-toast`. + +Наличие отдельных accessibility modifiers не подтверждает независимый аудит VoiceOver, всех Dynamic Type размеров, контрастности и управления клавиатурой. Такие проверки требуют запуска на Apple-платформах. + +### 30.3. Производительность и ресурсы + +Цели реализации: не блокировать UI большим MAM burst, не выполнять Signal decrypt на main actor, ограничить archive buffers и pending operations, повторно использовать media preview и не держать вечный spinner после сетевого сбоя. + +Числовых SLA для FPS, времени запуска, RAM, диска, батареи или максимального размера локального архива в репозитории нет. Работа с Data и загрузка массива сообщений могут увеличивать расход памяти; page/window constants не доказывают streaming всей базы. + +## 31. Сборка, проверки и CI + +### 31.1. Команды + +```sh +make project +make open +make verify +``` + +`make project`: проверка xcodegen, генерация проекта и `Scripts/patch-xcodeproj.py`. `make open`: регенерация, затем открытие Xcode. Двойной клик `Regenerate-Luma-Project.command` запускает подготовленный shell workflow. + +`make clean` удаляет generated project, DerivedData и `.build`. `make reset` удаляет Xcode package cache данного проекта и Package.resolved; по комментарию перед этим нужно закрыть Xcode. Это команды очистки для разработчика, не автоматически выполняемые пользовательские сценарии Luma. + +Для тестов на Mac: + +```sh +xcodebuild test -project Luma.xcodeproj -scheme Luma \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max' +``` + +Фактическое имя доступного simulator должно существовать в установленном Xcode. + +### 31.2. Структурный guard + +`Scripts/verify.sh` проверяет наличие файлов, policy constants, symbols/путей, архитектурных паттернов, resource catalogs и plist keys. При наличии xcodegen регенерирует проект; при наличии xcodebuild и project выполняет unsigned simulator build. + +Grep-check не доказывает семантику функции, устойчивость network flow, корректность криптографии или успешность UI-тестов. При изменении policy constant правила репозитория требуют синхронно обновить tests и guard. + +В этом сеансе `make verify` завершился кодом 0 с сообщением `Static project checks passed. Run on macOS with Xcode for a full build.` Apple build/XCTest не запускались. + +### 31.3. Фактический CI + +`.github/workflows/ios.yml`: + +- triggers push main/develop/issue-*; +- pull_request main/develop; +- runner macos-26; +- выбирает `/Applications/Xcode_26.6.app/Contents/Developer`; +- `xcodebuild build` scheme Luma, Release, generic/platform=iOS; +- подпись отключена; +- собирает Payload/Luma.app в `Luma.ipa`; +- публикует artifact `Luma`. + +Текущий workflow не вызывает `xcodebuild test`, `make verify` или `make project`. Поэтому формулировка AGENTS.md о запуске тестов CI не соответствует этому файлу. Availability указанного Xcode на runner в текущем сеансе не проверялась. + +### 31.4. Unit/UI tests + +XCTest использует `@testable import Luma`; тесты pure policy, parsers, crypto helpers, SwiftData migration и взаимодействий модели. Полный индекс методов приведён ниже. + +UI test mode `-luma-ui-test-chat` создаёт in-memory seed conversation без XMPP сервера. Timeline tests проверяют вертикальную прокрутку и swipe reply; layout tests — верхнее расположение поиска и скрытие tab bar в чате. Seed не заменяет реальную авторизацию/сеть/камера/OMEMO interoperability. + +## 32. Приёмочные сценарии + +Следующая матрица — предлагаемый план проверки сохранения описанного поведения. Это не отчёт о выполнении этих сценариев в этом сеансе. + +| ID | Сценарий | Критерий | +| --- | --- | --- | +| ACC-01 | Первый запуск | LoginView, пустые поля, дополнительные настройки свёрнуты | +| ACC-02 | Валидный login | соединение, история, сохранение конфигурации/Keychain | +| ACC-03 | Неверный password | ошибка, повторный ввод без ложного success | +| ACC-04 | Перезапуск с аккаунтом | bootstrap локальной истории и reconnect | +| ACC-05 | Два аккаунта | переключение баз/паролей, единственное активное соединение | +| ACC-06 | Logout с/без history | выбранная политика локальных записей, следующий аккаунт | +| TLS-01 | Невалидный сертификат | отказ подключения | +| AUTH-01 | Набор SCRAM/PLUS сервера | наиболее предпочтительный допустимый механизм | +| AUTH-02 | Некорректный challenge/signature | отказ аутентификации | +| CHAT-01 | Отправить текст | sending → sent, receipt → delivered, displayed → read | +| CHAT-02 | MAM/Carbons повтор | одна запись, delivery не понижается | +| CHAT-03 | Self-chat | успешное исходящее read | +| CHAT-04 | Поиск | имя/JID списка, без ложного обещания body search | +| EDIT-01 | Исправить direct text | обновлена одна запись, editedAt, original id сохраняется | +| EDIT-02 | Correction раньше original | применение pending после оригинала | +| REPLY-01 | Direct XML reply и fallback | preview, target, ветка без лишней цитаты в body | +| REPLY-02 | Legacy quoted text | preview без выдуманного target | +| REPLY-03 | MUC до/после stanzaID | действие недоступно/доступно соответственно | +| SELECT-01 | Переслать несколько | порядок timestamp/id, новая политика encryption | +| DELETE-01 | Локально удалить, повторить MAM | удалённое не появляется снова | +| DELETE-02 | Отозвать direct outgoing | tombstone, очистка файловых/reply/reaction полей | +| REACT-01 | Добавить/снять реакцию | полный новый набор, count/own indicator | +| TYPE-01 | Начать/закончить набор | composing, paused через 5 с, remote expiry 8 с | +| MUC-01 | Создать/войти/пригласить | group state, nickname, occupants и история | +| MUC-02 | Нет полного набора recipients | encrypted send отказ, без plaintext fallback | +| MUC-03 | Выйти и удалить | разная семантика сохранения local history | +| ENC-01 | Global/per-chat override | правильная итоговая политика для следующей отправки | +| ENC-02 | Peer OMEMO 2 / legacy | выбор поддержанного encrypted wire format | +| ENC-03 | Identity изменился | undecided, ручная verification | +| FILE-01 | Photo/video/file/plaintext | upload, ссылка/OOB и inline/QuickLook | +| FILE-02 | Encrypted media | blob encrypted, key fragment в защищённом body | +| FILE-03 | HTTP URL slot | отказ до небезопасной загрузки | +| FILE-04 | Пакет с одной ошибкой | failedDrafts сохранены, успехи не повторяются | +| FILE-05 | Retryable HTTP | максимум две попытки, корректное снятие activity | +| MEDIA-01 | Photo zoom/pan и dismiss | zoom не конфликтует с закрытием, вертикальный dismiss | +| MEDIA-02 | Voice/music | play/pause/seek, одна audio playback, speeds для voice | +| MEDIA-03 | Экспорт/отмена | правильное место, toast только при успехе | +| REC-01 | Voice 0.2 с и нормальная запись | отказ короткой, валидная отправка | +| REC-02 | Отпустить до старта камеры | нет скрытой продолжающейся capture | +| REC-03 | VideoNote 60 с/rotation/interruption | допустимый finish, правильная ориентация, partial не autosend | +| GEO-01 | Denied location | ручной выбор доступен | +| GEO-02 | Geo URI invalid/valid | диапазоны и корректная serialization | +| MAM-01 | Fresh install | последние 40, затем backward history | +| MAM-02 | Repeating/missing cursor | stop/recovery, checkpoint не теряется | +| MAM-03 | Страница только reactions | before cursor движется по RSM first | +| MAM-04 | Background/media work | pause/resume без вечного spinner | +| CALL-01 | Audio/video direct | выбранный full JID, согласованный SDP/ICE | +| CALL-02 | Неполный TURN config | отбрасывается, fallback не crash | +| CALL-03 | Decline/miss/timeout/connected loss | корректная карточка и один outcome | +| CALL-04 | Другое устройство | history/read sync без duplicate | +| NOTE-01 | Новое incoming в открытом чате | повторный banner подавлен | +| LOCK-01 | Launch/background/inactive | lock при первых двух, picker не прерван inactive | +| WATCH-01 | Snapshot после MAM overlap | последние 20 ×20, правильный хронологический порядок | +| WATCH-02 | Voice transfer и XMPP failure | различим transport/result, ошибка на часах | +| STORE-01 | Legacy migration и save failure | legacy JSON не удалён до успешной записи | + +## 33. Обнаруженные расхождения и вопросы дальнейшего развития + +Для составления этого документа блокирующих вопросов не возникло: объект анализа однозначно определён текущим репозиторием, а неизвестные свойства отмечены как ограничения. Следующие пункты требуют решения только если документ будет превращаться в **целевое ТЗ новой версии**: + +1. Нужны ли одновременные соединения всех аккаунтов и настоящий общий inbox? Сейчас соединение одно. +2. Требуется ли account identity в протоколе Watch, чтобы исключить ответы из старого snapshot после переключения аккаунта? +3. Нужно ли шифровать реакции и custom metadata? Сейчас они имеют plaintext stanza paths. +4. Должен ли logout с удалением данных удалять также OMEMO state, avatars, queues и exports? Сейчас archive erase уже имеет более узкий смысл. +5. Нужно ли добавить durable offline outbox и подтверждения текстовых reply с Watch? +6. Нужны ли SQLCipher, passcode attempt limits и скрытие notification previews при lock? +7. Какой поддерживаемый server/client interoperability matrix требуется официально подтвердить? +8. Должен ли CI запускать unit/UI tests и генерировать проект перед build? +9. Нужен ли полнотекстовый поиск истории, которого текущие search fields не предоставляют? +10. Нужны ли APNs gateway и VoIP/CallKit как отдельные production-подсистемы? + +Зафиксированные расхождения существующих текстов: + +- AGENTS.md описывает тесты CI; workflow сейчас делает build/package unsigned IPA. +- README упоминает Xcode 16+, тогда как deployment/API и текущий CI относятся к ОС/Xcode 26. +- THIRD_PARTY_NOTICES утверждает отсутствие vendored source, хотя ThirdParty/MartinOMEMO существует. +- OpenSSL имеет lower bound from 3.4.0; утверждение о точном закреплении всех direct dependencies неверно. +- «Следующие модули» в ARCHITECTURE перечисляет OMEMO 2 и реакции, уже имеющиеся в исходниках. +- Старые ограничения SECURITY про исключительно legacy OMEMO не отражают собственного нового модуля. +- Правило о русских UI-строках имеет исключение: MessageBubble.deliveryText возвращает `Sending...`, `Sended`, `Delivered`, `Error`, `Read`; это фактические строки текущего компонента, включая опечатку `Sended`. +- Комментарий ArchiveSyncWorkBudget о двух страницах на активацию не отражает continuation после flush/reset budget. +- Формулировки о «полном удалении истории» не следует трактовать как криптографическое стирание всех локальных и внешних данных. + +Документ фиксирует эти факты без изменения исполняемого поведения, старых документов или CI. + + +## Приложение A. Реестр исходников собственной реализации + +Реестр построен по файлам текущего checkout, учитываемым rg; игнорируемые служебные каталоги исключены. Типы извлечены из объявлений. Строки относятся к исходникам на дату анализа. + +| Файл | Типы / ориентир | +| --- | --- | +| `Sources/App/LumaApp.swift` | `LumaApp` (L5) | +| `Sources/Shared/Models/AccountConfiguration.swift` | `AccountConfiguration` (L3); `AccountValidationError` (L73) | +| `Sources/Shared/Models/AppLockPolicy.swift` | `AppLockPolicy` (L5) | +| `Sources/Shared/Models/AppModel.swift` | `RuntimeEnvironment` (L9); `AppModel` (L27); `PendingReadMarker` (L118); `SignInError` (L3710); `AttachmentError` (L3718); `ReconnectError` (L3729) | +| `Sources/Shared/Models/ArchiveMessageBatchPolicy.swift` | `ArchiveMessageBatchPolicy` (L6) | +| `Sources/Shared/Models/ArchiveSyncCheckpoint.swift` | `MAMArchiveKey` (L5); `Kind` (L6); `MAMArchiveCheckpoint` (L28); `ArchiveSyncCheckpoint` (L41); `ArchiveSyncRequestPosition` (L62); `ArchiveSyncCursorPolicy` (L69) | +| `Sources/Shared/Models/ArchiveSyncPagination.swift` | `ArchiveSyncPageDecision` (L3); `ArchiveSyncPagination` (L11) | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | `ArchiveSyncRecoveryPolicy` (L5) | +| `Sources/Shared/Models/ArchiveSyncWorkBudget.swift` | `ArchiveSyncWorkBudget` (L5) | +| `Sources/Shared/Models/AttachmentDraft.swift` | `AttachmentDraft` (L3); `AttachmentBatchResult` (L37) | +| `Sources/Shared/Models/CallSnapshot.swift` | `CallMedia` (L3); `CallDirection` (L8); `CallPhase` (L13); `CallHistoryOutcome` (L20); `CallHistoryMetadata` (L30); `CallTerminationCause` (L36); `CallHistoryEntry` (L48); `CallHistoryPolicy` (L61); `CallSnapshot` (L94) | +| `Sources/Shared/Models/ChatMessage.swift` | `ChatMessage` (L5); `Direction` (L6); `Delivery` (L11); `Security` (L40); `Kind` (L46) | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | `ChatMessageEntrancePolicy` (L10) | +| `Sources/Shared/Models/ChatScrollPositionPolicy.swift` | `ChatScrollPositionPolicy` (L3) | +| `Sources/Shared/Models/ChatTimelineEntry.swift` | `ChatTimelineEntry` (L6) | +| `Sources/Shared/Models/ChatTypingPolicy.swift` | `ChatTypingState` (L3); `ChatTypingPolicy` (L11) | +| `Sources/Shared/Models/Conversation.swift` | `Conversation` (L5); `Kind` (L6); `AccountConversation` (L83) | +| `Sources/Shared/Models/DeviceResource.swift` | `DeviceResource` (L9) | +| `Sources/Shared/Models/EmojiCatalog.swift` | `EmojiCategory` (L4); `EmojiCatalog` (L14) | +| `Sources/Shared/Models/EncryptionPreference.swift` | `EncryptionPreference` (L3) | +| `Sources/Shared/Models/GeoLocation.swift` | `GeoLocation` (L3) | +| `Sources/Shared/Models/MediaMetadata.swift` | `MediaMetadata` (L4); `Inferred` (L5) | +| `Sources/Shared/Models/MediaPickerSelectionPolicy.swift` | `MediaPickerRepresentation` (L1); `MediaPickerSelectionPolicy` (L6) | +| `Sources/Shared/Models/MediaSendActivityTracker.swift` | `MediaSendActivityTracker` (L5) | +| `Sources/Shared/Models/MediaViewerItem.swift` | `MediaViewerItem` (L3) | +| `Sources/Shared/Models/MessageReaction.swift` | `MessageReaction` (L3); `MessageReactionSummary` (L21); `MessageReactionPolicy` (L29) | +| `Sources/Shared/Models/MessageReplyFallback.swift` | `MessageReplyFallback` (L5); `Parsed` (L6) | +| `Sources/Shared/Models/MessageReplySwipePolicy.swift` | `MessageReplySwipePolicy` (L3) | +| `Sources/Shared/Models/NotificationPolicy.swift` | `NotificationPolicy` (L3) | +| `Sources/Shared/Models/OMEMODevice.swift` | `OMEMODevice` (L3); `Trust` (L4) | +| `Sources/Shared/Models/OlderHistoryScrollPolicy.swift` | `OlderHistoryScrollPage` (L5); `OlderHistoryScrollPolicy` (L16) | +| `Sources/Shared/Models/SASLMechanismPreference.swift` | `SASLMechanismPreference` (L11) | +| `Sources/Shared/Models/ServerInformation.swift` | `ServerInformation` (L7); `CapabilityStatus` (L8); `Software` (L12); `Identity` (L18); `ConferenceServer` (L28); `ExternalService` (L37); `ConnectionStats` (L46); `Capability` (L58) | +| `Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift` | `VideoNoteRecordingCompletionPolicy` (L6) | +| `Sources/Shared/Models/VideoNoteRecordingLifecycle.swift` | `VideoNoteRecordingLifecycle` (L3) | +| `Sources/Shared/Models/VideoNoteRotationPolicy.swift` | `VideoNoteRotationPolicy` (L12); `InterfaceOrientation` (L13) | +| `Sources/Shared/Persistence/AccountPreferences.swift` | `AccountPreferences` (L3) | +| `Sources/Shared/Persistence/ArchiveMetadataRecord.swift` | `MAMCheckpointEntry` (L6); `ArchiveMetadata` (L14) | +| `Sources/Shared/Persistence/ArchiveStore.swift` | `ArchiveStore` (L8); `Loaded` (L9) | +| `Sources/Shared/Persistence/AvatarCache.swift` | `AvatarCache` (L3) | +| `Sources/Shared/Persistence/LegacyArchiveImporter.swift` | `LegacyArchiveImporter` (L8); `LegacyConversation` (L9); `Kind` (L10); `CodingKeys` (L61); `LegacyMessage` (L102); `Direction` (L103); `Delivery` (L104); `Security` (L105); `Kind` (L106); `CodingKeys` (L195); `Snapshot` (L260); `CodingKeys` (L291); `Imported` (L329) | +| `Sources/Shared/Security/AppLockVault.swift` | `AppLockVault` (L7); `AppLockVaultError` (L92) | +| `Sources/Shared/Security/CertificateTrustEvaluator.swift` | `CertificateTrustEvaluator` (L4) | +| `Sources/Shared/Security/CredentialVault.swift` | `CredentialVault` (L4); `CredentialVaultError` (L74) | +| `Sources/Shared/Services/AudioMessageRecorder.swift` | `AudioMessageRecorder` (L6); `Recording` (L7); `AudioRecorderError` (L129) | +| `Sources/Shared/Services/AvatarImageProcessor.swift` | `AvatarImageProcessor` (L5); `AvatarImageError` (L37) | +| `Sources/Shared/Services/ChatMediaImageCache.swift` | `ChatMediaImageCache` (L15) | +| `Sources/Shared/Services/LocationProvider.swift` | `LocationProvider` (L6) | +| `Sources/Shared/Services/MediaDownloadService.swift` | `MediaDownloadService` (L17); `SaveError` (L18); `FileExportPresenter` (L192) | +| `Sources/Shared/Services/MediaFileIO.swift` | `LoadedMediaFile` (L4); `StagedMediaFile` (L11); `MediaFileIOError` (L18); `MediaFileIO` (L37) | +| `Sources/Shared/Services/MediaPlaybackCoordinator.swift` | `MediaPlaybackCoordinator` (L10) | +| `Sources/Shared/Services/MediaPreviewProcessor.swift` | `MediaPreviewProcessor` (L7); `Analysis` (L8) | +| `Sources/Shared/Services/NotificationCoordinator.swift` | `NotificationCoordinator` (L4) | +| `Sources/Shared/Services/PhoneWatchBridge.swift` | `PhoneWatchSnapshot` (L3); `Chat` (L4); `Message` (L11); `PhoneWatchChatData` (L25); `PhoneWatchMessageData` (L31); `WatchVoiceMessage` (L40); `PhoneWatchBridge` (L55); `StoredWatchVoiceMessage` (L407); `PhoneWatchBridge` (L418); `WatchVoiceBridgeError` (L435) | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | `VideoNoteRecorder` (L17); `Recording` (L18); `CaptureGraph` (L69); `MovieOutputSnapshot` (L75); `VideoNoteFileInspector` (L1108); `Inspection` (L1109); `Finalization` (L1113); `Composition` (L1118); `VideoNoteStopPolicy` (L1488); `VideoNoteRecorderError` (L1527) | +| `Sources/Shared/UI/AppLockView.swift` | `AppLockView` (L5); `AppLockPasscodeSheet` (L119); `Mode` (L120); `VerificationAction` (L125) | +| `Sources/Shared/UI/AttachmentPreviewView.swift` | `AttachmentPreviewView` (L14); `AttachmentPreviewHost` (L302) | +| `Sources/Shared/UI/CallView.swift` | `CallView` (L6); `CallControlButton` (L217) | +| `Sources/Shared/UI/ChatView.swift` | `EmojiPickerPresentation` (L14); `ChatView` (L27); `TimelineBottomYPreferenceKey` (L2109); `ChatScrollMetricsObserver` (L2121); `ObserverView` (L2150); `ComposerRecordingGestureResolution` (L2265); `ComposerRecordingGesturePolicy` (L2271); `ComposerCaptureMode` (L2307); `TelegramRecordButton` (L2324); `ComposerRecordTouchSurface` (L2499); `TouchView` (L2522); `ReplyThreadSelection` (L2606); `MessageForwardSelection` (L2611); `FileImportMode` (L2616); `MediaPickerError` (L2635); `MessageDestructiveAction` (L2643); `Kind` (L2644); `PickedPhoto` (L2707); `PickedVideo` (L2724); `ChatNavigationTitle` (L2857) | +| `Sources/Shared/UI/Components/AudioMessagePlayer.swift` | `AudioMessagePlayer` (L5); `WaveformScrubber` (L246); `MediaProgressScrubber` (L282); `MediaTimeFormatter` (L319) | +| `Sources/Shared/UI/Components/AvatarView.swift` | `AvatarView` (L10) | +| `Sources/Shared/UI/Components/BubbleShapes.swift` | `ReplyThreadCurve` (L4); `ReplyThreadBracket` (L29) | +| `Sources/Shared/UI/Components/ChatBubbleShape.swift` | `ChatBubbleShape` (L3) | +| `Sources/Shared/UI/Components/ConnectionBanner.swift` | `ConnectionBanner` (L3) | +| `Sources/Shared/UI/Components/ConversationRow.swift` | `ConversationRow` (L4) | +| `Sources/Shared/UI/Components/EmojiPickerView.swift` | `EmojiPickerView` (L6) | +| `Sources/Shared/UI/Components/InlineVideoPlayer.swift` | `InlineVideoPlayer` (L7); `PlayerView` (L20); `InlineVideoPlayer` (L40); `PlayerView` (L53) | +| `Sources/Shared/UI/Components/LocationMessagePreview.swift` | `LocationMessagePreview` (L4) | +| `Sources/Shared/UI/Components/MediaViewer.swift` | `MediaViewerDismissGesturePolicy` (L13); `MediaViewer` (L40); `FullWindowVideoPlayer` (L252); `ZoomablePhotoView` (L276) | +| `Sources/Shared/UI/Components/MessageBubble.swift` | `MessageBubble` (L10) | +| `Sources/Shared/UI/Components/MessageBubbleEntrance.swift` | `MessageBubbleEntrance` (L10); `Mode` (L11) | +| `Sources/Shared/UI/Components/PhotoAttachmentPreview.swift` | `PhotoAttachmentPreview` (L10) | +| `Sources/Shared/UI/Components/RTCVideoRendererView.swift` | `RTCVideoRendererView` (L7); `Coordinator` (L36); `RTCVideoRendererView` (L56); `Coordinator` (L83) | +| `Sources/Shared/UI/Components/ReplyThreadOverlay.swift` | `ReplyThreadOverlay` (L12); `ReplyThreadMessageRow` (L168) | +| `Sources/Shared/UI/Components/SwiftChatView.swift` | Extension / платформенный адаптер; см. файл | +| `Sources/Shared/UI/Components/VideoAttachmentPreview.swift` | `VideoAttachmentPreview` (L10) | +| `Sources/Shared/UI/Components/VideoNotePreview.swift` | `VideoNotePreview` (L11) | +| `Sources/Shared/UI/EncryptionDevicesView.swift` | `EncryptionDevicesView` (L3); `DeviceRow` (L89) | +| `Sources/Shared/UI/ForwardMessageView.swift` | `ForwardMessageView` (L5) | +| `Sources/Shared/UI/GroupInfoView.swift` | `GroupInfoView` (L5) | +| `Sources/Shared/UI/LocationPickerView.swift` | `LocationPickerView` (L8) | +| `Sources/Shared/UI/LoginView.swift` | `LoginView` (L3) | +| `Sources/Shared/UI/MainTabView.swift` | `MainTabView` (L10); `SearchField` (L27); `MainTabBarView` (L65); `Tab` (L66); `ChatsTab` (L143); `ContactsTab` (L334); `CallsTab` (L471); `CallHistoryRow` (L549); `ContactRow` (L588); `MainSplitView` (L645); `Mode` (L646) | +| `Sources/Shared/UI/NewChatView.swift` | `NewChatView` (L3) | +| `Sources/Shared/UI/NewGroupView.swift` | `NewGroupView` (L5) | +| `Sources/Shared/UI/PreviewSupport.swift` | `PreviewSupport` (L13) | +| `Sources/Shared/UI/RootView.swift` | `RootView` (L5) | +| `Sources/Shared/UI/ServerInfoView.swift` | `ServerInfoView` (L6) | +| `Sources/Shared/UI/SettingsView.swift` | `SettingsView` (L5) | +| `Sources/Shared/UI/SystemPhotoCameraView.swift` | `CapturedCameraMedia` (L4); `SystemPhotoCameraView` (L15); `Coordinator` (L56); `SystemCameraError` (L164) | +| `Sources/Shared/UI/VideoNoteCaptureView.swift` | `VideoNoteCaptureView` (L11); `CameraPreview` (L170); `PreviewView` (L184); `CameraPreview` (L193); `PreviewView` (L207) | +| `Sources/Shared/XMPP/CallHistorySync.swift` | `CallHistorySync` (L10); `Envelope` (L13) | +| `Sources/Shared/XMPP/CryptoKitAESGCMEngine.swift` | `CryptoKitAESGCMEngine` (L5) | +| `Sources/Shared/XMPP/LumaCallEngine.swift` | `LumaCallEngine` (L15); `LumaJingleSession` (L773); `Action` (L774); `LumaWebRTCCall` (L892); `CallSDPOrdering` (L1614); `CallSDPNormalization` (L1633); `CallSDPCompatibility` (L1650); `SourceGroup` (L1651); `CallCandidateDispatchGate` (L1773); `LumaCallError` (L1787) | +| `Sources/Shared/XMPP/LumaConnectionStatsModule.swift` | `LumaConnectionStatsModule` (L8); `Snapshot` (L20) | +| `Sources/Shared/XMPP/LumaOMEMO2Module.swift` | `LumaOMEMO2Module` (L25); `PayloadKeys` (L740); `OMEMO2Bundle` (L835) | +| `Sources/Shared/XMPP/LumaOMEMOStore.swift` | `LumaOMEMOStore` (L4); `LumaSessionStore` (L180); `LumaPreKeyStore` (L228); `LumaSignedPreKeyStore` (L277); `LumaIdentityKeyStore` (L307); `LumaSenderKeyStore` (L405); `OMEMOStateRepository` (L428); `StoredOMEMOState` (L587); `StoredOMEMOIdentity` (L611); `StoredAddress` (L620) | +| `Sources/Shared/XMPP/LumaRoomStore.swift` | `LumaRoomStore` (L4) | +| `Sources/Shared/XMPP/LumaRosterStore.swift` | `LumaRosterStore` (L4) | +| `Sources/Shared/XMPP/LumaSaslChallengeModule.swift` | `LumaSaslChallengeModule` (L9) | +| `Sources/Shared/XMPP/LumaSaslFailureModule.swift` | `LumaSaslFailureModule` (L9); `Failure` (L13) | +| `Sources/Shared/XMPP/LumaScramPlusMechanism.swift` | `SCRAMHash` (L8); `SCRAMDowngradeProtection` (L104); `SCRAMPlusExchange` (L134); `LumaScramPlusMechanism` (L242) | +| `Sources/Shared/XMPP/LumaScramSha512Mechanism.swift` | `SCRAMSHA512` (L9); `ServerFirst` (L10); `LumaScramSha512Mechanism` (L127) | +| `Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift` | `LumaTLSState` (L10); `LumaChannelBindingStore` (L24); `LumaTLSNetworkProcessorProvider` (L147); `LumaTLSNetworkProcessor` (L165); `LumaTLSProcessorError` (L490) | +| `Sources/Shared/XMPP/ReadStateSync.swift` | `ReadStateSync` (L17); `Envelope` (L20) | +| `Sources/Shared/XMPP/SASLprep.swift` | `SASLprep` (L17); `PreparationError` (L18) | +| `Sources/Shared/XMPP/SaslFailureMessage.swift` | `SaslFailureMessage` (L8) | +| `Sources/Shared/XMPP/StaticDNSSrvResolver.swift` | `StaticDNSSrvResolver` (L4) | +| `Sources/Shared/XMPP/TLSVersionProbe.swift` | `TLSProbeResult` (L6); `TLSVersionProbe` (L18); `Phase` (L25) | +| `Sources/Shared/XMPP/XMPPService.swift` | `BufferedArchiveStanza` (L8); `BufferedArchivePage` (L15); `BufferedLiveDelivery` (L21); `ArchiveStanzaInbox` (L30); `State` (L31); `XMPPService` (L144); `ConnectionStatus` (L145); `MessageEnvelope` (L151); `ReplyReference` (L177); `RoomStateEnvelope` (L184); `RoomInvitationEnvelope` (L191); `RetractionEnvelope` (L198); `ReactionEnvelope` (L207); `ChatStateEnvelope` (L217); `MediaSendResult` (L225); `ArchiveMutation` (L230); `Event` (L236); `LumaXMPPError` (L4468) | +| `Sources/Watch/LumaWatchApp.swift` | `LumaWatchApp` (L5) | +| `Sources/Watch/WatchSessionModel.swift` | `WatchSnapshot` (L4); `Chat` (L5); `Message` (L13); `WatchSessionModel` (L25); `OutgoingVoiceTransfer` (L193) | +| `Sources/Watch/WatchViews.swift` | `WatchChatListView` (L4); `WatchChatView` (L55) | +| `Sources/Watch/WatchVoiceRecorder.swift` | `WatchVoiceRecorder` (L6); `Recording` (L7); `WatchVoiceRecorderError` (L184) | + +## Приложение B. Полный индекс тестовых сценариев + +Имена методов и строки позволяют однозначно найти assertion. Индекс подтверждает наличие сценария, но не успешность его выполнения: XCTest в этом сеансе не запускался. Тесты стороннего пакета не смешиваются с тестами приложения. + +### B.1. `Tests/AccountConfigurationTests.swift` + +- `testValidAccountIsNormalized` — L5. +- `testLegacyDefaultResourceIsMigratedToAUniqueDeviceResource` — L23. +- `testExplicitResourceIsPreserved` — L33. +- `testInvalidJIDIsRejected` — L42. +- `testInvalidPortIsRejected` — L48. + +### B.2. `Tests/AppLockPolicyTests.swift` + +- `testMinimumLengthIsFour` — L5. +- `testValidationRejectsShortPasscodes` — L9. +- `testValidationAcceptsFourOrMoreCharacters` — L15. + +### B.3. `Tests/ArchiveMessageBatchPolicyTests.swift` + +- `testInteractivePageSize` — L5. +- `testStanzasAreDecodedInSmallBatchesPerMainActorSlice` — L9. +- `testInboxCanHoldAProtocolViolatingDoublePageWithoutGrowingForever` — L15. + +### B.4. `Tests/ArchiveStoreTests.swift` + +- `testStoreRoundTrip` — L7. +- `testLegacyImportMapsConversationAndMessage` — L45. +- `testLegacyImportToleratesMissingOptionalFields` — L97. +- `testLocallyDeletedRowsArePurgedFromTheStore` — L149. +- `testCorruptLegacyJSONIsKeptForRetry` — L185. + +### B.5. `Tests/ArchiveSyncCheckpointTests.swift` + +- `testStoredCursorIsPreferredOverTimestampOverlap` — L5. +- `testLegacyTimestampUsesBoundedOverlap` — L21. +- `testResumeCursorWinsOverDurableCursor` — L33. +- `testCaughtUpCheckpointKeepsCursorAndAdvancesTimestamp` — L47. +- `testSameTimestampStillAdvancesWhenCursorChanges` — L66. +- `testStoredCursorFallbackCanRunOnlyOnFirstPageAndOnlyOnce` — L83. + +### B.6. `Tests/ArchiveSyncPaginationTests.swift` + +- `testCompleteResponseFinishesEvenWhenCursorExists` — L5. +- `testIncompleteResponseContinuesWithNewCursor` — L18. +- `testRepeatedCursorStopsPaginationLoop` — L31. +- `testCursorCycleStopsPaginationLoop` — L49. +- `testIncompleteResponseWithoutCursorDoesNotAdvanceCheckpoint` — L78. + +### B.7. `Tests/ArchiveSyncRecoveryPolicyTests.swift` + +- `testIncrementalOverlapDoesNotReplaySeveralMinutesPerPass` — L5. +- `testQueryTimeoutIsFinite` — L9. +- `testPageApplicationAlsoHasAFiniteWatchdog` — L17. +- `testFailedPagesHaveAtMostOneAutomaticRetry` — L25. +- `testCatchUpRetryDelayAndCapAreBounded` — L29. +- `testCaptureResumeDelayLetsCameraReleaseResources` — L38. + +### B.8. `Tests/ArchiveSyncWorkBudgetTests.swift` + +- `testStopsAfterFinitePageBudget` — L5. +- `testStopsAfterRuntimeBudgetEvenWithFewPages` — L17. + +### B.9. `Tests/CallHistorySyncTests.swift` + +- `testEnvelopeParsesPayload` — L20. +- `testEnvelopePrefersOriginID` — L34. +- `testEnvelopeIgnoresOrdinaryMessages` — L42. +- `testPayloadRoundTripPreservesCallDetails` — L48. + +### B.10. `Tests/CallSDPOrderingTests.swift` + +- `testAnswerKeepsOfferMediaOrder` — L6. +- `testAnswerWithReversedJingleContentsIsReordered` — L16. +- `testAnswerWithMissingOrDuplicateContentIsRejected` — L26. +- `testJingleCandidateIsNormalizedForWebRTC` — L41. +- `testMartinParseInputPreservesFinalMSIDCharacter` — L52. +- `testMartinParseInputUsesExactlyOneTrailingCRLF` — L76. +- `testFIDMSIDsUseTheCompleteTrackIdentifier` — L86. +- `testFIDMSIDRepairAlsoHandlesMissingRTXMetadata` — L101. +- `testCandidateDispatchWaitsForRemoteAnswerAndFullJID` — L119. +- `testCandidateDispatchOpensAfterSessionAccept` — L136. + +### B.11. `Tests/CallSnapshotTests.swift` + +- `testIncomingVideoCallProjection` — L6. +- `testConnectedAudioCallIsNotRinging` — L25. +- `testConnectedCallAlwaysBecomesCompletedHistory` — L44. +- `testDeclinedCallClassification` — L56. +- `testMissedCancelledAndUnansweredClassification` — L77. + +### B.12. `Tests/ChatMessageDeliveryTests.swift` + +- `testMergeKeepsTheStrongestState` — L5. +- `testMAMReplayNeverDowngradesReadState` — L14. +- `testFailedIsSticky` — L21. +- `testReadMessagesRemainEditableAndRetractable` — L26. + +### B.13. `Tests/ChatScrollPositionPolicyTests.swift` + +- `testBottomSentinelCrossesOnlyTheTelegramButtonThreshold` — L5. +- `testInvalidFirstLayoutDoesNotFlashTheButton` — L16. +- `testNativeScrollDistanceCanHideAndRestoreButtonRepeatedly` — L23. + +### B.14. `Tests/ChatTimelineEntryTests.swift` + +- `testDayBoundariesArePrecomputedOnceForStableRows` — L5. + +### B.15. `Tests/ChatTypingPolicyTests.swift` + +- `testDirectChatUsesCompactTypingText` — L5. +- `testGroupChatNamesOneOrTwoParticipants` — L12. +- `testEmptyTypingSetHasNoIndicator` — L23. + +### B.16. `Tests/ComposerRecordingGestureTests.swift` + +- `testShortMovementKeepsRecording` — L6. +- `testHorizontalSwipeCancelsRecording` — L15. +- `testVerticalSwipeLocksRecording` — L24. +- `testDominantDiagonalDirectionWins` — L33. +- `testCancelHintOpacityUsesCGFloatMathAndReturnsDouble` — L48. +- `testDelayedMainActorStillRecognizesPhysicalLongPress` — L77. + +### B.17. `Tests/CryptoKitAESGCMEngineTests.swift` + +- `testRoundTrip` — L6. +- `testModifiedTagFails` — L23. + +### B.18. `Tests/EncryptionPreferenceTests.swift` + +- `testResolutionUsesGlobalDefaultAndPerChatOverride` — L6. +- `testGlobalSettingIsStoredPerAccount` — L13. + +### B.19. `Tests/GeoLocationTests.swift` + +- `testGeoURIFormattingAndParsing` — L5. +- `testGeoURIWithoutUncertainty` — L18. +- `testInvalidCoordinatesAreRejected` — L26. + +### B.20. `Tests/GroupConversationTests.swift` + +- `testGroupConversationProperties` — L6. +- `testNewGroupInheritsGlobalEncryptionByDefault` — L26. + +### B.21. `Tests/LumaOMEMO2Tests.swift` + +- `testDerivePayloadKeysHasStableStructure` — L12. +- `testCBCRoundTrip` — L28. +- `testHMACTruncationMatchesManualComputation` — L51. +- `testEnvelopeRoundTrip` — L67. +- `testEnvelopeIncludesToAffixForMUC` — L92. +- `testSCEEnvelopeUsesSCE0Namespace` — L105. +- `testBundleParsing` — L117. +- `testOMEMODeviceProtocolLabel` — L141. +- `testStoreMarksOMEMO2DeviceIDs` — L169. +- `testRatchetSessionRoundTrip` — L190. + +### B.22. `Tests/MediaFileIOTests.swift` + +- `testStagedDraftSurvivesThePickerURLBeingRemoved` — L6. +- `testEmptyPickerFileIsRejectedBeforePreviewOrUpload` — L27. + +### B.23. `Tests/MediaMetadataTests.swift` + +- `testVoiceFilenameRoundTrip` — L6. +- `testVideoNoteFilenameRoundTrip` — L21. +- `testStandardExtensionsAreClassified` — L34. +- `testEncryptedTransportNameKeepsOriginalExtension` — L41. + +### B.24. `Tests/MediaPickerSelectionPolicyTests.swift` + +- `testLivePhotoPrefersStillImageButRetainsVideoFallback` — L5. +- `testVideoOnlyAssetPrefersMovieButRetainsPhotoFallback` — L15. + +### B.25. `Tests/MediaPreviewProcessorVideoTests.swift` + +- `testVideoAnalysisProducesDurationAndThumbnail` — L9. + +### B.26. `Tests/MediaSendActivityTrackerTests.swift` + +- `testOverlappingOperationsDoNotRestoreStaleBusyState` — L5. +- `testEndingSameOperationTwiceIsSafe` — L19. +- `testResetRecoversFromEveryOutstandingOperation` — L30. + +### B.27. `Tests/MediaTimeFormatterTests.swift` + +- `testFormatsShortAndLongMediaDurations` — L5. +- `testInvalidDurationFallsBackToZero` — L11. + +### B.28. `Tests/MediaViewerDismissGestureTests.swift` + +- `testLongDownwardSwipeDismissesViewer` — L6. +- `testLongUpwardSwipeDismissesViewer` — L16. +- `testFastShortVerticalFlickDismissesViewer` — L26. +- `testShortVerticalDragReturnsToViewer` — L36. +- `testHorizontalMediaGestureDoesNotDismissViewer` — L46. + +### B.29. `Tests/MessageCorrectionTests.swift` + +- `testOnlyDeliveredOrSentOutgoingTextCanBeEdited` — L6. +- `testEditedTimestampIsStored` — L32. + +### B.30. `Tests/MessageInteractionTests.swift` + +- `testReplyMetadataIsStored` — L6. +- `testRetractedMessageCannotBeEditedRepliedOrForwarded` — L24. +- `testGroupReplyRequiresRoomAssignedStanzaID` — L42. +- `testCompletedVideoCallHistoryShowsMetadata` — L63. + +### B.31. `Tests/MessageReactionTests.swift` + +- `testReactionUpdateKeepsOneCopyOfEachEmoji` — L5. +- `testSummariesCountSendersAndMarkOwnReaction` — L12. + +### B.32. `Tests/MessageReplyFallbackTests.swift` + +- `testXEP0428ScalarRangeRemovesUnicodeQuote` — L5. +- `testLegacyConversationsQuoteBecomesReplyPreview` — L23. +- `testPlainBodyWithoutQuoteIsUnchanged` — L34. + +### B.33. `Tests/MessageReplySwipeTests.swift` + +- `testRightToLeftSwipeReplies` — L6. +- `testLeftToRightSwipeReplies` — L18. +- `testVerticalTimelineScrollDoesNotReply` — L35. +- `testDiagonalTimelineScrollDoesNotReply` — L43. +- `testDiagonalScrollAtOldThresholdDoesNotReply` — L55. +- `testShortSwipeDoesNotReply` — L69. +- `testFastHorizontalFlickUsesPredictedEnd` — L77. +- `testIndicatorStaysHiddenUntilActivationDistance` — L85. +- `testVisualOffsetIsCapped` — L96. +- `testCanLockRequiresClearHorizontalStart` — L103. +- `testCanLockIsUnavailableOnceTheGestureTurnsVertical` — L111. +- `testLockedSwipeToleratesVerticalDrift` — L118. +- `testRightwardDiagonalScrollDoesNotReply` — L132. +- `testRightwardVisualOffsetIsCapped` — L144. +- `testRightwardIndicatorStaysHiddenUntilActivationDistance` — L151. +- `testRightwardLockedSwipeToleratesVerticalDrift` — L162. + +### B.34. `Tests/NotificationPolicyTests.swift` + +- `testForegroundMessageFromAnotherConversationIsPresented` — L5. +- `testVisibleConversationDoesNotPresentDuplicateBanner` — L15. +- `testBackgroundIncomingMessageIsPresented` — L25. +- `testOutgoingOrDuplicateMessageIsNotPresented` — L35. + +### B.35. `Tests/OlderHistoryScrollPolicyTests.swift` + +- `testIncompletePageWithFirstCursorContinues` — L5. +- `testCompletePageStopsEvenWithCursor` — L15. +- `testEmptyPageNeverReportsMore` — L25. +- `testWhitespaceOnlyCursorIsDiscarded` — L34. +- `testPageFirstIDMustNotBecomeAnEmptyCursor` — L41. + +### B.36. `Tests/ReadStateSyncTests.swift` + +- `testEnvelopeParsesPayload` — L18. +- `testEnvelopePrefersOriginID` — L30. +- `testEnvelopeIgnoresOrdinaryMessages` — L38. +- `testEnvelopeAllowsMissingStanzaID` — L44. +- `testPayloadRoundTripPreservesReadDetails` — L55. + +### B.37. `Tests/SASLMechanismPreferenceTests.swift` + +- `testPrefersScramSha512WhenAdvertised` — L5. +- `testFallsBackToSha256WithoutSha512` — L14. +- `testFallsBackToSha1AndPlain` — L21. +- `testReturnsNilWhenNothingKnownIsOffered` — L32. +- `testStrongestAvailableRanking` — L39. +- `testPLAINIsNeverChosenWhileSCRAMIsOffered` — L60. +- `testPlusVariantsArePreferredOverPlainSCRAM` — L66. +- `testWeakMechanismsAreFlagged` — L86. +- `testPlusVariantsSkippedWithoutChannelBinding` — L94. + +### B.38. `Tests/SASLprepTests.swift` + +- `testASCIIPasswordIsUnchanged` — L5. +- `testCyrillicPasswordIsUnchanged` — L9. +- `testNonASCIISpacesMapToSpace` — L13. +- `testMappedToNothingCharactersAreRemoved` — L18. +- `testZeroWidthSpaceMapsToSpacePerRFC4013` — L23. +- `testFullwidthAndCompatibilityFormsAreNormalized` — L28. +- `testDecomposedAccentIsComposed` — L33. +- `testMixedPassword` — L37. +- `testProhibitedCharactersThrow` — L41. + +### B.39. `Tests/SCRAMDowngradeProtectionTests.swift` + +- `testHashMatchesXEP0474Example` — L7. +- `testHashSortsListsPerIOctetCollation` — L16. +- `testHashOmitsBindingSectionWhenNoTypesAdvertised` — L32. +- `testServerFirstParsesDowngradeProtectionHash` — L50. +- `testServerFirstWithoutHashParsesCleanly` — L59. +- `testClientFinalIncludesXAttributeWhenHashPresent` — L68. + +### B.40. `Tests/SCRAMPlusTests.swift` + +- `testClientFirstMessageMatchesReference` — L23. +- `testClientFinalMessageMatchesReference` — L32. +- `testExpectedServerSignatureMatchesReference` — L42. +- `testVerifierValueExtractsOnlyTheBase64Payload` — L48. +- `testChannelBindingStoreDerivesEndPointDigest` — L65. +- `testChannelBindingStoreFallsBackToEndPoint` — L88. + +### B.41. `Tests/SCRAMSHA512Tests.swift` + +- `testSaltedPasswordMatchesReference` — L12. +- `testClientProofMatchesReference` — L22. +- `testServerSignatureMatchesReference` — L35. +- `testVerifyServerSignatureAcceptsAndRejects` — L48. +- `testParseServerFirst` — L69. +- `testParseServerFirstRejectsWrongNonce` — L79. + +### B.42. `Tests/SaslFailureMessageTests.swift` + +- `testNotAuthorizedProducesActionableMessage` — L6. +- `testServerTextWinsOverCondition` — L14. +- `testAccountDisabledIsExplained` — L22. +- `testCredentialsExpiredIsExplained` — L32. +- `testProsodyProofMismatchIsExplained` — L40. +- `testTemporaryAuthFailureIsExplained` — L50. + +### B.43. `Tests/VideoNoteRecordingCompletionPolicyTests.swift` + +- `testNilErrorKeepsRecording` — L6. +- `testSuccessfulFinishedFlagKeepsRecordingDespiteError` — L10. +- `testFailedFinishedFlagRejectsRecording` — L20. +- `testNSNumberSuccessfulFinishedFlagKeepsRecording` — L30. +- `testMaximumDurationStillKeepsRecording` — L40. +- `testExplicitStopAllowsCompletedRecordingToSend` — L49. +- `testUnexpectedCameraCompletionDoesNotSendPartialRecording` — L58. +- `testAutomaticMaximumDurationCompletionMaySend` — L67. + +### B.44. `Tests/VideoNoteRecordingLifecycleTests.swift` + +- `testOnlyBusyPhasesBlockASecondRecording` — L6. +- `testStaleCompletionCannotFinishANewerRecording` — L14. +- `testMacOSTemporaryPathAliasesIdentifyTheSameRecording` — L28. +- `testTemporaryPathAliasDoesNotAcceptAnotherRecording` — L38. + +### B.45. `Tests/VideoNoteRotationPolicyTests.swift` + +- `testPortraitNeedsClockwiseQuarterTurn` — L5. +- `testPortraitUpsideDownNeedsThreeQuarterTurn` — L14. +- `testLandscapeOrientationsMapToNativeAngles` — L21. +- `testAllAnglesAreSupportedCaptureAngles` — L32. + +### B.46. `Tests/VideoNoteStopPolicyTests.swift` + +- `testEarlyReleaseWaitsForRecordedMedia` — L6. +- `testStopDoesNotWaitAfterMinimumMediaDurationElapsed` — L19. +- `testRecordingFloorIsLongerThanLegacyShortClipThreshold` — L30. +- `testRecordedMediaTimeControlsTheFinalStop` — L34. + +### B.47. `Tests/WatchVoiceMessageTests.swift` + +- `testTransferUsesStableXMPPMessageIDAcrossRetries` — L6. + +### B.48. `UITests/LayoutCheckUITests.swift` + +- `testChatsAndContactsSearchFieldsAreAtTop` — L18. +- `testTabBarHidesInsideChat` — L53. + +### B.49. `UITests/TimelineUITests.swift` + +- `testTimelineVerticalScrollWorks` — L30. +- `testReplySwipeShowsPlateAndScrollStillWorks` — L45. + +Итого: 114 Swift-файлов собственной реализации; 49 файлов тестов приложения; 217 методов test*. + +## Приложение C. Реестр именованных констант + +Извлечены static let из Models и компонентов с gesture/capture policies. Для многострочных выражений указано начало объявления; полный состав находится в исходнике. Наносекунды делятся на 1 000 000 000 для перевода в секунды. Локальные layout literals не выдаются за глобальные требования. + +| Источник | Строка | Объявление | +| --- | --- | --- | +| `Sources/Shared/Models/AppLockPolicy.swift` | 6 | `static let minimumLength = 4` | +| `Sources/Shared/Models/ArchiveMessageBatchPolicy.swift` | 7 | `static let pageSize = 40` | +| `Sources/Shared/Models/ArchiveMessageBatchPolicy.swift` | 8 | `static let bootstrapMessageLimit = 40` | +| `Sources/Shared/Models/ArchiveMessageBatchPolicy.swift` | 13 | `static let decodeSliceSize = 8` | +| `Sources/Shared/Models/ArchiveMessageBatchPolicy.swift` | 14 | `static let maximumBufferedStanzas = 96` | +| `Sources/Shared/Models/ArchiveMessageBatchPolicy.swift` | 18 | `static let interSliceDelayNanoseconds: UInt64 = 1_000_000` | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | 6 | `static let incrementalOverlap: TimeInterval = 60` | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | 7 | `static let interPageDelayNanoseconds: UInt64 = 100_000_000` | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | 8 | `static let queryTimeoutNanoseconds: UInt64 = 20_000_000_000` | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | 9 | `static let pageApplyTimeoutNanoseconds: UInt64 = 20_000_000_000` | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | 10 | `static let pageRetryLimit = 1` | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | 11 | `static let resumeAfterCaptureDelayNanoseconds: UInt64 = 3_000_000_000` | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | 15 | `static let retryAfterFailureDelayNanoseconds: UInt64 = 20_000_000_000` | +| `Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift` | 18 | `static let maximumAutomaticRetries = 3` | +| `Sources/Shared/Models/ArchiveSyncWorkBudget.swift` | 6 | `static let maximumCompletedPages = 2` | +| `Sources/Shared/Models/ArchiveSyncWorkBudget.swift` | 7 | `static let maximumRuntime: TimeInterval = 2` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 13 | `static let entranceStagger: TimeInterval = 0.035` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 17 | `static let entranceCascadeLimit = 12` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 19 | `static let entranceRise: CGFloat = 16` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 20 | `static let entranceScale: CGFloat = 0.94` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 22 | `static let sendScale: CGFloat = 0.2` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 25 | `static let sendRise: CGFloat = 12` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 27 | `static let receiveScale: CGFloat = 0.4` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 28 | `static let receiveRise: CGFloat = 10` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 33 | `static let entranceAnimationDuration: TimeInterval = 0.75` | +| `Sources/Shared/Models/ChatMessageEntrancePolicy.swift` | 37 | `static let appendAnimationLimit = 4` | +| `Sources/Shared/Models/ChatScrollPositionPolicy.swift` | 4 | `static let nearBottomThreshold: CGFloat = 96` | +| `Sources/Shared/Models/ChatTypingPolicy.swift` | 12 | `static let pauseDelayNanoseconds: UInt64 = 5_000_000_000` | +| `Sources/Shared/Models/ChatTypingPolicy.swift` | 13 | `static let remoteExpiryNanoseconds: UInt64 = 8_000_000_000` | +| `Sources/Shared/Models/DeviceResource.swift` | 13 | `static let legacyDefault = "Luma"` | +| `Sources/Shared/Models/DeviceResource.swift` | 15 | `private static let storageKey = "app.luma.xmpp.resource"` | +| `Sources/Shared/Models/EmojiCatalog.swift` | 15 | `static let categories: [EmojiCategory] = [` | +| `Sources/Shared/Models/MessageReaction.swift` | 30 | `static let quickChoices = ["👍", "❤️", "😂", "😮", "😢", "🔥"]` | +| `Sources/Shared/Models/MessageReplySwipePolicy.swift` | 4 | `static let triggerDistance: CGFloat = 64` | +| `Sources/Shared/Models/MessageReplySwipePolicy.swift` | 7 | `static let activationDistance: CGFloat = 24` | +| `Sources/Shared/Models/MessageReplySwipePolicy.swift` | 8 | `static let maximumOffset: CGFloat = 76` | +| `Sources/Shared/Models/MessageReplySwipePolicy.swift` | 11 | `static let lockDominance: CGFloat = 1.6` | +| `Sources/Shared/Models/MessageReplySwipePolicy.swift` | 14 | `static let followDominance: CGFloat = 1.05` | +| `Sources/Shared/Models/SASLMechanismPreference.swift` | 12 | `static let order: [String] = [` | +| `Sources/Shared/Models/SASLMechanismPreference.swift` | 38 | `static let weakMechanisms: Set = ["PLAIN", "OAUTHBEARER"]` | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | 1489 | `static let minimumCaptureDuration: TimeInterval = 1.2` | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | 1490 | `static let minimumValidFileDuration: TimeInterval = 0.8` | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | 1491 | `static let maximumCaptureDuration: TimeInterval = 60` | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | 1492 | `static let recordedDurationWaitTimeout: TimeInterval = 5` | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | 1493 | `static let recordedDurationPollNanoseconds: UInt64 = 80_000_000` | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | 1494 | `static let startupTimeoutNanoseconds: UInt64 = 6_000_000_000` | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | 1495 | `static let finalizationTimeoutNanoseconds: UInt64 = 12_000_000_000` | +| `Sources/Shared/Services/VideoNoteRecorder.swift` | 1496 | `static let finalizationRecoveryGraceNanoseconds: UInt64 = 1_000_000_000` | +| `Sources/Shared/UI/ChatView.swift` | 28 | `private static let bottomAnchorID = "luma-chat-timeline-bottom"` | +| `Sources/Shared/UI/ChatView.swift` | 29 | `private static let timelineCoordinateSpace = "luma-chat-timeline-space"` | +| `Sources/Shared/UI/ChatView.swift` | 30 | `private static let localHistoryPageSize = 60` | +| `Sources/Shared/UI/ChatView.swift` | 2272 | `static let cancelDistance: CGFloat = 92` | +| `Sources/Shared/UI/ChatView.swift` | 2273 | `static let lockDistance: CGFloat = 78` | +| `Sources/Shared/UI/ChatView.swift` | 2274 | `static let cancelHintFadeDistance: CGFloat = 150` | +| `Sources/Shared/UI/ChatView.swift` | 2275 | `static let maximumCancelHintFade: CGFloat = 0.72` | +| `Sources/Shared/UI/ChatView.swift` | 2276 | `static let longPressDelayNanoseconds: UInt64 = 240_000_000` | +| `Sources/Shared/UI/ChatView.swift` | 2277 | `static let minimumLongPressDuration: TimeInterval = 0.22` | +| `Sources/Shared/UI/Components/MediaViewer.swift` | 14 | `private static let verticalDominance: CGFloat = 1.15` | + +## Приложение D. Протоколы и назначение + +| Протокол / расширение | Использование | Существенная граница | +| --- | --- | --- | +| RFC 6120/6121 | XMPP stream, authentication, roster, presence | Нужен внешний XMPP-сервер | +| RFC 4013 | SASLprep | Не полная проверка всех возможных JID | +| RFC 5870 | Geo URI | Точка, не live tracking | +| XEP-0030 | Discovery | Объявленная feature не означает клиентскую интеграцию | +| XEP-0045 | MUC | Group OMEMO требует real JID | +| XEP-0066 | OOB URL | Plaintext media | +| XEP-0084 / vCard-temp | Аватары | Не E2EE | +| XEP-0092 | Software version | Диагностика через IQ | +| XEP-0163 | PEP | Нужны publish/subscribe permissions | +| XEP-0166/0167 | Jingle / RTP | Личные звонки | +| XEP-0184 | Delivery receipts | Delivered, не read | +| XEP-0198 | Stream Management | Не APNs | +| XEP-0199 | Ping | Не гарантия доступности peer | +| XEP-0215 | External services | Validation STUN/TURN | +| XEP-0280 | Carbons | Другие собственные ресурсы | +| XEP-0308 | Corrections | Исходящее UI редактирование direct text | +| XEP-0313 / RSM | Archive history | Отдельные cursors account/MUC | +| XEP-0333 | Chat markers | Luma не отправляет displayed в MUC | +| XEP-0352 | CSI | Не постоянный фон iOS | +| XEP-0353 | JMI | Endpoint full JID | +| XEP-0357 | Server push capability в диагностике | APNs client registration/gateway отсутствуют | +| XEP-0359 | origin-id / stanza-id | Идентичности для dedup/reply | +| XEP-0363 | HTTP Upload | Проверка HTTPS slot | +| XEP-0384 | Legacy и OMEMO 2 | Нужна отдельная interoperability проверка | +| XEP-0420 | SCE | Envelope нового модуля OMEMO | +| XEP-0424 | Retraction | Не secure erase чужих копий | +| XEP-0428 | Fallback | Unicode scalar offsets | +| XEP-0444 | Reactions | Plaintext metadata | +| XEP-0454 | aesgcm URI | Key material во fragment | +| XEP-0461 | Reply | MUC требует room stanzaID | +| XEP-0474 | SCRAM downgrade protection | Зависит от server/auth flow | +| `https://luma.chat/read-marker` | Own-device read state | Custom plaintext, без body | +| `https://luma.chat/call-history` | Own-device call cards | Custom plaintext, с body | +| WatchConnectivity | Snapshot / reply / file / result | Через companion iPhone | +| WebRTC ICE / DTLS-SRTP | Медиатранспорт | Сигналинг не защищён OMEMO | + +## Приложение E. Трассировка основных требований к реализации + +| Область документа | Основные источники | +| --- | --- | +| Платформы, зависимости и products | `project.yml`, `Config/*`, `ThirdParty/MartinOMEMO/Package.swift` | +| Account lifecycle | `AppModel.swift`: bootstrap, signIn, switchAccount, signOut; `AccountPreferences.swift`, `CredentialVault.swift` | +| Auth/TLS | `XMPPService.configureModules/configureConnection`, `LumaTLSNetworkProcessor.swift`, `LumaScramPlusMechanism.swift`, `LumaScramSha512Mechanism.swift`, `SASLprep.swift` | +| Модели и predicates | `ChatMessage.swift`, `Conversation.swift`, `MessageReaction.swift`, `EncryptionPreference.swift` | +| Главная навигация | `LumaApp.swift`, `RootView.swift`, `MainTabView.swift` | +| Composer и timeline | `ChatView.swift`, `MessageBubble.swift`, `ChatTimelineEntry.swift`, gesture/scroll policies | +| Corrections/retractions | `AppModel.consumeCorrection/applyRetraction`, `XMPPService.sendText/sendRetraction` | +| Reply/fallback | `MessageReplyFallback.swift`, `ReplyThreadOverlay.swift`, `XMPPService.addReply` | +| Reactions | `AppModel.toggleReaction/applyReaction`, `XMPPService.sendReactions`, `EmojiPickerView.swift` | +| MUC recipients | `XMPPService.groupOMEMORecipients/configureRoomForOMEMO/ensureRoomIsNonAnonymous` | +| Crypto state | `LumaOMEMOStore.swift`, `CryptoKitAESGCMEngine.swift`, `LumaOMEMO2Module.swift`, local MartinOMEMO | +| Upload/retry | `XMPPService.uploadAndSendMedia`, `AppModel.uploadAndSendMediaWithRetry/sendAttachmentBatch` | +| Media files/preview | `MediaFileIO.swift`, `MediaPreviewProcessor.swift`, `MediaDownloadService.swift`, `MediaViewer.swift` | +| Recorders | `AudioMessageRecorder.swift`, `VideoNoteRecorder.swift`, `VideoNoteCaptureView.swift`, lifecycle/rotation policies | +| Location | `GeoLocation.swift`, `LocationProvider.swift`, `LocationPickerView.swift` | +| MAM | `XMPPService` archive methods; ArchiveSync policies; OlderHistoryScrollPolicy | +| Durable history | `ArchiveStore.swift`, `ArchiveMetadataRecord.swift`, `LegacyArchiveImporter.swift` | +| Read sync | `ReadStateSync.swift`, AppModel pending markers, XMPPService syncReadState | +| Calls | `LumaCallEngine.swift`, `CallSnapshot.swift`, `CallView.swift`, `CallHistorySync.swift` | +| Notifications | `NotificationCoordinator.swift`, `NotificationPolicy.swift` | +| App lock | `AppLockVault.swift`, `AppLockPolicy.swift`, `AppLockView.swift`, AppModel lock actions | +| Watch | `PhoneWatchBridge.swift`, все `Sources/Watch/*.swift` | +| Checks/CI | `Scripts/verify.sh`, `Tests/*`, `UITests/*`, `.github/workflows/ios.yml` | + +## Приложение F. Дополнительные точные особенности + +1. `AttachmentBatchResult.isComplete` проверяет только пустоту failedDrafts; успешность caption надо читать отдельно по captionSent. Название isComplete не гарантирует отправленную подпись. +2. Предел 20 применяется и к файловым URL в prepareAttachmentDrafts, а не только PhotosPicker. +3. NewChatView по умолчанию включает «Добавить в roster». Начать chat без добавления можно после выключения переключателя. +4. NewGroupView делит приглашённых по запятой, точке с запятой, переводу строки, пробелу и tab; AppModel нормализует и фильтрует невалидные JID. Не каждый неверный invitee вызывает отказ всей операции. +5. Group OMEMO recipients исключают текущий own bare JID и room JID из peer-набора: собственные дополнительные устройства добавляются соответствующим OMEMO-путём, нельзя требовать сессию с активным собственным device как с чужим участником. +6. LoadArchive сбрасывает persisted joined/occupantCount комнат перед реальным сетевым входом. Сохранённое isGroupJoined не используется как доказательство живой сессии после перезапуска. +7. LoadArchive вычищает прежние outgoing decryptionFailed placeholders как фантомные собственные эхо и сохраняет эту очистку; это не общая фильтрация всех incoming decryption failures. +8. AppModel fallback ModelContext создаётся in-memory; его создание содержит `try!`. Поэтому fallback предназначен для продолжения сессии при недоступном disk store, но не гарантирует восстановление после абсолютно любой ошибки контейнера. +9. Call negotiation после принятия/получения proceed имеет timeout 90 секунд; ICE disconnected получает 12 секунд, failed — 8 секунд до окончательного завершения при отсутствии восстановления. +10. ConnectionStats.unacknowledged вычисляется как sent − acknowledged только при sent > acknowledged, иначе 0, чтобы не получить underflow. +11. Copy text использует системный clipboard/pasteboard. Это явный вывод plaintext за пределы модели чата; app lock не отзывает уже скопированный текст. +12. Названия delivery в MessageBubble сейчас частично английские: `Sending...`, `Sended`, `Delivered`, `Error`, `Read`. Документ сохраняет фактическое состояние и не объявляет исправление этих строк выполненным. +13. Автоматический ресурс имеет восемь hex-символов suffix; placeholder формы `Luma-XXXX` является примером в UI, а не точной длиной реализации. +14. При обычном logout удаляется серверный пароль аккаунта; OMEMO identity/session file этим действием отдельно не стирается. Повторный вход того же JID может использовать сохранённое OMEMO state. +15. File size из ChatMessage относится к исходным байтам, а slot maxSize проверяется по реально загружаемому, при encryption увеличенному blob. + +## Приложение G. Правила актуализации + +Обновлять `tz.md` при изменении schema/model fields, predicates действий, account lifecycle, transport/auth, crypto selection, MAM commit/recovery, media capture/retry, Watch payload, deployment/SDK, dependencies, entitlements, CI и семантики удаления. Policy constants изменять совместно с unit tests и Scripts/verify.sh по AGENTS.md. + +Документ не включает secrets, реальные пароли, токены или содержимое пользовательских переписок. Примеры адресов условные. Создание этого файла не изменяет поведение приложения и не выполняет операции от XMPP-аккаунта пользователя. diff --git a/tz_clear.md b/tz_clear.md new file mode 100644 index 0000000..f94ceb3 --- /dev/null +++ b/tz_clear.md @@ -0,0 +1,793 @@ +# Luma — техническое задание для Web, Desktop и Android + +Версия: 1.0. Дата: 14 октября 2026 года. Статус: целевой стандарт разработки. + +Связанный документ: [UI Kit](uikit_clear.md). + +## Содержание + +- [1. Назначение документа](#1-назначение-документа) +- [2. Продукт и пользователи](#2-продукт-и-пользователи) +- [3. Платформы и объём](#3-платформы-и-объём) +- [4. Общие требования к реализации](#4-общие-требования-к-реализации) +- [5. Предметная модель](#5-предметная-модель) +- [6. Аккаунты и вход](#6-аккаунты-и-вход) +- [7. Соединение и аутентификация](#7-соединение-и-аутентификация) +- [8. Навигация и основные экраны](#8-навигация-и-основные-экраны) +- [9. Контакты, presence и аватары](#9-контакты-presence-и-аватары) +- [10. Текстовые сообщения и доставка](#10-текстовые-сообщения-и-доставка) +- [11. Действия над сообщениями](#11-действия-над-сообщениями) +- [12. Реакции и typing](#12-реакции-и-typing) +- [13. Группы MUC](#13-группы-muc) +- [14. Шифрование и устройства](#14-шифрование-и-устройства) +- [15. Файлы и вложения](#15-файлы-и-вложения) +- [16. Просмотр и сохранение медиа](#16-просмотр-и-сохранение-медиа) +- [17. Запись аудио, видео и геопозиция](#17-запись-аудио-видео-и-геопозиция) +- [18. Локальная история и MAM](#18-локальная-история-и-mam) +- [19. Звонки](#19-звонки) +- [20. Уведомления, push и фоновая работа](#20-уведомления-push-и-фоновая-работа) +- [21. Локальная защита и хранение](#21-локальная-защита-и-хранение) +- [22. Приватность и безопасность](#22-приватность-и-безопасность) +- [23. Совместимость протоколов](#23-совместимость-протоколов) +- [24. Нефункциональные требования](#24-нефункциональные-требования) +- [25. Критерии приёмки](#25-критерии-приёмки) +- [26. Состав результата разработки](#26-состав-результата-разработки) +- [27. Паспорт конкретного приложения](#27-паспорт-конкретного-приложения) + +## 1. Назначение документа + +Разработать семейство XMPP-приложений Luma с общими пользовательскими сценариями, совместимыми сообщениями и единым визуальным языком для Web, Desktop и Android. + +Документ является самостоятельным техническим заданием. Он определяет результат, поведение и критерии приёмки без привязки к языку программирования, UI framework, структуре исходников и конкретной библиотеке XMPP. + +«Обязательно» означает требование базового продукта. «При наличии возможности» означает управляемую зависимость от разрешений, оборудования, сервера или платформы, а не необязательную реализацию обработки отказа. Даже при отсутствии функции приложение обязано корректно объяснить ограничение и сохранить доступность остальных сценариев. + +Требования с идентификаторами используются в тестах и задачах. Выбранные технологии, поддерживаемые версии ОС/браузеров и инфраструктурные адреса фиксируются отдельно в паспорте конкретного приложения до его выпуска. + +## 2. Продукт и пользователи + +Luma — мессенджер для пользователей существующих XMPP-аккаунтов. Пользователь входит по JID и паролю, выбирает совместимый сервер, общается лично и в группах, отправляет файлы и звонит другим пользователям. + +Основные ценности: + +- независимость от одного XMPP-провайдера; +- совместимость с другими XMPP-клиентами; +- конфиденциальность содержимого через OMEMO; +- локальная история и восстановление с сервера; +- одинаковая семантика сообщений и действий на разных устройствах; +- понятные ошибки и отсутствие молчаливого отключения защиты. + +Интерфейс первого выпуска — русский. Вся пользовательская строка должна допускать последующее добавление локализаций. Идентификаторы протокола и адреса пользователей не переводятся. + +## 3. Платформы и объём + +| Платформа | Форма | Обязательный сценарий | +| --- | --- | --- | +| Web | Приложение в браузере, адаптивное для телефона и компьютера | Работа в активной сессии, локальная история, сообщения, медиа и звонки при доступных разрешениях | +| Desktop | Устанавливаемое приложение для утверждённого набора настольных ОС | Оконный интерфейс, системные уведомления, работа при скрытом окне в пределах жизненного цикла процесса | +| Android | Устанавливаемое мобильное приложение | Сенсорный интерфейс, системный lifecycle, уведомления и входящие вызовы через поддержанный фоновый механизм | + +### 3.1. Базовый объём + +- несколько сохранённых аккаунтов, один активный XMPP-контекст; +- вход/выход/переключение, настройки подключения; +- контакты, presence, личные чаты и чат с собой; +- MUC: создание, вход, приглашение, выход, локальное удаление; +- текст, replies, reactions, correction, retraction и локальное удаление; +- множественный выбор и пересылка; +- OMEMO legacy и OMEMO 2, доверие устройствам; +- файлы, фото, видео, музыка, voice, video notes и геоточки; +- локальная история, MAM, Carbons, receipts и read state; +- аудио-/видеозвонки один на один и история звонков; +- аватары; +- настройка уведомлений, app lock и биометрия при доступности; +- серверная диагностика; +- push и фоновые входящие в пределах утверждённой платформенной интеграции. + +### 3.2. За пределами базового объёма + +Apple Watch и другие companion-устройства, регистрация новых XMPP-аккаунтов, восстановление серверного пароля, групповые звонки, каналы/MIX, боты, платежи, истории, live location, полнотекстовый поиск всех сообщений, обязательные одновременные соединения всех сохранённых аккаунтов. + +Развёртывание XMPP-провайдера не является функцией пользовательского клиента. Инфраструктура push/relay, если нужна для заявленной доставки, входит в состав решения отдельным компонентом и должна быть поставлена либо явно подключена к утверждённому внешнему сервису. + +## 4. Общие требования к реализации + +**GEN-001.** Одинаковые действия на разных платформах должны иметь одинаковый смысл: «Отправлено», «Доставлено», «Прочитано», локальное удаление и отзыв нельзя интерпретировать по-разному. + +**GEN-002.** UI, предметная логика, протоколы, хранение и системные интеграции разделяются устойчивыми интерфейсами. UI не владеет криптографической сессией или активным медиасоединением. + +**GEN-003.** Криптография, обработка больших файлов, индексирование и архивная загрузка не должны монопольно занимать поток отображения интерфейса. + +**GEN-004.** Все долгие операции имеют состояние выполнения, завершения, ошибки и отмены там, где отмена безопасна. Ошибка не оставляет интерфейс навсегда заблокированным. + +**GEN-005.** Недоступная возможность сервера отключает соответствующую функцию с объяснением, сохраняя остальные функции. Поддержка, объявленная сервером, не отображается как уже настроенная интеграция приложения. + +**GEN-006.** Приложение не обещает гарантированную доставку, прочтение, удаление чужих копий, работу закрытого процесса или конфиденциальность метаданных, если механизм этого не обеспечивает. + +**GEN-007.** Исходные документы, тесты и поставляемая версия приложения должны соответствовать друг другу. Релиз имеет номер версии и журнал изменений. + +## 5. Предметная модель + +Ниже приведён логический контракт. Физическая схема БД и способы сериализации выбираются реализацией. + +### 5.1. Аккаунт + +| Атрибут | Содержание | +| --- | --- | +| accountId | Нормализованный bare JID, устойчивый ключ локального контекста | +| jid | Адрес пользователя | +| displayName | Отображаемое имя | +| deviceResource | Уникальный для установки ресурс соединения | +| connectionSettings | Endpoint/host/port, транспортный режим, настройки discovery | +| encryptionDefault | По умолчанию включён | +| typingEnabled | По умолчанию включён | +| notificationSettings | Разрешение/предпочтения, показ preview | +| credentialReference | Ссылка на защищённое хранилище, не пароль в общедоступном объекте | + +Пароли, refresh tokens и приватные ключи не входят в открытый экспорт настроек. + +### 5.2. Диалог + +Идентичность диалога — accountId + conversationJid, а не один JID без контекста аккаунта. + +Атрибуты: JID, direct/group, имя, avatar reference, lastActivity, lastMessagePreview, unreadCount, presence, optional pinned flag, encryptionOverride, group nickname, joined, autojoin, occupantCount и invitation metadata. + +Значение joined после перезапуска определяется реальным соединением, а не только записью в БД. Режим шифрования: наследовать аккаунт / всегда включено / выключено. + +### 5.3. Сообщение + +| Группа | Атрибуты | +| --- | --- | +| Идентичность | accountId, conversationId, clientId, optional originId, server stanzaId, archive UID | +| Автор | sender JID, optional nickname/display name, incoming/outgoing | +| Содержимое | kind, body, timestamp | +| Доставка | sending / sent / delivered / read / failed | +| Защита | encrypted / plaintext / decryptionFailed, optional device/fingerprint reference | +| Медиа | remote URI, filename, MIME, byte count, duration, local asset reference | +| Изменения | editedAt, retractedAt | +| Reply | targetId, author JID, preview | +| Пересылка | Атрибуция источника, без ложного сохранения исходного авторства нового сообщения | +| Реакции | Автор → текущий набор emoji и timestamp | +| Звонок | Направление, audio/video, исход, начало, duration | + +Kinds: text, attachment, photo, video, audio, voice, videoNote, location, system. Исторический звонок — системная запись с типизированными metadata. + +### 5.4. Служебные сущности + +- archive checkpoint: account/room archive key, opaque cursor, timestamp; +- local deletion suppression: account + conversation + идентификаторы удалённого сообщения; +- device: owner JID, id, protocol support, identity fingerprint, active/trust; +- attachment draft: staged asset, kind, filename, MIME, size, duration, thumbnail; +- call session: id, endpoint full JID, direction, media, phase, tracks/control state; +- pending operation: тип, target, автор, timestamp и ограниченный срок/объём удержания. + +## 6. Аккаунты и вход + +**ACC-001.** Входная форма содержит JID, пароль, показать/скрыть пароль и дополнительные настройки. Пустой JID/пароль не принимаются. + +**ACC-002.** JID валидируется по выбранному XMPP address preparation; пробелы по краям ввода обрабатываются, resource не смешивается с bare account identity. Пароль не обрезается молча. + +**ACC-003.** Native-клиенты поддерживают discovery и ручной host/port с STARTTLS/direct TLS. Стандартные порты: 5222 для STARTTLS, 5223 для direct TLS. Порт проверяется как число 1…65535; нечисловой ввод показывает ошибку, а не молча превращается в default. + +**ACC-004.** Web-клиент использует защищённый WebSocket XMPP endpoint. Форма допускает ввод/выбор WSS endpoint и не обещает прямое использование произвольного TCP host/port браузером. BOSH не обязателен; если включён отдельно, допускается только HTTPS. + +**ACC-005.** Endpoint discovery не должен незаметно отправлять пароль произвольному стороннему шлюзу. Пользователь видит домен выбранного endpoint; для неизвестной конфигурации требуется явный выбор сервера. + +**ACC-006.** На время входа показывается progress и блокируется повторная отправка той же формы. После успешного входа открываются локальная история и основной интерфейс. Ошибка оставляет возможность исправить credentials. + +**ACC-007.** Можно сохранить несколько аккаунтов. Добавление аккаунта делает его активным после успешного входа. Одновременно обязательно только одно активное соединение; неактивные аккаунты не представляются как постоянно online. + +**ACC-008.** Переключение закрывает/отделяет старый контекст и открывает данные нового. Завершившаяся поздно операция прошлого аккаунта не может записать результат в новый аккаунт или отправить сообщение от его имени. + +**ACC-009.** При доступных credentials запуск восстанавливает активный аккаунт и локальную историю, затем соединение. Если secrets недоступны/истекли, приложение запрашивает повторный вход без потери сохранённой истории. + +**ACC-010.** Для каждого устройства создаётся устойчивый уникальный ресурс; одновременный запуск разных устройств одного аккаунта не должен создавать конфликт одинаковых default resources. + +### 6.1. Выход и удаление данных + +**ACC-011.** «Выйти» прекращает соединение, unregister/disable применимую push-привязку, удаляет сохранённые credentials текущего аккаунта и очищает его чувствительное session state. Локальную историю можно сохранить. + +**ACC-012.** Отдельный вариант «Выйти и удалить локальные данные» удаляет историю, suppression, drafts, медиа cache и данные аккаунта в локальном профиле. Удаление OMEMO identity — явно обозначенный вариант со предупреждением о потере доступа к старым encrypted сообщениям; без согласия identity не уничтожается как побочный эффект обычного выхода. + +**ACC-013.** Удаление локальных данных не называется secure erase и не обещает удаление server archives, exports, backup или чужих копий. + +**ACC-014.** Перед уходом из аккаунта незавершённые записи/загрузки имеют определённое поведение: отмена либо сохранение безопасного локального результата; нет продолжения отправки от случайного другого аккаунта. + +## 7. Соединение и аутентификация + +**NET-001.** Подключение защищено TLS; проверяются certificate chain и ожидаемое имя сервиса/endpoint согласно используемому транспорту. Insecure HTTP/WS и обход ошибок сертификата не включены в пользовательский стандарт. + +**NET-002.** Нативный транспорт поддерживает DNS discovery и ручные параметры. В Web используется WSS endpoint, утверждённый для выбранного сервера. + +**NET-003.** Аутентификация выбирает наиболее сильный совместимый объявленный механизм. Приоритет SCRAM-PLUS с доступным channel binding, затем SCRAM-SHA-512/256/1, затем разрешённый защищённым транспортом fallback. + +**NET-004.** PLUS не имитируется при отсутствии данных реального TLS-канала. В частности, реализации, не имеющие доступа к exporter/binding, выбирают совместимый non-PLUS механизм и корректно отражают это в диагностике. + +**NET-005.** Проверяются nonce, server proof/signature, параметры challenge и применимые downgrade-protection данные. Для limits на challenge/iterations определяется защитная конфигурация, предотвращающая блокировку клиента недоверенным вводом. + +**NET-006.** Состояния: отключено, подключение, подключено, восстановление, ошибка/требуется вход. Причина ошибки локализована. Нельзя показывать «Подключено» только по наличию сохранённого аккаунта. + +**NET-007.** Поддерживаются stream management, carbons, ping и client-state indication при поддержке транспорта/сервера. + +**NET-008.** Повторное соединение использует ограниченный backoff с jitter, прекращается при явном logout и не создаёт параллельных конкурирующих клиентов одного контекста. Возврат в foreground инициирует проверку/восстановление. + +**NET-009.** Смена сети, sleep/wake, закрытие вкладки и потеря credentials обрабатываются без повреждения локального состояния. Возобновление stream не должно повторно создавать bubbles уже известных сообщений. + +## 8. Навигация и основные экраны + +**NAV-001.** Основные разделы: Контакты, Звонки, Чаты, Настройки. Начальный раздел — Чаты. Compact/wide варианты заданы UI Kit. + +**NAV-002.** Поиск расположен над соответствующим списком. Поиск чатов — по имени/JID/аккаунту, контактов — по имени/JID, звонков — по собеседнику. Это фильтр списков; полнотекстовый поиск истории отдельно не обещается. + +**NAV-003.** Чаты сортируются pinned-first при наличии pinned-функции, затем по последней активности, затем по имени для стабильности. Изменение unread не должно произвольно менять порядок при одинаковой активности. + +**NAV-004.** В списке видны avatar, имя, preview, время, unread и применимый status. Аккаунт отображается при необходимости различать одинаковые JID. + +**NAV-005.** Пустые данные и отсутствие совпадений поиска имеют разные объяснения. Создание чата/комнаты остаётся доступным в empty state. + +**NAV-006.** Back возвращает в предыдущий логический контекст. На широком экране смена selection не должна ломать sidebar; клавиатурная навигация обязательна для Web/Desktop. + +## 9. Контакты, presence и аватары + +**CON-001.** Контакты синхронизируются через roster: initial state и последующие изменения. Удаление контакта из roster не удаляет историю. + +**CON-002.** Новый direct chat открывается по валидному JID с optional name и выбором добавления в roster. Отсутствие roster membership не запрещает сообщение само по себе. + +**CON-003.** Presence показывает реальное известное состояние; нет объявления online только из-за cached avatar/history. + +**CON-004.** MUC-комнаты показываются отдельно от людей. Список сохранённых комнат не выдаётся за полный серверный список членства. + +**CON-005.** Аватары публикуются/получаются через совместимые XMPP PEP mechanisms; legacy fallback допустим. Новый avatar нормализуется до разумного размера, baseline 512 px по длинной стороне. + +**CON-006.** При отсутствии изображения используется стабильный цвет и инициалы, для группы — group symbol. Обновление cache не требует перезапуска клиента. + +**CON-007.** Пользователю не обещается OMEMO для аватаров; доступ определяется серверными правами. + +## 10. Текстовые сообщения и доставка + +**MSG-001.** Пустое после trim сообщение не отправляется. Каждая новая отправка имеет устойчивый уникальный client/origin id и зафиксированный аккаунт/получателя. + +**MSG-002.** До сетевого результата создаётся optimistic запись sending. Success transport send → sent, receipt → delivered, displayed → read. Ошибка → failed с объяснением. + +**MSG-003.** Sent означает передачу по клиентскому протокольному пути, delivered — полученный receipt, read — подтверждённый marker. UI не выводит read только из времени или online собеседника. + +**MSG-004.** Объединение live, Carbons и MAM не создаёт duplicate. Проверяются контекст аккаунта/чата и origin/stanza/client identities; archive UID не используется глобально для разных архивов. + +**MSG-005.** Доставка повышается монотонно sending → sent → delivered → read. Failed не «исправляется» молча архивной копией; явная повторная отправка — отдельное действие с контролем дубликатов. + +**MSG-006.** Порядок ленты — timestamp и устойчивый id при равных датах. Архивная вставка сверху сохраняет viewport. + +**MSG-007.** Служебные receipts, reactions, markers и пустые protocol messages не становятся обычными bubbles. Реальная ошибка дешифрования отображается как отдельное безопасное состояние. + +**MSG-008.** Чат с собой поддерживает текст/медиа и sync. Успешное собственное сообщение допускается помечать read по определённому self-chat правилу. + +**MSG-009.** У исходящего failed text доступно явное «Повторить», если payload сохранён. Повтор не должен незаметно менять encryption policy уже подготовленного сообщения; изменение режима требует нового явного решения. + +### 10.1. Непрочитанные + +**READ-001.** Новое входящее увеличивает локальный unread, если чат не просматривается активно. Архивный replay не раздувает счётчик повторно. + +**READ-002.** При активном просмотре last-visible/latest incoming direct message отправляется displayed по протоколу; одно и то же состояние не рассылается без необходимости. + +**READ-003.** Outgoing direct messages помечаются markable. Для MUC приложение не рассылает direct displayed markers в комнату как универсальный механизм прочтения всеми участниками. + +**READ-004.** Поддерживается own-device sync read state через совместимое служебное расширение. Маркер, пришедший раньше сообщения, применяется после получения target. Пользователь не видит служебную запись в self-chat. + +**READ-005.** Read marker — metadata; нельзя заявлять его E2EE только по encrypted body исходного сообщения. + +## 11. Действия над сообщениями + +### 11.1. Доступность + +| Действие | Требование | +| --- | --- | +| Ответить | Не удалённое, не служебное; в MUC нужен server room stanza id | +| Переслать | Не удалённое и не служебное | +| Копировать | Есть пользовательский текст/подпись, не tombstone | +| Редактировать | Собственный отправленный direct text | +| Удалить у всех | Собственное отправленное direct сообщение, поддерживаемый retraction | +| Удалить у меня | Локальная запись текущего аккаунта | +| Реакция | Доступное пользовательское сообщение с устойчивым reference id | +| Сохранить | Доступное/получаемое медиа по явному действию | +| Повторить | Failed собственная отправка, payload ещё доступен | + +Для group edit/retraction базовый UI не обещает поддержку. Состояние sending ограничивает действия, требующие завершённой идентичности. + +### 11.2. Редактирование + +**EDIT-001.** Используется совместимая message correction. Оригинал сохраняет свою идентичность и положение; заменяется body, устанавливается editedAt. + +**EDIT-002.** Применение проверяет чат, автора, допустимый тип, security context и timestamp. Сторонний отправитель не может исправить чужое сообщение. + +**EDIT-003.** Correction до оригинала удерживается в ограниченной pending очереди. Более старая correction не заменяет новую. + +**EDIT-004.** Шифрование исправления соответствует исходному сообщению; не допускается plaintext раскрытие исправляемого encrypted body из-за текущего глобального переключателя. + +### 11.3. Ответы + +**REPLY-001.** Поддерживаются стандартная reply reference и fallback для совместимых старых клиентов. Unicode offsets считаются согласно протоколу, без смешения байтов и символов. + +**REPLY-002.** Отображается компактный preview источника, а fallback quote не дублируется в основном body. + +**REPLY-003.** Нажатие открывает источник/ветку, если reference известен. При недоступном оригинале показывается preview и объяснение без crash. + +**REPLY-004.** Legacy `>` quote допускается отображать без ложного target id. MUC replies ссылаются на room-assigned stanza id и корректный occupant identity. + +**REPLY-005.** Reference нельзя применить к другому чату с совпавшим id. Визуальная ветка — группировка reply, не отдельная серверная комната. + +### 11.4. Пересылка + +**FWD-001.** Можно выбрать одно/несколько сообщений и переслать в существующий чат либо на валидный JID. Порядок: timestamp, устойчивый id. + +**FWD-002.** Создаются новые сообщения с новой identity и понятной атрибуцией. Пересылка не подделывает исходного sender. + +**FWD-003.** Media скачивается/расшифровывается и заново загружается для нового recipient и его encryption policy. Старый encrypted URI/key не переиспользуется автоматически. + +**FWD-004.** При частичной ошибке показывается количество успешно пересланного; уже доставленные элементы не откатываются и не повторяются молча. + +### 11.5. Удаление + +**DEL-001.** «Удалить у меня» удаляет local row и сохраняет suppression всех известных identities, чтобы MAM/live echo не восстановили удалённое сообщение. + +**DEL-002.** «Удалить у всех» отправляет retraction, проверяет авторство при применении и заменяет target на tombstone. Очищаются body/media/reply/reaction sensitive payload и связанные доступные cache. + +**DEL-003.** Retract до оригинала удерживается pending. Generic fallback не повторяет удаляемое содержимое. + +**DEL-004.** UI явно сообщает разницу локального удаления и отзыва. Ни одно не обещает стирание screenshots, backups, чужих exports или неподдерживающего клиента. + +**DEL-005.** Массовое действие фильтрует/объясняет недоступные элементы; подтверждение показывает область действия и число сообщений. + +## 12. Реакции и typing + +**REACT-001.** Реакции совместимы с XMPP reactions: target id и полный актуальный набор автора; пустой набор снимает реакцию. + +**REACT-002.** Emoji валидируются, дубликаты удаляются. Один reaction value — один grapheme, ограниченный безопасной длиной; baseline до 64 UTF-8 bytes. + +**REACT-003.** Показываются emoji, count и own state. Повторный tap переключает свой вклад. Более старое событие не отменяет новое. + +**REACT-004.** Direct авторы сравниваются по подготовленному bare JID, group — по корректной occupant identity. Нельзя объединять разных участников из-за неверной нормализации resource. + +**REACT-005.** Реакции не шифруются автоматически только из-за OMEMO текста. Базовая совместимая stanza является видимой серверу metadata; отдельное encrypted extension требует самостоятельного согласования и interoperability tests. + +**TYPE-001.** Typing имеет active/composing/paused/inactive/gone. Baseline пауза после 5 с, удалённый composing истекает через 8 с без обновления. + +**TYPE-002.** Настройка typing per-account; выключение прекращает отправку/отображение и очищает pending indicators. Для direct учитывается поддержка peer. + +**TYPE-003.** В группе показываются имена для одного/двух участников, затем обобщённый статус. Переход между чатами/уход из foreground не оставляет вечное «печатает». + +## 13. Группы MUC + +**MUC-001.** Пользователь создаёт/открывает room JID, задаёт nickname и optional name; для защищённых комнат предусмотрен запрос password при требовании сервера. + +**MUC-002.** Поддерживаются invitations, accept/join, occupant count, autojoin и информация о комнате. Invitees валидируются; некорректные адреса явно обозначаются. + +**MUC-003.** Новая encrypted-friendly комната конфигурируется persistent, members-only, nonanonymous и с доступными участникам affiliation lists, если сервер даёт эти права. + +**MUC-004.** Для encrypted send проверяются real JID всех необходимых online/offline recipients через occupants и member/admin/owner lists. Недоступный/неполный набор получателей блокирует безопасную отправку. + +**MUC-005.** Скрытые JID, отсутствие устройств или невозможность построить session не вызывают fallback к plaintext. Ошибка объясняет, что может изменить владелец/участник. + +**MUC-006.** Own echo дополняет room stanza identity даже при повторном encrypted payload. Replies/reactions становятся доступны после устойчивой серверной identity. + +**MUC-007.** «Выйти» сохраняет локальный чат/историю и снимает joined. «Удалить чат» удаляет локальную историю/сохранённую комнату после выхода; это не уничтожение комнаты сервера. + +**MUC-008.** После локального удаления поздние события текущей room session не создают чат заново без явного основания. Повторное приглашение/вход имеют определённую семантику. + +## 14. Шифрование и устройства + +**ENC-001.** OMEMO включён по умолчанию для аккаунта. Per-chat override: наследовать / включено / выключено. UI показывает итоговый режим до отправки. + +**ENC-002.** Ошибка encryption никогда не приводит к автоматической plaintext отправке. Plaintext возможен только при явно выбранной политике. + +**ENC-003.** Поддерживаются legacy OMEMO и OMEMO 2 с совместимыми wire formats, device lists/bundles и согласованной lifecycle политикой identity/session. + +**ENC-004.** Для direct предпочтителен OMEMO 2 при готовности и поддержке peer; иначе совместимый legacy. Для group OMEMO 2 используется только при поддержке всех recipients; legacy остаётся encrypted fallback. + +**ENC-005.** Encrypt-to-self охватывает собственные другие устройства, чтобы Carbons/MAM могли быть прочитаны. Active own device не включается как чужой recipient с некорректной обязательной self-session. + +**ENC-006.** Private identity, prekeys и sessions сохраняются устойчиво и раздельно по аккаунтам. Обновление приложения не создаёт новую identity без необходимости. + +**ENC-007.** TOFU и verified различаются. Первое доверие отмечается как автоматическое; changed identity переводится в undecided и требует понятного решения. Compromised key не используется как trusted. + +**ENC-008.** Пользователь видит список устройств, protocol support, fingerprint, active state и trust, может отметить проверенный fingerprint и снять verification. + +**ENC-009.** OMEMO encryption/decryption выполняются в клиентском endpoint. Web gateway может передавать encrypted stanza, но не получает password/crypto state для скрытой серверной расшифровки. Применяются проверенные криптографические реализации и совместимые test vectors. Собственный новый алгоритм, замена примитивов или трактовка общего session storage как доказанной interoperability не допускаются. + +**ENC-010.** Зашифрованный text не означает шифрование JID, времени, stanza ids, replies metadata, reactions, typing, avatars или custom sync. Матрица раскрываемых metadata является частью security документации каждого релиза. + +**ENC-011.** Криптографический формат OMEMO 2 реализуется по утверждённой совместимой редакции с SCE и необходимыми payload/key/MAC rules; он не заменяется media AES-GCM только из-за удобства библиотеки. + +## 15. Файлы и вложения + +**FILE-001.** Поддерживаются файл, фото, видео, музыка, voice и videoNote. Тип определяется безопасно по metadata/MIME/доступному анализу; расширение не даёт права автоматически выполнять содержимое. + +**FILE-002.** Можно выбрать до 20 элементов за пакет, просмотреть, удалить отдельные и добавить общую подпись. Перевышение лимита объясняется; элементы не теряются молча. + +**FILE-003.** Источник копируется/удерживается в staged draft с достаточным сроком доступа. Не предполагается, что временный URL picker остаётся доступным после его закрытия. + +**FILE-004.** При encrypted policy blob шифруется до upload случайным file key; ключ передаётся внутри защищённого сообщения, не upload service. Для plaintext это явно обозначено. + +**FILE-005.** Используется HTTP File Upload discovery/slot. Выбирается подходящий component по реально загружаемому размеру. Ограничение сервера и памяти отображается до бессмысленного повторения операции. + +**FILE-006.** Upload/download допускаются по HTTPS. Проверка распространяется на redirect chain; переход к небезопасной схеме не допускается. HTTP headers/credentials slot не отправляются произвольному другому origin после redirect. + +**FILE-007.** Сохраняется исходное безопасное расширение filename. Encrypted media URI использует совместимую aesgcm scheme/key fragment, а не вымышленное обязательное расширение файла. + +**FILE-008.** Body media-сообщения остаётся распознаваемой ссылкой; plaintext дополнительно передаёт совместимый OOB. Подпись пакета — отдельный text/reply к первому подходящему вложению. + +**FILE-009.** Пакет отправляется с контролем порядка. Successful drafts удаляются из pending, failed остаются для retry; caption отправляется после успешных вложений. Ошибка caption не откатывает уже отправленные файлы. + +**FILE-010.** Retry сохраняет account/recipient/message identity и encryption context. Сбой после PUT, но до XMPP send может оставить orphan blob; это не обозначается как гарантированно удалённый файл. + +**FILE-011.** Загрузки поддерживают progress по доступным данным, cancellation и bounded retry. Недостаток диска/памяти обрабатывается как ошибка без повреждения остальных сообщений. + +**FILE-012.** В Web endpoint обязан поддерживать необходимые browser access/CORS политики. Proxy не получает file key или decrypted blob для обхода ограничений незаметно для пользователя. + +### 15.1. Совместимые transport metadata + +Созданные Luma voice/videoNote сохраняют распознаваемое имя при загрузке: + +| Тип | Формат transport filename | +| --- | --- | +| Voice | `luma-voice--` | +| VideoNote | `luma-videonote--` | +| Другой файл | Безопасное исходное имя с расширением | + +Duration — положительное конечное значение, округлённое до миллисекунд. При отсутствии валидной duration имя не должно содержать неверную/отрицательную длительность. Получатель распознаёт специальные prefixes, обычный MIME/расширение и прежние совместимые encrypted URI. Transport filename и размер доступны upload service; они не являются секретными metadata. + +`byteCount` сообщения относится к исходному файлу; лимит slot сравнивается с размером реально загружаемого encrypted/plain blob. + +### 15.2. Baseline сетевых параметров + +| Параметр | Значение по умолчанию | +| --- | --- | +| Upload request timeout | 120 с | +| Upload total resource timeout | 300 с | +| Automatic attempts | 2 суммарно | +| Задержка перед второй попыткой | 600 мс, допустим небольшой jitter | +| Retryable HTTP | 408, 429 с учётом Retry-After, 5xx | +| Неповторяемые без изменения условий | Insecure URL, invalid input/key, неподходящий размер, постоянный permission error | + +Повтор discovery/slot/transport ограничен. Таймауты конфигурируемы и не являются обещанием скорости сети. + +## 16. Просмотр и сохранение медиа + +**MEDIA-001.** Photo/video имеют inline preview с сохранением aspect ratio без неожиданного crop. Loading/error/placeholder различаются. + +**MEDIA-002.** Фото открывается в viewer с zoom/pan. Видео имеет play/pause/seek/fullscreen и доступные системные controls. Voice/audio — inline player, duration, seek, progress; voice speeds 1×/1.5×/2×. + +**MEDIA-003.** В пределах приложения активный media playback согласован: начало другого audio/video не создаёт неконтролируемое одновременное звучание. Системный audio focus обрабатывается по платформе. + +**MEDIA-004.** VideoNote показан кругом, воспроизводится inline и увеличивается при playback. Изменение размера не вызывает потерю позиции чтения. + +**MEDIA-005.** Preview cache не скачивает один файл многократно из-за повторного появления row. Decrypted cache ограничивается размером/сроком и очищается согласно privacy/account lifecycle. + +**MEDIA-006.** Экспорт/скачивание/копирование выполняются явно. Пользователь выбирает доступное платформенное место сохранения; отмена не показывает success. + +**MEDIA-007.** Viewer закрывается доступной кнопкой/keyboard action и gesture по UI Kit. Zoomed pan не конфликтует с dismiss. + +**MEDIA-008.** Неподдерживаемый codec/file открывается безопасным fallback/download с объяснением; не имитируется успешное воспроизведение. + +## 17. Запись аудио, видео и геопозиция + +**REC-001.** Разрешения microphone/camera запрашиваются по действию пользователя. Отказ не ломает текстовый composer. + +**REC-002.** Voice: start, elapsed, cancel, finish; запись короче 0.4 с не отправляется. Длительность голоса не ограничивается автоматически 60 секундами: ограничения файла/сервера действуют отдельно. + +**REC-003.** VideoNote: до 60 с, корректная ориентация, переключение камеры при доступном hardware. Минимальный валидный media asset — 0.8 с; early stop может дождаться достаточного capture, baseline 1.2 с. + +**REC-004.** Отпускание/отмена до асинхронного старта capture не оставляет скрытую запись. Finalization имеет timeout и завершает UI в определённое состояние. + +**REC-005.** При interruption пригодный partial asset может быть предложен для восстановления/предпросмотра, но не отправляется автоматически как подтверждённое действие. + +**REC-006.** На touch поддерживаются hold/slide cancel/lock. На mouse/keyboard всегда есть явные start/stop/cancel; жест не является единственным способом записи. + +**GEO-001.** Отправка текущей или выбранной точки доступна через меню вложения. Разрешение location запрашивается только в этом сценарии. + +**GEO-002.** При denied/отсутствии GPS доступен ручной выбор координат на карте либо ввод точки. Live tracking в scope не входит. + +**GEO-003.** Передаётся совместимый geo URI: finite latitude −90…90, longitude −180…180, optional nonnegative uncertainty. UI не выдаёт число знаков после запятой за точность датчика. + +**GEO-004.** Карта/tiles provider и передача ему coordinates отражены в privacy конфигурации решения. OMEMO чата не скрывает запрос карты от её провайдера. + +## 18. Локальная история и MAM + +**HIST-001.** История хранится локально с изоляцией аккаунтов. Существующие сообщения доступны при временном отсутствии сети, если устройство/профиль допускает persistent storage. + +**HIST-002.** На первом входе загружается ограниченное недавнее окно, baseline 40 сообщений. Полная многолетняя история не блокирует запуск; старое догружается по запросу/прокрутке. + +**HIST-003.** Account archive и каждая MUC имеют собственные checkpoint. Cursor непрозрачен, не вычисляется из local id и не переносится между архивами. + +**HIST-004.** Incremental запрос использует stored cursor. При его истечении допустим однократный timestamp fallback с overlap 60 с. Цикл cursor↔timestamp запрещён. + +**HIST-005.** Проверяются query id, допустимый источник, порядок завершения и размер страницы. Чужой результат/overflow не считается успешной историей. + +**HIST-006.** Missing/repeating cursor у incomplete page прекращает проход с recoverable error. Не допускаются вечный spinner и пропуск остатка как якобы complete. + +**HIST-007.** Архивные mutations применяются согласованными пачками. Durable checkpoint продвигается только после успешной локальной транзакции соответствующих данных. Ошибка диска сохраняет предыдущую точку восстановления. + +**HIST-008.** Backward pagination берёт следующий before из server RSM first даже если страница содержала только reactions/retractions/дубликаты. + +**HIST-009.** Pending correction/retraction/reaction/read marker применяются после target. Очереди ограничены и очищаются по политике; overflow не создаёт crash. + +**HIST-010.** Расшифрование и большие batches уступают время UI. Background/media capture/upload могут временно снижать архивную активность; возобновление не теряет checkpoint. + +**HIST-011.** Автоматические retries ограничены; после исчерпания показывается recoverable state и доступно повторить. Незавершённый проход не объявляется «История полностью загружена». + +**HIST-012.** Локальная миграция выполняется транзакционно; исходный формат удаляется после успешной записи. Upgrade не должен уничтожать единственную durable копию данных. + +### 18.1. Baseline ограничений + +| Параметр | Значение | +| --- | --- | +| MAM page/bootstrap | 40 / 40 | +| Максимум buffered stanza одной query | 96 | +| Decode slice | 8, с уступкой UI между срезами | +| Ориентир порции применения | До 2 страниц или 2 с перед yield/commit | +| Query/apply timeout | 20 / 20 с | +| Interactive backward query timeout | 15 с | +| Межстраничная пауза | 100 мс | +| Page retry | 1 дополнительный | +| Automatic catch-up recovery | До 3 повторов, пауза 20 с | +| Pending corrections/retractions | До 100 каждого вида | +| Pending reactions | До 200 | + +Порция работы не ограничивает всю историю двумя страницами: после yield допустимо продолжение. Реализация может уменьшать slice по нагрузке и увеличивать configurable server-compatible limits после измерений, но не отменять контроль объёма. + +## 19. Звонки + +**CALL-001.** Поддерживаются аудио-/видеозвонки один на один, один активный звонок на клиентский контекст. Второй входящий/исходящий получает определённый busy/reject сценарий. + +**CALL-002.** До запуска запрашиваются microphone/camera permissions. Есть incoming accept/decline, connecting, connected, elapsed и завершение. + +**CALL-003.** Доступны mute, camera enable/disable, switch camera при нескольких камерах и speaker/audio output выбор в пределах платформенных возможностей. + +**CALL-004.** Используется совместимый Jingle/JMI signaling и WebRTC media с ICE/DTLS-SRTP. Endpoint выбирается как подходящий full JID; последующие сообщения согласованы с тем же ресурсом. + +**CALL-005.** SDP media sections сохраняют порядок offer. ICE candidates удерживаются до готового remote description, затем передаются/применяются без вечного ожидания bare/full JID. + +**CALL-006.** STUN/TURN обнаруживаются через extdisco или утверждённую конфигурацию. Проверяются scheme, host/port, transport, expiration и credentials. Невалидная запись не приводит к crash медиадвижка. + +**CALL-007.** TURN relay необходим для утверждённой тестовой матрицы сложных сетей. Отсутствие relay показывается диагностически; STUN не описывается как гарантия соединения за любым NAT. + +**CALL-008.** Внешний fallback STUN допускается только как задокументированный конфигурационный выбор. Сервис-получатель сетевых адресов отражается в privacy документации. + +**CALL-009.** Смена сети/краткая ICE failure имеют ограниченное recovery окно. После него звонок завершается корректно, освобождает tracks/camera/microphone и снимает active state. + +**CALL-010.** Закрытие экрана звонка не может оставить скрыто включённый microphone/camera вопреки показанному состоянию. Background behavior задаётся интеграцией платформы, не визуальным состоянием окна. + +### 19.1. История + +**CALL-011.** Каждый звонок создаёт одну history card: направление, media type, начало, duration и outcome. + +Outcomes: completed, declined, missed, cancelled, unanswered, failed, answeredElsewhere. Если media было connected, последующая transport error не переименовывает состоявшийся разговор в неудачную попытку; duration сохраняется. + +Классификация до connected: отказ стороны → declined; собственная отмена → cancelled; timeout incoming ringing → missed; timeout outgoing ringing → unanswered; сбой negotiation → failed; подтверждённый ответ другим собственным устройством → answeredElsewhere. + +**CALL-012.** Missed incoming увеличивает unread. Дубликаты completion callbacks не создают несколько карточек. + +**CALL-013.** Call history синхронизируется с собственными устройствами через совместимое расширение и MAM/Carbons, если сервер хранит такие события. Metadata не выдаётся за OMEMO body. + +### 19.2. Baseline таймеров + +| Фаза | Значение | +| --- | --- | +| Ringing | 60 с | +| Negotiation | 90 с | +| ICE failed grace | 8 с | +| ICE disconnected grace | 12 с | + +Таймеры должны согласовываться с сервером и remote signaling; они не гарантируют фонового пробуждения. + +## 20. Уведомления, push и фоновая работа + +**PUSH-001.** Поддерживаются локальные уведомления для новых событий активного клиента и отдельная push-интеграция для разрешённых платформенных состояний. + +**PUSH-002.** Notification создаётся для нового incoming, не для outgoing/повторного MAM. В активно просматриваемом чате дублирующий banner подавляется. + +**PUSH-003.** Уведомление содержит безопасную идентичность account/conversation/event для перехода в правильный контекст. Повторная доставка push и network event дедуплицируется. + +**PUSH-004.** Default privacy: push payload не содержит plaintext body, file key или credentials. Preview после получения сообщения определяется пользовательской настройкой; при app lock default preview скрыт. + +**PUSH-005.** Gateway/registration lifecycle включает register/update token, enable по XMPP, disable/logout/revoke и обработку expired token. Наличие server push feature без registration не отображается как «Push работает». + +**PUSH-006.** Push — сигнал получения/пробуждения, а не источник истины истории. После него клиент синхронизируется с сервером; утерянный push не должен навсегда потерять сообщение. + +**PUSH-007.** Базовый single-active-account режим регистрирует доставку для активного аккаунта; переключение согласует старую и новую registration. Уведомления неактивных аккаунтов не обещаются без отдельно реализованного multiaccount background режима. Поздний push сохраняет исходную account identity и не открывает переписку другого аккаунта. + +### 20.1. Web + +**WEB-001.** Для уведомлений вне открытой страницы поставляется поддержанный Web Push/service worker путь и gateway, если браузер/пользователь предоставили возможность. При отсутствии поддержки/permission приложение честно показывает статус «Только при открытом приложении». + +**WEB-002.** Закрытая страница не считается постоянно подключённой XMPP-сессией. Service worker notification не гарантирует непрерывный microphone, медиазвонок или автоматический показ полноэкранного incoming UI. + +**WEB-003.** Incoming push может уведомить о вызове и открыть приложение по действию. После открытия проверяются актуальность и состояние вызова; истёкший вызов не «принимается» из старого notification. + +**WEB-004.** Несколько вкладок координируют активное соединение/device state либо используют изолированные ресурсы с безопасным доступом к crypto state. Не допускаются конкурентные повреждающие записи ratchet sessions. + +### 20.2. Desktop + +**DESK-001.** Пользователь понимает разницу «закрыть окно», «скрыть/свернуть» и «выйти из приложения». Работа в tray/фоновой сессии и автозапуск — явно управляемые настройки. + +**DESK-002.** При живом фоновом процессе поддерживаются сообщения и incoming calls. Полностью завершённый процесс не получает обещание гарантированного пробуждения без отдельного системного механизма. + +**DESK-003.** Sleep/wake, network change и logout восстанавливают/отключают соединение и push registration корректно. Tray icon не показывает active call после завершения. + +### 20.3. Android + +**AND-001.** Поставляется интеграция системной доставки уведомлений и входящих вызовов для утверждённого режима устройства. Выбор push provider и доступность устройств без соответствующего сервиса фиксируются в паспорте релиза. + +**AND-002.** Фоновая работа соответствует lifecycle/permissions ОС. Постоянный socket в обычном скрытом UI не используется как единственная гарантия доставки. + +**AND-003.** Для ongoing call используется разрешённый системный foreground/call механизм с видимым статусом; control из notification согласован с app state. + +**AND-004.** Force stop, отозванные разрешения, ограничения батареи и отключённая сеть имеют задокументированную деградацию. Приложение не обещает обход пользовательского запрета ОС. + +### 20.4. Приёмка интеграции + +Push и фоновые звонки нельзя принять только по работающему foreground demo. Для каждой платформы проверяются foreground, скрытый UI, закрытый процесс/вкладка, denied permission, sleep, token rotation, logout и переход по старому notification. Недоступные платформой режимы обозначаются как ограничения, а не как выполненная гарантия доставки. + +## 21. Локальная защита и хранение + +**STORE-001.** История, crypto state и настройки раздельны по accountId; операции сохранения транзакционны либо имеют проверяемый equivalent recovery механизм. + +**STORE-002.** Native credentials защищены системным secure storage; private key material защищается подходящим устройству key wrapping/access control. Пароль не хранится в открытом preferences файле. + +**STORE-003.** В Web пароль не хранится plaintext в localStorage, URLs, analytics или общедоступных configuration objects. Persistent login и разблокировка crypto state используют отдельное задокументированное защищённое решение; отсутствие такого решения означает повторный вход, а не незаметное ослабление. + +**STORE-004.** Web storage может иметь квоты/очистку. Приложение обрабатывает quota/exhaustion и показывает, когда история не сохранена устойчиво. Browser storage не представляется эквивалентом hardware-backed native vault. + +**STORE-005.** Media cache имеет лимит и eviction. Draft/failed payload не исчезает без объяснения; при невозможном retry действие отключено с причиной. + +**STORE-006.** Автосохранение не теряет введённый текст при переходах/обычном восстановлении. Draft принадлежит account + conversation; он не переносится в другой чат из-за reuse UI. + +**STORE-007.** Миграции versioned и тестируются на старых данных/сбоях. Ошибка открытия persistent store показывает ограниченный режим, а не ложное подтверждение durable записи. + +**LOCK-001.** App lock скрывает всю sensitive UI: чат, media, call details и previews. Поддерживаются пользовательский passcode и доступная системная биометрия. + +**LOCK-002.** Минимальная длина passcode 4 символа, предусмотрены ограничение частоты ошибочных попыток и корректное хранение verifier/секрета. App lock не объявляется шифрованием базы без отдельного encryption механизма. + +**LOCK-003.** После запуска и ухода в background lock применяется по настройке. Краткая потеря focus для системного picker/permission dialog не прерывает незавершённую операцию. + +**LOCK-004.** Web не обещает native biometric unlock на любом браузере; при отсутствии защищённой интеграции остаётся passcode либо повторная авторизация. Lock settings показывают фактическую возможность. + +## 22. Приватность и безопасность + +**SEC-001.** Документируется разделение transport TLS, OMEMO payload, file encryption, DTLS-SRTP media, local protection и UI lock. Термины не подменяют друг друга. + +**SEC-002.** Server/upload/STUN/TURN/maps/push providers получают только необходимые данные. Перечень providers и metadata доступен пользователю. + +**SEC-003.** Логи исключают passwords, tokens, private/session keys, decrypted bodies и file key fragments. Сетевые трассы по умолчанию обезличены; диагностический экспорт требует явного действия и предупреждения о metadata. + +**SEC-004.** Полученные text/filenames/URLs/XML не выполняются как HTML/script/команда. Web rendering безопасен для XSS; переход по внешним URL не передаёт секретный контекст. + +**SEC-005.** У ограничений входных данных есть предельный размер/depth/count. Недоверенные stanza, challenges, media metadata и custom sync parsers не должны создавать бесконечную работу/выделение памяти. + +**SEC-006.** Copy/export явно выводит plaintext за границы клиента; локальная очистка не обещает удалить clipboard или пользовательский файл. + +**SEC-007.** «Удалить у всех» — протокольный отзыв; «Удалить локальные данные» — удаление собственного профиля. В UI нет обещания абсолютного стирания всех копий. + +**SEC-008.** Для push/служебной own-device синхронизации проверяется источник и принадлежность аккаунту. Remote peer не может подделать собственную call/read history только прислав похожий XML element. + +## 23. Совместимость протоколов + +Обязательный профиль включает: + +| Область | Протокол/семантика | +| --- | --- | +| XMPP | Базовые stream/session, roster, presence; XEP-0030 discovery | +| Web transport | Защищённый XMPP WebSocket endpoint | +| Устойчивость | XEP-0198 Stream Management, XEP-0199 Ping, XEP-0280 Carbons, XEP-0352 CSI | +| Группы | XEP-0045 MUC, invitations/room configuration и MUC-MAM | +| История | XEP-0313 MAM, XEP-0059 RSM, XEP-0359 origin-id/stanza-id | +| Доставка | XEP-0184 Receipts и XEP-0333 Chat Markers | +| Действия | XEP-0308 Correction, XEP-0461 Reply, XEP-0428 Fallback, XEP-0444 Reactions, XEP-0424 Retraction | +| Медиа | XEP-0363 HTTP Upload, XEP-0066 OOB, XEP-0454 encrypted file URI | +| Защита | XEP-0384: совместимые legacy/OMEMO 2; XEP-0420 SCE для нового формата | +| Аватары | XEP-0084 User Avatar PEP, optional legacy compatibility | +| Звонки | XEP-0166/0167 Jingle/RTP, XEP-0353 JMI, ICE/DTLS-SRTP, XEP-0215 extdisco | +| Push | XEP-0357 registration и платформенная доставка | + +### 23.1. Own-device расширения + +Для совместимости устройств Luma используются согласованные пространства имён: + +- `https://luma.chat/read-marker`: conversation JID, target origin/client id, optional stanza id и timestamp; без видимого body; +- `https://luma.chat/call-history`: peer JID, direction, outcome, audio/video, startedAt, duration и устойчивый event id. + +Wire names read-marker: `conversation`, `id`, optional `stanza`, `when`; call-history: `with`, `direction`, `status`, `video`, `start`, `duration`. Даты сериализуются ISO 8601 с timezone, duration — неотрицательные секунды, ids — непрозрачные строки. Origin-id внешней stanza служит устойчивой идентичностью события. Отсутствие неизвестной новой версии не должно ломать ранее поддержанный payload. + +Схемы versioned при изменении; неизвестные поля игнорируются безопасно, обязательные валидируются. Повторная доставка идемпотентна. Совместимость с существующими payload не ломается без миграции. Эти расширения не считаются универсально поддерживаемыми всеми XMPP-клиентами. + +### 23.2. Матрица испытаний + +Релиз подтверждает обмен с минимум двумя независимыми XMPP-клиентами из утверждённого набора и минимум одним поддерживаемым сервером. Отдельно проверяются legacy/OMEMO 2, direct/MUC, media и calls. Поддержка XEP по имени без сценариев interoperability недостаточна. + +## 24. Нефункциональные требования + +**NFR-001.** UI Kit является обязательным стандартом visual/interaction behavior. Small/wide layout определяется доступной шириной, не строкой User-Agent. + +**NFR-002.** Списки/лента виртуализируются или используют equivalent bounded rendering. Большой архив не создаёт все визуальные rows одновременно. + +**NFR-003.** Проект задаёт измеряемые бюджеты startup, RAM, cache и frame responsiveness для утверждённых reference devices. До утверждения devices не заявляется произвольный универсальный SLA. + +**NFR-004.** Encryption/decoding/uploads уступают ресурсы active input/capture. Scroll и text entry остаются доступными во время catch-up. + +**NFR-005.** Системные permissions запрашиваются по контексту. Denied, hardware missing и revoked during session входят в тестовую матрицу. + +**NFR-006.** Web/Desktop полностью управляются клавиатурой; Android поддерживает системную accessibility. Focus, text scale, reduced motion и contrast проверяются по UI Kit. + +**NFR-007.** Сборки воспроизводимы: pinned/locked dependencies, migrations, build instructions, CI, license notices и release artifacts. Выбор framework не включён в пользовательские требования. + +**NFR-008.** CI запускает unit/integration checks и smoke UI tests для поставляемых targets. Подпись/публикация и secrets отделены от публичного репозитория. + +**NFR-009.** Crash/error reports не включают sensitive content по умолчанию. Телеметрия имеет утверждённую privacy-конфигурацию. + +## 25. Критерии приёмки + +Каждый сценарий выполняется на каждой заявленной платформе либо имеет заранее согласованную аппаратную/системную деградацию. «Не удалось проверить» не равнозначно «Выполнено». + +| Сценарий | Ожидаемый результат | Требования | +| --- | --- | --- | +| Первый вход/ошибка/перезапуск | Верный lifecycle, история сохранена, secrets не раскрыты | ACC-001…010 | +| Нечисловой port / WSS endpoint | Понятная validation и правильный транспорт | ACC-003…005 | +| Переключение во время upload | Нет отправки/записи от другого аккаунта | ACC-008,014 | +| Logout с удалением/сохранением | Объяснённая область очистки и push disable | ACC-011…013 | +| TLS/auth отказ | Нет ослабления доверия или подделанного PLUS | NET-001…005 | +| Смена сети/sleep/reconnect | Один согласованный клиент, без duplicate | NET-006…009 | +| Text/receipts/displayed | sending/sent/delivered/read имеют разные основания | MSG-001…005 | +| Replay live/Carbons/MAM | Одна запись, правильные ids/delivery | MSG-004,005 | +| Draft переход между чатами | Текст остаётся у правильного recipient/account | STORE-006 | +| Edit/retract до оригинала | Отложенное корректное применение | EDIT-003, DEL-003 | +| Чужая correction/retraction | Не изменяет чужое сообщение | EDIT-002, SEC-008 | +| Reply XML/legacy/MUC | Корректный preview/reference без вымышленного id | REPLY-001…005 | +| Multi-forward и ошибка | Порядок, новая encryption policy, partial result | FWD-001…004 | +| Local delete + MAM | Удалённое не возвращается | DEL-001 | +| Реакция/снятие/старое событие | Count/own state и порядок актуальны | REACT-001…005 | +| Typing timeout/отключение | Нет вечного composing | TYPE-001…003 | +| Group hidden recipients | Fail closed, без plaintext | MUC-003…006 | +| OMEMO2/legacy/changed identity | Совместимый формат, правильное доверие | ENC-003…009 | +| Upload HTTPS/redirect/error | Secret-safe transport и bounded retry | FILE-004…012 | +| 20 media, partial failure, caption | Ошибки не уничтожают успешное/оставшееся | FILE-002,009 | +| Viewer/player/export cancel | Correct ratio/control и отсутствие ложного success | MEDIA-001…008 | +| Permissions/short record/early release | Нет скрытой capture и autosend partial | REC-001…006 | +| Location denied/manual | Точка отправляется без фонового tracking | GEO-001…004 | +| Fresh MAM/repeating cursor/disk failure | Checkpoint не теряется/не перескакивает | HIST-002…012 | +| JMI/full JID/SDP/late ICE | Correct endpoint и ограниченный recovery | CALL-004…009 | +| Call outcome/sync/duplicate callback | Одна правильная history card | CALL-011…013 | +| Push и network event вместе | Одна notification/event, правильный чат | PUSH-001…006 | +| Web вкладка закрыта / stale call | Notification/open по доступной платформе, без ложного active call | WEB-001…004 | +| Desktop скрыт/завершён/sleep | Поведение соответствует заявленному lifecycle | DESK-001…003 | +| Android background/permission/force stop | Проверенная доставка и честная деградация | AND-001…004 | +| Lock / preview / export | Sensitive UI скрыта, границы защиты объяснены | LOCK-001…004, SEC-006 | +| Quota/migration/cache eviction | Нет ложного durable success/повреждения | STORE-001…007 | +| Keyboard/text scale/dark/reduced motion | Доступный интерфейс | NFR-006, UI Kit | + +## 26. Состав результата разработки + +1. Устанавливаемое/развёрнутое приложение каждого согласованного target. +2. Исходники, locked dependencies, reproducible build и CI. +3. Реализованный набор UI components/tokens по `uikit_clear.md`. +4. Версионированные data schemas/migrations и протокольные adapters. +5. Push/gateway/TURN/WSS конфигурация в пределах выбранной инфраструктуры, без secrets в репозитории. +6. Unit, integration, interoperability и UI/accessibility tests с результатами. +7. Пользовательская справка, privacy/security описание и известные ограничения. +8. Паспорт релиза и checklist поддерживаемых систем. + +## 27. Паспорт конкретного приложения + +До начала platform-specific реализации фиксируются решения, не изменяющие пользовательский контракт: + +| Решение | Что должно быть указано | +| --- | --- | +| Поддерживаемые targets | Desktop ОС, Android min/target, Web браузеры и режим PWA | +| Технологии | UI/runtime, XMPP/crypto/media libraries и стратегия shared code | +| Транспорт | Native discovery и Web endpoint configuration | +| Backend | WSS, push gateway/provider, TURN, upload и maps | +| Secrets | Native vault, Web persistent-login/crypto unlock модель | +| Lifecycle | Что означает закрыть окно/вкладку, background и explicit quit | +| Лимиты | Размеры файлов/cache, pending limits, performance reference devices | +| Совместимость | Серверы/клиенты и редакции encrypted wire formats | +| Дистрибуция | Signing, обновления, hosting и каналы поставки | + +Паспорт заполняется до выпуска. Отсутствующее решение не заменяется непроверенным обещанием общей поддержки всех ОС, браузеров или фоновых состояний. diff --git a/uikit_clear.md b/uikit_clear.md new file mode 100644 index 0000000..8c729f8 --- /dev/null +++ b/uikit_clear.md @@ -0,0 +1,613 @@ +# Luma UI Kit — единый стандарт Web, Desktop и Android + +Версия: 1.0. Дата: 14 октября 2026 года. Статус: целевой стандарт интерфейса. + +Связанный документ: [техническое задание](tz_clear.md). + +## Содержание + +- [1. Назначение](#1-назначение) +- [2. Дизайн-принципы](#2-дизайн-принципы) +- [3. Единицы и токены](#3-единицы-и-токены) +- [4. Палитра](#4-палитра) +- [5. Типографика](#5-типографика) +- [6. Отступы, размеры и формы](#6-отступы-размеры-и-формы) +- [7. Поверхности, границы и elevation](#7-поверхности-границы-и-elevation) +- [8. Адаптивная компоновка](#8-адаптивная-компоновка) +- [9. Иконки](#9-иконки) +- [10. Компонент Avatar](#10-компонент-avatar) +- [11. Компонент ConversationRow](#11-компонент-conversationrow) +- [12. SearchField](#12-searchfield) +- [13. MessageRow и Bubble](#13-messagerow-и-bubble) +- [14. CompactReply и ThreadFocus](#14-compactreply-и-threadfocus) +- [15. ReactionChip и EmojiPicker](#15-reactionchip-и-emojipicker) +- [16. Composer](#16-composer) +- [17. MediaIsland и viewer](#17-mediaisland-и-viewer) +- [18. AttachmentPreview](#18-attachmentpreview) +- [19. LocationPicker](#19-locationpicker) +- [20. CallScene и CallHistoryCard](#20-callscene-и-callhistorycard) +- [21. Auth, Settings, Encryption и Lock](#21-auth-settings-encryption-и-lock) +- [22. Feedback, dialogs и слои](#22-feedback-dialogs-и-слои) +- [23. Анимации и жесты](#23-анимации-и-жесты) +- [24. Accessibility и ввод](#24-accessibility-и-ввод) +- [25. Каталог состояний и приёмка UI](#25-каталог-состояний-и-приёмка-ui) +- [26. Развитие стандарта](#26-развитие-стандарта) + +## 1. Назначение + +UI Kit определяет визуальный язык, токены, компоненты, состояния и адаптацию Luma для Web, Desktop и Android. Документ применяется независимо от UI framework и не требует конкретной реализации нативных материалов, шрифтов или системных иконок. + +Цель — узнаваемый продукт с одинаковой иерархией и понятным поведением на разных устройствах. Совпадение физического пикселя между ОС не требуется; соответствие ролям, пропорциям и состояниям обязательно. + +Документ нормативный: значения токенов реализуются централизованно, компоненты показываются в отдельном каталоге состояний. Примеры токенов и размеров не обозначают уже написанную библиотеку. + +## 2. Дизайн-принципы + +**UI-001.** Контент и общение находятся на первом плане. Декор не конкурирует с текстом, avatar и основным действием. + +**UI-002.** Основной accent — синий. Outgoing сообщения синие, incoming нейтральные. Направление различается также выравниванием, а состояние — иконкой/подписью. + +**UI-003.** Элементы округлые, поверхности спокойные, navigation и forms следуют привычкам платформы. Нет обязательного копирования внешнего вида одной ОС на другую. + +**UI-004.** Полупрозрачность поддерживает иерархию, но не ухудшает чтение. При отсутствии blur/пониженной прозрачности используется определённый opaque fallback. + +**UI-005.** Состояния loading/empty/error/disabled/selected являются частью компонента, а не отдельной импровизацией каждого экрана. + +**UI-006.** Жест ускоряет действие, но для значимого действия есть видимый/доступный альтернативный control. + +**UI-007.** Пользовательские строки — русские, короткие и точные. «Отправлено», «Доставлено» и «Прочитано» не заменяют друг друга. + +**UI-008.** Маленькое окно, клавиатура, увеличение текста и reduced motion поддерживаются системно, а не исправляются после завершения дизайна. + +## 3. Единицы и токены + +| Величина | Web | Android | Desktop | +| --- | --- | --- | --- | +| Layout unit `u` | CSS px при стандартном масштабе | dp | Логическая единица UI, independent от device pixels | +| Размер текста | rem/em с role mapping | sp с font scale | Системный scaling / logical font size | +| Border | 1 CSS px или адаптированный hairline | 1 dp по роли | Логическая линия с корректным pixel snapping | +| Анимация | ms | ms | ms | +| Цвет | sRGB HEX + alpha | Эквивалент sRGB | Эквивалент sRGB | + +При изменении device pixel ratio компонент не должен физически уменьшаться. Растровые assets поставляются в нужных масштабах; logo и функциональные icons предпочтительно в vector. + +### 3.1. Обязательные группы токенов + +`color.*`, `type.*`, `space.*`, `radius.*`, `size.*`, `border.*`, `shadow.*`, `motion.*`, `layer.*`, `breakpoint.*`. + +Каждый токен имеет light/dark значение или единое значение, назначение и допустимое применение. Локальная константа не заменяет существующий токен тем же смыслом. + +## 4. Палитра + +### 4.1. Semantic colors + +| Токен | Light | Dark | Назначение | +| --- | --- | --- | --- | +| `color.brand.accent` | #248FF5 | #68B5FF | Выбор, active icon, ссылка по проверенному контрасту | +| `color.action.fill` | #0969C8 | #0969C8 | Filled primary button с белым текстом | +| `color.action.onFill` | #FFFFFF | #FFFFFF | Текст primary action | +| `color.text.primary` | #172B40 | #F3F6FA | Основной текст | +| `color.text.secondary` | #526174 | #B8C3CF | Вторичный текст | +| `color.text.muted` | #5E6C7E | #A7B4C4 | Метаданные при достаточном контрасте | +| `color.surface.page` | #F4F7FA | #101821 | Фон приложения | +| `color.surface.base` | #FFFFFF | #18232F | Основные панели | +| `color.surface.raised` | #FFFFFF | #213040 | Dialog/card/popover | +| `color.surface.incoming` | #E7ECF2 | #293847 | Incoming bubble | +| `color.border.subtle` | #D7DFE8 | #3C4B5D | Divider/декоративная рамка | +| `color.border.control` | #7A899A | #8794A3 | Значимая граница control | +| `color.state.success` | #16713B | #6DDA91 | Успех/online/verified | +| `color.state.successFill` | #16713B | #16713B | Filled accept/success control, белый текст | +| `color.state.warning` | #8A4B00 | #FFC46B | Plaintext/предупреждение | +| `color.state.error` | #B4232F | #FF9199 | Ошибка/destructive text | +| `color.state.errorFill` | #B4232F | #B4232F | Destructive filled control | +| `color.state.onErrorFill` | #FFFFFF | #FFFFFF | Текст destructive filled control | +| `color.focus.ring` | #0969C8 | #8BC6FF | Keyboard focus | +| `color.overlay.scrim` | #000000 / 0.52 | #000000 / 0.64 | Модальная подложка | + +Brand accent используется как цвет бренда; это не разрешение печатать мелкий белый текст на любой голубой поверхности. Filled actions используют `action.fill`. Для ссылок на светлом фоне применяется достаточно тёмный `action.fill` либо другой проверенный role token. + +### 4.2. Сообщения и градиенты + +| Токен | Значение | Назначение | +| --- | --- | --- | +| `gradient.message.outgoing` | #0969C8 → #0874BA | Outgoing text/audio/file bubble | +| `color.message.onOutgoing` | #FFFFFF | Основной текст outgoing | +| `color.message.outgoingMetadata` | Основной secondary вне bubble | Дата/status за пределами синей поверхности | +| `gradient.brand.hero` | #1461C7 → #1F9EF5 → #70D6F5 | Login/lock background, декоративная область | +| `gradient.media.video` | #0F2B4A → #176EA3 | Video placeholder | +| `gradient.media.videoNote` | #144D8F → #12A3D4 | VideoNote placeholder | +| `gradient.call` | #1C2B3D → #000000 | Call scene | + +Направление градиентов — от верхнего левого угла к нижнему правому. Для текстовых bubbles используется тёмный сине-голубой градиент, чтобы белый body оставался читаемым; более яркий hero gradient остаётся декоративным. + +Для обычного белого текста расчётный контраст с концами outgoing gradient — около 5.43:1 и 4.98:1. Контраст промежуточных цветов и всех фактических состояний проверяется при реализации. Полупрозрачный secondary text внутри bubble не должен снижать контраст ниже целевого порога. + +### 4.3. Аватары и имена участников + +Fallback avatar получает стабильный цвет по нормализованной identity. Цвет не меняется при rerender/перезапуске. Palette должна обеспечивать читаемый белый или тёмный foreground; случайный hue не используется без выбора контрастного текста. + +Имя автора MUC может иметь устойчивый цвет, но не является единственным признаком identity. Имя остаётся читаемым в обеих темах. Цвет для имени и фон аватара могут отличаться по насыщенности. + +### 4.4. Запрещённые применения + +- Новый близкий синий для той же роли без добавления token. +- Белая мелкая подпись на ярком accent без проверки контраста. +- Ошибка/online/selected, различимые только цветом. +- Hardcoded light background с унаследованным dark foreground из другой темы. +- Прозрачный control без определённого фона/границы/focus state. + +## 5. Типографика + +Используется системный sans-serif шрифт платформы. Единая роль важнее одинакового имени font family. Fallback поддерживает кириллицу, emoji и символы JID. Mono нужен для fingerprint, не для всего интерфейса. + +| Токен | Размер / line height | Weight | Применение | +| --- | --- | --- | --- | +| `type.brand` | 42 / 50 | 700 | Название на login | +| `type.hero` | 30 / 38 | 600 | Имя звонящего | +| `type.title` | 22 / 28 | 700 | Крупный заголовок формы/dialog | +| `type.heading` | 17 / 24 | 600 | Имя списка, toolbar title | +| `type.body` | 16 / 24 | 400 | Сообщение, поле, основной текст | +| `type.bodyStrong` | 16 / 24 | 600 | Выделенный текст/primary label | +| `type.subbody` | 14 / 20 | 400 | Preview списка, вторичные поля | +| `type.caption` | 12 / 16 | 400/500 | Метаданные, пояснения | +| `type.badge` | 12 / 16 | 700 | Unread/count | +| `type.mono` | 12 / 18 | 400 | Fingerprint/техническое значение | + +Размеры масштабируются пользовательскими настройками; фиксированная высота row не должна обрезать увеличенный текст. Desktop compact density может уменьшать вторичный текст до 13/18, но основной message body не становится мелкой служебной подписью. + +Таймеры/duration используют tabular numerals. Link/упоминания различимы, доступны клавиатуре и не ломают line wrapping. + +### 5.1. Ограничение строк + +- Имя и preview списка: 1 строка при обычном масштабе; при большом тексте допустим адаптивный рост. +- Имя звонящего: до 2 строк. +- Toast: до 2 строк, дальнейшее сообщение — dialog/inline error. +- Compact reply: 1 строка и ellipsis. +- Message body: без произвольного line clamp; длинный контент может иметь доступное «Показать полностью» только при отдельном согласованном лимите. +- JID/filename/fingerprint: безопасный wrap/ellipsis с возможностью прочитать/скопировать полное значение. + +## 6. Отступы, размеры и формы + +### 6.1. Spacing scale + +| Токен | u | +| --- | --- | +| `space.1` | 4 | +| `space.2` | 8 | +| `space.3` | 12 | +| `space.4` | 16 | +| `space.5` | 20 | +| `space.6` | 24 | +| `space.7` | 32 | +| `space.8` | 40 | +| `space.9` | 48 | + +Компонентные исключения допустимы для оптического выравнивания: bubble padding 13×9, input 14/12/9, metadata gap 4. Они именуются внутри компонента, не создают бессистемный глобальный scale. + +### 6.2. Радиусы + +| Токен | u | Применение | +| --- | --- | --- | +| `radius.small` | 6 | Reply thumbnail | +| `radius.control` | 12 | Компактная карточка/системный fallback | +| `radius.field` | 16 | Caption field | +| `radius.reply` | 17 | Compact reply | +| `radius.media` | 20 | Photo/video island | +| `radius.composer` | 21 | Поле composer | +| `radius.dialog` | 28 | Auth/dialog card | +| `radius.pill` | Половина высоты | Badge/chip/toast | +| `radius.circle` | 50% | Avatar/round control | + +Message bubble имеет отдельную directional geometry: основное скругление 20, нижняя асимметрия/небольшой хвост на стороне отправителя с выступом около 4 u. Он не должен уменьшать внутреннюю область текста. Vector/path реализации обеих направлений являются зеркальными вариантами одного компонента. + +Фото/видео не получают текстовый хвостик: у них самостоятельная островковая рамка. + +### 6.3. Контролы + +| Роль | Visual size | Hit area | +| --- | --- | --- | +| Composer send/attach/record | 42×42 | Touch min 48×48; pointer min 44×44 | +| Audio play | 44×44 | Touch min 48×48 | +| Standard icon button | 24 icon в 40/44 visual | Touch min 48×48 | +| Primary form action | Height min 48 | Вся кнопка | +| Call control | 58×58 circle | Не меньше visual size | +| Unread badge | min height 22 | Не отдельная обязательная target | +| Input | min height 44, composer visual min 42 | Не обрезается при scale | + +Увеличение hit area не должно перекрывать target соседней кнопки. При узкой ширине gap и общий layout перераспределяются. + +## 7. Поверхности, границы и elevation + +| Уровень | Поверхность | Применение | +| --- | --- | --- | +| Page | page | Фон, timeline | +| Base | base | Sidebar, forms, основной контент | +| Raised | raised + border/elevation | Popover, dialog, floating panel | +| Glass | Полупрозрачная base surface + blur | Search/composer/toolbars | +| Inverse | Тёмная сцена + контрастный текст | Media viewer/call | + +Glass light fallback: base opaque. Glass dark fallback: base opaque. При blur фон имеет достаточно непрозрачный слой для текста; blur не единственный источник читаемости. Reduced transparency отключает blur и сохраняет border. + +Shadows: + +- `shadow.floating`: black alpha 0.14, blur 24, offsetY 12 в light; более мягкий/ограниченный вариант в dark; +- `shadow.toast`: black alpha 0.18, blur 12, offsetY 4; +- `shadow.callPreview`: black alpha 0.38, blur 16, offsetY 8. + +List row не получает отдельную тяжёлую тень. Border subtle декоративный; значимые focus/control boundaries используют более контрастный role token. + +## 8. Адаптивная компоновка + +### 8.1. Breakpoints + +Это продуктовые диапазоны доступной ширины, а не определение типа устройства. + +| Диапазон | Ширина | Layout | +| --- | --- | --- | +| Compact | <720 u | Одна основная панель, переход список → чат | +| Medium | 720…1099 u | Sidebar + detail при достаточной полезной ширине | +| Wide | ≥1100 u | Sidebar + detail, расширенная зона контента | + +В medium split включается только если после sidebar остаётся минимум 400 u для detail; иначе compact. Приложение работает при viewport 320 u через reflow; специальные фиксированные размеры уменьшаются до доступной ширины. + +### 8.2. Sidebar и detail + +Sidebar min 240, preferred 290, max 380. Default desktop window ориентир 1080×720, рекомендуемый minimum 840×560; при фактически меньшем окне допускается compact layout, а не обрезанный недоступный контент. + +Detail растёт с окном. Максимальная читаемая ширина обычного text bubble — 640 u и не более 78% доступной message row. Очень короткий текст не растягивается до максимума. Timeline может иметь центрированный content column с max 1000 u. + +### 8.3. Platform shell + +| Платформа/режим | Navigation | +| --- | --- | +| Android compact | Нижняя навигация: Контакты / Звонки / Чаты / Настройки | +| Mobile Web | Аналогичная compact bottom navigation, с учётом browser safe areas | +| Desktop/Web wide | Sidebar navigation/list + выбранный detail | +| Android large screen | Adaptive split/rail по доступной ширине | + +Внутри compact чата нижняя глобальная навигация скрыта, Back возвращает к списку. Search остаётся сверху соответствующего списка. Keyboard/IME не перекрывает composer; изменение viewport не сбрасывает draft. + +## 9. Иконки + +Единый набор outline/filled vector icons выбирается для каждой платформы с согласованной семантикой. Использование конкретного proprietary symbol set не требуется. Толщина, оптический размер и baseline согласованы в каталоге. + +| Роль | Графическая семантика | +| --- | --- | +| Контакты | Человек/контакты | +| Звонки | Телефон | +| Чаты | Два сообщения | +| Настройки | Шестерёнка | +| Новый чат | Карандаш/новое сообщение | +| Новая группа | Несколько людей | +| Поиск/очистить | Лупа/крестик в круге | +| Вложения | Плюс | +| Отправить | Стрелка вверх | +| Ответить/переслать | Стрелка влево/вправо с поворотом | +| OMEMO/plaintext | Закрытый/открытый замок | +| Decryption error | Щит с предупреждением | +| Retry | Круговая стрелка | +| Сохранить | Стрелка вниз/скачивание | +| Завершить звонок | Опущенная трубка | +| Turn camera | Камера с круговыми стрелками | + +Обычный icon size 20/24 u; metadata 12/14. Иконка без текста имеет accessible name и tooltip на pointer, если смысл не очевиден. Emoji не заменяет функциональную пиктограмму кнопки. + +## 10. Компонент Avatar + +**UI-AVATAR.** Circle, photo fill с crop, fallback — initials/group icon и стабильный фон. Не смешивать photo fit с avatar fill: фотографии сообщений не обрезаются как avatar. + +| Вариант | Размер | +| --- | --- | +| List | 50×50 | +| Profile | 64×64 | +| Call hero | 132×132 | +| Compact inline | 32×32 при необходимом контексте | + +Initials около 35% diameter, semibold; group icon около 31%. Status dot bottom-end, diameter 25% avatar, border 2.5 u цветом окружающей поверхности. Direct dot означает online, group — joined. Accessible label включает имя и статус. Offline не рисуется ложным зелёным dot. + +Состояния: photo, fallback, loading cached image, online/joined, offline, image error. Broken image не создаёт пустой невидимый avatar. + +## 11. Компонент ConversationRow + +Композиция: avatar → текстовая колонка → trailing time/unread. Gap avatar/text 12. Внешние insets horizontal 12, vertical 8; высота определяется содержимым, ориентир 72 u. + +- Имя — heading. +- Preview — subbody secondary. +- Время — caption secondary. +- Межстрочный gap 4/5. +- Иконка group/encryption рядом с preview, не вместо имени. +- Badge count >0, white on action.fill, padding 7×3, min width 22. +- В основном списке значение выше 99 показывается «99+»; accessible name сообщает фактическое число. +- Multiaccount подпись — caption secondary, только когда нужно различать контекст. + +Состояния: default, hover на pointer, pressed, keyboard focus, selected wide-panel, unread, online, muted при наличии функции, loading/empty отдельно от row. + +Selected row: accent-tinted surface и ненавязчивый маркер/фон; hover слабее selected. Focus ring не исчезает на selected. Baseline focus outline 2 u с offset 2 u; при недостаточном контрасте используется двойная граница/контрастный separating layer. + +## 12. SearchField + +Поле располагается над фильтруемым списком. Height min 40 visual/48 touch target, radius pill либо control по platform shell. Horizontal inset 12, vertical 8, icon/text gap 8, outer margin 12. + +Leading search icon, placeholder «Поиск чатов»/«Поиск контактов»/«Поиск звонков». Clear button появляется при непустом запросе. Native input behaviors сохраняются: selection, keyboard focus, paste и Escape очистки/закрытия по контексту. + +Состояния: empty, filled, focused, disabled только при реальной невозможности, no results через отдельный empty state. Search field не обещает поиск message body, если реализована фильтрация списка. + +## 13. MessageRow и Bubble + +**UI-MESSAGE.** Incoming слева, outgoing справа. Outer horizontal inset 12, row gap 8. Opposite minimum space 54 для текста и 16 для media, с уменьшением в узком viewport. Body wrapping не расширяет страницу горизонтально. + +Message stack: + +1. optional group sender name; +2. optional compact source reply; +3. body/media; +4. reaction chips; +5. metadata. + +### 13.1. Body + +Text/file padding horizontal 13, vertical 9. Audio/voice — 13×10. Outgoing gradient + white; incoming neutral surface + primary. Body type.body. + +Plaintext/decryption warning расположен в metadata и доступен screen reader. Не добавлять замок каждому слову или watermark поверх читаемого текста. + +Text bubble использует общую directional rounded shape; media островок имеет независимый radius 20. Long URL/word безопасно переносится; copy возвращает исходный текст, а не строку с декоративными разрывами. + +### 13.2. Metadata + +Caption, gap 4, horizontal inset 4, вне основного colored body. Время hour:minute, marker «изм.», security warning и outgoing status. + +| Состояние | Icon/подпись | +| --- | --- | +| Sending | Часы / «Отправка…» | +| Sent | Галочка / «Отправлено» | +| Delivered | Отличимая delivery-галочка / «Доставлено» | +| Read | Заполненный/двойной read marker / «Прочитано» | +| Failed | Warning red / «Не отправлено», доступен retry по ТЗ | + +На compact можно оставить icon и доступный подробный label, на focus/hover показывать расшифровку. Статусы не различаются только tint. + +### 13.3. Tombstone + +Иконка удаления + «Сообщение удалено», subdued text/italic по доступности. Положение исходного сообщения сохраняется, прежние media/reaction/reply content не видны. Tombstone не приглашает к запрещённым edit/forward. + +### 13.4. Selection и context menu + +Touch: long press открывает actions; pointer: context click/явное menu; keyboard: доступная menu action. В selection mode видны checkmarks/count и панель массовых действий. Swipe reply отключён. + +Список actions определяется ТЗ. Недоступное действие скрывается либо disabled с причиной, но не выполняет частично другую операцию под тем же названием. + +## 14. CompactReply и ThreadFocus + +Compact source bubble: max width 230, min height 34, radius 17, vertical padding 7, horizontal 10…12. Text subbody medium, one line, optional thumbnail 27×27/radius 6. Тонкий border 1.25 u; фон приглушённый. + +Отступ/дуга отражают направление исходного сообщения. Curve width 58/height 34, stroke 2.4 round caps, horizontal offset около 18. Нельзя снижать opacity всего текста ниже readable contrast; muted effect достигается background/border, а не нечитаемой надписью. + +При известном target компонент interactive и имеет label «Открыть ветку ответа». При preview без target он статичен и не показывает ложный hover-link. + +ThreadFocus: затемнённый/raised отдельный слой, источник, число ответов, хронологическая ветка, selected reply metadata. Есть Close/Escape/Back и корректный focus restore. Bracket декоративный, не отдельный target. Это фокус replies в существующем чате. + +## 15. ReactionChip и EmojiPicker + +Chip: emoji + count, gap 3/4, padding 8×4, pill. Strip gap 5/8, wrap в узкой row. + +| Вариант | Представление | +| --- | --- | +| Чужие реакции | Neutral subtle background + readable border | +| Есть own reaction | Accent-tinted background + accent border + accessible own state | +| Hover/focus | Нативная/общая interactive обратная связь | +| Pending mutation | Ненавязчивый progress, без ложного подтверждения failure | + +Quick reactions: 👍 ❤️ 😂 😮 😢 🔥. Полный picker: header «Выберите эмодзи», Close/«Готово», grid, category navigation. Emoji visual 28, cell 46×46 с touch hit area минимум 48. Grid адаптивный; focused item виден и управляется keyboard. + +Text search в emoji picker не обязателен. Если добавлен, он следует общему SearchField и не меняет базовую семантику reactions. + +## 16. Composer + +Horizontal structure: attach → multiline input → send/record. Gap 8. Outer horizontal 10/12, top 6/8, bottom 8 плюс safe area. Control visuals 42×42, hit targets по разделу размеров. + +Input: min height 42 visual, radius 21, leading 14/trailing 12/vertical 9. Body font, от 1 до 6 строк до внутренней прокрутки. Placeholder «Сообщение»; edit mode «Изменить сообщение». Emoji control справа input, secondary, icon 20. + +- Непустой draft: send arrow accent/action. +- Пустой draft: record action с доступным выбором audio/video. +- Reply/edit banner: над input body, автор/preview и Cancel. +- Upload preparation: progress и доступный cancel, текст не теряется. +- Recording: elapsed, waveform/indicator, cancel, finish/lock. +- Selection: separate action bar, обычная отправка не конфликтует с массовыми actions. + +Keyboard contract Web/Desktop: Enter отправляет при настройке «Enter для отправки», Shift+Enter — новая строка; можно переключить поведение. IME composition не отправляет сообщение по промежуточному Enter. Esc закрывает локальный popup/отменяет reply/edit с сохранением исходного draft по сценарию. + +Touch recording gestures не являются единственным способом: есть доступные «Начать», «Остановить», «Отменить». Back во время capture спрашивает о потере записи либо явно отменяет по утверждённому сценарию. + +## 17. MediaIsland и viewer + +### 17.1. Фото/видео + +Default width 268, max available width минус row insets. Radius 20, border 1, thumbnail inset 6. Photo natural height по ratio, baseline clamp 164…328, с proportional уменьшением на узком экране. Object-fit/scale mode — contain. + +Filename/size badge снизу: caption, white on достаточно тёмной scrim, padding 8×5, pill; inset 9. Expand icon сверху справа с hit area. Video имеет центральный play и duration. + +Состояния: placeholder, loading, thumbnail, ready, playing для inline применимого типа, failed/retry. Error не замещается бесконечным spinner. + +### 17.2. Audio/voice + +Content default width 252 с clamp доступной ширины. Play circle 44, waveform около 30 high, progress/seek, duration и speed chip. Voice speed 1×/1.5×/2×; audio filename visible. + +Seek имеет keyboard arrows/accessible slider semantics и touch target выше визуальной линии. Loading, paused, playing, ended и failed различимы. + +### 17.3. VideoNote + +Circle 144 idle, 176 playing при достаточном месте; при узком viewport размер уменьшается. Play icon 24, duration pill снизу, border 1. Увеличение не должно резко двигать viewport или перекрывать соседние controls. + +### 17.4. Fullscreen viewer + +Root overlay, тёмный фон, zoom/pan для фото, controls для видео. Обязательно доступны Close button, Escape на keyboard и Back на Android; vertical dismiss — дополнительный touch gesture. Close target располагается в safe area и не закрывает важное media. + +Zoomed photo pan не запускает dismiss. После закрытия focus возвращается к исходному элементу, viewport остаётся на том же сообщении. Download/save — отдельное явное действие. + +## 18. AttachmentPreview + +Modal/panel с title «Перед отправкой», grid, подписью и action row. Grid cell min 128/max 190, column gap 18/20, row gap 22/24, container padding 16. В compact grid адаптируется до одной/двух колонок. + +Caption field radius 16/padding 13×10, 1…4 строки. Нижняя panel raised/glass с opaque fallback, padding 14/16. Summary слева, primary send справа; mobile может переносить action row без обрезки. + +На sending отмена/закрытие имеют определённую доступность; failed items остаются с clear status и retry. Caption не показывается как доставленная, если её отдельная отправка завершилась ошибкой. + +## 19. LocationPicker + +Header «Отправить геопозицию», карта/marker, текущая выбранная точка, «Моя геопозиция» и primary «Отправить». При denied permission — inline explanation и ручной выбор. + +Map не поглощает все keyboard/Back actions. Выбор координат доступен через альтернативный ввод, если map недоступна. Preview location message — компактная карта/иконка и координаты/label; tap открывает просмотр без автоматического фонового tracking. + +## 20. CallScene и CallHistoryCard + +CallScene — inverse fullscreen/large overlay. Remote video занимает доступный фон, контрастная scrim защищает labels. Local video 118×168/radius 22, top-end с safe area; на compact масштабируется и не перекрывает controls. + +Без video: call gradient, avatar 132, name hero до 2 строк, media type subbody, status/duration tabular. Основные controls снизу, visual circles 58 и подписанные labels. + +- Incoming: decline red и accept green/dark-success fill с readable white text. +- Connecting: status и доступный cancel. +- Connected: mute, audio route при поддержке, camera/switch при наличии, hangup red. +- Permission/error: понятное пояснение и возврат без скрытой активной записи. + +Control selected state передаётся icon + label/accessible state, а не только прозрачностью. Caption labels имеют достаточный контраст на scrim. + +HistoryCard: max width 300, icon circle 42, content gap 12, title bodyStrong/subbodyStrong, subtitle caption, padding 14×10. Outcome icon/text: completed success, missed/failed/declined error, cancelled/answeredElsewhere neutral. Направление определяется также arrow/текстом. + +## 21. Auth, Settings, Encryption и Lock + +### 21.1. AuthCard + +Hero gradient background; centered column max 460, outer horizontal 24/vertical 48, адаптация к keyboard. Logo tile 88/radius 26, brand 42, card radius 28/padding 24…28. + +Form: JID, password с show/hide, advanced connection options, primary «Войти». Loading «Подключение…» и spinner. Error у поля/общая область без потери ввода. Native host/port и Web WSS endpoint представлены соответствующими полями, а не общим неработающим transport selector. + +### 21.2. Settings + +Grouped sections, стандартные switches/list rows/links. Profile avatar 64. Основные sections: аккаунты, профиль, соединение, шифрование, уведомления, блокировка, приватность. + +Long values читаются/копируются; destructive logout отделён от обычных preferences. Multiaccount selection имеет checkmark и account identity. + +### 21.3. EncryptionPanel + +Итоговый режим чата, global/override control, список устройств, protocol badge, fingerprint mono, trust. Verified/trusted/undecided/compromised различаются текстом и символом. «Готово» не означает автоматической verification. + +Fingerprint selectable/copyable, разбит на читаемые группы; label устройства не заменяет fingerprint. Empty devices объясняет следующий шаг, не показывает фиктивный secure success. + +### 21.4. LockScreen + +Hero/нейтральный фон и полностью скрытое sensitive content. Card radius 28, lock icon 44, title 22, field/action max 260 при обычной ширине, spacing около 22/24. Passcode input masked, explicit unlock, error inline и biometric action только при реальной доступности. + +Lock не имитируется прозрачным blur поверх читаемого чата. Не существует автоматически перенесённой native-biometric кнопки в неподдержанном браузере. + +## 22. Feedback, dialogs и слои + +| Компонент | Контракт | +| --- | --- | +| Toast | Краткий результат, max 2 строки, закрываемый/доступный screen reader | +| Inline error | Ошибка поля/операции рядом с источником | +| Alert/dialog | Важная ошибка/подтверждение с ясными действиями | +| Loading | Spinner/progress + конкретная операция | +| Empty state | Название, пояснение, доступное next action | +| Connection status | Компактный читаемый статус в заголовке/панели | + +Toast: padding 16×10, gap 8, pill, raised/glass fallback, shadow.toast. Default display 2.5 с; при accessibility/длинном тексте длительность увеличивается или доступно persistent alternative. Toast не единственное место критической ошибки. + +Layers: + +| Токен | Значение | +| --- | --- | +| `layer.base` | 0 | +| `layer.sticky` | 10 | +| `layer.popover` | 40 | +| `layer.dialog` | 80 | +| `layer.media` | 100 | +| `layer.call` | 200 | +| `layer.toast` | 300 | +| `layer.lock` | 400 | + +Это роли stacking, не обязательные literal z-index в каждой технологии. При открытии слоя проверяются активные dialogs: неподходящий старый modal закрывается/уступает по сценарию. App lock имеет приоритет sensitive content. Error dialog при lock не раскрывает текст переписки. + +Modal traps focus, имеет label и close action. После close focus восстанавливается. Browser/Android Back сначала закрывает верхний modal, затем navigates. + +## 23. Анимации и жесты + +| Токен/эффект | Значение | +| --- | --- | +| `motion.fast` | 120 ms | +| `motion.base` | 180…220 ms | +| `motion.panel` | 240…300 ms | +| Timeline entrance | До12 rows, stagger 35 ms, rise 16, scale 0.94 | +| Outgoing append | Spring около 440 ms; start scale 0.2/rise 12 при допустимом layout | +| Incoming append | Start scale 0.4/rise 10 | +| VideoNote resize | Spring около 320 ms | +| Reply swipe return | Spring около 240 ms | + +Burst >4 appended rows/архив не анимируется как несколько свежих сообщений. Recycled rows не играют entrance повторно при каждом scroll. + +Reduced motion: убрать scale/slide/stagger, оставить короткое fade или мгновенную смену. Важное состояние не зависит от завершения анимации. Animation cancellation не оставляет invisible interactive control. + +Touch reply: horizontal activation 24, trigger 64, max offset 76; раннее доминирование горизонтали 1.6, после lock 1.05. Вертикальный scroll приоритетен. Haptic при пороге там, где поддержан и разрешён. + +Record: hold около 240 ms, cancel left 92, lock up 78; размеры адаптируются к ограниченному пространству, доступные кнопки остаются. + +Viewer swipe: vertical dominance 1.15, distance около 14% height clamp 88…140; fast predicted endpoint может завершать жест. На pointer Close/Escape остаются основным способом. + +## 24. Accessibility и ввод + +**UI-A11Y-001.** Целевой контраст обычного текста минимум 4.5:1, крупного 3:1; значимых controls/focus минимум 3:1 относительно соседних поверхностей. Проверяются фактические composite colors, не только исходные HEX. + +**UI-A11Y-002.** Все действия доступны screen reader и имеют names/states. Icon-only button не называется именем файла icon. + +**UI-A11Y-003.** Web/Desktop полностью поддерживают Tab/Shift+Tab, Enter/Space для действий, Escape для закрытия и предсказуемый focus order. Hover-only actions доступны также по focus/tap. + +**UI-A11Y-004.** При text scale/zoom до 200% интерфейс reflow без потери основных действий. Fingerprint/URI можно прочитать полностью; text body не скрывается за fixed height. + +**UI-A11Y-005.** Touch targets минимум 48×48 u для действий; визуально меньшая icon сохраняет просторный hit region. На pointer target не меньше 44×44 для основных icon actions. + +**UI-A11Y-006.** Errors связаны с input, loading не бесконечно повторяется screen reader, live messages объявляются умеренно и не перехватывают focus. + +**UI-A11Y-007.** Reduced motion/transparency и high contrast имеют определённые варианты. Цвет не единственный способ различения unread/failed/verified. + +**UI-A11Y-008.** Record/call/media controls отражают фактическую активность camera/microphone. Для destructive есть явное подтверждение области действия. + +## 25. Каталог состояний и приёмка UI + +Поставляемая библиотека компонентов имеет отдельный доступный каталог/preview, не требующий реального XMPP login. + +Обязательные fixtures: + +- light/dark и reduced motion/transparency; +- compact 320/390, medium 840, wide 1280/1440; +- обычный и 200% текст; +- короткие/длинные имена/JID/URL/filename; +- чат без сообщений, search без результатов, offline и syncing; +- все delivery/security states; +- incoming/outgoing/group/reply/tombstone/reactions; +- media loading/ready/error и partial batch; +- recording start/locked/finalizing/error; +- incoming/connecting/active/failed call; +- keyboard focus, selection и menus; +- lock, denied permissions и уведомления без preview. + +### 25.1. Чек-лист + +- [ ] Все цвета/шрифты/spacing берутся из токенов по роли. +- [ ] Navigation соответствует ширине и платформе. +- [ ] Любая важная action доступна без жеста/hover. +- [ ] Focus не теряется в modal/viewer и возвращается после close. +- [ ] Long content и text scale не ломают layout. +- [ ] Incoming/outgoing/delivery/security определяются без одной лишь окраски. +- [ ] Media не обрезается неожиданно и не растягивается. +- [ ] Keyboard/IME не отправляет незавершённый ввод. +- [ ] Loading/ошибка/cancel завершаются корректным состоянием. +- [ ] Contrast проверен для фактических цветов и прозрачностей. +- [ ] Motion/transparency preferences работают. +- [ ] UI соответствует functional predicates `tz_clear.md`. + +## 26. Развитие стандарта + +Новый компонент сначала определяется как набор ролей/состояний, затем добавляется в tokens/catalog и после этого используется в экранах. Новый вариант существующего компонента получает имя и назначение; нельзя создавать похожую локальную копию с немного другим цветом/radius без причины. + +Изменение токена проверяется на всех затронутых компонентах, light/dark и платформах. Несовместимые изменения версии UI Kit документируются и мигрируются согласованно. + +Стандарт сохраняет общий характер Luma, при этом concrete native widget и способ реализации blur/vector/animation выбираются платформой. Ни одна из библиотек Web/Desktop/Android не считается реализованной только по существованию этого документа.