Files
Luma/tz.md
T
wt e018be346a
Build Unsigned iOS ipa / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned macOS App / Build macOS ZIP (push) Canceled after 0s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
added Docs/UIKIT.md uikit_clear.md tz.md tz_clear.md
2026-10-14 17:56:31 +07:00

192 KiB
Raw Blame History

Luma — обратное техническое задание

Подробная спецификация фактически реализованной системы.

  • Дата анализа: 14 октября 2026 года.
  • Исходный коммит: 6969863d1e4db434004f8e5e032ada270a4b948f.
  • Источник истины: исходники, project.yml, конфигурация, тесты и скрипты этого репозитория.
  • Язык документа и пользовательского интерфейса: русский.
  • Назначение: восстановить требования по реализации, дать основу для сопровождения, повторной реализации и приёмки.
  • Статус проверки: статический анализ и успешный make verify; сборка Apple-targets, XCTest и сетевые сценарии в этом сеансе не выполнялись.

Содержание

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. Архитектура и ответственность компонентов

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 равен <account normalized JID>|<conversation JID>. Это предотвращает совпадение идентичностей строк между аккаунтами, но текущий экран не является объединённым 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: <reply xmlns="urn:xmpp:reply:0" id="..." to="..."/>. Для 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<body>.
  • 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 формата <lowercased conversationID>\u001F<messageID>. Этот набор сохраняется в 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-<округлённые миллисекунды>-<originalFilename>;
  • videoNote: luma-videonote-<округлённые миллисекунды>-<originalFilename>;
  • остальные типы: исходное имя после 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://...#<key material>; 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 берётся из <first> предыдущей серверной страницы. Нельзя брать id старейшего локального bubble: страница может содержать только reactions/retractions/дубликаты без новых строк и всё равно должна продвинуть cursor.

hasMore true только при !complete и непустом first UID. Пустая страница/отсутствие RSM не должна создавать бесконечный spinner. Позиция чтения сохраняется при вставке старых сообщений; пользователь, читающий вверх, не должен автоматически улетать вниз от каждого update.

19. Прочтение между собственными устройствами

Исходящее direct сообщение имеет XEP-0333 <markable/>. 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-<transferID>.

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/<hash>.store SwiftData, по аккаунту
Legacy history Luma/Accounts/<hash>.json миграция
OMEMO identity/session Application Support Luma/OMEMO/<hash>.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-<id>, 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. Команды

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:

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<github.run_id>.

Текущий 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<String> = ["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-аккаунта пользователя.