commit 92660a4ba0bb2f4095bfd32ededadf9c54f2cddf Author: TolaMironcenko Date: Thu Jul 30 20:07:51 2026 +0700 0.10.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68151d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +DerivedData/ +.build/ +build/ +*.xcuserstate +xcuserdata/ +Luma.xcodeproj/ +Luma.xcworkspace/ + diff --git a/Brand/LumaIcon-1024.png b/Brand/LumaIcon-1024.png new file mode 100644 index 0000000..67e5630 Binary files /dev/null and b/Brand/LumaIcon-1024.png differ diff --git a/Config/Luma-Info.plist b/Config/Luma-Info.plist new file mode 100644 index 0000000..0d31df7 --- /dev/null +++ b/Config/Luma-Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Luma + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSCameraUsageDescription + Luma использует камеру для фотографий, видеосообщений и видеозвонков. + NSLocalNetworkUsageDescription + Luma использует локальную сеть для прямого соединения аудио- и видеозвонков. + NSLocationWhenInUseUsageDescription + Luma использует геопозицию только когда вы выбираете отправку точки в чате. + NSMicrophoneUsageDescription + Luma использует микрофон для голосовых сообщений и звонков. + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + audio + fetch + remote-notification + + UILaunchScreen + + UISupportsDocumentBrowser + + + diff --git a/Config/Luma.entitlements b/Config/Luma.entitlements new file mode 100644 index 0000000..58079e2 --- /dev/null +++ b/Config/Luma.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/Config/LumaMac-Info.plist b/Config/LumaMac-Info.plist new file mode 100644 index 0000000..8bc023e --- /dev/null +++ b/Config/LumaMac-Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Luma + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.social-networking + NSCameraUsageDescription + Luma использует камеру для фотографий, видеосообщений и видеозвонков. + NSHighResolutionCapable + + NSLocationUsageDescription + Luma использует геопозицию только когда вы выбираете отправку точки в чате. + NSMicrophoneUsageDescription + Luma использует микрофон для голосовых сообщений и звонков. + + diff --git a/Config/LumaMac.entitlements b/Config/LumaMac.entitlements new file mode 100644 index 0000000..5c62a47 --- /dev/null +++ b/Config/LumaMac.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-only + + com.apple.security.device.camera + + com.apple.security.device.audio-input + + com.apple.security.personal-information.location + + + diff --git a/Config/LumaWatch-Info.plist b/Config/LumaWatch-Info.plist new file mode 100644 index 0000000..613282f --- /dev/null +++ b/Config/LumaWatch-Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Luma + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconName + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Luma + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSMicrophoneUsageDescription + Luma использует микрофон Apple Watch для голосовых сообщений. + WKApplication + + WKCompanionAppBundleIdentifier + app.luma.chat + + diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md new file mode 100644 index 0000000..b127d78 --- /dev/null +++ b/Docs/ARCHITECTURE.md @@ -0,0 +1,204 @@ +# Архитектура Luma + +## Слои + +1. `XMPPService` конфигурирует Martin, TLS/SASL, XEP-модули, MUC, MAM, + HTTP Upload, XEP-0084 PEP User Avatar и Jingle. +2. `LumaCallEngine` связывает Martin Jingle с WebRTC, получает STUN/TURN через + XEP-0215 и публикует value-type `CallSnapshot` для интерфейса. +3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks; + AES-GCM выполняется CryptoKit. +4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live + сообщения и сохраняет snapshot локально. +5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS. +6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot, + текстовые ответы возвращаются немедленно или через гарантированную очередь, + а голосовые `.m4a` — фоновой файловой передачей WatchConnectivity. iPhone + копирует временный файл внутри callback, при необходимости переподключает + XMPP, отправляет запись через тот же XEP-0363/OMEMO-пайплайн и возвращает + часам отдельный результат фактической отправки. + +## Контакты + +`RosterModule` Martin передаёт initial roster и последующие roster-push в +`AppModel`. Модель отдельно сохраняет множество roster JID, поэтому удаление +контакта на Prosody сразу убирает его из раздела «Люди», но не уничтожает +локальную историю чата. Стандартный XMPP roster содержит людей, а не членство в +MUC, поэтому экран «Контакты» объединяет серверный roster с сохранёнными +XEP-0045-комнатами в отдельном разделе «Групповые чаты». + +## Поток сообщения + +Исходящий текст получает `origin-id`. Итоговая политика шифрования вычисляется +из глобальной настройки аккаунта и переопределения конкретного чата. При +включённом режиме текст шифруется OMEMO для известных устройств контакта и +собственных дополнительных устройств; при выключенном отправляется обычный +`` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический +fallback на plaintext. UI сразу показывает optimistic message и обновляет +состояние по результату записи/receipt. + +Входящая stanza может прийти напрямую, через carbons или MAM. Сервис определяет +peer, расшифровывает payload, использует `origin-id`/stanza id для дедупликации и +передаёт value-type envelope в `AppModel`. + +Свежая установка начинает MAM с ограниченной последней страницы RSM, а не с +самого старого сообщения. Инкрементальные проходы используют сохранённый +непрозрачный UID архива как RSM `after`; timestamp с небольшим перекрытием — +только миграционный и одноразовый recovery-путь для удалённого сервером UID. +Каждая stanza проходит проверку `queryid` и источника личного архива. Martin +публикует MAM-результаты на parser queue: ограниченный inbox принимает там всю +страницу и делает единственный hand-off на main actor после финального IQ. +Дешифрование выполняется по одной stanza с уступкой event loop, а декодированные +изменения становятся видимыми только одной атомарной пачкой вместе с новой +контрольной точкой. Один foreground-проход имеет конечный бюджет страниц и +времени; ошибка страницы не перезапускает весь архив сама. При уходе приложения +с экрана и на время записи/отправки видеосообщения активный MAM-запрос +закрывается, чтобы дешифрование истории не конкурировало с камерой и upload. +Только после commit `AppModel` обновляет SwiftUI, локальный snapshot и Apple +Watch; незавершённый проход при timeout/disconnect отбрасывается целиком. + +Редактирование реализовано стандартным XEP-0308: исправление получает новый id и +`` с id исходного сообщения. `AppModel` проверяет совпадение bare JID +отправителя, заменяет только текстовый payload и отмечает сообщение как +изменённое. Один и тот же путь обрабатывает live, Carbons и MAM; исправления, +пришедшие раньше исходной stanza во время синхронизации, временно удерживаются в +очереди. + +Ответ содержит `` с id и JID автора исходного +сообщения; в MUC это полный occupant JID комнаты. Для клиентов без XEP-0461 тело дополнено `>`-цитатой, а её +границы отмечены XEP-0428 в Unicode scalar offsets. На приёме Luma удаляет +fallback из отображаемого текста и строит компактный bubble исходного сообщения. +Нажатие собирает все сообщения с тем же reply target id и открывает отдельный +фокус-слой: источник, количество ответов, ветку, дату и delivery status выбранного +ответа. Если XML-метаданных нет, но тело начинается с цитаты Conversations/Monal, +legacy parser отделяет цитату от ответа и показывает неподвижный bubble без +ложной ссылки на id. + +Для XEP-0045 комната хранится как отдельный тип conversation. Martin MUC module +управляет входом, приглашениями, состоянием и occupants; сообщения `groupchat` +попадают в общий `ChatMessage` pipeline с псевдонимом отправителя. Ответ в +комнате разрешён только после получения XEP-0359 `stanza-id`, выданного самой +комнатой: локальный или origin id для межклиентной MUC-цитаты не используется. +При включённом OMEMO сервис проверяет disco feature `muc_nonanonymous`, получает +реальные JID online occupants и полные affiliation lists member/admin/owner, +строит Signal sessions для устройств каждого bare JID и передаёт их в +MartinOMEMO одним encrypted groupchat stanza. Если список недоступен, хотя бы +один получатель не имеет устройства или комната скрывает JID, stanza не +отправляется. Эхо собственной зашифрованной +stanza отдельно сохраняет назначенный комнатой `stanza-id`, даже когда OMEMO +декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются +межклиентно совместимыми. + +XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное +сообщение. Перед применением `AppModel` проверяет conversation и JID автора, +после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее +редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM, +временно удерживается в ограниченной очереди. Локальное удаление физически +убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы +последующая MAM-синхронизация не добавила её обратно. + +Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не +переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и +запускает новую XEP-0363/OMEMO-загрузку для политики шифрования выбранного чата. + +При включённом OMEMO медиа сначала шифруется случайным AES-GCM ключом, затем +encrypted blob загружается по XEP-0363. В OMEMO-сообщении передаётся +`aesgcm://` URL с ключевым материалом во fragment, поэтому upload service не +получает ключ расшифрования. При выключенном режиме исходный файл загружается по +HTTPS. Тело сообщения остаётся ровно одной обычной ссылкой, поэтому другие +клиенты могут распознать файл; для plaintext дополнительно отправляется +стандартный XEP-0066 OOB URL. По XEP-0454 `aesgcm` используется как схема URI, +но не добавляется к имени файла: исходное расширение остаётся в upload URL, чтобы +Conversations, Monal и другие клиенты могли восстановить медиатип после +расшифровки. Luma при этом продолжает читать старые собственные ссылки с +суффиксом `.aesgcm`. Тип и длительность созданных в приложении voice/video-note +кодируются в имени файла без изменения расширения. + +Для исходящих фото и видео preview строится до загрузки. Для входящего файла +отдельной XMPP-миниатюры нет, поэтому общий медиакэш скачивает blob один раз, при +необходимости расшифровывает его, уменьшает фото через ImageIO или извлекает +первый кадр видео через AVFoundation. Корневой SwiftUI overlay показывает +исходное фото с pan/zoom либо видео через `VideoPlayer` на всё окно приложения. +Круглые видеосообщения продолжают воспроизводить локальный файл через +`AVPlayerLayer` прямо в ленте. Для voice тот же процессор читает PCM через +`AVAudioFile` и строит waveform. Один `MediaPlaybackCoordinator` управляет +музыкой и голосовыми: он гарантирует одно активное воспроизведение, +синхронизирует прогресс, перемотку и скорость речи. + +Множественный picker сначала копирует security-scoped файлы во временный draft +каталог. `MediaPreviewProcessor` строит миниатюры и длительности до отправки; +пользователь может удалить элементы и добавить подпись. Файлы затем загружаются +последовательно тем же XEP-0363 pipeline, а подпись отправляется стандартным +текстовым ответом на первое вложение, когда доступен стабильный target id. + +Геопозиция запрашивается через Core Location только после явного выбора пункта +вложения. Пользователь может поправить точку на MapKit-карте; сообщение +передаётся обычным телом RFC 5870 `geo:latitude,longitude`, поэтому проходит тем +же OMEMO/plaintext-путём, что и текст, и распознаётся совместимыми клиентами. + +Аватары публикуются и читаются через XEP-0084. PNG нормализуется до 512 пикселей, +PEP-события обновляют интерфейс, а локальный cache позволяет показать изображение +до завершения подключения. vCard-temp используется только как best-effort мост +для старых клиентов. + +## Поток звонка + +В личном чате `AppModel` сначала запрашивает доступ к микрофону и, для видео, +камере. `LumaCallEngine` выбирает доступный full JID по presence/capabilities и +начинает XEP-0353 Jingle Message Initiation на выбранный full JID, а для клиента +без этой возможности использует прямой IQ session-initiate. Адресация JMI на +конкретный presence/caps resource нужна потому, что некоторые комбинации +клиента и сервера возвращают bare `proceed`: ожидание full JID тогда блокирует +trickle ICE, а отправка на bare JID маршрутизирует `transport-info` случайному +ресурсу. SDP преобразуется между Martin Jingle RTP и WebRTC; ICE-кандидаты +передаются через transport-info. + +WebRTC создаёт отдельные audio/video tracks и шифрует медиапоток DTLS-SRTP. +XEP-0215 External Service Discovery даёт временные STUN/TURN параметры Prosody; +если сервер их не публикует, остаются host candidates и резервный публичный +STUN. Перед передачей в WebRTC записи проверяются: STUN URI формируется без +TURN-only query, а TURN принимается только с полной парой временных credentials. +Если WebRTC всё же отклоняет серверную конфигурацию, движок повторяет создание с +публичным STUN и затем только с host candidates. Активный звонок проецируется в +`CallSnapshot`, поэтому SwiftUI не получает +владение сетевой сессией или peer connection. Одновременно разрешён один звонок. + +Для исходящего аудио+видео звонка порядок `` в `session-accept` +приводится к порядку `m=`-линий локального offer: Jingle идентифицирует потоки по +имени, тогда как WebRTC требует стабильного порядка media sections. Локальные +trickle ICE candidates накапливаются до успешного начального Jingle stanza и +получения удалённого SDP. Full JID уже выбран по presence/caps; последующие +`proceed`, `session-accept` и `transport-info` дополнительно проверяются против +этого endpoint, поэтому Prosody не маршрутизирует кандидаты другому ресурсу. +Полученный Jingle candidate преобразуется из SDP-атрибута +`a=candidate:…` в ожидаемую WebRTC candidate line `candidate:…`. Краткий ICE +`failed` во время поступления позднего TURN/video candidate получает +восьмисекундное окно восстановления. + +Каждая точка завершения сессии передаёт в `CallHistoryPolicy` направление, фазу и +причину. Движок публикует один `CallHistoryEntry`, а `AppModel` сохраняет его как +локальный `ChatMessage.Kind.system` с типизированными call-метаданными. Наличие +`connectedAt` имеет приоритет над поздней ошибкой ICE: состоявшийся звонок остаётся +успешным и получает длительность. Удаление `activeCall` до закрытия Jingle-сессии +гарантирует, что ответный callback не создаст дубликат карточки. + +Текущая реализация принимает вызов при живом XMPP-соединении. Wake-up завершённого +iOS-приложения намеренно не имитируется: production-путь требует VoIP push, +PushKit/CallKit и серверной APNs-инфраструктуры. + +## Локальные уведомления + +`NotificationCoordinator` является delegate системного notification center и +разрешает banner/list/sound в foreground. `NotificationPolicy` создаёт +уведомление только для нового входящего сообщения и подавляет дубликат, когда +пользователь уже смотрит этот же диалог. Для выгруженного процесса по-прежнему +нужен XEP-0357/APNs. + +## Следующие модули + +- APNs provider + XEP-0357 registration; +- современный `urn:xmpp:omemo:2` adapter и миграция устройств; +- MIX и реакции; +- SQLite/SQLCipher вместо JSON snapshot; +- PushKit/CallKit для фоновых входящих звонков и групповые звонки; +- share extension и notification service extension. diff --git a/Docs/PROSODY.md b/Docs/PROSODY.md new file mode 100644 index 0000000..19f24ff --- /dev/null +++ b/Docs/PROSODY.md @@ -0,0 +1,215 @@ +# Настройка Prosody для Luma + +Ниже приведён ориентир для актуальных Prosody 0.12/13. Сохраните существующие +модули аутентификации, TLS и federation вашего сервера — пример показывает +только функции, необходимые клиенту. + +```lua +VirtualHost "example.org" + +modules_enabled = { + -- ваши базовые модули; + "roster"; -- RFC 6121: серверный список контактов + "pep"; -- PEP: OMEMO device lists/bundles и XEP-0084 аватары + "smacks"; -- XEP-0198 + "carbons"; -- XEP-0280 + "mam"; -- XEP-0313 + "turn_external"; -- XEP-0215: STUN/TURN для Jingle-звонков +} + +-- Тот же секрет укажите в coturn как static-auth-secret. +turn_external_secret = "ЗАМЕНИТЕ_ДЛИННЫМ_СЛУЧАЙНЫМ_СЕКРЕТОМ" +turn_external_host = "turn.example.org" +turn_external_port = 3478 +-- Необязательно: если coturn принимает TURN/TLS на 5349. +turn_external_tls_port = 5349 + +default_archive_policy = true +archive_expires_after = "1mon" +max_archive_query_results = 100 + +-- Рекомендуется SQL-хранилище для архива. +storage = { + archive = "sql"; +} + +Component "upload.example.org" "http_file_share" + -- Luma не задаёт собственного предела; выберите серверный лимит под диск + -- и reverse proxy. Ниже пример на 1 ГиБ. + http_file_share_size_limit = 1024 * 1024 * 1024 + http_file_share_expires_after = "1 month" -- Prosody 13 + modules_disabled = { "s2s" } + +-- XEP-0045: групповые комнаты Luma/Conversations/Monal +Component "conference.example.org" "muc" + name = "Групповые чаты" + restrict_room_creation = false -- либо "local" для локальных пользователей + modules_enabled = { "muc_mam" } + muc_log_by_default = true + muc_log_expires_after = "1mon" +``` + +## Аудио- и видеозвонки + +Сам XMPP-сервер только согласовывает Jingle-сессию. Для двух клиентов с +доступными адресами этого достаточно, но за NAT/CGNAT нужен coturn. Начиная с +Prosody 0.12 проще всего использовать встроенный `mod_turn_external`, как в +примере выше: он публикует STUN/TURN и выдаёт клиенту временные учётные данные +через XEP-0215. + +Минимальные соответствующие параметры coturn: + +```ini +fingerprint +use-auth-secret +static-auth-secret=ЗАМЕНИТЕ_ТЕМ_ЖЕ_СЕКРЕТОМ +realm=example.org +listening-port=3478 +tls-listening-port=5349 +# Для TURN/TLS также задайте cert= и pkey=. +``` + +Откройте для coturn UDP/TCP 3478, при использовании TLS — TCP 5349, а также +настроенный UDP relay range. После перезапуска проверьте выдачу сервиса: + +```bash +prosodyctl check turn +``` + +Без собственного TURN Luma использует публичный STUN только для обнаружения +адресов; STUN не может ретранслировать медиапоток, поэтому звонок между двумя +сложными NAT может не установиться. + +Luma проверяет ответы XEP-0215 до создания WebRTC-соединения. Для `turn`/`turns` +Prosody должен выдать и `username`, и `password`; `stuns`/`turns` допускают TCP +(или отсутствие явного `transport`), а истёкшие временные credentials +игнорируются. Если одна серверная конфигурация всё же не принимается WebRTC, +клиент повторяет запуск с публичным STUN, затем с прямыми host candidates. В +Debug-консоли при таком fallback появляется строка `Luma WebRTC:` без URL и +учётных данных. + +Для группового OMEMO комната должна быть неанонимной; рекомендуется также +members-only. При создании комнаты Luma сама отправляет owner configuration с +`muc#roomconfig_whois = anyone`, `muc#roomconfig_membersonly = true` и +`muc#roomconfig_persistentroom = true`, а также разрешает ролям moderator и +participant читать member list. Для уже существующей комнаты включите в +настройках владельца показ реальных JID всем участникам и доступ к списку +участников. Если вошедший +пользователь — владелец, Luma попробует изменить `whois` при первой зашифрованной +отправке; обычный участник не может менять эту настройку. + +В Prosody 0.12.x срок хранения задаётся числом секунд, например +`http_file_share_expires_after = 31 * 24 * 60 * 60`; строковый интервал выше +используйте на Prosody 13. + +Для `upload.example.org` нужен корректный HTTPS URL. Если компонент не является +прямым поддоменом VirtualHost, добавьте его в `disco_items`. При reverse proxy +задайте `http_host`/`http_external_url` согласно вашей топологии. Например, для +`wthome.ru` за HTTPS reverse proxy: + +```lua +Component "upload.wthome.ru" "http_file_share" + http_file_share_size_limit = 1024 * 1024 * 1024 + http_host = "upload.wthome.ru" + http_external_url = "https://upload.wthome.ru/" + trusted_proxies = { "127.0.0.1", "::1" } + modules_disabled = { "s2s" } +``` + +Reverse proxy должен передавать `Host: upload.wthome.ru` и +`X-Forwarded-Proto: https`. Иначе Prosody может выдать клиенту `http://` URL или +маршрут, возвращающий 404; Luma намеренно не понижает upload до HTTP. + +Проверьте также: + +- валидный сертификат и STARTTLS на 5222 либо direct TLS на 5223; +- SRV-записи `_xmpp-client._tcp` и, если используется, `_xmpps-client._tcp`; +- доступность server disco и upload component disco; +- права пользователей на PEP/pubsub nodes; +- доставку PEP notifications `urn:xmpp:avatar:metadata+notify` контактам; +- что MAM действительно пишет в постоянное хранилище, а не fallback memory. +- доступность `conference.example.org` через service discovery и возможность + локального пользователя создать/войти в комнату. +- успешный `prosodyctl check turn` и доступность relay-портов coturn извне. + +Полезные команды: + +```bash +prosodyctl check config +prosodyctl check certs +prosodyctl shell module info http_file_share +prosodyctl shell http list upload.wthome.ru +``` + +Начиная с 0.3.0 Luma различает ошибки discovery, получения upload slot, +транспортную ошибку PUT и HTTP status. Если приложение пишет, что XEP-0363 не +найден, проверьте `disco_items`; если показывает HTTP 404/413/5xx — проверяйте +`http_host`, proxy path и лимит файла соответственно. + +## Push на iOS + +`mod_cloud_notify` реализует серверную сторону XEP-0357, но одного включения +модуля недостаточно. Нужен доступный по XMPP push gateway разработчика Luma, +который принимает события Prosody и отправляет APNs. В текущем MVP клиент не +регистрирует такой endpoint, потому что его адрес, APNs topic и ключи зависят от +вашей Apple Developer учётной записи. + +Когда gateway будет готов, включите `cloud_notify` вместе со `smacks`, `mam` и +`carbons`; не включайте передачу реального тела или sender в push без отдельного +решения по приватности. + +### Совместимость с сервером Monal + +Можно использовать открытый сервер +[`monal-im/fpush`](https://github.com/monal-im/fpush), но его нужно развернуть +для Luma либо договориться с владельцем уже работающего экземпляра о добавлении +отдельного push-модуля. Подключить Luma к production endpoint Monal как к +универсальному APNs relay нельзя: APNs device token относится к конкретному +приложению, а gateway должен подписывать запросы сертификатом этого приложения +и указывать его bundle ID (`app.luma.chat`) как topic. + +`fpush` подключается к Prosody как XEP-0114 component и поддерживает несколько +приложений через `pushModule`. Минимальная конфигурация его APNs-модуля выглядит +так (секреты и сертификат не храните в репозитории): + +```json +{ + "component": { + "componentHostname": "push.example.org", + "componentKey": "CHANGE_ME", + "serverHostname": "127.0.0.1", + "serverPort": 5347 + }, + "pushModules": { + "lumaProdIOS": { + "type": "apple", + "is_default_module": true, + "apns": { + "certFilePath": "/run/secrets/luma-apns.p12", + "certPassword": "CHANGE_ME", + "topic": "app.luma.chat", + "environment": "production" + }, + "ratelimit": { + "ratelimitTime": "20s", + "ratelimitCleanupInterval": "300s", + "enabled": true + } + } + }, + "timeout": { "xmppconnectionError": "20s" } +} +``` + +Для полного подключения ещё нужны: + +1. Push Notifications capability и подходящий provisioning profile для Luma. +2. Регистрация iOS-приложения в APNs и получение device token. +3. Отправка XEP-0357 `` с `node` = APNs token, JID компонента и полем + `pushModule=lumaProdIOS`; `` при выходе из аккаунта. +4. `smacks`, `mam`, `carbons`, `cloud_notify` в Prosody и доступ компонента + `push.example.org` по component-порту или s2s — в зависимости от схемы + развёртывания. + +Без APNs credentials от Apple Developer аккаунта приложение продолжит +показывать только уже реализованные локальные уведомления, пока процесс жив. diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md new file mode 100644 index 0000000..22bfb87 --- /dev/null +++ b/Docs/SECURITY.md @@ -0,0 +1,78 @@ +# Безопасность и модель угроз MVP + +- Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или + snapshot истории. +- Проверка сертификата не отключается. Ручной host меняет маршрут подключения, + но XMPP domain/JID остаётся исходной идентичностью. +- TLS trust оценивается системным Apple Security framework как сертификат + сервера (`SecPolicyCreateSSL(true, ...)`) для домена из JID; hostname и цепочка + доверия обязательны даже при ручном адресе подключения. +- Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения + не откатываются на plaintext при ошибке: ошибка показывается пользователю. +- Пользователь может осознанно отключить OMEMO глобально или для отдельного + чата. В этом режиме сервер, MAM-хранилище и участники TLS-терминации видят текст. +- OMEMO доступно и для XEP-0045, но только в неанонимной комнате: клиенту нужны + реальные bare JID участников, чтобы зашифровать ключ для каждого устройства и + расшифровать входящее сообщение от правильной Signal identity. Luma проверяет + режим комнаты, собирает online occupants и доступные списки member/admin/owner, + а при скрытом JID или отсутствии устройств отменяет отправку без plaintext + fallback. Новые комнаты Luma создаются members-only и persistent с + `muc#roomconfig_whois=anyone`. +- Первый увиденный ключ получает доверие TOFU. Изменившийся ключ становится + undecided и требует ручной проверки отпечатка. +- При включённом OMEMO вложения шифруются до загрузки, а URL с ключом находится + внутри OMEMO payload. При выключенном OMEMO файл хранится на HTTP Upload + сервисе в исходном виде; ссылка и метаданные также не имеют E2EE. +- Даже для зашифрованного blob HTTP Upload сервис видит размер и транспортное + имя файла; у voice/video-note это имя содержит тип и округлённую длительность. +- Клиент принимает только HTTPS `put`/`get` URL от XEP-0363 компонента и не + понижает транспорт загрузки до обычного HTTP. +- В OMEMO-исправлении новое тело зашифровано, но стандартный XEP-0308 + `` остаётся метаданными stanza: сервер видит id исправляемого + сообщения, но не новый текст. +- Ответ содержит id исходного сообщения и JID его автора. В зависимости от + поведения OMEMO-библиотеки и клиента эти XEP-0461 метаданные могут быть видны + серверу, даже когда текст ответа зашифрован. +- XEP-0424 не гарантирует стирание: retract может быть проигнорирован сервером + или клиентом, а получатель мог сохранить исходный текст. Fallback retraction + намеренно общий и никогда не повторяет удаляемое содержимое. +- Для inline-preview расшифрованные фото, видео, музыка и голосовые временно + записываются в sandbox `tmp`; Luma очищает известные preview-файлы при смене + аккаунта, а ОС может удалить их раньше как временные данные. +- Выбранные для пакетной отправки файлы временно копируются в `tmp` для экрана + предпросмотра и удаляются при отмене либо после завершения отправки. +- Core Location вызывается только из экрана явной отправки точки. Выбранные + координаты становятся содержимым сообщения и защищены OMEMO лишь тогда, когда + шифрование включено для этого чата; при plaintext сервер и MAM видят точку. +- Аудио/видео звонка шифруются WebRTC DTLS-SRTP между конечными клиентами. + Это не OMEMO: XMPP-сервер видит Jingle-сигналинг и адрес собеседника, а + STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже + зашифрованный медиапоток. Если Prosody не публикует собственный сервис через + XEP-0215, резервный публичный STUN получает запрос на определение адреса. +- На iOS файлы OMEMO-состояния и локальной истории получают data-protection + `completeUntilFirstUserAuthentication`. + +## Известные ограничения + +- На macOS история и OMEMO state лежат в sandbox Application Support без + дополнительного SQLCipher-слоя; защиту обеспечивает учётная запись и FileVault + пользователя. Для повышенной модели угроз нужна зашифрованная база. +- Локальная история содержит уже расшифрованные тексты, как и у большинства + клиентов. Полное удаление требует выхода с опцией удаления истории. +- «Удалить у меня» удаляет запись только из локального snapshot этого устройства + и запоминает id против повторной MAM-загрузки; оно не удаляет серверный архив, + другие собственные устройства или копию собеседника. +- Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого + JID, а переопределение чата — в локальном snapshot. Эти значения не являются + секретами, но прямо влияют на конфиденциальность следующей отправки. +- Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется + настройками PEP и roster на сервере; не используйте аватар как секретное фото. +- Используемая реализация MartinOMEMO поддерживает распространённый legacy + namespace. Она не должна называться полной реализацией новой редакции + XEP-0384 без отдельного interoperability test suite. +- В открытой или плохо настроенной сторонней MUC списки офлайн-участников могут + быть недоступны. Для предсказуемого группового OMEMO используйте members-only + комнату, разрешающую участникам получать affiliation lists; иначе отключите + OMEMO именно для этой комнаты осознанно. +- Jailbreak/root, скомпрометированный endpoint и запись экрана находятся вне + модели угроз MVP. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a275014 --- /dev/null +++ b/LICENSE @@ -0,0 +1,671 @@ +Luma — XMPP client MVP +Copyright (C) 2026 Luma contributors +SPDX-License-Identifier: AGPL-3.0-or-later + +Luma is free software: you can redistribute it and/or modify it under the +terms of the GNU Affero General Public License as published by the Free +Software Foundation, either version 3 of the License, or (at your option) +any later version. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dd871b4 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: project open verify clean + +project: + @command -v xcodegen >/dev/null || (echo "Install XcodeGen first: brew install xcodegen" && exit 1) + xcodegen generate + +open: project + open Luma.xcodeproj + +verify: + ./Scripts/verify.sh + +clean: + rm -rf Luma.xcodeproj DerivedData .build + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c8b780 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# Luma + +Luma — рабочий MVP XMPP-клиента для iOS, iPadOS, macOS и Apple Watch с +интерфейсом в духе Telegram/iMessage. Клиент не привязан к одному провайдеру: +он подключается к обычному XMPP-серверу по JID, включая Prosody. + +## Что уже реализовано + +- вход по JID и паролю; пароль хранится в Apple Keychain; +- DNS SRV, STARTTLS и direct TLS, а также ручной host/port; +- roster, presence, личные чаты и групповые комнаты XEP-0045 (MUC), включая + создание/вход, приглашения, автоподключение и число участников; +- отдельный раздел «Контакты»: люди синхронизируются с roster Prosody, а рядом + показываются сохранённые MUC-комнаты; есть общий поиск, аватары и статусы; +- OMEMO для исходящих и входящих сообщений в личных и неанонимных MUC-чатах, + постоянные ключи, TOFU и ручная проверка отпечатков устройств; +- глобальный переключатель OMEMO и отдельный режим для каждого чата: + наследовать глобальную настройку, всегда шифровать или отправлять без OMEMO; +- XEP-0198 Stream Management, XEP-0280 Message Carbons и XEP-0313 MAM; +- delivery receipts и локальная история; +- редактирование текстовых сообщений через XEP-0308, включая исправления в + OMEMO-чатах и синхронизацию через Carbons/MAM; +- ответы через XEP-0461 и XEP-0428: Luma показывает исходное сообщение + уменьшенным полупрозрачным bubble с дугой и открывает по нажатию фокус-режим + всей ветки в стиле iMessage; текстовые `>`-цитаты старых Conversations/Monal + также разбираются, а в MUC используется выданный комнатой `stanza-id`; +- пересылка текста, геопозиции и медиа в существующий чат или на введённый JID; + через пункт «Выбрать» можно отметить несколько сообщений и переслать их в + исходном порядке; медиа скачивается/расшифровывается и загружается заново для + получателя; +- «Удалить у всех» через XEP-0424 Message Retraction с tombstone в истории и + отдельное постоянное «Удалить у меня», которое не возвращает сообщение после + очередной синхронизации MAM; оба действия поддерживают несколько выбранных + сообщений; +- съёмка фото прямо системной камерой iPhone, а также выбор фото и видео из + медиатеки; фото, видео, музыка и файлы отправляются через XEP-0363 HTTP File + Upload; при включённом + OMEMO содержимое шифруется до загрузки, а исходное расширение имени сохраняется + для совместимости с Conversations и Monal; +- множественный выбор до 20 фото/видео или файлов, полноэкранный предпросмотр + фото и воспроизведение видео до отправки, удаление отдельных элементов и + подпись; после сетевой ошибки неотправленные файлы остаются для повторной + попытки, а параллельные загрузки не могут оставить интерфейс заблокированным; +- фото и видео показывают inline-превью и по нажатию открываются поверх всего + окна приложения; полноэкранный просмотр закрывается вертикальным свайпом без + отдельного крестика, увеличенное фото продолжает свободно перемещаться внутри, + а видео использует системные элементы воспроизведения; музыка получает + inline-плеер с названием файла, длительностью и перемоткой; +- каждое фото и видео помещается в отдельный скруглённый островок с внутренним + отступом и `aspectFit`: миниатюра не обрезается, не выходит за границы и не + слипается с соседними вложениями; +- запись голосовых сообщений и круглых видеосообщений (до 60 секунд) с камеры; + голосовые показывают waveform, поддерживают паузу, перемотку и скорости + 1×/1.5×/2×, а видеосообщения воспроизводятся прямо в круглом bubble, который + слегка увеличивается на время воспроизведения; +- явно видимый пункт «Отправить геопозицию» в меню `+`, выбор текущей или + произвольной точки на карте и отправка стандартного `geo:` URI, + распознаваемого другими XMPP-клиентами; +- аватары контактов и обновление собственного аватара через XEP-0084 PEP с + совместимым fallback на vCard-temp; +- личные аудио- и видеозвонки через XMPP Jingle (XEP-0166/XEP-0167), + ICE-UDP/DTLS-SRTP и WebRTC; есть входящий экран, mute, динамик, камера и её + переключение, а STUN/TURN автоматически берутся через XEP-0215; некорректные + ICE-записи от сервера отбрасываются, затем используются публичный STUN и + прямые host candidates, поэтому они не блокируют запуск media engine; + исходящий `session-accept` нормализуется по порядку media-линий offer; Jingle + Message Initiation сразу адресуется выбранному по presence/caps full + JID; trickle ICE ждёт удалённый SDP, но больше не зависает в ожидании ресурса, + поэтому `transport-info` не попадает на другой endpoint и не остаётся в + очереди до timeout; +- завершённые аудио- и видеозвонки показываются в истории чата отдельными + Telegram-подобными карточками: входящий/исходящий, длительность, отклонённый, + пропущенный, отменённый, без ответа или неудачный; пропущенный входящий помечает чат + непрочитанным; +- у обычных текстовых и файловых сообщений используются чистые скруглённые + островки без «хвостиков»; +- единый заполненный `AppAssets.xcassets/AppIcon` со всеми 42 размерами для + iPhone, iPad, Mac и Apple Watch; один и тот же catalog явно подключён ко всем + app-target’ам, а имя закреплено в build settings и Info.plist; +- адаптивный SwiftUI-интерфейс для iPhone, iPad и Mac; +- companion-приложение watchOS: последние чаты, диктовка/ответы, а также запись + голосового до 60 секунд с отменой и предпрослушиванием; `.m4a` передаётся на + iPhone фоновой файловой очередью WatchConnectivity, после чего iPhone + отправляет его через обычный XEP-0363/OMEMO media-пайплайн и возвращает часам + итоговый статус; это современный single-target watchOS app, поэтому + SwiftUI-код и executable собираются в одном target без устаревшего WatchKit + App/Extension контейнера; +- системные уведомления для новых сообщений, пока приложение запущено в фоне + или на экране; в открытом сейчас диалоге дублирующий banner не показывается. + +## Сборка + +Нужны macOS, Xcode 16 или новее и XcodeGen. + +```bash +brew install xcodegen +cd Luma +make project +open Luma.xcodeproj +``` + +После обновления архива обязательно снова выполните `make project`: новые +build settings AppIcon берутся из `project.yml`. Если устройство или Simulator +закэшировали старую пустую иконку, один раз удалите установленную Luma и +установите её заново; на macOS после новой сборки перезапустите Dock или сам Mac. +Можно вместо команд дважды нажать `Regenerate-Luma-Project.command` в корне +проекта (XcodeGen всё равно должен быть установлен). +При переходе с версии 0.8.7 один раз выполните в Xcode **Product → Clean Build +Folder**, чтобы удалить кэш старого `application.watchapp2` target. + +После открытия проекта: + +1. Выберите свою Apple Development Team для `Luma`, `LumaMac` и `LumaWatch`. +2. При необходимости замените bundle identifiers `app.luma.chat*` в + `project.yml` на уникальные. +3. Дождитесь загрузки Swift packages. +4. Запустите scheme `Luma` на iPhone/iPad или `LumaMac` на Mac. + +Для проверки из терминала на Mac: + +```bash +make verify +``` + +Версии Martin, MartinOMEMO и WebRTC закреплены в `project.yml`. Зависимости +Martin имеют copyleft-лицензии, поэтому Luma распространяется под +AGPL-3.0-or-later. + +## Prosody + +Минимально нужны `pep`, `smacks`, `carbons`, `mam` и MUC component. `pep` используется и для +OMEMO, и для XEP-0084 аватаров. Для медиа и вложений добавьте +компонент `http_file_share`; для надёжных звонков за NAT — coturn и +`turn_external`. Готовый пример и команды проверки находятся в +[`Docs/PROSODY.md`](Docs/PROSODY.md). + +## Важные границы MVP + +- OMEMO-модуль использует широко развёрнутый профиль с namespace + `eu.siacs.conversations.axolotl`, совместимый со многими версиями Monal и + Conversations. Поддержку нового namespace `urn:xmpp:omemo:2` следует + добавить отдельным криптографическим адаптером до объявления полной + совместимости с актуальным XEP-0384. +- Надёжный push при выгруженном iOS-приложении не может работать только через + XMPP-сокет. Для production нужны Apple Developer credentials, APNs provider, + XEP-0357 push-компонент и регистрация приложения в этом компоненте. +- При первом запуске Luma запрашивает ограниченную последнюю страницу + серверного MAM-архива, чтобы огромная история не блокировала интерфейс. + После успешного прохода сохраняются серверный UID последней записи и время; + следующие подключения продолжают RSM строго после UID. Временное перекрытие + используется только для старых snapshots без UID или один раз, если сервер + удалил сохранённый UID. Сырые stanza собираются вне main queue, расшифровываются + по одной с уступкой UI, а готовый проход публикуется одной пачкой. Один + foreground-проход также ограничен по страницам и времени; после ошибки он не + перезапускает сам себя бесконечно. +- Клиент не вводит собственного ограничения размера вложения: фактический + предел сообщает XEP-0363 upload-компонент сервера. Видеосообщение ограничено + 60 секундами. +- XEP-0424 retract является запросом на удаление: федеративный XMPP не может + гарантировать стирание уже полученной или скопированной версии в чужом + клиенте. «Удалить у меня» влияет только на это устройство. +- Групповое OMEMO требует неанонимную MUC-комнату и OMEMO-устройство у каждого + получателя. Luma создаёт новые комнаты members-only/persistent с + `muc#roomconfig_whois=anyone`; для несовместимой чужой комнаты безопасная + отправка блокируется без автоматического перехода на plaintext. Сервер также + должен разрешать участникам читать списки member/admin/owner. +- Звонки в MVP работают один-на-один, пока приложение запущено и XMPP-соединение + активно. WebRTC защищает медиапоток DTLS-SRTP, но это отдельный механизм от + OMEMO. Для надёжного входящего звонка после выгрузки iOS-приложения нужны + PushKit/CallKit, APNs provider и серверный push-компонент. Карточка звонка хранится в локальной + истории участвовавшего устройства Luma; она не отправляется собеседнику как фальшивое + текстовое сообщение и поэтому не синхронизируется через MAM. +- Реакции и групповые звонки относятся к следующему этапу. + +Архитектура описана в [`Docs/ARCHITECTURE.md`](Docs/ARCHITECTURE.md), модель +угроз и ограничения хранения — в [`Docs/SECURITY.md`](Docs/SECURITY.md). diff --git a/Regenerate-Luma-Project.command b/Regenerate-Luma-Project.command new file mode 100755 index 0000000..379d6f7 --- /dev/null +++ b/Regenerate-Luma-Project.command @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec "$(dirname "$0")/Scripts/regenerate-project.command" diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-AppStore-1024.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-AppStore-1024.png new file mode 100644 index 0000000..67e5630 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-AppStore-1024.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-128.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-128.png new file mode 100644 index 0000000..8e29c5c Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-128.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-128@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-128@2x.png new file mode 100644 index 0000000..dc748ed Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-128@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-16.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-16.png new file mode 100644 index 0000000..6a3bb3d Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-16.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-16@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-16@2x.png new file mode 100644 index 0000000..0d5c979 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-16@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-256.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-256.png new file mode 100644 index 0000000..dc748ed Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-256.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-256@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-256@2x.png new file mode 100644 index 0000000..73a643d Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-256@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-32.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-32.png new file mode 100644 index 0000000..0d5c979 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-32.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-32@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-32@2x.png new file mode 100644 index 0000000..ae9b76f Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-32@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-512.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-512.png new file mode 100644 index 0000000..73a643d Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-512.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-512@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-512@2x.png new file mode 100644 index 0000000..67e5630 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Mac-512@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-38mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-38mm@2x.png new file mode 100644 index 0000000..4eded2d Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-38mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-40mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-40mm@2x.png new file mode 100644 index 0000000..d685b28 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-40mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-41mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-41mm@2x.png new file mode 100644 index 0000000..de945d2 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-41mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-44mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-44mm@2x.png new file mode 100644 index 0000000..dc1aaf3 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-44mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-45mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-45mm@2x.png new file mode 100644 index 0000000..2df8f7c Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-45mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-49mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-49mm@2x.png new file mode 100644 index 0000000..82d8b4c Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-49mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-38mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-38mm@2x.png new file mode 100644 index 0000000..48d4b36 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-38mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-42mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-42mm@2x.png new file mode 100644 index 0000000..697518b Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-42mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-38mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-38mm@2x.png new file mode 100644 index 0000000..0daa90e Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-38mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-42mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-42mm@2x.png new file mode 100644 index 0000000..136b3df Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-42mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-44mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-44mm@2x.png new file mode 100644 index 0000000..f022813 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-44mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@2x.png new file mode 100644 index 0000000..4118389 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@3x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@3x.png new file mode 100644 index 0000000..02dccf1 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@3x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-WatchStore-1024.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-WatchStore-1024.png new file mode 100644 index 0000000..67e5630 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-WatchStore-1024.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@1x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@1x.png new file mode 100644 index 0000000..23c8d5b Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@1x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png new file mode 100644 index 0000000..40a971e Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@1x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@1x.png new file mode 100644 index 0000000..0195468 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@1x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png new file mode 100644 index 0000000..4118389 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@1x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@1x.png new file mode 100644 index 0000000..40a971e Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@1x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png new file mode 100644 index 0000000..4eded2d Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@1x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@1x.png new file mode 100644 index 0000000..8c7ac1e Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@1x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png new file mode 100644 index 0000000..7b11b09 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png new file mode 100644 index 0000000..5043434 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png new file mode 100644 index 0000000..40a971e Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png new file mode 100644 index 0000000..9e348ca Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png new file mode 100644 index 0000000..4118389 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png new file mode 100644 index 0000000..02dccf1 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png new file mode 100644 index 0000000..4eded2d Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png new file mode 100644 index 0000000..0ee9093 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png new file mode 100644 index 0000000..0ee9093 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png new file mode 100644 index 0000000..e0af2b2 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json b/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0cfcd9b --- /dev/null +++ b/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,50 @@ +{ + "images" : [ + { "filename" : "AppIcon-iPhone-20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, + { "filename" : "AppIcon-iPhone-20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, + { "filename" : "AppIcon-iPhone-29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, + { "filename" : "AppIcon-iPhone-29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, + { "filename" : "AppIcon-iPhone-40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, + { "filename" : "AppIcon-iPhone-40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, + { "filename" : "AppIcon-iPhone-60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, + { "filename" : "AppIcon-iPhone-60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, + { "filename" : "AppIcon-iPad-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, + { "filename" : "AppIcon-iPad-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, + { "filename" : "AppIcon-iPad-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, + { "filename" : "AppIcon-iPad-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, + { "filename" : "AppIcon-iPad-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, + { "filename" : "AppIcon-iPad-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, + { "filename" : "AppIcon-iPad-76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, + { "filename" : "AppIcon-iPad-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, + { "filename" : "AppIcon-iPad-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, + { "filename" : "AppIcon-AppStore-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }, + { "filename" : "AppIcon-Mac-16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, + { "filename" : "AppIcon-Mac-16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, + { "filename" : "AppIcon-Mac-32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, + { "filename" : "AppIcon-Mac-32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, + { "filename" : "AppIcon-Mac-128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, + { "filename" : "AppIcon-Mac-128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, + { "filename" : "AppIcon-Mac-256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, + { "filename" : "AppIcon-Mac-256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, + { "filename" : "AppIcon-Mac-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, + { "filename" : "AppIcon-Mac-512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }, + { "filename" : "AppIcon-Watch-Notification-38mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" }, + { "filename" : "AppIcon-Watch-Notification-42mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "27.5x27.5", "subtype" : "42mm" }, + { "filename" : "AppIcon-Watch-Settings@2x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "2x", "size" : "29x29" }, + { "filename" : "AppIcon-Watch-Settings@3x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "3x", "size" : "29x29" }, + { "filename" : "AppIcon-Watch-Launcher-38mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" }, + { "filename" : "AppIcon-Watch-Launcher-40mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" }, + { "filename" : "AppIcon-Watch-Launcher-41mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" }, + { "filename" : "AppIcon-Watch-Launcher-44mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "50x50", "subtype" : "44mm" }, + { "filename" : "AppIcon-Watch-Launcher-45mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "51x51", "subtype" : "45mm" }, + { "filename" : "AppIcon-Watch-Launcher-49mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "54x54", "subtype" : "49mm" }, + { "filename" : "AppIcon-Watch-QuickLook-38mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" }, + { "filename" : "AppIcon-Watch-QuickLook-42mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" }, + { "filename" : "AppIcon-Watch-QuickLook-44mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" }, + { "filename" : "AppIcon-WatchStore-1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/AppAssets.xcassets/Contents.json b/Resources/AppAssets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/AppAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Shared.xcassets/AccentColor.colorset/Contents.json b/Resources/Shared.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..64d76df --- /dev/null +++ b/Resources/Shared.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,21 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.965", + "green" : "0.565", + "red" : "0.145" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} + diff --git a/Resources/Shared.xcassets/Contents.json b/Resources/Shared.xcassets/Contents.json new file mode 100644 index 0000000..6cc1226 --- /dev/null +++ b/Resources/Shared.xcassets/Contents.json @@ -0,0 +1,7 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} + diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-AppStore-1024.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-AppStore-1024.png new file mode 100644 index 0000000..67e5630 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-AppStore-1024.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-20@1x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-20@1x.png new file mode 100644 index 0000000..23c8d5b Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-20@1x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png new file mode 100644 index 0000000..40a971e Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-20@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-29@1x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-29@1x.png new file mode 100644 index 0000000..0195468 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-29@1x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png new file mode 100644 index 0000000..4118389 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-29@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-40@1x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-40@1x.png new file mode 100644 index 0000000..40a971e Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-40@1x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png new file mode 100644 index 0000000..4eded2d Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-40@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-76@1x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-76@1x.png new file mode 100644 index 0000000..8c7ac1e Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-76@1x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png new file mode 100644 index 0000000..7b11b09 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-76@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png new file mode 100644 index 0000000..5043434 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPad-83.5@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png new file mode 100644 index 0000000..40a971e Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png new file mode 100644 index 0000000..9e348ca Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-20@3x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png new file mode 100644 index 0000000..4118389 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png new file mode 100644 index 0000000..02dccf1 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-29@3x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png new file mode 100644 index 0000000..4eded2d Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png new file mode 100644 index 0000000..0ee9093 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-40@3x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png new file mode 100644 index 0000000..0ee9093 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@2x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png new file mode 100644 index 0000000..e0af2b2 Binary files /dev/null and b/Resources/iOS.xcassets/AppIcon.appiconset/AppIcon-iPhone-60@3x.png differ diff --git a/Resources/iOS.xcassets/AppIcon.appiconset/Contents.json b/Resources/iOS.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..68fd88e --- /dev/null +++ b/Resources/iOS.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { "filename" : "AppIcon-iPhone-20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, + { "filename" : "AppIcon-iPhone-20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, + { "filename" : "AppIcon-iPhone-29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, + { "filename" : "AppIcon-iPhone-29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, + { "filename" : "AppIcon-iPhone-40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, + { "filename" : "AppIcon-iPhone-40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, + { "filename" : "AppIcon-iPhone-60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, + { "filename" : "AppIcon-iPhone-60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, + { "filename" : "AppIcon-iPad-20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, + { "filename" : "AppIcon-iPad-20@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, + { "filename" : "AppIcon-iPad-29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, + { "filename" : "AppIcon-iPad-29@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, + { "filename" : "AppIcon-iPad-40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, + { "filename" : "AppIcon-iPad-40@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, + { "filename" : "AppIcon-iPad-76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, + { "filename" : "AppIcon-iPad-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, + { "filename" : "AppIcon-iPad-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, + { "filename" : "AppIcon-AppStore-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/iOS.xcassets/Contents.json b/Resources/iOS.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/iOS.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-128.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-128.png new file mode 100644 index 0000000..8e29c5c Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-128.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-128@2x.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-128@2x.png new file mode 100644 index 0000000..dc748ed Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-128@2x.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-16.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-16.png new file mode 100644 index 0000000..6a3bb3d Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-16.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-16@2x.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-16@2x.png new file mode 100644 index 0000000..0d5c979 Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-16@2x.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-256.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-256.png new file mode 100644 index 0000000..dc748ed Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-256.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-256@2x.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-256@2x.png new file mode 100644 index 0000000..73a643d Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-256@2x.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-32.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-32.png new file mode 100644 index 0000000..0d5c979 Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-32.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-32@2x.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-32@2x.png new file mode 100644 index 0000000..ae9b76f Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-32@2x.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-512.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-512.png new file mode 100644 index 0000000..73a643d Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-512.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-512@2x.png b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-512@2x.png new file mode 100644 index 0000000..67e5630 Binary files /dev/null and b/Resources/macOS.xcassets/AppIcon.appiconset/AppIcon-Mac-512@2x.png differ diff --git a/Resources/macOS.xcassets/AppIcon.appiconset/Contents.json b/Resources/macOS.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..901aa78 --- /dev/null +++ b/Resources/macOS.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { "filename" : "AppIcon-Mac-16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, + { "filename" : "AppIcon-Mac-16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, + { "filename" : "AppIcon-Mac-32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, + { "filename" : "AppIcon-Mac-32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, + { "filename" : "AppIcon-Mac-128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, + { "filename" : "AppIcon-Mac-128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, + { "filename" : "AppIcon-Mac-256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, + { "filename" : "AppIcon-Mac-256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, + { "filename" : "AppIcon-Mac-512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, + { "filename" : "AppIcon-Mac-512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/macOS.xcassets/Contents.json b/Resources/macOS.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/macOS.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-38mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-38mm@2x.png new file mode 100644 index 0000000..4eded2d Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-38mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-40mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-40mm@2x.png new file mode 100644 index 0000000..d685b28 Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-40mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-41mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-41mm@2x.png new file mode 100644 index 0000000..de945d2 Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-41mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-44mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-44mm@2x.png new file mode 100644 index 0000000..dc1aaf3 Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-44mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-45mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-45mm@2x.png new file mode 100644 index 0000000..2df8f7c Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-45mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-49mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-49mm@2x.png new file mode 100644 index 0000000..82d8b4c Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Launcher-49mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-38mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-38mm@2x.png new file mode 100644 index 0000000..48d4b36 Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-38mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-42mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-42mm@2x.png new file mode 100644 index 0000000..697518b Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-42mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-38mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-38mm@2x.png new file mode 100644 index 0000000..0daa90e Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-38mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-42mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-42mm@2x.png new file mode 100644 index 0000000..136b3df Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-42mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-44mm@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-44mm@2x.png new file mode 100644 index 0000000..f022813 Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-44mm@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@2x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@2x.png new file mode 100644 index 0000000..4118389 Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@2x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@3x.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@3x.png new file mode 100644 index 0000000..02dccf1 Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-Watch-Settings@3x.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-WatchStore-1024.png b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-WatchStore-1024.png new file mode 100644 index 0000000..344a8aa Binary files /dev/null and b/Resources/watchOS.xcassets/AppIcon.appiconset/AppIcon-WatchStore-1024.png differ diff --git a/Resources/watchOS.xcassets/AppIcon.appiconset/Contents.json b/Resources/watchOS.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4d7162d --- /dev/null +++ b/Resources/watchOS.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { "filename" : "AppIcon-Watch-Notification-38mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" }, + { "filename" : "AppIcon-Watch-Notification-42mm@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "27.5x27.5", "subtype" : "42mm" }, + { "filename" : "AppIcon-Watch-Settings@2x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "2x", "size" : "29x29" }, + { "filename" : "AppIcon-Watch-Settings@3x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "3x", "size" : "29x29" }, + { "filename" : "AppIcon-Watch-Launcher-38mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" }, + { "filename" : "AppIcon-Watch-Launcher-40mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" }, + { "filename" : "AppIcon-Watch-Launcher-41mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" }, + { "filename" : "AppIcon-Watch-Launcher-44mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "50x50", "subtype" : "44mm" }, + { "filename" : "AppIcon-Watch-Launcher-45mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "51x51", "subtype" : "45mm" }, + { "filename" : "AppIcon-Watch-Launcher-49mm@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "54x54", "subtype" : "49mm" }, + { "filename" : "AppIcon-Watch-QuickLook-38mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" }, + { "filename" : "AppIcon-Watch-QuickLook-42mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" }, + { "filename" : "AppIcon-Watch-QuickLook-44mm@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" }, + { "filename" : "AppIcon-WatchStore-1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/watchOS.xcassets/Contents.json b/Resources/watchOS.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/watchOS.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Scripts/regenerate-project.command b/Scripts/regenerate-project.command new file mode 100755 index 0000000..2e9d5bb --- /dev/null +++ b/Scripts/regenerate-project.command @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v xcodegen >/dev/null; then + echo "XcodeGen не найден. Установите его командой: brew install xcodegen" + read -r -p "Нажмите Enter, чтобы закрыть окно…" + exit 1 +fi + +xcodegen generate +open Luma.xcodeproj diff --git a/Scripts/verify.sh b/Scripts/verify.sh new file mode 100755 index 0000000..df7d01d --- /dev/null +++ b/Scripts/verify.sh @@ -0,0 +1,640 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +required=( + project.yml + Sources/App/LumaApp.swift + Sources/Shared/XMPP/XMPPService.swift + Sources/Shared/XMPP/LumaCallEngine.swift + Sources/Shared/XMPP/LumaRoomStore.swift + Sources/Shared/XMPP/LumaOMEMOStore.swift + Sources/Shared/Models/MediaMetadata.swift + Sources/Shared/Models/AttachmentDraft.swift + Sources/Shared/Models/MediaSendActivityTracker.swift + Sources/Shared/Models/ArchiveSyncCheckpoint.swift + Sources/Shared/Models/ArchiveSyncPagination.swift + Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift + Sources/Shared/Models/ArchiveSyncWorkBudget.swift + Sources/Shared/Models/ArchiveMessageBatchPolicy.swift + Sources/Shared/Models/ChatScrollPositionPolicy.swift + Sources/Shared/Models/ChatTimelineEntry.swift + Sources/Shared/Models/ChatTypingPolicy.swift + Sources/Shared/Models/MessageReaction.swift + Sources/Shared/Models/MediaPickerSelectionPolicy.swift + Sources/Shared/Models/VideoNoteRecordingCompletionPolicy.swift + Sources/Shared/Models/VideoNoteRecordingLifecycle.swift + Sources/Shared/Models/MessageReplySwipePolicy.swift + Sources/Shared/Models/MessageReplyFallback.swift + Sources/Shared/Models/CallSnapshot.swift + Sources/Shared/Models/NotificationPolicy.swift + Sources/Shared/Services/AudioMessageRecorder.swift + Sources/Shared/Services/MediaPlaybackCoordinator.swift + Sources/Shared/Services/MediaPreviewProcessor.swift + Sources/Shared/Services/MediaFileIO.swift + Sources/Shared/Services/ChatMediaImageCache.swift + Sources/Shared/Services/LocationProvider.swift + Sources/Shared/Services/VideoNoteRecorder.swift + Sources/Shared/UI/SystemPhotoCameraView.swift + Sources/Shared/UI/VideoNoteCaptureView.swift + Sources/Shared/UI/CallView.swift + Sources/Shared/UI/Components/AudioMessagePlayer.swift + Sources/Shared/UI/Components/BubbleShapes.swift + Sources/Shared/UI/Components/InlineVideoPlayer.swift + Sources/Shared/UI/Components/VideoAttachmentPreview.swift + Sources/Shared/UI/Components/VideoNotePreview.swift + Sources/Shared/UI/Components/PhotoAttachmentPreview.swift + Sources/Shared/UI/Components/ReplyThreadOverlay.swift + Sources/Shared/UI/Components/MediaViewer.swift + Sources/Shared/UI/Components/LocationMessagePreview.swift + Sources/Shared/UI/Components/RTCVideoRendererView.swift + Sources/Shared/UI/LocationPickerView.swift + Sources/Shared/UI/ForwardMessageView.swift + Sources/Shared/UI/NewGroupView.swift + Sources/Shared/UI/GroupInfoView.swift + Sources/Shared/UI/AttachmentPreviewView.swift + Sources/Watch/LumaWatchApp.swift + Sources/Watch/WatchSessionModel.swift + Sources/Watch/WatchViews.swift + Sources/Watch/WatchVoiceRecorder.swift + Tests/CallSDPOrderingTests.swift + Tests/CallSnapshotTests.swift + Tests/NotificationPolicyTests.swift + Tests/MediaSendActivityTrackerTests.swift + Tests/ComposerRecordingGestureTests.swift + Tests/MediaViewerDismissGestureTests.swift + Tests/ArchiveSyncCheckpointTests.swift + Tests/ArchiveSyncPaginationTests.swift + Tests/ArchiveSyncRecoveryPolicyTests.swift + Tests/ArchiveSyncWorkBudgetTests.swift + Tests/ArchiveMessageBatchPolicyTests.swift + Tests/ChatScrollPositionPolicyTests.swift + Tests/ChatTimelineEntryTests.swift + Tests/ChatTypingPolicyTests.swift + Tests/MessageReactionTests.swift + Tests/MediaFileIOTests.swift + Tests/MediaPickerSelectionPolicyTests.swift + Tests/VideoNoteRecordingCompletionPolicyTests.swift + Tests/VideoNoteRecordingLifecycleTests.swift + Tests/VideoNoteStopPolicyTests.swift + Tests/MessageReplySwipeTests.swift + Tests/WatchVoiceMessageTests.swift + Brand/LumaIcon-1024.png + Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json + Regenerate-Luma-Project.command + Scripts/regenerate-project.command +) + +for file in "${required[@]}"; do + test -f "$file" || { echo "Missing $file"; exit 1; } +done + +test "$(grep -c 'path: Resources/AppAssets.xcassets' project.yml)" -eq 3 || { + echo "Every app target must reference Resources/AppAssets.xcassets" + exit 1 +} +test "$(grep -c 'ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon' project.yml)" -eq 3 || { + echo "Every app target must compile the AppIcon set" + exit 1 +} + +if grep -q 'type: application.watchapp2' project.yml; then + echo "The legacy WatchKit container cannot compile single-target SwiftUI watch sources" + exit 1 +fi +grep -A8 '^ LumaWatch:' project.yml | grep -q 'type: application$' || { + echo "LumaWatch must be a modern single-target watchOS application" + exit 1 +} +test "$(grep -c 'PRODUCT_NAME: LumaWatch' project.yml)" -eq 1 || { + echo "The watch product must use its own bundle filename" + exit 1 +} +grep -q 'TARGETED_DEVICE_FAMILY: "4"' project.yml || { + echo "The watch target must select the watch device family" + exit 1 +} +grep -q 'NSMicrophoneUsageDescription' Config/LumaWatch-Info.plist || { + echo "The watch app microphone usage description is missing" + exit 1 +} +grep -q 'session.transferFile(fileURL, metadata: metadata)' Sources/Watch/WatchSessionModel.swift || { + echo "Background Apple Watch voice transfer is missing" + exit 1 +} +grep -q 'voiceTransferCacheKey' Sources/Watch/WatchSessionModel.swift || { + echo "Apple Watch transfer acknowledgements must survive app restarts" + exit 1 +} +grep -q 'persistVoiceMessage(message)' Sources/Shared/Services/PhoneWatchBridge.swift || { + echo "Received Apple Watch voice files must survive iPhone app restarts" + exit 1 +} +grep -q 'kind: \.voice' Sources/Shared/Models/AppModel.swift || { + echo "Apple Watch recordings are not connected to the voice media pipeline" + exit 1 +} + +test "$(grep -c -- '- package: WebRTC' project.yml)" -eq 2 || { + echo "WebRTC must be linked to the iOS/iPadOS and macOS targets" + exit 1 +} + +if grep -R -q 'ChatBubbleShape\|maximumMediaSize\|25 МБ' Sources/Shared; then + echo "Legacy bubble tails or the old client attachment limit are still present" + exit 1 +fi + +if grep -q 'DtlsSrtpKeyAgreement\|continualGatheringPolicy\|iceCandidatePoolSize = 5\|OfferToReceive' \ + Sources/Shared/XMPP/LumaCallEngine.swift; then + echo "Legacy WebRTC peer-connection configuration is still present" + exit 1 +fi + +grep -q 'attempts.append(("public STUN"' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "WebRTC public STUN fallback is missing" + exit 1 +} +grep -q 'attempts.append(("host candidates", \[\]))' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "WebRTC host-candidate fallback is missing" + exit 1 +} +grep -q 'CallCandidateDispatchGate.canSend' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "Jingle ICE candidate signaling gate is missing" + exit 1 +} +grep -q 'session.pinPeerResource(jid)' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "Jingle full-resource pinning is missing" + exit 1 +} +grep -q 'return (jid: jmiJID, usesMessageInitiation: true)' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "JMI must target an advertised full JID" + exit 1 +} +grep -q 'CallSDPOrdering.answerIndices' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "Outgoing video answer ordering guard is missing" + exit 1 +} +grep -q 'CallSDPNormalization.martinParseInput' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "WebRTC-to-Martin SDP terminator normalization is missing" + exit 1 +} +grep -q 'CallSDPCompatibility.repairLocalFIDMSIDs' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "Outgoing video FID/MSID compatibility repair is missing" + exit 1 +} +grep -q 'historyHandler' Sources/Shared/XMPP/LumaCallEngine.swift || { + echo "Call completion history bridge is missing" + exit 1 +} +grep -q 'callHistoryContent' Sources/Shared/UI/Components/MessageBubble.swift || { + echo "Call history message card is missing" + exit 1 +} +grep -q 'defaultScrollAnchor(.bottom)' Sources/Shared/UI/ChatView.swift || { + echo "Chat timeline must open at the latest message" + exit 1 +} +grep -q 'ArchiveSyncPagination' Sources/Shared/XMPP/XMPPService.swift || { + echo "MAM synchronization must validate every RSM cursor" + exit 1 +} +grep -q 'guard activeQueryID == queryID else' Sources/Shared/XMPP/XMPPService.swift || { + echo "MAM results must be restricted to the active query ID" + exit 1 +} +grep -q 'allowedSources.contains(source.stringValue.lowercased())' Sources/Shared/XMPP/XMPPService.swift || { + echo "MAM results must be restricted to the account archive source" + exit 1 +} +grep -q 'archiveStanzaInbox.append' Sources/Shared/XMPP/XMPPService.swift || { + echo "Raw MAM stanzas must enter the bounded parser-queue inbox" + exit 1 +} +if sed -n '/archivedMessagesPublisher/,/store(in: &cancellables)/p' \ + Sources/Shared/XMPP/XMPPService.swift | grep -q 'receive(on: DispatchQueue.main)'; then + echo "Every raw MAM stanza must not be dispatched onto the main queue" + exit 1 +fi +grep -q 'RSM.Query(lastItems: archiveBootstrapMessageLimit)' Sources/Shared/XMPP/XMPPService.swift || { + echo "Fresh MAM bootstrap must request the latest bounded page" + exit 1 +} +grep -q 'archiveWorkBudget.recordCompletedPage' Sources/Shared/XMPP/XMPPService.swift || { + echo "Incremental MAM passes must have a finite foreground work budget" + exit 1 +} +if grep -q 'scheduleArchiveRestart\|restartDelayNanoseconds' Sources/Shared/XMPP/XMPPService.swift; then + echo "Failed MAM queries must not restart themselves indefinitely" + exit 1 +fi +grep -q 'archiveRetrySuppressedUntilActivation = true' Sources/Shared/XMPP/XMPPService.swift || { + echo "MAM failures must remain paused until a later app activation" + exit 1 +} +grep -q 'pauseArchiveSync(client: client)' Sources/Shared/XMPP/XMPPService.swift || { + echo "MAM must stop cleanly while the app or camera is inactive" + exit 1 +} +grep -q 'isArchiveSyncing = false' Sources/Shared/Models/AppModel.swift || { + echo "A transport disconnect must clear a stale MAM indicator" + exit 1 +} +grep -q 'case archiveBatch(\[ArchiveMutation\])' Sources/Shared/XMPP/XMPPService.swift || { + echo "A decoded MAM pass must be exposed as one atomic mutation batch" + exit 1 +} +grep -q 'applyArchiveBatch(mutations)' Sources/Shared/Models/AppModel.swift || { + echo "MAM mutations must be applied atomically before publishing SwiftUI state" + exit 1 +} +grep -q 'decodeSliceSize = 1' Sources/Shared/Models/ArchiveMessageBatchPolicy.swift || { + echo "MAM decoding must remain limited to one stanza per main-actor slice" + exit 1 +} +grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ChatArchive.swift || { + echo "The durable archive snapshot must persist the XEP-0313 UID cursor" + exit 1 +} +grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || { + echo "MAM reconnects must prefer the durable archive UID over timestamps" + exit 1 +} +grep -q 'shouldFallbackFromStoredCursor' Sources/Shared/XMPP/XMPPService.swift || { + echo "An expired archive UID must have one bounded timestamp fallback" + exit 1 +} +grep -q 'await Task.yield()' Sources/Shared/XMPP/XMPPService.swift || { + echo "MAM decoding must yield to scrolling and camera callbacks" + exit 1 +} +grep -q 'pageApplyTimeoutNanoseconds' Sources/Shared/XMPP/XMPPService.swift || { + echo "MAM page application must have a finite watchdog" + exit 1 +} +grep -q 'setArchiveSyncIndicator(false)' Sources/Shared/XMPP/XMPPService.swift || { + echo "Every MAM terminal path must be able to clear the indicator" + exit 1 +} +grep -q 'selectedMessagesCacheConversationID' Sources/Shared/Models/AppModel.swift || { + echo "Chat timeline sorting must not repeat on every recording timer tick" + exit 1 +} +if sed -n '/private func upsertMessage(/,/private func updateMessage/p' \ + Sources/Shared/Models/AppModel.swift | grep -q 'messages.firstIndex'; then + echo "MAM insertion must use the indexed message lookup" + exit 1 +fi +grep -q 'incrementUnread: envelope.isArchived ? false : nil' Sources/Shared/Models/AppModel.swift || { + echo "Historical MAM replay must not inflate unread counters" + exit 1 +} +grep -q 'persistenceQueue.async' Sources/Shared/XMPP/LumaOMEMOStore.swift || { + echo "OMEMO state persistence must not block MAM decryption on the main actor" + exit 1 +} +grep -q 'persistWhenQuiet' Sources/Shared/XMPP/LumaOMEMOStore.swift || { + echo "OMEMO persistence must wait for a quiet coalescing window" + exit 1 +} +grep -q 'mediaFileIO.load' Sources/Shared/Models/AppModel.swift || { + echo "Large media files must be loaded outside the main actor" + exit 1 +} +grep -q 'stageAttachmentDraft' Sources/Shared/Models/AppModel.swift || { + echo "Picker media must be copied outside the main actor before preview" + exit 1 +} +grep -q 'NSFileCoordinator' Sources/Shared/Services/MediaFileIO.swift || { + echo "Security-scoped and iCloud media reads must be coordinated" + exit 1 +} +grep -q 'preferredItemEncoding: .current' Sources/Shared/UI/ChatView.swift || { + echo "The photo picker must avoid unnecessary representation transcoding" + exit 1 +} +grep -q 'MediaPickerSelectionPolicy.preferredOrder' Sources/Shared/UI/ChatView.swift || { + echo "Ambiguous photo/video providers must retain a fallback representation" + exit 1 +} +grep -q 'item.loadTransferable(type: Data.self)' Sources/Shared/UI/ChatView.swift || { + echo "PhotosPicker images need a data fallback when no file representation is available" + exit 1 +} +grep -q 'CGImageSourceGetType' Sources/Shared/UI/ChatView.swift || { + echo "Fallback image data must retain its real media type and extension" + exit 1 +} +grep -q 'setArchiveSyncSuspendedForMediaWork' Sources/Shared/Models/AppModel.swift || { + echo "MAM must pause while gallery media is staged or sent" + exit 1 +} +grep -q 'withMediaPreparationActivity' Sources/Shared/UI/ChatView.swift || { + echo "MAM must pause before Photos/iCloud item-provider loading starts" + exit 1 +} +grep -q 'thumbnailData: draft.thumbnailData' Sources/Shared/Models/AppModel.swift || { + echo "Prepared gallery thumbnails must not be regenerated during send" + exit 1 +} +grep -q 'thumbnailImages: \[UUID: DraftThumbnailImage\]' Sources/Shared/UI/AttachmentPreviewView.swift || { + echo "Attachment thumbnails must be decoded once and cached by draft ID" + exit 1 +} +grep -q '@State private var player: AVPlayer?' Sources/Shared/UI/Components/MediaViewer.swift || { + echo "The full-screen video player must survive SwiftUI body updates" + exit 1 +} +if grep -q 'archiveLookback\|page < 10' Sources/Shared/XMPP/XMPPService.swift; then + echo "MAM synchronization must not silently truncate history" + exit 1 +fi +grep -q 'performInitialScrollAfterLayout' Sources/Shared/UI/ChatView.swift || { + echo "Deferred initial chat scroll is missing" + exit 1 +} +grep -q 'scrollDismissesKeyboard(.interactively)' Sources/Shared/UI/ChatView.swift || { + echo "Interactive keyboard dismissal is missing" + exit 1 +} +grep -q 'selectedTimelineEntries' Sources/Shared/UI/ChatView.swift || { + echo "Chat rows and day boundaries must use the cached stable timeline" + exit 1 +} +grep -q 'jumpToLatestButton' Sources/Shared/UI/ChatView.swift || { + echo "The chat must expose a jump-to-latest button away from the bottom" + exit 1 +} +grep -q 'ChatScrollMetricsObserver' Sources/Shared/UI/ChatView.swift || { + echo "iOS bottom-button state must follow native UIScrollView metrics" + exit 1 +} +grep -q 'scheduleMetricsReport' Sources/Shared/UI/ChatView.swift || { + echo "iOS scroll metrics must be coalesced before publishing SwiftUI state" + exit 1 +} +if sed -n '/func attachToAncestorScrollViewIfNeeded/,/func resetReportedState/p' \ + Sources/Shared/UI/ChatView.swift | grep -q 'observedScrollView == nil'; then + echo "The iOS scroll observer must be able to attach to a replacement UIScrollView" + exit 1 +fi +grep -q 'ChatScrollPositionPolicy.isNearBottom' Sources/Shared/UI/ChatView.swift || { + echo "The jump-to-latest button must follow a bounded bottom threshold" + exit 1 +} +grep -q 'safeAreaInset(edge: .bottom' Sources/Shared/UI/ChatView.swift || { + echo "The glass composer must float without an opaque full-width background" + exit 1 +} +if sed -n '/private var messageInput:/,/private var composerActionControl:/p' \ + Sources/Shared/UI/ChatView.swift | grep -q 'DragGesture'; then + echo "The composer must not compete with vertical timeline scrolling" + exit 1 +fi +grep -q 'ComposerRecordingGesturePolicy.resolution' Sources/Shared/UI/ChatView.swift || { + echo "Telegram-style recording gestures are missing" + exit 1 +} +grep -q 'MessageReplySwipePolicy.shouldReply' Sources/Shared/UI/Components/MessageBubble.swift || { + echo "Horizontal reply swipe is missing" + exit 1 +} +grep -q 'ChatMediaImageCache.image' Sources/Shared/UI/Components/PhotoAttachmentPreview.swift || { + echo "Visible chat thumbnails must not be decoded on every SwiftUI pass" + exit 1 +} +if sed -n '/private var composer:/,/private var messageInput:/p' Sources/Shared/UI/ChatView.swift \ + | grep -q 'background(.ultraThinMaterial)'; then + echo "The message composer must not have a full-width grey material background" + exit 1 +fi +grep -q 'Image(systemName: "paperclip")' Sources/Shared/UI/ChatView.swift || { + echo "Telegram-style attachment control is missing" + exit 1 +} +grep -q 'presentVideoNoteRecorder()' Sources/Shared/UI/ChatView.swift || { + echo "The attachment menu must open the dedicated video-note recorder" + exit 1 +} +grep -q 'Label("Видеосообщение"' Sources/Shared/UI/ChatView.swift || { + echo "The attachment menu video-note action is missing" + exit 1 +} +grep -q 'try recorder.start()' Sources/Shared/UI/VideoNoteCaptureView.swift || { + echo "The dedicated video-note screen must start recording after camera preparation" + exit 1 +} +if grep -q 'Режим видеосообщения\|composerCaptureMode.*video' Sources/Shared/UI/ChatView.swift; then + echo "Video-note recording must not reuse the inline voice-message gesture" + exit 1 +fi +grep -q 'resetCaptureGraph()' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Reusable video-note capture cleanup is missing" + exit 1 +} +grep -q 'sessionQueue.async' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "AVCaptureSession start/stop must run outside the main actor" + exit 1 +} +grep -q 'afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration' \ + Sources/Shared/UI/VideoNoteCaptureView.swift || { + echo "The dedicated video-note stop button must preserve the minimum valid recording duration" + exit 1 +} +if grep -q '450_000_000\|afterMinimumDuration: 0.45' Sources/Shared/UI/ChatView.swift; then + echo "Video-note stop must follow AVCapture didStart rather than a guessed UI delay" + exit 1 +fi +grep -q 'VideoNoteRecordingCompletionPolicy.shouldKeepOutput' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Successful AVFoundation video files must survive non-nil completion errors" + exit 1 +} +grep -q 'VideoNoteRecordingLifecycle.acceptsCompletion' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Stale AVFoundation callbacks must not reset a newer video note" + exit 1 +} +grep -q 'model.setVideoNoteCaptureActive(true)' Sources/Shared/UI/ChatView.swift || { + echo "Video-note capture must pause competing MAM work" + exit 1 +} +grep -q 'releaseArchiveSyncAfterCapture' Sources/Shared/UI/ChatView.swift || { + echo "MAM must stay paused until video-note upload has yielded the main actor" + exit 1 +} +grep -q 'ComposerRecordingGesturePolicy.isLongPress' Sources/Shared/UI/ChatView.swift || { + echo "Recording long-press must survive a delayed main actor" + exit 1 +} +grep -q 'beginPhysicalPress(at: value.time)' Sources/Shared/UI/ChatView.swift || { + echo "Recording duration must use physical gesture timestamps" + exit 1 +} +grep -q 'ComposerRecordTouchSurface' Sources/Shared/UI/ChatView.swift || { + echo "iOS voice recording must retain the physical touch across SwiftUI updates" + exit 1 +} +grep -q 'override func touchesCancelled' Sources/Shared/UI/ChatView.swift || { + echo "A cancelled iOS recording touch must not be treated as release-to-send" + exit 1 +} +grep -q 'remainingRecordedDuration' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Video-note minimum duration must use recorded media time" + exit 1 +} +grep -q 'wasRequestedOrReachedLimit' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Unexpected camera completion must not send a partial video note" + exit 1 +} +grep -q 'normalizedRecordingPath' Sources/Shared/Models/VideoNoteRecordingLifecycle.swift || { + echo "macOS temporary-path aliases must identify the active recording" + exit 1 +} +grep -q 'AVVideoCodecType.h264' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Video notes must use the interoperable H.264 codec when available" + exit 1 +} +grep -q 'VideoNoteFileInspector' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Finalized video notes must be inspected before upload" + exit 1 +} +grep -q 'UIImagePickerController' Sources/Shared/UI/SystemPhotoCameraView.swift || { + echo "Native iPhone camera capture is missing" + exit 1 +} +grep -q 'picker.mediaTypes = \[UTType.image.identifier, UTType.movie.identifier\]' \ + Sources/Shared/UI/SystemPhotoCameraView.swift || { + echo "The native camera must support both photos and videos" + exit 1 +} +grep -q 'Label("Камера"' Sources/Shared/UI/ChatView.swift || { + echo "The attachment menu must expose the photo/video camera" + exit 1 +} +grep -q 'presentAttachmentPreviewAfterPickerDismissal' Sources/Shared/UI/ChatView.swift || { + echo "PhotosPicker dismissal must complete before attachment preview presentation" + exit 1 +} +grep -q 'attachmentPreviewPresentationPending' Sources/Shared/UI/ChatView.swift || { + echo "A prepared attachment preview must remain queued across picker dismissal" + exit 1 +} +if sed -n '/private func presentAttachmentPreviewAfterPickerDismissal/,/private func loadPickedMediaFile/p' \ + Sources/Shared/UI/ChatView.swift | grep -q 'showingMediaPicker = false'; then + echo "Attachment preview must not force-close PhotosPicker while UIKit is dismissing it" + exit 1 +fi +if sed -n '/private func copyPickedMediaFile/,/^}/p' Sources/Shared/UI/ChatView.swift \ + | grep -q 'options: \.forUploading'; then + echo "PhotosPicker files must be coordinated as files, not upload packages" + exit 1 +fi +grep -q 'case reaction(ReactionEnvelope)' Sources/Shared/XMPP/XMPPService.swift || { + echo "XEP-0444 reaction events are missing" + exit 1 +} +grep -q 'urn:xmpp:reactions:0' Sources/Shared/XMPP/XMPPService.swift || { + echo "XEP-0444 capability advertisement is missing" + exit 1 +} +grep -q 'MessageReactionPolicy.quickChoices' Sources/Shared/UI/Components/MessageBubble.swift || { + echo "Message reaction UI is missing" + exit 1 +} +grep -q 'ChatStateNotificationsModule' Sources/Shared/XMPP/XMPPService.swift || { + echo "XEP-0085 chat-state support is missing" + exit 1 +} +grep -q 'model.updateComposerActivity' Sources/Shared/UI/ChatView.swift || { + echo "The composer is not connected to typing notifications" + exit 1 +} +grep -q 'snapshotQueue.async' Sources/Shared/Services/PhoneWatchBridge.swift || { + echo "Apple Watch snapshot encoding must not block SwiftUI" + exit 1 +} +awk ' + /^#if os\(iOS\)$/ { ios_only = 1; next } + /^#endif$/ && ios_only { ios_only = 0; next } + /availableVideoCodecTypes/ { + found = 1 + if (!ios_only) escaped = 1 + } + END { exit !(found && !escaped) } +' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "iOS-only movie codec discovery escaped its platform guard" + exit 1 +} +grep -q 'canRetryMediaMessage' Sources/Shared/UI/Components/MessageBubble.swift || { + echo "Failed media messages must expose a retry action" + exit 1 +} +grep -q 'MediaViewerDismissGesturePolicy.shouldDismiss' Sources/Shared/UI/Components/MediaViewer.swift || { + echo "Full-screen media vertical swipe dismissal is missing" + exit 1 +} +if grep -q 'Image(systemName: "xmark")' Sources/Shared/UI/Components/MediaViewer.swift; then + echo "Full-screen media viewer must not show a close button" + exit 1 +fi +grep -q 'selectedMessageIDs' Sources/Shared/UI/ChatView.swift || { + echo "Multiple message selection is missing" + exit 1 +} +grep -q 'forwardMessages' Sources/Shared/UI/ForwardMessageView.swift || { + echo "Multiple message forwarding is missing" + exit 1 +} +grep -q 'MediaSendActivityTracker' Sources/Shared/Models/AppModel.swift || { + echo "Overlapping media send recovery is missing" + exit 1 +} +grep -q 'failedDrafts' Sources/Shared/UI/AttachmentPreviewView.swift || { + echo "Failed media drafts must remain available for retry" + exit 1 +} +grep -q 'rosterContactJIDs' Sources/Shared/UI/MainChatView.swift || { + echo "Prosody roster contacts screen is missing" + exit 1 +} +grep -q '\[.banner, .list, .sound\]' Sources/Shared/Services/NotificationCoordinator.swift || { + echo "Foreground notification presentation is missing" + exit 1 +} + +for catalog in Resources/*.xcassets/AppIcon.appiconset/Contents.json; do + python3 -m json.tool "$catalog" >/dev/null + python3 - "$catalog" <<'PY' +import json +import pathlib +import sys + +catalog = pathlib.Path(sys.argv[1]) +contents = json.loads(catalog.read_text()) +filenames = [item.get("filename") for item in contents.get("images", [])] +if not filenames or any(not name for name in filenames): + raise SystemExit(f"Incomplete AppIcon catalog: {catalog}") +missing = [name for name in filenames if not (catalog.parent / name).is_file()] +if missing: + raise SystemExit(f"Missing AppIcon files in {catalog}: {', '.join(missing)}") +PY +done + +for plist in Config/Luma-Info.plist Config/LumaMac-Info.plist Config/LumaWatch-Info.plist; do + grep -q 'CFBundleIconName' "$plist" || { echo "AppIcon is not configured in $plist"; exit 1; } +done + +if command -v plutil >/dev/null; then + plutil -lint Config/*.plist Config/*.entitlements >/dev/null +fi + +if command -v xcodegen >/dev/null; then + xcodegen generate --quiet +fi + +if command -v xcodebuild >/dev/null && test -d Luma.xcodeproj; then + xcodebuild -project Luma.xcodeproj -scheme Luma \ + -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +else + echo "Static project checks passed. Run on macOS with Xcode for a full build." +fi diff --git a/Sources/App/LumaApp.swift b/Sources/App/LumaApp.swift new file mode 100644 index 0000000..99fb20e --- /dev/null +++ b/Sources/App/LumaApp.swift @@ -0,0 +1,21 @@ +import SwiftUI + +@main +@MainActor +struct LumaApp: App { + @StateObject private var model = AppModel() + @Environment(\.scenePhase) private var scenePhase + + var body: some Scene { + WindowGroup { + RootView(model: model) + .tint(Color(red: 0.14, green: 0.56, blue: 0.96)) + .onChange(of: scenePhase) { _, phase in + model.setApplicationActive(phase == .active) + } + } +#if os(macOS) + .defaultSize(width: 1_080, height: 720) +#endif + } +} diff --git a/Sources/Shared/Models/AccountConfiguration.swift b/Sources/Shared/Models/AccountConfiguration.swift new file mode 100644 index 0000000..d558660 --- /dev/null +++ b/Sources/Shared/Models/AccountConfiguration.swift @@ -0,0 +1,92 @@ +import Foundation + +struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendable { + var jid: String + var displayName: String + var resource: String + var manualHost: String? + var manualPort: Int? + var usesDirectTLS: Bool + + var id: String { normalizedJID } + + var normalizedJID: String { + jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + var domain: String? { + let pieces = normalizedJID.split(separator: "@", omittingEmptySubsequences: false) + guard pieces.count == 2, !pieces[1].isEmpty else { return nil } + return String(pieces[1]) + } + + var effectiveResource: String { + let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Luma" : trimmed + } + + init( + jid: String, + displayName: String = "", + resource: String = "Luma", + manualHost: String? = nil, + manualPort: Int? = nil, + usesDirectTLS: Bool = false + ) { + self.jid = jid + self.displayName = displayName + self.resource = resource + self.manualHost = manualHost?.nilIfBlank + self.manualPort = manualPort + self.usesDirectTLS = usesDirectTLS + } + + func validated() throws -> AccountConfiguration { + let normalized = normalizedJID + let pieces = normalized.split(separator: "@", omittingEmptySubsequences: false) + guard pieces.count == 2, + !pieces[0].isEmpty, + !pieces[1].isEmpty, + !normalized.contains(where: { $0.isWhitespace }) else { + throw AccountValidationError.invalidJID + } + + if let manualHost, manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + throw AccountValidationError.invalidHost + } + + if let manualPort, !(1...65_535).contains(manualPort) { + throw AccountValidationError.invalidPort + } + + var copy = self + copy.jid = normalized + copy.manualHost = manualHost?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfBlank + copy.resource = effectiveResource + return copy + } +} + +enum AccountValidationError: LocalizedError { + case invalidJID + case invalidHost + case invalidPort + + var errorDescription: String? { + switch self { + case .invalidJID: + return "Введите полный JID в формате user@example.org." + case .invalidHost: + return "Адрес сервера не может быть пустым." + case .invalidPort: + return "Порт должен быть числом от 1 до 65535." + } + } +} + +private extension String { + var nilIfBlank: String? { + isEmpty ? nil : self + } +} + diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift new file mode 100644 index 0000000..e23ca77 --- /dev/null +++ b/Sources/Shared/Models/AppModel.swift @@ -0,0 +1,2771 @@ +import Combine +import AVFoundation +import Foundation +import UniformTypeIdentifiers +import WebRTC + +@MainActor +final class AppModel: ObservableObject { + @Published private(set) var account: AccountConfiguration? + @Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected(reason: nil) + private(set) var conversations: [Conversation] = [] { + willSet { + if !isApplyingArchiveBatch { + objectWillChange.send() + } + } + } + @Published private(set) var rosterContactJIDs: Set = [] + private(set) var messages: [ChatMessage] = [] { + willSet { + if !isApplyingArchiveBatch { + objectWillChange.send() + } + } + didSet { + if !isApplyingArchiveBatch { + selectedMessagesCacheConversationID = nil + selectedTimelineEntriesCacheConversationID = nil + } + } + } + @Published var selectedConversationID: String? + @Published private(set) var isOMEMOReady = false + @Published private(set) var ownFingerprint: String? + @Published private(set) var isArchiveSyncing = false + @Published private(set) var isAuthenticating = false + @Published private(set) var isSendingAttachment = false + @Published private(set) var isUpdatingAvatar = false + @Published private(set) var avatarDataByJID: [String: Data] = [:] + @Published private(set) var globalEncryptionEnabled = true + @Published private(set) var typingIndicatorsEnabled = true + @Published private(set) var typingParticipantsByConversation: [String: [String: String]] = [:] + @Published var errorMessage: String? + @Published var informationalMessage: String? + @Published var previewURL: URL? + @Published private(set) var mediaViewerItem: MediaViewerItem? + @Published private(set) var mediaPreviewURLs: [String: URL] = [:] + @Published private(set) var mediaThumbnailData: [String: Data] = [:] + @Published private(set) var audioWaveformSamples: [String: [Float]] = [:] + @Published private(set) var loadingMediaIDs: Set = [] + @Published private(set) var activeCall: CallSnapshot? + + let audioPlayback = MediaPlaybackCoordinator() + + private let xmpp: XMPPService + private let credentials: CredentialVault + private let preferences: AccountPreferences + private let notifications: NotificationCoordinator + private let watchBridge: PhoneWatchBridge + private let avatarCache: AvatarCache + private let mediaPreviewProcessor = MediaPreviewProcessor() + private let mediaFileIO = MediaFileIO() + private var archive: ChatArchive? + private var bootstrapTask: Task? + private var persistTask: Task? + private var watchSyncTask: Task? + private var isApplyingArchiveBatch = false + private var messageIndexByStorageKey: [String: Int] = [:] + private var messageIndexByStanzaKey: [String: Int] = [:] + private var firstMessageIndexByID: [String: Int] = [:] + private var selectedMessagesCacheConversationID: String? + private var selectedMessagesCache: [ChatMessage] = [] + private var selectedTimelineEntriesCacheConversationID: String? + private var selectedTimelineEntriesCache: [ChatTimelineEntry] = [] + private var appIsActive = true + private var avatarCacheRequests: Set = [] + private var pendingCorrections: [String: XMPPService.MessageEnvelope] = [:] + private var pendingRetractions: [String: XMPPService.RetractionEnvelope] = [:] + private var pendingReactions: [String: XMPPService.ReactionEnvelope] = [:] + private var correctionReceiptTargets: [String: String] = [:] + private var localChatStateByConversation: [String: ChatTypingState] = [:] + private var localTypingPauseTasks: [String: Task] = [:] + private var remoteTypingExpiryTasks: [String: Task] = [:] + private var locallyDeletedMessageIDs: Set = [] + private var lastSuccessfulMAMSync: Date? + private var lastSuccessfulMAMCursor: String? + private var pendingRoomPasswords: [String: String] = [:] + private var joiningRoomJIDs: Set = [] + private var mediaSendActivity = MediaSendActivityTracker() + private var mediaPreparationTokens: Set = [] + private var videoNoteCaptureIsActive = false + private var archiveSyncIsSuspendedForMedia = false + + init( + xmpp: XMPPService? = nil, + credentials: CredentialVault = CredentialVault(), + preferences: AccountPreferences = AccountPreferences(), + notifications: NotificationCoordinator = NotificationCoordinator(), + watchBridge: PhoneWatchBridge = PhoneWatchBridge(), + avatarCache: AvatarCache = AvatarCache() + ) { + // Default arguments are evaluated at the call site, which may be + // nonisolated. Construct the @MainActor service inside this + // @MainActor initializer instead. + let xmpp = xmpp ?? XMPPService() + self.xmpp = xmpp + self.credentials = credentials + self.preferences = preferences + self.notifications = notifications + self.watchBridge = watchBridge + self.avatarCache = avatarCache + + xmpp.eventHandler = { [weak self] event in + self?.consume(event) + } + watchBridge.onReply = { [weak self] jid, text in + Task { @MainActor in + guard let self else { return } + if !self.conversations.contains(where: { $0.id == jid.lowercased() }) { + self.upsertConversation(jid: jid, name: nil) + } + await self.sendText(text, to: jid) + } + } + watchBridge.onVoiceMessage = { [weak self] voice in + Task { @MainActor in + guard let self else { return } + self.errorMessage = nil + if let bootstrapTask = self.bootstrapTask { + await bootstrapTask.value + } + + guard self.account != nil else { + let error = "Войдите в XMPP-аккаунт на iPhone, затем повторите отправку с Apple Watch." + self.errorMessage = error + self.watchBridge.reportVoiceResult( + transferID: voice.transferID, + success: false, + error: error + ) + return + } + + if self.connectionStatus != .connected { + await self.reconnect() + } + guard self.connectionStatus == .connected else { + let error = self.errorMessage ?? "iPhone не подключён к XMPP-серверу." + self.errorMessage = error + self.watchBridge.reportVoiceResult( + transferID: voice.transferID, + success: false, + error: error + ) + return + } + + if !self.conversations.contains(where: { $0.id == voice.jid.lowercased() }) { + self.upsertConversation(jid: voice.jid, name: nil) + } + let messageID = await self.sendMedia( + data: voice.data, + filename: voice.filename, + mimeType: "audio/mp4", + kind: .voice, + duration: voice.duration, + to: voice.jid, + messageID: voice.stableMessageID + ) + let success = messageID != nil + let failure = success ? nil : (self.errorMessage ?? "Не удалось отправить голосовое сообщение.") + if success { + self.informationalMessage = "Голосовое сообщение с Apple Watch отправлено." + } else if self.errorMessage == nil { + self.errorMessage = failure + } + self.watchBridge.reportVoiceResult( + transferID: voice.transferID, + success: success, + error: failure + ) + } + } + + bootstrapTask = Task { [weak self] in + guard let self else { return } + await self.bootstrap() + } + } + + deinit { + bootstrapTask?.cancel() + persistTask?.cancel() + watchSyncTask?.cancel() + localTypingPauseTasks.values.forEach { $0.cancel() } + remoteTypingExpiryTasks.values.forEach { $0.cancel() } + } + + var selectedConversation: Conversation? { + guard let selectedConversationID else { return nil } + return conversations.first { $0.id == selectedConversationID } + } + + private var durableArchiveSyncCheckpoint: ArchiveSyncCheckpoint? { + guard let lastSuccessfulMAMSync else { return nil } + return ArchiveSyncCheckpoint( + timestamp: lastSuccessfulMAMSync, + cursor: lastSuccessfulMAMCursor + ) + } + + var selectedMessages: [ChatMessage] { + guard let selectedConversationID else { return [] } + if selectedMessagesCacheConversationID == selectedConversationID { + return selectedMessagesCache + } + let sorted = messages + .filter { $0.conversationID == selectedConversationID } + .sorted { lhs, rhs in + if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } + return lhs.timestamp < rhs.timestamp + } + selectedMessagesCacheConversationID = selectedConversationID + selectedMessagesCache = sorted + return sorted + } + + var selectedTimelineEntries: [ChatTimelineEntry] { + guard let selectedConversationID else { return [] } + if selectedTimelineEntriesCacheConversationID == selectedConversationID { + return selectedTimelineEntriesCache + } + let entries = ChatTimelineEntry.make(from: selectedMessages) + selectedTimelineEntriesCacheConversationID = selectedConversationID + selectedTimelineEntriesCache = entries + return entries + } + + func bootstrap() async { + guard let saved = preferences.load() else { return } + account = saved + globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID) + typingIndicatorsEnabled = preferences.chatStatesEnabled(for: saved.normalizedJID) + xmpp.setChatStatesEnabled(typingIndicatorsEnabled) + await loadArchive(for: saved.normalizedJID) + let password: String + do { + guard let storedPassword = try credentials.password(for: saved.normalizedJID) else { + account = nil + archive = nil + conversations = [] + rosterContactJIDs = [] + messages = [] + rebuildMessageIndex() + informationalMessage = "Введите пароль заново: запись Keychain не найдена." + return + } + password = storedPassword + } catch { + account = nil + archive = nil + conversations = [] + rosterContactJIDs = [] + messages = [] + rebuildMessageIndex() + errorMessage = error.localizedDescription + return + } + do { + try await xmpp.connect( + account: saved, + password: password, + archiveCheckpoint: durableArchiveSyncCheckpoint + ) + await notifications.requestAuthorization() + } catch { + errorMessage = error.localizedDescription + } + } + + func signIn(account: AccountConfiguration, password: String) async { + isAuthenticating = true + errorMessage = nil + defer { isAuthenticating = false } + + do { + let account = try account.validated() + guard !password.isEmpty else { throw SignInError.emptyPassword } + + resetArchiveBatchState() + avatarDataByJID = [:] + avatarCacheRequests = [] + pendingCorrections = [:] + pendingRetractions = [:] + pendingReactions = [:] + correctionReceiptTargets = [:] + locallyDeletedMessageIDs = [] + pendingRoomPasswords = [:] + joiningRoomJIDs = [] + rosterContactJIDs = [] + resetMediaSendActivity() + resetMediaPreviews() + self.account = account + globalEncryptionEnabled = preferences.encryptionEnabled(for: account.normalizedJID) + typingIndicatorsEnabled = preferences.chatStatesEnabled(for: account.normalizedJID) + xmpp.setChatStatesEnabled(typingIndicatorsEnabled) + await loadArchive(for: account.normalizedJID) + try await xmpp.connect( + account: account, + password: password, + archiveCheckpoint: durableArchiveSyncCheckpoint + ) + try credentials.save(password: password, for: account.normalizedJID) + try preferences.save(account) + await notifications.requestAuthorization() + } catch { + await xmpp.disconnect() + self.account = nil + archive = nil + conversations = [] + rosterContactJIDs = [] + messages = [] + rebuildMessageIndex() + errorMessage = error.localizedDescription + } + } + + func signOut(forgetHistory: Bool = false) async { + let oldAccount = account + await xmpp.disconnect() + preferences.clear() + if let oldAccount { + try? credentials.deletePassword(for: oldAccount.normalizedJID) + if forgetHistory { + try? await archive?.erase() + } + } + persistTask?.cancel() + resetArchiveBatchState() + watchSyncTask?.cancel() + watchSyncTask = nil + archive = nil + account = nil + conversations = [] + rosterContactJIDs = [] + messages = [] + rebuildMessageIndex() + selectedConversationID = nil + isOMEMOReady = false + ownFingerprint = nil + avatarDataByJID = [:] + avatarCacheRequests = [] + globalEncryptionEnabled = true + typingIndicatorsEnabled = true + isUpdatingAvatar = false + errorMessage = nil + previewURL = nil + pendingCorrections = [:] + pendingRetractions = [:] + pendingReactions = [:] + correctionReceiptTargets = [:] + locallyDeletedMessageIDs = [] + lastSuccessfulMAMSync = nil + lastSuccessfulMAMCursor = nil + pendingRoomPasswords = [:] + joiningRoomJIDs = [] + activeCall = nil + resetTypingState() + resetMediaSendActivity() + resetMediaPreviews() + } + + func reconnect() async { + guard let account else { return } + do { + guard let password = try credentials.password(for: account.normalizedJID) else { + throw ReconnectError.passwordMissing + } + try await xmpp.reconnectIfNeeded(password: password) + } catch { + errorMessage = error.localizedDescription + } + } + + func setApplicationActive(_ active: Bool) { + appIsActive = active + xmpp.setApplicationActive(active) + if !active { + resetTypingState() + syncWatch(immediate: true) + } + if active, case .disconnected(_) = connectionStatus { + Task { await reconnect() } + } + } + + func setVideoNoteCaptureActive(_ active: Bool) { + videoNoteCaptureIsActive = active + updateArchiveSyncSuspensionForMedia() + } + + func startCall(to rawJID: String, withVideo: Bool) async { + let jid = rawJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !jid.isEmpty else { return } + guard conversations.first(where: { $0.id == jid })?.isGroup != true else { + errorMessage = "Звонки в текущем MVP доступны только в личных чатах." + return + } + guard await requestCallPermissions(includeVideo: withVideo) else { return } + audioPlayback.stop() + do { + try await xmpp.startCall(to: jid, withVideo: withVideo) + } catch { + errorMessage = error.localizedDescription + } + } + + func answerCall() async { + guard let activeCall else { return } + guard await requestCallPermissions(includeVideo: activeCall.isVideoCall) else { return } + audioPlayback.stop() + do { + try await xmpp.answerCall() + } catch { + errorMessage = error.localizedDescription + } + } + + func rejectCall() { + xmpp.rejectCall() + } + + func endCall() { + xmpp.endCall() + } + + func setCallMuted(_ muted: Bool) { + xmpp.setCallMuted(muted) + } + + func setCallCameraEnabled(_ enabled: Bool) { + xmpp.setCallCameraEnabled(enabled) + } + + func setCallSpeakerEnabled(_ enabled: Bool) { + xmpp.setCallSpeakerEnabled(enabled) + } + + func switchCallCamera() { + xmpp.switchCallCamera() + } + + func localCallVideoTrack(for callID: UUID) -> RTCVideoTrack? { + xmpp.localCallVideoTrack(for: callID) + } + + func remoteCallVideoTrack(for callID: UUID) -> RTCVideoTrack? { + xmpp.remoteCallVideoTrack(for: callID) + } + + func openConversation(jid: String, name: String? = nil, addToRoster: Bool = false) { + let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard Self.isValidJID(normalized) else { + errorMessage = "Введите JID в формате user@example.org." + return + } + upsertConversation(jid: normalized, name: name) + selectedConversationID = normalized + if let index = conversations.firstIndex(where: { $0.id == normalized }) { + conversations[index].unreadCount = 0 + } + if addToRoster { + rosterContactJIDs.insert(normalized) + xmpp.addToRoster(jid: normalized, name: name) + } + xmpp.fetchAvatar(for: normalized) + schedulePersist() + syncWatch() + } + + func selectConversation(id: String) { + let normalized = id.lowercased() + selectedConversationID = normalized + if let index = conversations.firstIndex(where: { $0.id == normalized }) { + conversations[index].unreadCount = 0 + } + schedulePersist() + syncWatch() + } + + func createOrJoinGroup( + roomJID rawRoomJID: String, + name: String?, + nickname rawNickname: String, + invitees: [String] + ) async { + let roomJID = rawRoomJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard Self.isValidJID(roomJID) else { + errorMessage = "Введите адрес комнаты в формате room@conference.example.org." + return + } + let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !nickname.isEmpty else { + errorMessage = "Введите псевдоним для группового чата." + return + } + let validInvitees = invitees + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter(Self.isValidJID) + + upsertGroupConversation( + jid: roomJID, + name: name, + nickname: nickname, + shouldAutojoin: true + ) + selectConversation(id: roomJID) + guard joiningRoomJIDs.insert(roomJID).inserted else { return } + defer { joiningRoomJIDs.remove(roomJID) } + do { + try await xmpp.joinRoom(roomJID: roomJID, nickname: nickname) + if !validInvitees.isEmpty { + try await xmpp.inviteMembers(validInvitees, to: roomJID) + informationalMessage = "Приглашения отправлены: \(validInvitees.count)." + } + } catch { + if let index = conversations.firstIndex(where: { $0.id == roomJID }) { + conversations[index].isGroupJoined = false + } + errorMessage = error.localizedDescription + } + schedulePersist() + } + + func joinGroup(jid rawJID: String, nickname preferredNickname: String? = nil) async { + let jid = rawJID.lowercased() + guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else { return } + guard !conversations[index].isGroupJoined, + joiningRoomJIDs.insert(jid).inserted else { return } + defer { joiningRoomJIDs.remove(jid) } + let trimmedNickname = preferredNickname?.trimmingCharacters(in: .whitespacesAndNewlines) + let nickname: String + if let trimmedNickname, !trimmedNickname.isEmpty { + nickname = trimmedNickname + } else { + nickname = conversations[index].groupNickname ?? defaultGroupNickname + } + do { + try await xmpp.joinRoom( + roomJID: jid, + nickname: nickname, + password: pendingRoomPasswords[jid] + ) + conversations[index].groupNickname = nickname + conversations[index].shouldAutojoin = true + conversations[index].invitedBy = nil + pendingRoomPasswords.removeValue(forKey: jid) + } catch { + conversations[index].isGroupJoined = false + errorMessage = error.localizedDescription + } + schedulePersist() + } + + func leaveGroup(jid rawJID: String) { + let jid = rawJID.lowercased() + xmpp.leaveRoom(roomJID: jid) + guard let index = conversations.firstIndex(where: { $0.id == jid && $0.isGroup }) else { return } + conversations[index].isGroupJoined = false + conversations[index].shouldAutojoin = false + conversations[index].occupantCount = 0 + schedulePersist() + } + + func inviteMembers(_ rawJIDs: [String], to roomJID: String) async { + let jids = rawJIDs + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter(Self.isValidJID) + guard !jids.isEmpty else { + errorMessage = "Введите хотя бы один корректный JID участника." + return + } + do { + try await xmpp.inviteMembers(jids, to: roomJID) + informationalMessage = "Приглашения отправлены: \(jids.count)." + } catch { + errorMessage = error.localizedDescription + } + } + + var suggestedGroupNickname: String { defaultGroupNickname } + + func sendText( + _ rawText: String, + to explicitJID: String? = nil, + replyingTo: ChatMessage? = nil + ) async { + let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, + let account, + let peer = explicitJID ?? selectedConversation?.jid else { return } + let conversation = conversations.first { $0.id == peer.lowercased() } + let isGroup = conversation?.isGroup == true + let encrypted = encryptionEnabled(for: peer) + let replyTarget = replyingTo.flatMap { message -> ChatMessage? in + guard message.canBeRepliedTo, + message.conversationID == peer.lowercased() else { return nil } + return message + } + + let id = UUID().uuidString + let pending = ChatMessage( + id: id, + conversationID: peer, + senderJID: account.normalizedJID, + body: text, + direction: .outgoing, + delivery: .sending, + security: encrypted ? .omemo : .plaintext, + replyToID: replyTarget?.replyIdentifier, + replyToJID: replyTarget?.senderJID, + replyPreview: replyTarget?.quotePreview, + senderDisplayName: isGroup ? conversation?.groupNickname : nil, + isGroupMessage: isGroup + ) + upsertMessage(pending) + + do { + let fingerprint = try await xmpp.sendText( + text, + to: peer, + messageID: id, + encrypted: encrypted, + replyTo: replyTarget.flatMap { target in + guard let replyID = target.replyIdentifier else { return nil } + return XMPPService.ReplyReference( + id: replyID, + authorJID: target.senderJID, + fallbackAuthor: target.senderDisplayName ?? displayName(for: target.senderJID), + preview: target.quotePreview + ) + }, + isGroup: isGroup + ) + updateMessage(id: id) { + $0.delivery = .sent + $0.encryptionFingerprint = fingerprint + } + let conversationID = peer.lowercased() + localTypingPauseTasks[conversationID]?.cancel() + localTypingPauseTasks[conversationID] = nil + localChatStateByConversation[conversationID] = .active + } catch { + updateMessage(id: id) { $0.delivery = .failed } + errorMessage = error.localizedDescription + } + schedulePersist() + syncWatch() + } + + func sendLocation(_ location: GeoLocation, to explicitJID: String? = nil) async { + guard let account, + let peer = explicitJID ?? selectedConversation?.jid else { return } + let conversation = conversations.first { $0.id == peer.lowercased() } + let isGroup = conversation?.isGroup == true + let encrypted = encryptionEnabled(for: peer) + let id = UUID().uuidString + let pending = ChatMessage( + id: id, + conversationID: peer, + senderJID: account.normalizedJID, + body: location.uriString, + direction: .outgoing, + delivery: .sending, + security: encrypted ? .omemo : .plaintext, + kind: .location, + senderDisplayName: isGroup ? conversation?.groupNickname : nil, + isGroupMessage: isGroup + ) + upsertMessage(pending) + + do { + let fingerprint = try await xmpp.sendText( + location.uriString, + to: peer, + messageID: id, + encrypted: encrypted, + isGroup: isGroup + ) + updateMessage(id: id) { + $0.delivery = .sent + $0.encryptionFingerprint = fingerprint + } + } catch { + updateMessage(id: id) { $0.delivery = .failed } + errorMessage = error.localizedDescription + } + schedulePersist() + syncWatch() + } + + func editMessage(id: String, newBody rawText: String) async { + let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, + let index = messages.firstIndex(where: { $0.id == id }), + messages[index].canBeEdited else { return } + + let previous = messages[index] + guard text != previous.body else { return } + + let correctionID = UUID().uuidString + let encrypted = previous.security == .omemo + correctionReceiptTargets[correctionID] = id + if correctionReceiptTargets.count > 100, + let staleID = correctionReceiptTargets.keys.first(where: { $0 != correctionID }) { + correctionReceiptTargets.removeValue(forKey: staleID) + } + updateMessage(id: id) { + $0.body = text + $0.editedAt = Date() + $0.delivery = .sending + } + + do { + let fingerprint = try await xmpp.sendText( + text, + to: previous.conversationID, + messageID: correctionID, + encrypted: encrypted, + replacingMessageID: previous.id + ) + updateMessage(id: id) { + $0.delivery = .sent + $0.encryptionFingerprint = fingerprint + } + } catch { + correctionReceiptTargets.removeValue(forKey: correctionID) + if let currentIndex = messages.firstIndex(where: { $0.id == id }), + messages[currentIndex].delivery == .sending { + messages[currentIndex] = previous + updateConversationPreview(for: previous, incrementUnread: false) + } + errorMessage = error.localizedDescription + } + schedulePersist() + syncWatch() + } + + func retractMessage(id: String) async { + guard let account, + let message = messages.first(where: { $0.id == id }), + message.canBeRetracted else { return } + + let retractionID = UUID().uuidString + do { + try await xmpp.sendRetraction( + to: message.conversationID, + targetID: message.id, + retractionID: retractionID, + encrypted: message.security == .omemo + ) + applyRetraction(XMPPService.RetractionEnvelope( + peerJID: message.conversationID, + senderJID: account.normalizedJID, + targetID: message.id, + retractionID: retractionID, + timestamp: Date(), + isOutgoing: true + )) + } catch { + errorMessage = error.localizedDescription + } + schedulePersist() + syncWatch() + } + + func deleteMessageLocally(id: String) { + deleteMessagesLocally(ids: [id]) + } + + func deleteMessagesLocally(ids: Set, in conversationID: String? = nil) { + guard !ids.isEmpty else { return } + let normalizedConversationID = conversationID?.lowercased() + let selected = messages.filter { message in + ids.contains(message.id) + && (normalizedConversationID == nil || message.conversationID == normalizedConversationID) + } + guard !selected.isEmpty else { return } + + let affectedConversations = Set(selected.map(\.conversationID)) + for message in selected { + locallyDeletedMessageIDs.insert(Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + )) + let pendingKey = Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + ) + pendingCorrections.removeValue(forKey: pendingKey) + pendingRetractions.removeValue(forKey: pendingKey) + pendingReactions = pendingReactions.filter { _, envelope in + guard envelope.peerJID.lowercased() == message.conversationID else { + return true + } + if envelope.targetID == message.id { return false } + if let stanzaID = message.stanzaID, + envelope.targetID == stanzaID { return false } + return true + } + removeCachedMedia(for: message.id) + } + let selectedKeys = Set(selected.map { Self.localDeletionKey( + messageID: $0.id, + conversationID: $0.conversationID + ) }) + messages.removeAll { message in + selectedKeys.contains(Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + )) + } + rebuildMessageIndex() + for conversationID in affectedConversations { + rebuildConversationPreview(for: conversationID) + } + schedulePersist() + syncWatch() + } + + @discardableResult + func forwardMessage(_ message: ChatMessage, to rawRecipient: String) async -> Bool { + await forwardMessages([message], to: rawRecipient) + } + + @discardableResult + func forwardMessages(_ selectedMessages: [ChatMessage], to rawRecipient: String) async -> Bool { + let recipient = rawRecipient + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let forwardableMessages = selectedMessages + .filter(\.canBeForwarded) + .sorted { lhs, rhs in + if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } + return lhs.timestamp < rhs.timestamp + } + guard !forwardableMessages.isEmpty else { return false } + guard Self.isValidJID(recipient) else { + errorMessage = "Введите JID в формате user@example.org." + return false + } + + errorMessage = nil + upsertConversation(jid: recipient, name: nil) + + var forwardedCount = 0 + for message in forwardableMessages { + guard await forwardMessageItem(message, to: recipient) else { + if forwardedCount > 0 { + informationalMessage = "Переслано сообщений: \(forwardedCount) из \(forwardableMessages.count)." + } + return false + } + forwardedCount += 1 + } + if forwardedCount > 1 { + informationalMessage = "Переслано сообщений: \(forwardedCount)." + } + return true + } + + private func forwardMessageItem(_ message: ChatMessage, to recipient: String) async -> Bool { + let attribution = "↪ Переслано от \(message.senderDisplayName ?? displayName(for: message.senderJID))" + + switch message.kind { + case .text: + await sendText("\(attribution)\n\(message.body)", to: recipient) + return errorMessage == nil + case .location: + guard let location = GeoLocation(uri: message.body) else { + errorMessage = "Не удалось прочитать геопозицию из сообщения." + return false + } + await sendText(attribution, to: recipient) + guard errorMessage == nil else { return false } + await sendLocation(location, to: recipient) + return errorMessage == nil + case .attachment, .photo, .video, .audio, .voice, .videoNote: + do { + let url: URL + if let cached = mediaPreviewURLs[message.id] { + url = cached + } else { + url = try await localAttachmentURL( + for: message, + directoryName: "LumaForwarding" + ) + } + let data = try await mediaFileIO.load( + from: url, + preferredKind: message.kind + ).data + await sendText(attribution, to: recipient) + guard errorMessage == nil else { return false } + let sentID = await sendMedia( + data: data, + filename: message.localFilename ?? url.lastPathComponent, + mimeType: message.mimeType ?? "application/octet-stream", + kind: message.kind, + duration: message.duration, + to: recipient + ) + return sentID != nil + } catch { + errorMessage = error.localizedDescription + return false + } + case .system: + return false + } + } + + func message(withID id: String, in conversationID: String? = nil) -> ChatMessage? { + guard let conversationID else { + if let index = firstMessageIndexByID[id], + messages.indices.contains(index), + messages[index].id == id { + return messages[index] + } + return messages.first { $0.stanzaID == id } + } + let normalized = conversationID.lowercased() + guard let index = messageIndex( + referenceID: id, + conversationID: normalized + ) else { return nil } + return messages[index] + } + + func displayName(for jid: String) -> String { + conversationName(for: jid) + } + + @discardableResult + func sendAttachment( + from url: URL, + preferredKind: ChatMessage.Kind? = nil, + duration: TimeInterval? = nil, + to explicitJID: String? = nil + ) async -> String? { + guard let peer = explicitJID ?? selectedConversation?.jid else { return nil } + let preparationToken = beginMediaPreparationActivity() + defer { endMediaPreparationActivity(preparationToken) } + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + + do { + let loaded = try await mediaFileIO.load( + from: url, + preferredKind: preferredKind + ) + let resolvedDuration: TimeInterval? + if let duration { + resolvedDuration = duration + } else { + resolvedDuration = await Self.mediaDuration( + of: url, + kind: loaded.inferredKind + ) + } + return await sendMedia( + data: loaded.data, + filename: loaded.filename, + mimeType: loaded.mimeType, + kind: loaded.inferredKind, + duration: resolvedDuration, + to: peer + ) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + @discardableResult + func sendMedia( + data: Data, + filename rawFilename: String, + mimeType: String, + kind rawKind: ChatMessage.Kind, + duration suppliedDuration: TimeInterval? = nil, + to explicitJID: String? = nil, + messageID explicitMessageID: String? = nil, + thumbnailData preparedThumbnailData: Data? = nil + ) async -> String? { + guard let account, + let peer = explicitJID ?? selectedConversation?.jid else { return nil } + errorMessage = nil + if connectionStatus != .connected { + await reconnect() + } + let activityToken = beginMediaSendActivity() + defer { endMediaSendActivity(activityToken) } + let id = explicitMessageID ?? UUID().uuidString + let filename = Self.safeFilename(rawFilename) + let kind = rawKind.isMedia ? rawKind : .attachment + let conversation = conversations.first { $0.id == peer.lowercased() } + let isGroup = conversation?.isGroup == true + let encrypted = encryptionEnabled(for: peer) + var duration = suppliedDuration + var didInsertPending = false + var sentID: String? + + do { + if let value = duration, !value.isFinite || value <= 0 { + duration = nil + } + if duration == nil { + duration = await Self.mediaDuration(data: data, filename: filename, kind: kind) + } + + let pending = ChatMessage( + id: id, + conversationID: peer, + senderJID: account.normalizedJID, + body: Self.mediaTitle(kind: kind, filename: filename), + direction: .outgoing, + delivery: .sending, + security: encrypted ? .omemo : .plaintext, + kind: kind, + localFilename: filename, + mimeType: mimeType, + duration: duration, + byteCount: data.count, + senderDisplayName: isGroup ? conversation?.groupNickname : nil, + isGroupMessage: isGroup + ) + upsertMessage(pending) + didInsertPending = true + if Self.supportsInlinePreview(kind) { + await cacheMediaPreview( + data: data, + messageID: id, + filename: filename, + kind: kind, + mimeType: mimeType, + preparedThumbnailData: preparedThumbnailData + ) + } + let result = try await uploadAndSendMediaWithRetry( + data: data, + filename: filename, + mimeType: mimeType, + kind: kind, + duration: duration, + to: peer, + messageID: id, + encrypted: encrypted, + isGroup: isGroup + ) + updateMessage(id: id) { + $0.delivery = .sent + $0.remoteAttachmentURL = result.remoteURL + $0.encryptionFingerprint = result.fingerprint + } + sentID = id + } catch { + if didInsertPending { + updateMessage(id: id) { $0.delivery = .failed } + } + errorMessage = error.localizedDescription + } + schedulePersist() + syncWatch() + return sentID + } + + func canRetryMediaMessage(_ message: ChatMessage) -> Bool { + message.direction == .outgoing + && message.delivery == .failed + && message.kind.isMedia + && message.remoteAttachmentURL == nil + && mediaPreviewURLs[message.id] != nil + } + + func retryMediaMessage(_ message: ChatMessage) async { + guard canRetryMediaMessage(message), + let localURL = mediaPreviewURLs[message.id] else { return } + let activityToken = beginMediaSendActivity() + defer { endMediaSendActivity(activityToken) } + errorMessage = nil + + if connectionStatus != .connected { + await reconnect() + } + + do { + let data = try await mediaFileIO.load( + from: localURL, + preferredKind: message.kind + ).data + updateMessage(id: message.id) { $0.delivery = .sending } + let result = try await uploadAndSendMediaWithRetry( + data: data, + filename: message.localFilename ?? localURL.lastPathComponent, + mimeType: message.mimeType ?? "application/octet-stream", + kind: message.kind, + duration: message.duration, + to: message.conversationID, + messageID: message.id, + encrypted: message.security == .omemo, + isGroup: message.isGroupMessage + ) + updateMessage(id: message.id) { + $0.delivery = .sent + $0.remoteAttachmentURL = result.remoteURL + $0.encryptionFingerprint = result.fingerprint + } + } catch { + updateMessage(id: message.id) { $0.delivery = .failed } + errorMessage = error.localizedDescription + } + schedulePersist() + syncWatch() + } + + func prepareAttachmentDrafts( + from urls: [URL], + preferredKind: ChatMessage.Kind? = nil, + for peerJID: String? = nil + ) async -> [AttachmentDraft] { + guard !urls.isEmpty else { return [] } + let preparationToken = beginMediaPreparationActivity() + defer { endMediaPreparationActivity(preparationToken) } + var drafts: [AttachmentDraft] = [] + var lastPreparationError: Error? + + for source in urls.prefix(20) { + do { + let staged = try await mediaFileIO.stageAttachmentDraft(from: source) + let filename = Self.safeFilename(staged.filename) + let contentType = staged.contentTypeIdentifier.flatMap { UTType($0) } + ?? UTType(filenameExtension: source.pathExtension) + let kind = preferredKind ?? Self.mediaKind(for: contentType, filename: filename) + let mimeType = contentType?.preferredMIMEType ?? "application/octet-stream" + let analysis = await mediaPreviewProcessor.analyze( + url: staged.url, + kind: kind, + mimeType: mimeType + ) + drafts.append(AttachmentDraft( + url: staged.url, + filename: filename, + mimeType: mimeType, + kind: kind, + duration: analysis.duration, + byteCount: staged.byteCount, + thumbnailData: analysis.thumbnailData + )) + } catch { + lastPreparationError = error + } + } + if drafts.isEmpty, let lastPreparationError { + errorMessage = lastPreparationError.localizedDescription + } + return drafts + } + + /// Extends the same finite MAM pause across item-provider/iCloud work that + /// happens before URLs enter `prepareAttachmentDrafts`. + func withMediaPreparationActivity( + _ operation: () async -> T + ) async -> T { + let token = beginMediaPreparationActivity() + defer { endMediaPreparationActivity(token) } + return await operation() + } + + func sendAttachmentBatch( + _ drafts: [AttachmentDraft], + caption rawCaption: String, + to roomOrPeerJID: String + ) async -> AttachmentBatchResult { + guard !drafts.isEmpty else { + return AttachmentBatchResult(sentCount: 0, failedDrafts: [], captionSent: false) + } + let activityToken = beginMediaSendActivity() + defer { endMediaSendActivity(activityToken) } + errorMessage = nil + + var firstSentID: String? + var sentCount = 0 + var failedDrafts: [AttachmentDraft] = [] + for draft in drafts { + let data: Data + do { + data = try await mediaFileIO.load( + from: draft.url, + preferredKind: draft.kind + ).data + } catch { + errorMessage = "Не удалось прочитать \(draft.filename)." + failedDrafts.append(draft) + continue + } + let sentID = await sendMedia( + data: data, + filename: draft.filename, + mimeType: draft.mimeType, + kind: draft.kind, + duration: draft.duration, + to: roomOrPeerJID, + thumbnailData: draft.thumbnailData + ) + if let sentID { + if firstSentID == nil { firstSentID = sentID } + sentCount += 1 + discardAttachmentDrafts([draft]) + } else { + failedDrafts.append(draft) + } + } + + let caption = rawCaption.trimmingCharacters(in: .whitespacesAndNewlines) + var captionSent = false + if failedDrafts.isEmpty, !caption.isEmpty { + let target = firstSentID.flatMap { message(withID: $0, in: roomOrPeerJID) } + errorMessage = nil + await sendText( + caption, + to: roomOrPeerJID, + replyingTo: target?.canBeRepliedTo == true ? target : nil + ) + captionSent = errorMessage == nil + } + return AttachmentBatchResult( + sentCount: sentCount, + failedDrafts: failedDrafts, + captionSent: captionSent + ) + } + + func discardAttachmentDrafts(_ drafts: [AttachmentDraft]) { + for draft in drafts where draft.isTemporary { + try? FileManager.default.removeItem(at: draft.url) + } + } + + func previewAttachment(_ message: ChatMessage) async { + do { + let output: URL + if let cached = mediaPreviewURLs[message.id] { + output = cached + } else { + guard message.remoteAttachmentURL != nil else { return } + output = try await localAttachmentURL(for: message, directoryName: "LumaPreview") + } + previewURL = output + } catch { + errorMessage = error.localizedDescription + } + } + + func mediaPreviewURL(for message: ChatMessage) -> URL? { + mediaPreviewURLs[message.id] + } + + func mediaThumbnail(for message: ChatMessage) -> Data? { + mediaThumbnailData[message.id] + } + + func audioWaveform(for message: ChatMessage) -> [Float] { + audioWaveformSamples[message.id] ?? MediaPreviewProcessor.placeholderWaveform + } + + func isMediaPreviewLoading(_ message: ChatMessage) -> Bool { + loadingMediaIDs.contains(message.id) + } + + func prepareMediaPreview(_ message: ChatMessage) async { + guard Self.supportsInlinePreview(message.kind), + message.remoteAttachmentURL != nil, + mediaPreviewURLs[message.id] == nil, + loadingMediaIDs.insert(message.id).inserted else { return } + defer { loadingMediaIDs.remove(message.id) } + + do { + let output = try await localAttachmentURL(for: message, directoryName: "LumaMediaPreviews") + mediaPreviewURLs[message.id] = output + await analyzeMediaPreview( + at: output, + messageID: message.id, + kind: message.kind, + mimeType: message.mimeType + ) + } catch { + // Automatic previews are best-effort. An explicit tap still uses + // previewAttachment(_:) and presents the actionable error. + } + } + + func presentMediaViewer(_ message: ChatMessage) async { + guard message.kind == .photo || message.kind == .video else { return } + + do { + let output: URL + if let cached = mediaPreviewURLs[message.id] { + output = cached + } else { + guard message.remoteAttachmentURL != nil else { return } + loadingMediaIDs.insert(message.id) + defer { loadingMediaIDs.remove(message.id) } + output = try await localAttachmentURL( + for: message, + directoryName: "LumaMediaPreviews" + ) + mediaPreviewURLs[message.id] = output + await analyzeMediaPreview( + at: output, + messageID: message.id, + kind: message.kind, + mimeType: message.mimeType + ) + } + + audioPlayback.stop() + NotificationCenter.default.post( + name: .lumaExclusiveMediaPlayback, + object: message.id + ) + mediaViewerItem = MediaViewerItem( + id: message.id, + url: output, + kind: message.kind, + title: message.localFilename ?? (message.kind == .photo ? "Фото" : "Видео") + ) + } catch { + errorMessage = error.localizedDescription + } + } + + func closeMediaViewer() { + mediaViewerItem = nil + } + + func encryptionEnabled(for jid: String) -> Bool { + return encryptionPreference(for: jid).resolved(globalEnabled: globalEncryptionEnabled) + } + + func encryptionPreference(for jid: String) -> EncryptionPreference { + conversations + .first(where: { $0.id == jid.lowercased() })? + .encryptionPreference ?? .inheritGlobal + } + + func setGlobalEncryptionEnabled(_ enabled: Bool) { + globalEncryptionEnabled = enabled + if let account { + preferences.setEncryptionEnabled(enabled, for: account.normalizedJID) + } + } + + func setTypingIndicatorsEnabled(_ enabled: Bool) { + typingIndicatorsEnabled = enabled + xmpp.setChatStatesEnabled(enabled) + if let account { + preferences.setChatStatesEnabled(enabled, for: account.normalizedJID) + } + if !enabled { + resetTypingState() + } + } + + func typingText(for conversation: Conversation) -> String? { + let names = typingParticipantsByConversation[conversation.id.lowercased()] + .map { Array($0.values) } ?? [] + return ChatTypingPolicy.displayText(names: names, isGroup: conversation.isGroup) + } + + func updateComposerActivity(_ text: String, in conversation: Conversation) { + let conversationID = conversation.id.lowercased() + localTypingPauseTasks[conversationID]?.cancel() + localTypingPauseTasks[conversationID] = nil + guard typingIndicatorsEnabled, appIsActive else { return } + + let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + guard hasText else { + transitionLocalChatState(.paused, in: conversation) + return + } + + transitionLocalChatState(.composing, in: conversation) + localTypingPauseTasks[conversationID] = Task { @MainActor [weak self] in + do { + try await Task.sleep(nanoseconds: ChatTypingPolicy.pauseDelayNanoseconds) + } catch { + return + } + guard let self, + self.localChatStateByConversation[conversationID] == .composing else { return } + self.transitionLocalChatState(.paused, in: conversation) + self.localTypingPauseTasks[conversationID] = nil + } + } + + func endComposerActivity(in conversation: Conversation) { + let conversationID = conversation.id.lowercased() + localTypingPauseTasks[conversationID]?.cancel() + localTypingPauseTasks[conversationID] = nil + transitionLocalChatState(.inactive, in: conversation) + } + + func toggleReaction(_ emoji: String, on sourceMessage: ChatMessage) async { + guard let account, + let message = message( + withID: sourceMessage.id, + in: sourceMessage.conversationID + ), + message.canBeReactedTo, + let targetID = message.reactionIdentifier, + let selectedEmoji = MessageReactionPolicy.sanitized([emoji]).first else { return } + + let previous = message.reactionEmojis(from: account.normalizedJID) + var updated = previous + if let index = updated.firstIndex(of: selectedEmoji) { + updated.remove(at: index) + } else { + updated.append(selectedEmoji) + } + updated = MessageReactionPolicy.sanitized(updated) + + let optimisticTimestamp = Date() + let optimistic = XMPPService.ReactionEnvelope( + peerJID: message.conversationID, + senderJID: account.normalizedJID, + targetID: targetID, + emojis: updated, + timestamp: optimisticTimestamp, + isOutgoing: true, + isGroupMessage: message.isGroupMessage + ) + applyReaction(optimistic) + + do { + try await xmpp.sendReactions( + to: message.conversationID, + targetID: targetID, + emojis: updated, + reactionMessageID: UUID().uuidString, + isGroup: message.isGroupMessage + ) + } catch { + let rollback = XMPPService.ReactionEnvelope( + peerJID: message.conversationID, + senderJID: account.normalizedJID, + targetID: targetID, + emojis: previous, + timestamp: optimisticTimestamp.addingTimeInterval(0.001), + isOutgoing: true, + isGroupMessage: message.isGroupMessage + ) + applyReaction(rollback) + errorMessage = error.localizedDescription + } + schedulePersist() + } + + func setEncryptionPreference(_ preference: EncryptionPreference, for jid: String) { + guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else { return } + conversations[index].encryptionPreference = preference + schedulePersist() + syncWatch() + } + + func avatarData(for jid: String) -> Data? { + avatarDataByJID[jid.lowercased()] + } + + func updateOwnAvatar(from sourceData: Data) async { + guard let account else { return } + isUpdatingAvatar = true + defer { isUpdatingAvatar = false } + do { + let pngData = try AvatarImageProcessor.pngData(from: sourceData) + try await xmpp.updateOwnAvatar(pngData: pngData) + avatarDataByJID[account.normalizedJID] = pngData + try? await avatarCache.store(pngData, for: account.normalizedJID) + informationalMessage = "Аватар обновлён." + } catch { + errorMessage = error.localizedDescription + } + } + + func devices(for jid: String) -> [OMEMODevice] { + xmpp.devices(for: jid) + } + + func setDeviceVerified(_ verified: Bool, jid: String, deviceID: Int32) { + xmpp.setDeviceVerified(verified, jid: jid, deviceID: deviceID) + objectWillChange.send() + } + + func clearError() { + errorMessage = nil + } + + private func beginMediaSendActivity() -> UUID { + let token = mediaSendActivity.begin() + isSendingAttachment = mediaSendActivity.isActive + updateArchiveSyncSuspensionForMedia() + return token + } + + private func endMediaSendActivity(_ token: UUID) { + mediaSendActivity.end(token) + isSendingAttachment = mediaSendActivity.isActive + updateArchiveSyncSuspensionForMedia() + } + + private func resetMediaSendActivity() { + mediaSendActivity.reset() + mediaPreparationTokens.removeAll(keepingCapacity: false) + videoNoteCaptureIsActive = false + isSendingAttachment = false + updateArchiveSyncSuspensionForMedia() + } + + private func beginMediaPreparationActivity() -> UUID { + let token = UUID() + mediaPreparationTokens.insert(token) + updateArchiveSyncSuspensionForMedia() + return token + } + + private func endMediaPreparationActivity(_ token: UUID) { + mediaPreparationTokens.remove(token) + updateArchiveSyncSuspensionForMedia() + } + + private func updateArchiveSyncSuspensionForMedia() { + let shouldSuspend = videoNoteCaptureIsActive + || mediaSendActivity.isActive + || !mediaPreparationTokens.isEmpty + guard shouldSuspend != archiveSyncIsSuspendedForMedia else { return } + archiveSyncIsSuspendedForMedia = shouldSuspend + xmpp.setArchiveSyncSuspendedForMediaWork(shouldSuspend) + } + + private func uploadAndSendMediaWithRetry( + data: Data, + filename: String, + mimeType: String, + kind: ChatMessage.Kind, + duration: TimeInterval?, + to recipient: String, + messageID: String, + encrypted: Bool, + isGroup: Bool + ) async throws -> XMPPService.MediaSendResult { + let maximumAttempts = 2 + for attempt in 1...maximumAttempts { + do { + return try await xmpp.uploadAndSendMedia( + data: data, + filename: filename, + mimeType: mimeType, + kind: kind, + duration: duration, + to: recipient, + messageID: messageID, + encrypted: encrypted, + isGroup: isGroup + ) + } catch { + guard attempt < maximumAttempts, + Self.isRetryableMediaSendError(error) else { throw error } + if connectionStatus != .connected { + await reconnect() + } + try await Task.sleep(nanoseconds: 600_000_000) + } + } + throw LumaXMPPError.uploadFailed(statusCode: nil) + } + + private static func isRetryableMediaSendError(_ error: Error) -> Bool { + guard let error = error as? LumaXMPPError else { return false } + switch error { + case .notConnected, .connection, .uploadDiscoveryFailed, + .uploadSlotFailed, .uploadTransportFailed: + return true + case .uploadFailed(let statusCode): + guard let statusCode else { return true } + return statusCode == 408 || statusCode == 429 || statusCode >= 500 + default: + return false + } + } + + private func requestCallPermissions(includeVideo: Bool) async -> Bool { + guard await Self.requestCaptureAccess(for: .audio) else { + errorMessage = "Разрешите Luma доступ к микрофону в системных настройках." + return false + } + if includeVideo { + guard await Self.requestCaptureAccess(for: .video) else { + errorMessage = "Разрешите Luma доступ к камере в системных настройках." + return false + } + } + return true + } + + private static func requestCaptureAccess(for mediaType: AVMediaType) async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: mediaType) { + case .authorized: + return true + case .notDetermined: + return await withCheckedContinuation { continuation in + AVCaptureDevice.requestAccess(for: mediaType) { granted in + continuation.resume(returning: granted) + } + } + case .denied, .restricted: + return false + @unknown default: + return false + } + } + + private func consume(_ event: XMPPService.Event) { + switch event { + case .connection(let status): + connectionStatus = status + if status == .connected { + if let account { + xmpp.fetchAvatar(for: account.normalizedJID) + } + conversations.filter { !$0.isGroup }.forEach { + xmpp.prepareDirectChat(with: $0.jid) + xmpp.fetchAvatar(for: $0.jid) + } + for room in conversations where room.isGroup && room.shouldAutojoin { + Task { await joinGroup(jid: room.jid) } + } + } else if case .disconnected = status { + // A transport disconnect is a hard boundary for MAM. Keep the + // UI from displaying a stale spinner even if the archive + // completion event was overtaken by a socket state change. + isArchiveSyncing = false + for index in conversations.indices where conversations[index].isGroup { + conversations[index].isGroupJoined = false + conversations[index].occupantCount = 0 + } + resetTypingState() + } + case .rosterItem(let jid, let name): + let normalized = jid.lowercased() + rosterContactJIDs.insert(normalized) + upsertConversation(jid: normalized, name: name) + case .rosterRemoved(let jid): + // Keep the chat history but remove it from the server-backed + // contacts screen as soon as Prosody confirms the roster push. + rosterContactJIDs.remove(jid.lowercased()) + case .presence(let jid, let online): + if let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) { + conversations[index].isOnline = online + } + case .message(let envelope): + consumeMessage(envelope) + case .retraction(let envelope): + applyRetraction(envelope) + case .reaction(let envelope): + applyReaction(envelope) + case .chatState(let envelope): + if typingIndicatorsEnabled { + applyRemoteChatState(envelope) + } + return + case .groupMessageEcho(let roomJID, let messageID, let stanzaID, let senderJID): + if let index = messageIndex(id: messageID, conversationID: roomJID) + ?? messageIndex(referenceID: stanzaID, conversationID: roomJID) { + messages[index].stanzaID = stanzaID + messages[index].senderJID = senderJID + messageIndexByStanzaKey[Self.localDeletionKey( + messageID: stanzaID, + conversationID: roomJID + )] = index + if messages[index].delivery == .sending { + messages[index].delivery = .sent + } + applyPendingReactions(for: messages[index]) + } + case .roomState(let envelope): + upsertGroupConversation( + jid: envelope.roomJID, + name: nil, + nickname: envelope.nickname, + shouldAutojoin: envelope.joined + ) + if let index = conversations.firstIndex(where: { $0.id == envelope.roomJID.lowercased() }) { + conversations[index].isGroupJoined = envelope.joined + conversations[index].occupantCount = envelope.occupantCount + } + case .roomInvitation(let invitation): + let jid = invitation.roomJID.lowercased() + upsertGroupConversation( + jid: jid, + name: nil, + nickname: defaultGroupNickname, + shouldAutojoin: false, + invitedBy: invitation.inviterJID + ) + if let password = invitation.password { + pendingRoomPasswords[jid] = password + } + if let reason = invitation.reason, !reason.isEmpty { + informationalMessage = "Приглашение в \(jid): \(reason)" + } else { + informationalMessage = "Получено приглашение в групповой чат \(jid)." + } + case .avatar(let jid, let data): + let normalized = jid.lowercased() + avatarDataByJID[normalized] = data + Task { try? await avatarCache.store(data, for: normalized) } + case .delivered(let messageID): + let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID + updateMessage(id: targetID) { $0.delivery = .delivered } + case .omemo(let ready, let fingerprint): + isOMEMOReady = ready + ownFingerprint = fingerprint + case .archiveBatch(let mutations): + applyArchiveBatch(mutations) + return + case .archiveSyncing(let syncing): + isArchiveSyncing = syncing + case .archiveSyncCompleted(let checkpoint): + lastSuccessfulMAMSync = checkpoint.timestamp + lastSuccessfulMAMCursor = checkpoint.cursor + case .call(let call): + activeCall = call + case .callHistory(let entry): + recordCallHistory(entry) + case .callError(let message): + errorMessage = message + case .recoverableError(let message): + informationalMessage = message + } + if !isApplyingArchiveBatch { + sortConversations() + } + if !isArchiveSyncing { + schedulePersist() + syncWatch() + } + } + + private func applyArchiveBatch(_ mutations: [XMPPService.ArchiveMutation]) { + guard !mutations.isEmpty else { return } + let selectedConversationWasMutated = selectedConversationID.map { selectedID in + mutations.contains { mutation in + switch mutation { + case .message(let envelope): + return envelope.peerJID.lowercased() == selectedID + case .retraction(let envelope): + return envelope.peerJID.lowercased() == selectedID + case .reaction(let envelope): + return envelope.peerJID.lowercased() == selectedID + } + } + } ?? false + isApplyingArchiveBatch = true + for mutation in mutations { + switch mutation { + case .message(let envelope): + consumeMessage(envelope) + case .retraction(let envelope): + applyRetraction(envelope) + case .reaction(let envelope): + applyReaction(envelope) + } + } + sortConversations() + isApplyingArchiveBatch = false + if selectedConversationWasMutated { + selectedMessagesCacheConversationID = nil + selectedTimelineEntriesCacheConversationID = nil + } + // Publish only after the complete pass is internally consistent. This + // prevents SwiftUI from reading half-merged arrays while MAM is busy. + objectWillChange.send() + } + + private func resetArchiveBatchState() { + isApplyingArchiveBatch = false + } + + private func consumeMessage(_ envelope: XMPPService.MessageEnvelope) { + if envelope.isGroupMessage { + upsertGroupConversation( + jid: envelope.peerJID, + name: nil, + nickname: nil, + shouldAutojoin: true + ) + } + let deletionKey = Self.localDeletionKey( + messageID: envelope.id, + conversationID: envelope.peerJID + ) + let correctionWasDeleted = envelope.correctionTargetID.map { + locallyDeletedMessageIDs.contains(Self.localDeletionKey( + messageID: $0, + conversationID: envelope.peerJID + )) + } ?? false + guard !locallyDeletedMessageIDs.contains(deletionKey), + !correctionWasDeleted else { return } + if envelope.correctionTargetID != nil { + consumeCorrection(envelope) + return + } + + let message = ChatMessage( + id: envelope.id, + conversationID: envelope.peerJID, + senderJID: envelope.senderJID, + body: envelope.body, + timestamp: envelope.timestamp, + direction: envelope.isOutgoing ? .outgoing : .incoming, + delivery: envelope.isOutgoing ? .sent : .delivered, + security: envelope.security, + kind: envelope.kind, + remoteAttachmentURL: envelope.remoteAttachmentURL, + localFilename: envelope.localFilename, + mimeType: envelope.mimeType, + duration: envelope.duration, + byteCount: envelope.byteCount, + encryptionFingerprint: envelope.fingerprint, + replyToID: envelope.replyToID, + replyToJID: envelope.replyToJID, + replyPreview: envelope.replyPreview, + stanzaID: envelope.stanzaID, + senderDisplayName: envelope.senderDisplayName, + isGroupMessage: envelope.isGroupMessage + ) + let inserted = upsertMessage( + message, + incrementUnread: envelope.isArchived ? false : nil + ) + let pendingKey = Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + ) + if let correction = pendingCorrections.removeValue(forKey: pendingKey) { + consumeCorrection(correction) + } + if let retraction = pendingRetractions.removeValue(forKey: pendingKey) { + applyRetraction(retraction) + } + applyPendingReactions(for: message) + if !envelope.isArchived, + NotificationPolicy.shouldPresentMessage( + inserted: inserted, + isOutgoing: envelope.isOutgoing, + appIsActive: appIsActive, + selectedConversationID: selectedConversationID, + conversationID: envelope.peerJID + ) { + let notificationMessage = self.message( + withID: envelope.id, + in: envelope.peerJID + ) ?? message + notifications.showIncomingMessage( + id: envelope.id, + sender: envelope.senderDisplayName ?? conversationName(for: envelope.peerJID), + body: notificationMessage.previewText, + conversationID: envelope.peerJID + ) + } + } + + private func recordCallHistory(_ entry: CallHistoryEntry) { + guard let account else { return } + let direction: ChatMessage.Direction = entry.direction == .outgoing + ? .outgoing + : .incoming + let metadata = CallHistoryMetadata( + isVideo: entry.isVideo, + outcome: entry.outcome + ) + let message = ChatMessage( + id: entry.id, + conversationID: entry.peerJID, + senderJID: direction == .outgoing ? account.normalizedJID : entry.peerJID, + body: entry.isVideo ? "Видеозвонок" : "Аудиозвонок", + timestamp: entry.startedAt, + direction: direction, + delivery: direction == .outgoing ? .sent : .delivered, + security: .plaintext, + kind: .system, + duration: entry.duration, + callHistory: metadata + ) + let isUnreadMissedCall = direction == .incoming + && entry.outcome == .missed + && selectedConversationID != entry.peerJID.lowercased() + _ = upsertMessage(message, incrementUnread: isUnreadMissedCall) + } + + private func upsertConversation(jid: String, name: String?) { + let normalized = jid.lowercased() + if let index = conversations.firstIndex(where: { $0.id == normalized }) { + if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + conversations[index].displayName = name + } + } else { + conversations.append(Conversation(jid: normalized, displayName: name)) + } + if conversations.first(where: { $0.id == normalized })?.isGroup != true { + if !isApplyingArchiveBatch { + xmpp.prepareDirectChat(with: normalized) + } + loadCachedAvatarIfNeeded(for: normalized) + } + if !isApplyingArchiveBatch { + sortConversations() + } + } + + private func upsertGroupConversation( + jid: String, + name: String?, + nickname: String?, + shouldAutojoin: Bool, + invitedBy: String? = nil + ) { + let normalized = jid.lowercased() + let fallbackName = normalized.split(separator: "@").first.map(String.init) ?? normalized + let resolvedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) + let initialDisplayName: String + if let resolvedName, !resolvedName.isEmpty { + initialDisplayName = resolvedName + } else { + initialDisplayName = fallbackName + } + if let index = conversations.firstIndex(where: { $0.id == normalized }) { + conversations[index].kind = .group + if let resolvedName, !resolvedName.isEmpty { + conversations[index].displayName = resolvedName + } else if conversations[index].displayName == normalized { + conversations[index].displayName = fallbackName + } + if let nickname, !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + conversations[index].groupNickname = nickname + } + conversations[index].shouldAutojoin = conversations[index].shouldAutojoin || shouldAutojoin + if let invitedBy { conversations[index].invitedBy = invitedBy } + } else { + conversations.append(Conversation( + jid: normalized, + displayName: initialDisplayName, + encryptionPreference: .inheritGlobal, + kind: .group, + groupNickname: nickname, + shouldAutojoin: shouldAutojoin, + invitedBy: invitedBy + )) + } + if !isApplyingArchiveBatch { + sortConversations() + } + } + + private var defaultGroupNickname: String { + if let displayName = account?.displayName.trimmingCharacters(in: .whitespacesAndNewlines), + !displayName.isEmpty { + return displayName + } + return account?.normalizedJID.split(separator: "@").first.map(String.init) ?? "Luma" + } + + private func consumeCorrection(_ envelope: XMPPService.MessageEnvelope) { + guard let targetID = envelope.correctionTargetID, + !locallyDeletedMessageIDs.contains(Self.localDeletionKey( + messageID: targetID, + conversationID: envelope.peerJID + )), + envelope.kind == .text, + envelope.security != .decryptionFailed else { return } + + guard let index = messageIndex( + id: targetID, + conversationID: envelope.peerJID + ) else { + let pendingKey = Self.localDeletionKey( + messageID: targetID, + conversationID: envelope.peerJID + ) + pendingCorrections[pendingKey] = envelope + if pendingCorrections.count > 100, let key = pendingCorrections.keys.first { + pendingCorrections.removeValue(forKey: key) + } + return + } + + let original = messages[index] + guard !original.isRetracted, + original.kind == .text, + original.conversationID == envelope.peerJID.lowercased(), + original.senderJID == envelope.senderJID.lowercased(), + original.security == envelope.security, + original.editedAt == nil || envelope.timestamp >= original.editedAt! else { return } + + messages[index].body = envelope.body + messages[index].editedAt = envelope.timestamp + messages[index].security = envelope.security + messages[index].encryptionFingerprint = envelope.fingerprint + if envelope.isOutgoing { + messages[index].delivery = .sent + } + updateConversationPreview(for: messages[index], incrementUnread: false) + } + + private func applyReaction(_ envelope: XMPPService.ReactionEnvelope) { + let conversationID = envelope.peerJID.lowercased() + let senderJID = envelope.senderJID.lowercased() + if !envelope.isGroupMessage { + var allowedSenders: Set = [ + MessageReactionPolicy.bareJID(conversationID) + ] + if let account { + allowedSenders.insert(MessageReactionPolicy.bareJID(account.normalizedJID)) + } + guard allowedSenders.contains(MessageReactionPolicy.bareJID(senderJID)) else { return } + } + + guard let index = messageIndex( + referenceID: envelope.targetID, + conversationID: conversationID + ) else { + let key = pendingReactionKey(envelope) + if let previous = pendingReactions[key], + previous.timestamp > envelope.timestamp { return } + pendingReactions[key] = envelope + if pendingReactions.count > 200, + let staleKey = pendingReactions.min(by: { + $0.value.timestamp < $1.value.timestamp + })?.key { + pendingReactions.removeValue(forKey: staleKey) + } + return + } + + guard !messages[index].isRetracted else { return } + var updatedMessage = messages[index] + let existingIndex = updatedMessage.reactions.firstIndex { reaction in + reactionSenderMatches( + reaction.senderJID, + senderJID, + isGroup: envelope.isGroupMessage + ) + } + if let existingIndex, + updatedMessage.reactions[existingIndex].updatedAt > envelope.timestamp { + return + } + + let emojis = MessageReactionPolicy.sanitized(envelope.emojis) + if let existingIndex { + if emojis.isEmpty { + updatedMessage.reactions.remove(at: existingIndex) + } else { + updatedMessage.reactions[existingIndex] = MessageReaction( + senderJID: senderJID, + emojis: emojis, + updatedAt: envelope.timestamp + ) + } + } else if !emojis.isEmpty { + updatedMessage.reactions.append(MessageReaction( + senderJID: senderJID, + emojis: emojis, + updatedAt: envelope.timestamp + )) + } else { + return + } + updatedMessage.reactions.sort { $0.senderJID < $1.senderJID } + messages[index] = updatedMessage + } + + private func applyPendingReactions(for message: ChatMessage) { + let identifiers = Set([message.id, message.stanzaID].compactMap { $0 }) + guard !identifiers.isEmpty else { return } + let matches = pendingReactions.filter { _, envelope in + envelope.peerJID.lowercased() == message.conversationID + && identifiers.contains(envelope.targetID) + } + for (key, envelope) in matches { + pendingReactions.removeValue(forKey: key) + applyReaction(envelope) + } + } + + private func pendingReactionKey(_ envelope: XMPPService.ReactionEnvelope) -> String { + [ + envelope.peerJID.lowercased(), + envelope.targetID, + envelope.senderJID.lowercased() + ].joined(separator: "|") + } + + private func reactionSenderMatches( + _ lhs: String, + _ rhs: String, + isGroup: Bool + ) -> Bool { + if isGroup { return lhs.lowercased() == rhs.lowercased() } + return MessageReactionPolicy.bareJID(lhs) == MessageReactionPolicy.bareJID(rhs) + } + + private func applyRemoteChatState(_ envelope: XMPPService.ChatStateEnvelope) { + let conversationID = envelope.peerJID.lowercased() + let senderKey = envelope.isGroupMessage + ? envelope.senderJID.lowercased() + : MessageReactionPolicy.bareJID(envelope.senderJID) + let expiryKey = "\(conversationID)|\(senderKey)" + remoteTypingExpiryTasks[expiryKey]?.cancel() + remoteTypingExpiryTasks[expiryKey] = nil + + guard envelope.state == .composing else { + removeRemoteTypingParticipant( + conversationID: conversationID, + senderKey: senderKey + ) + return + } + + var participants = typingParticipantsByConversation[conversationID] ?? [:] + participants[senderKey] = envelope.senderDisplayName + ?? displayName(for: envelope.senderJID) + typingParticipantsByConversation[conversationID] = participants + remoteTypingExpiryTasks[expiryKey] = Task { @MainActor [weak self] in + do { + try await Task.sleep(nanoseconds: ChatTypingPolicy.remoteExpiryNanoseconds) + } catch { + return + } + self?.removeRemoteTypingParticipant( + conversationID: conversationID, + senderKey: senderKey + ) + self?.remoteTypingExpiryTasks[expiryKey] = nil + } + } + + private func removeRemoteTypingParticipant( + conversationID: String, + senderKey: String + ) { + guard var participants = typingParticipantsByConversation[conversationID] else { return } + participants.removeValue(forKey: senderKey) + if participants.isEmpty { + typingParticipantsByConversation.removeValue(forKey: conversationID) + } else { + typingParticipantsByConversation[conversationID] = participants + } + } + + private func transitionLocalChatState( + _ state: ChatTypingState, + in conversation: Conversation + ) { + guard typingIndicatorsEnabled, connectionStatus == .connected else { return } + let conversationID = conversation.id.lowercased() + let previous = localChatStateByConversation[conversationID] + if state == .paused, previous != .composing { return } + if state == .inactive, previous == nil { return } + guard previous != state else { return } + localChatStateByConversation[conversationID] = state + Task { [weak self] in + guard let self else { return } + try? await self.xmpp.sendChatState( + state, + to: conversation.jid, + isGroup: conversation.isGroup + ) + } + } + + private func resetTypingState() { + localTypingPauseTasks.values.forEach { $0.cancel() } + remoteTypingExpiryTasks.values.forEach { $0.cancel() } + localTypingPauseTasks.removeAll(keepingCapacity: false) + remoteTypingExpiryTasks.removeAll(keepingCapacity: false) + localChatStateByConversation.removeAll(keepingCapacity: false) + typingParticipantsByConversation.removeAll(keepingCapacity: false) + } + + private func applyRetraction(_ envelope: XMPPService.RetractionEnvelope) { + guard !locallyDeletedMessageIDs.contains(Self.localDeletionKey( + messageID: envelope.targetID, + conversationID: envelope.peerJID + )) else { return } + guard let index = messageIndex( + id: envelope.targetID, + conversationID: envelope.peerJID + ) else { + let pendingKey = Self.localDeletionKey( + messageID: envelope.targetID, + conversationID: envelope.peerJID + ) + pendingRetractions[pendingKey] = envelope + if pendingRetractions.count > 100, let staleID = pendingRetractions.keys.first { + pendingRetractions.removeValue(forKey: staleID) + } + return + } + + let original = messages[index] + guard original.conversationID == envelope.peerJID.lowercased(), + original.senderJID == envelope.senderJID.lowercased(), + original.retractedAt == nil || envelope.timestamp >= original.retractedAt! else { return } + + removeCachedMedia(for: original.id) + messages[index].body = "Сообщение удалено" + messages[index].kind = .system + messages[index].remoteAttachmentURL = nil + messages[index].localFilename = nil + messages[index].mimeType = nil + messages[index].duration = nil + messages[index].byteCount = nil + messages[index].editedAt = nil + messages[index].replyToID = nil + messages[index].replyToJID = nil + messages[index].replyPreview = nil + messages[index].forwardedFrom = nil + messages[index].callHistory = nil + messages[index].reactions = [] + messages[index].retractedAt = envelope.timestamp + if envelope.isOutgoing, messages[index].delivery == .sending { + messages[index].delivery = .sent + } + pendingCorrections.removeValue(forKey: Self.localDeletionKey( + messageID: original.id, + conversationID: original.conversationID + )) + updateConversationPreview(for: messages[index], incrementUnread: false) + } + + @discardableResult + private func upsertMessage( + _ message: ChatMessage, + incrementUnread unreadOverride: Bool? = nil + ) -> Bool { + guard !locallyDeletedMessageIDs.contains(Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + )) else { return false } + upsertConversation(jid: message.conversationID, name: nil) + if let index = messageIndex( + id: message.id, + conversationID: message.conversationID + ) { + let previous = messages[index] + if previous.isRetracted { + if message.delivery == .delivered { + messages[index].delivery = .delivered + } + updateConversationPreview(for: messages[index], incrementUnread: false) + return false + } + var merged = message + if previous.direction == .outgoing && message.direction == .outgoing { + merged.localFilename = previous.localFilename ?? message.localFilename + merged.mimeType = previous.mimeType ?? message.mimeType + merged.duration = previous.duration ?? message.duration + merged.byteCount = previous.byteCount ?? message.byteCount + if previous.delivery == .delivered { + merged.delivery = .delivered + } + } + merged.replyToID = message.replyToID ?? previous.replyToID + merged.replyToJID = message.replyToJID ?? previous.replyToJID + merged.replyPreview = message.replyPreview ?? previous.replyPreview + merged.forwardedFrom = message.forwardedFrom ?? previous.forwardedFrom + merged.stanzaID = message.stanzaID ?? previous.stanzaID + merged.senderDisplayName = message.senderDisplayName ?? previous.senderDisplayName + merged.isGroupMessage = message.isGroupMessage || previous.isGroupMessage + merged.callHistory = message.callHistory ?? previous.callHistory + if message.reactions.isEmpty { + merged.reactions = previous.reactions + } + if let editedAt = previous.editedAt, + message.editedAt == nil || editedAt > message.editedAt! { + merged.body = previous.body + merged.editedAt = editedAt + merged.security = previous.security + merged.encryptionFingerprint = previous.encryptionFingerprint + } + messages[index] = merged + if let stanzaID = merged.stanzaID { + messageIndexByStanzaKey[Self.localDeletionKey( + messageID: stanzaID, + conversationID: merged.conversationID + )] = index + } + updateConversationPreview(for: merged, incrementUnread: false) + return false + } + messages.append(message) + let insertedIndex = messages.index(before: messages.endIndex) + messageIndexByStorageKey[Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + )] = insertedIndex + if firstMessageIndexByID[message.id] == nil { + firstMessageIndexByID[message.id] = insertedIndex + } + if let stanzaID = message.stanzaID { + messageIndexByStanzaKey[Self.localDeletionKey( + messageID: stanzaID, + conversationID: message.conversationID + )] = insertedIndex + } + let shouldIncrement = unreadOverride + ?? (message.direction == .incoming && selectedConversationID != message.conversationID) + updateConversationPreview(for: message, incrementUnread: shouldIncrement) + return true + } + + private func updateMessage(id: String, mutation: (inout ChatMessage) -> Void) { + guard let index = firstMessageIndexByID[id], + messages.indices.contains(index), + messages[index].id == id else { return } + mutation(&messages[index]) + updateConversationPreview(for: messages[index], incrementUnread: false) + } + + private func updateConversationPreview(for message: ChatMessage, incrementUnread: Bool) { + guard let index = conversations.firstIndex(where: { $0.id == message.conversationID }) else { return } + if message.timestamp >= conversations[index].lastActivity { + conversations[index].lastActivity = message.timestamp + conversations[index].lastMessage = message.previewText + } + if incrementUnread { + conversations[index].unreadCount += 1 + } + if !isApplyingArchiveBatch { + sortConversations() + } + } + + private func rebuildConversationPreview(for conversationID: String) { + guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + let latest = messages + .filter { $0.conversationID == conversationID } + .max { lhs, rhs in + if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } + return lhs.timestamp < rhs.timestamp + } + conversations[index].lastActivity = latest?.timestamp ?? .distantPast + conversations[index].lastMessage = latest?.previewText ?? "" + sortConversations() + } + + private func conversationName(for jid: String) -> String { + conversations.first(where: { $0.id == jid.lowercased() })?.displayName ?? jid + } + + private func sortConversations() { + conversations.sort { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned } + if lhs.lastActivity != rhs.lastActivity { return lhs.lastActivity > rhs.lastActivity } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + private func messageIndex(id: String, conversationID: String) -> Int? { + let normalizedConversationID = conversationID.lowercased() + let key = Self.localDeletionKey( + messageID: id, + conversationID: normalizedConversationID + ) + guard let index = messageIndexByStorageKey[key] else { return nil } + if messages.indices.contains(index), + messages[index].id == id, + messages[index].conversationID == normalizedConversationID { + return index + } + rebuildMessageIndex() + return messageIndexByStorageKey[key] + } + + private func messageIndex(referenceID: String, conversationID: String) -> Int? { + if let directIndex = messageIndex(id: referenceID, conversationID: conversationID) { + return directIndex + } + let normalizedConversationID = conversationID.lowercased() + let key = Self.localDeletionKey( + messageID: referenceID, + conversationID: normalizedConversationID + ) + if let index = messageIndexByStanzaKey[key], + messages.indices.contains(index), + messages[index].conversationID == normalizedConversationID, + messages[index].stanzaID == referenceID { + return index + } + rebuildMessageIndex() + return messageIndexByStanzaKey[key] + } + + private func rebuildMessageIndex() { + messageIndexByStorageKey.removeAll(keepingCapacity: true) + messageIndexByStanzaKey.removeAll(keepingCapacity: true) + firstMessageIndexByID.removeAll(keepingCapacity: true) + for (index, message) in messages.enumerated() { + let key = Self.localDeletionKey( + messageID: message.id, + conversationID: message.conversationID + ) + if messageIndexByStorageKey[key] == nil { + messageIndexByStorageKey[key] = index + } + if firstMessageIndexByID[message.id] == nil { + firstMessageIndexByID[message.id] = index + } + if let stanzaID = message.stanzaID { + let stanzaKey = Self.localDeletionKey( + messageID: stanzaID, + conversationID: message.conversationID + ) + if messageIndexByStanzaKey[stanzaKey] == nil { + messageIndexByStanzaKey[stanzaKey] = index + } + } + } + } + + private func loadArchive(for jid: String) async { + resetArchiveBatchState() + resetMediaPreviews() + let archive = ChatArchive(accountJID: jid) + self.archive = archive + let snapshot = await archive.load() + let migrateLegacyGroupEncryption = snapshot.schemaVersion < 2 + conversations = snapshot.conversations.map { stored in + var conversation = stored + if conversation.isGroup { + conversation.isGroupJoined = false + conversation.occupantCount = 0 + if migrateLegacyGroupEncryption, conversation.encryptionPreference == .disabled { + // Older releases forced every MUC to disabled and had no + // group encryption control, so this value was not a user + // choice. Migrate it once, while preserving later explicit + // per-room opt-outs in schema v2 snapshots. + conversation.encryptionPreference = .inheritGlobal + } + } + return conversation + } + rosterContactJIDs = Set(snapshot.rosterContactJIDs.map { $0.lowercased() }) + locallyDeletedMessageIDs = snapshot.locallyDeletedMessageIDs + lastSuccessfulMAMSync = snapshot.lastSuccessfulMAMSync + lastSuccessfulMAMCursor = snapshot.lastSuccessfulMAMCursor + messages = snapshot.messages.filter { + !locallyDeletedMessageIDs.contains(Self.localDeletionKey( + messageID: $0.id, + conversationID: $0.conversationID + )) + } + rebuildMessageIndex() + let avatarJIDs = Set(snapshot.conversations.filter { !$0.isGroup }.map(\.jid) + [jid]) + for avatarJID in avatarJIDs { + if let data = await avatarCache.data(for: avatarJID) { + avatarDataByJID[avatarJID.lowercased()] = data + } + } + sortConversations() + syncWatch() + } + + private func schedulePersist() { + guard let archive else { return } + persistTask?.cancel() + persistTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled, let self else { return } + let snapshot = ChatArchive.Snapshot( + conversations: self.conversations, + messages: self.messages, + locallyDeletedMessageIDs: self.locallyDeletedMessageIDs, + rosterContactJIDs: self.rosterContactJIDs, + lastSuccessfulMAMSync: self.lastSuccessfulMAMSync, + lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor + ) + try? await archive.save(snapshot) + } + } + + private func syncWatch(immediate: Bool = false) { + watchSyncTask?.cancel() + watchSyncTask = nil + if immediate { + watchBridge.update(conversations: conversations, messages: messages) + return + } + watchSyncTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled, let self else { return } + self.watchSyncTask = nil + self.watchBridge.update( + conversations: self.conversations, + messages: self.messages + ) + } + } + + private func loadCachedAvatarIfNeeded(for jid: String) { + let normalized = jid.lowercased() + guard avatarDataByJID[normalized] == nil, + avatarCacheRequests.insert(normalized).inserted else { return } + Task { [weak self] in + guard let self, let data = await self.avatarCache.data(for: normalized) else { return } + self.avatarDataByJID[normalized] = data + } + } + + private func resetMediaPreviews() { + for directoryName in [ + "LumaMediaPreviews", + "LumaPreview", + "LumaForwarding", + "LumaAttachmentDrafts", + "LumaPhotoPicker", + "LumaDuration" + ] { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(directoryName, isDirectory: true) + try? FileManager.default.removeItem(at: directory) + } + mediaPreviewURLs = [:] + mediaThumbnailData = [:] + audioWaveformSamples = [:] + loadingMediaIDs = [] + mediaViewerItem = nil + audioPlayback.stop() + } + + private func removeCachedMedia(for messageID: String) { + if let url = mediaPreviewURLs.removeValue(forKey: messageID) { + try? FileManager.default.removeItem(at: url) + if previewURL == url { previewURL = nil } + } + let prefix = Self.safeFilename(messageID) + "-" + for directoryName in ["LumaMediaPreviews", "LumaPreview", "LumaForwarding"] { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(directoryName, isDirectory: true) + let urls = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + )) ?? [] + for url in urls where url.lastPathComponent.hasPrefix(prefix) { + if previewURL == url { previewURL = nil } + try? FileManager.default.removeItem(at: url) + } + } + mediaThumbnailData.removeValue(forKey: messageID) + audioWaveformSamples.removeValue(forKey: messageID) + loadingMediaIDs.remove(messageID) + if mediaViewerItem?.id == messageID { + mediaViewerItem = nil + } + audioPlayback.stop() + } + + private func cacheMediaPreview( + data: Data, + messageID: String, + filename: String, + kind: ChatMessage.Kind, + mimeType: String, + preparedThumbnailData: Data? + ) async { + do { + let output = try Self.previewFileURL( + messageID: messageID, + filename: filename, + directoryName: "LumaMediaPreviews" + ) + try await mediaFileIO.write(data, to: output) + mediaPreviewURLs[messageID] = output + if let preparedThumbnailData { + mediaThumbnailData[messageID] = preparedThumbnailData + } + let hasCompletePreparedPreview = preparedThumbnailData != nil + && (kind == .photo || kind == .video) + guard !hasCompletePreparedPreview else { return } + Task { @MainActor [weak self] in + await self?.analyzeMediaPreview( + at: output, + messageID: messageID, + kind: kind, + mimeType: mimeType + ) + } + } catch { + // Sending remains possible even if local preview caching fails. + } + } + + private func analyzeMediaPreview( + at url: URL, + messageID: String, + kind: ChatMessage.Kind, + mimeType: String? + ) async { + let analysis = await mediaPreviewProcessor.analyze( + url: url, + kind: kind, + mimeType: mimeType + ) + if let thumbnailData = analysis.thumbnailData { + mediaThumbnailData[messageID] = thumbnailData + } + if let waveform = analysis.waveform { + audioWaveformSamples[messageID] = waveform + } + if let duration = analysis.duration, + let index = firstMessageIndexByID[messageID], + messages.indices.contains(index), + messages[index].id == messageID, + messages[index].duration == nil { + messages[index].duration = duration + schedulePersist() + } + } + + private func localAttachmentURL( + for message: ChatMessage, + directoryName: String + ) async throws -> URL { + guard let remoteURL = message.remoteAttachmentURL else { + throw AttachmentError.missingRemoteURL + } + let output = try Self.previewFileURL( + messageID: message.id, + filename: message.localFilename ?? "attachment", + directoryName: directoryName + ) + if FileManager.default.fileExists(atPath: output.path) { + return output + } + let data = try await xmpp.downloadAttachment(remoteURL) + try await mediaFileIO.write(data, to: output) + return output + } + + private static func previewFileURL( + messageID: String, + filename: String, + directoryName: String + ) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent( + "\(safeFilename(messageID))-\(safeFilename(filename))" + ) + } + + private static func supportsInlinePreview(_ kind: ChatMessage.Kind) -> Bool { + [.photo, .video, .audio, .voice, .videoNote].contains(kind) + } + + private static func isValidJID(_ jid: String) -> Bool { + let pieces = jid.split(separator: "@", omittingEmptySubsequences: false) + return pieces.count == 2 && !pieces[0].isEmpty && !pieces[1].isEmpty && !jid.contains(where: { $0.isWhitespace }) + } + + private static func mediaKind(for contentType: UTType?, filename: String) -> ChatMessage.Kind { + if contentType?.conforms(to: .image) == true { return .photo } + if contentType?.conforms(to: .movie) == true { return .video } + if contentType?.conforms(to: .audio) == true { return .audio } + + let fallback = UTType(filenameExtension: URL(fileURLWithPath: filename).pathExtension) + if fallback?.conforms(to: .image) == true { return .photo } + if fallback?.conforms(to: .movie) == true { return .video } + if fallback?.conforms(to: .audio) == true { return .audio } + return .attachment + } + + private static func mediaTitle(kind: ChatMessage.Kind, filename: String) -> String { + switch kind { + case .photo: return "Фото" + case .video: return "Видео" + case .audio: return filename + case .voice: return "Голосовое сообщение" + case .videoNote: return "Видеосообщение" + case .attachment, .location, .text, .system: return filename + } + } + + private static func mediaDuration(of url: URL, kind: ChatMessage.Kind) async -> TimeInterval? { + guard [.video, .audio, .voice, .videoNote].contains(kind) else { return nil } + let asset = AVURLAsset(url: url) + guard let time = try? await asset.load(.duration) else { return nil } + let seconds = time.seconds + return seconds.isFinite && seconds > 0 ? seconds : nil + } + + private static func mediaDuration( + data: Data, + filename: String, + kind: ChatMessage.Kind + ) async -> TimeInterval? { + guard [.video, .audio, .voice, .videoNote].contains(kind) else { return nil } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaDuration", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("\(UUID().uuidString)-\(safeFilename(filename))") + do { + try data.write(to: url, options: [.atomic]) + defer { try? FileManager.default.removeItem(at: url) } + return await mediaDuration(of: url, kind: kind) + } catch { + return nil + } + } + + private static func safeFilename(_ value: String) -> String { + let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>").union(.controlCharacters) + let components = value.components(separatedBy: invalid) + let cleaned = components.filter { !$0.isEmpty }.joined(separator: "_") + return cleaned.isEmpty ? "attachment" : cleaned + } + + private static func localDeletionKey(messageID: String, conversationID: String) -> String { + conversationID.lowercased() + "\u{1F}" + messageID + } +} + +private enum SignInError: LocalizedError { + case emptyPassword + + var errorDescription: String? { + "Введите пароль XMPP-аккаунта." + } +} + +private enum AttachmentError: LocalizedError { + case missingRemoteURL + + var errorDescription: String? { + switch self { + case .missingRemoteURL: + return "Ссылка на вложение ещё не получена." + } + } +} + +private enum ReconnectError: LocalizedError { + case passwordMissing + + var errorDescription: String? { + "Пароль не найден в Keychain. Выйдите из аккаунта и войдите снова." + } +} diff --git a/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift b/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift new file mode 100644 index 0000000..fbef6d5 --- /dev/null +++ b/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Keeps MAM work below a visible frame-sized burst. A whole decoded pass is +/// still published atomically; these values only bound network and decryption +/// work performed before that publication. +enum ArchiveMessageBatchPolicy { + static let pageSize = 24 + static let bootstrapMessageLimit = 40 + static let decodeSliceSize = 1 + static let maximumBufferedStanzas = 48 + /// Leave roughly half of a 60 Hz frame between archived decryptions. This + /// keeps UIScrollView touch handling responsive even when OMEMO work is + /// unusually expensive on older devices. + static let interSliceDelayNanoseconds: UInt64 = 8_000_000 +} diff --git a/Sources/Shared/Models/ArchiveSyncCheckpoint.swift b/Sources/Shared/Models/ArchiveSyncCheckpoint.swift new file mode 100644 index 0000000..9401ca4 --- /dev/null +++ b/Sources/Shared/Models/ArchiveSyncCheckpoint.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Durable MAM position. The archive UID is the primary synchronization +/// anchor; the timestamp is retained as a compatibility fallback for legacy +/// snapshots and servers that have expired an old UID. +struct ArchiveSyncCheckpoint: Codable, Equatable, Sendable { + let timestamp: Date + let cursor: String? + + init(timestamp: Date, cursor: String? = nil) { + self.timestamp = timestamp + self.cursor = Self.normalizedCursor(cursor) + } + + func advances(over previous: ArchiveSyncCheckpoint?) -> Bool { + guard let previous else { return true } + return timestamp > previous.timestamp || cursor != previous.cursor + } + + static func normalizedCursor(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty else { return nil } + return value + } +} + +struct ArchiveSyncRequestPosition: Equatable, Sendable { + let start: Date? + let after: String? +} + +/// Keeps cursor-based catch-up and the timestamp fallback deterministic and +/// independently testable from Martin's callback lifecycle. +enum ArchiveSyncCursorPolicy { + static func requestPosition( + checkpoint: ArchiveSyncCheckpoint?, + resumeAfter: String?, + overlap: TimeInterval + ) -> ArchiveSyncRequestPosition { + let after = ArchiveSyncCheckpoint.normalizedCursor(resumeAfter) + ?? checkpoint?.cursor + if let after { + return ArchiveSyncRequestPosition(start: nil, after: after) + } + return ArchiveSyncRequestPosition( + start: checkpoint?.timestamp.addingTimeInterval(-overlap), + after: nil + ) + } + + static func resolvedCheckpoint( + previous: ArchiveSyncCheckpoint?, + cursor: String?, + highWatermark: Date?, + queryStartedAt: Date, + caughtUp: Bool + ) -> ArchiveSyncCheckpoint { + let previousTimestamp = previous?.timestamp ?? .distantPast + let boundedHighWatermark = min(highWatermark ?? previousTimestamp, queryStartedAt) + let timestamp = caughtUp + ? max(previousTimestamp, queryStartedAt) + : max(previousTimestamp, boundedHighWatermark) + return ArchiveSyncCheckpoint( + timestamp: timestamp, + cursor: ArchiveSyncCheckpoint.normalizedCursor(cursor) ?? previous?.cursor + ) + } + + static func shouldFallbackFromStoredCursor( + requestedAfter: String?, + checkpoint: ArchiveSyncCheckpoint?, + hasCompletedPage: Bool, + fallbackAlreadyUsed: Bool + ) -> Bool { + guard !fallbackAlreadyUsed, + !hasCompletedPage, + let requestedAfter = ArchiveSyncCheckpoint.normalizedCursor(requestedAfter), + requestedAfter == checkpoint?.cursor else { return false } + return true + } +} diff --git a/Sources/Shared/Models/ArchiveSyncPagination.swift b/Sources/Shared/Models/ArchiveSyncPagination.swift new file mode 100644 index 0000000..0f63f4c --- /dev/null +++ b/Sources/Shared/Models/ArchiveSyncPagination.swift @@ -0,0 +1,33 @@ +import Foundation + +enum ArchiveSyncPageDecision: Equatable, Sendable { + case finished + case next(String) + case invalidCursor +} + +/// Validates XEP-0313/RSM cursors so a misbehaving server cannot leave +/// history synchronization spinning forever on the same page. +struct ArchiveSyncPagination: Sendable { + private(set) var seenCursors: Set = [] + + mutating func decision( + complete: Bool, + lastCursor: String?, + requestedAfter: String? + ) -> ArchiveSyncPageDecision { + if complete { return .finished } + guard let cursor = lastCursor?.trimmingCharacters(in: .whitespacesAndNewlines), + !cursor.isEmpty else { + // An incomplete response without an RSM cursor cannot prove that + // the archive is exhausted. Advancing the checkpoint here would + // silently skip the remaining history. + return .invalidCursor + } + guard cursor != requestedAfter, + seenCursors.insert(cursor).inserted else { + return .invalidCursor + } + return .next(cursor) + } +} diff --git a/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift b/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift new file mode 100644 index 0000000..0098e4a --- /dev/null +++ b/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Bounds whole-query MAM recovery so a server that keeps returning the same +/// broken page cannot leave the client in an endless restart loop. +enum ArchiveSyncRecoveryPolicy { + static let incrementalOverlap: TimeInterval = 60 + static let interPageDelayNanoseconds: UInt64 = 100_000_000 + static let queryTimeoutNanoseconds: UInt64 = 12_000_000_000 + static let pageApplyTimeoutNanoseconds: UInt64 = 8_000_000_000 + static let pageRetryLimit = 1 + static let resumeAfterCaptureDelayNanoseconds: UInt64 = 3_000_000_000 +} diff --git a/Sources/Shared/Models/ArchiveSyncWorkBudget.swift b/Sources/Shared/Models/ArchiveSyncWorkBudget.swift new file mode 100644 index 0000000..61833e0 --- /dev/null +++ b/Sources/Shared/Models/ArchiveSyncWorkBudget.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Limits one foreground MAM pass. A large archive is advanced over several +/// app activations instead of monopolizing the main actor indefinitely. +struct ArchiveSyncWorkBudget: Equatable, Sendable { + static let maximumCompletedPages = 2 + static let maximumRuntime: TimeInterval = 2 + + private(set) var completedPages = 0 + let startedAt: Date + + init(startedAt: Date = Date()) { + self.startedAt = startedAt + } + + mutating func recordCompletedPage(at date: Date = Date()) -> Bool { + completedPages += 1 + return completedPages >= Self.maximumCompletedPages + || date.timeIntervalSince(startedAt) >= Self.maximumRuntime + } +} diff --git a/Sources/Shared/Models/AttachmentDraft.swift b/Sources/Shared/Models/AttachmentDraft.swift new file mode 100644 index 0000000..9c16182 --- /dev/null +++ b/Sources/Shared/Models/AttachmentDraft.swift @@ -0,0 +1,45 @@ +import Foundation + +struct AttachmentDraft: Identifiable, Hashable, Sendable { + let id: UUID + let url: URL + let filename: String + let mimeType: String + let kind: ChatMessage.Kind + let duration: TimeInterval? + let byteCount: Int + let thumbnailData: Data? + let isTemporary: Bool + + init( + id: UUID = UUID(), + url: URL, + filename: String, + mimeType: String, + kind: ChatMessage.Kind, + duration: TimeInterval? = nil, + byteCount: Int, + thumbnailData: Data? = nil, + isTemporary: Bool = true + ) { + self.id = id + self.url = url + self.filename = filename + self.mimeType = mimeType + self.kind = kind + self.duration = duration + self.byteCount = byteCount + self.thumbnailData = thumbnailData + self.isTemporary = isTemporary + } +} + +struct AttachmentBatchResult: Sendable { + let sentCount: Int + let failedDrafts: [AttachmentDraft] + let captionSent: Bool + + var isComplete: Bool { + failedDrafts.isEmpty + } +} diff --git a/Sources/Shared/Models/CallSnapshot.swift b/Sources/Shared/Models/CallSnapshot.swift new file mode 100644 index 0000000..26a17f6 --- /dev/null +++ b/Sources/Shared/Models/CallSnapshot.swift @@ -0,0 +1,114 @@ +import Foundation + +enum CallMedia: String, CaseIterable, Hashable, Sendable { + case audio + case video +} + +enum CallDirection: String, Equatable, Sendable { + case incoming + case outgoing +} + +enum CallPhase: Equatable, Sendable { + case ringing + case connecting + case connected +} + +/// Stable value stored with a local call-history message. +enum CallHistoryOutcome: String, Codable, Hashable, Sendable { + case completed + case declined + case missed + case cancelled + case unanswered + case failed + case answeredElsewhere +} + +struct CallHistoryMetadata: Codable, Hashable, Sendable { + let isVideo: Bool + let outcome: CallHistoryOutcome +} + +/// Internal reason used to turn every call-ending path into one history result. +enum CallTerminationCause: Equatable, Sendable { + case localEnded + case localRejected + case remoteEnded + case remoteRejected + case remoteCancelled + case timedOut + case failed + case connectionDetached + case answeredElsewhere +} + +struct CallHistoryEntry: Equatable, Sendable { + let id: String + let peerJID: String + let direction: CallDirection + let isVideo: Bool + let startedAt: Date + let endedAt: Date + let duration: TimeInterval? + let outcome: CallHistoryOutcome +} + +/// Pure classification kept outside WebRTC so every termination scenario can +/// be tested without constructing a live peer connection. +enum CallHistoryPolicy { + static func outcome( + direction: CallDirection, + phase: CallPhase, + connectedAt: Date?, + cause: CallTerminationCause + ) -> CallHistoryOutcome { + // Once media connected, a later hang-up or transport loss still belongs + // to the successful-call history and keeps its measured duration. + if connectedAt != nil || phase == .connected { + return .completed + } + + switch cause { + case .localRejected, .remoteRejected: + return .declined + case .localEnded: + return .cancelled + case .remoteCancelled, .remoteEnded: + guard phase == .ringing else { return .failed } + return direction == .incoming ? .missed : .declined + case .timedOut: + guard phase == .ringing else { return .failed } + return direction == .incoming ? .missed : .unanswered + case .answeredElsewhere: + return .answeredElsewhere + case .failed, .connectionDetached: + return .failed + } + } +} + +/// Value-type projection of the live WebRTC/Jingle session used by SwiftUI. +struct CallSnapshot: Identifiable, Equatable, Sendable { + let id: UUID + let peerJID: String + let direction: CallDirection + let media: Set + let phase: CallPhase + let connectedAt: Date? + let isMuted: Bool + let isCameraEnabled: Bool + let isSpeakerEnabled: Bool + let hasLocalVideo: Bool + let hasRemoteVideo: Bool + + var isVideoCall: Bool { + media.contains(.video) + } + + var isIncomingRinging: Bool { + direction == .incoming && phase == .ringing + } +} diff --git a/Sources/Shared/Models/ChatMessage.swift b/Sources/Shared/Models/ChatMessage.swift new file mode 100644 index 0000000..073af59 --- /dev/null +++ b/Sources/Shared/Models/ChatMessage.swift @@ -0,0 +1,319 @@ +import Foundation + +struct ChatMessage: Codable, Identifiable, Hashable, Sendable { + enum Direction: String, Codable, Sendable { + case incoming + case outgoing + } + + enum Delivery: String, Codable, Sendable { + case sending + case sent + case delivered + case failed + } + + enum Security: String, Codable, Sendable { + case omemo + case plaintext + case decryptionFailed + } + + enum Kind: String, Codable, Sendable { + case text + case attachment + case photo + case video + case audio + case voice + case videoNote + case location + case system + + var isMedia: Bool { + switch self { + case .attachment, .photo, .video, .audio, .voice, .videoNote: + return true + case .text, .location, .system: + return false + } + } + } + + let id: String + var conversationID: String + var senderJID: String + var body: String + var timestamp: Date + var direction: Direction + var delivery: Delivery + var security: Security + var kind: Kind + var remoteAttachmentURL: String? + var localFilename: String? + var mimeType: String? + var duration: TimeInterval? + var byteCount: Int? + var encryptionFingerprint: String? + var editedAt: Date? + var replyToID: String? + var replyToJID: String? + var replyPreview: String? + var forwardedFrom: String? + var retractedAt: Date? + var stanzaID: String? + var senderDisplayName: String? + var isGroupMessage: Bool + var callHistory: CallHistoryMetadata? + var reactions: [MessageReaction] + + init( + id: String = UUID().uuidString, + conversationID: String, + senderJID: String, + body: String, + timestamp: Date = Date(), + direction: Direction, + delivery: Delivery, + security: Security, + kind: Kind = .text, + remoteAttachmentURL: String? = nil, + localFilename: String? = nil, + mimeType: String? = nil, + duration: TimeInterval? = nil, + byteCount: Int? = nil, + encryptionFingerprint: String? = nil, + editedAt: Date? = nil, + replyToID: String? = nil, + replyToJID: String? = nil, + replyPreview: String? = nil, + forwardedFrom: String? = nil, + retractedAt: Date? = nil, + stanzaID: String? = nil, + senderDisplayName: String? = nil, + isGroupMessage: Bool = false, + callHistory: CallHistoryMetadata? = nil, + reactions: [MessageReaction] = [] + ) { + self.id = id + self.conversationID = conversationID.lowercased() + self.senderJID = Self.normalizedSenderJID(senderJID) + self.body = body + self.timestamp = timestamp + self.direction = direction + self.delivery = delivery + self.security = security + self.kind = kind + self.remoteAttachmentURL = remoteAttachmentURL + self.localFilename = localFilename + self.mimeType = mimeType + self.duration = duration + self.byteCount = byteCount + self.encryptionFingerprint = encryptionFingerprint + self.editedAt = editedAt + self.replyToID = replyToID + self.replyToJID = replyToJID.map(Self.normalizedSenderJID) + self.replyPreview = replyPreview + self.forwardedFrom = forwardedFrom + self.retractedAt = retractedAt + self.stanzaID = stanzaID + self.senderDisplayName = senderDisplayName + self.isGroupMessage = isGroupMessage + self.callHistory = callHistory + self.reactions = reactions + } + + private enum CodingKeys: String, CodingKey { + case id + case conversationID + case senderJID + case body + case timestamp + case direction + case delivery + case security + case kind + case remoteAttachmentURL + case localFilename + case mimeType + case duration + case byteCount + case encryptionFingerprint + case editedAt + case replyToID + case replyToJID + case replyPreview + case forwardedFrom + case retractedAt + case stanzaID + case senderDisplayName + case isGroupMessage + case callHistory + case reactions + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + conversationID = try values.decode(String.self, forKey: .conversationID).lowercased() + senderJID = Self.normalizedSenderJID(try values.decode(String.self, forKey: .senderJID)) + body = try values.decode(String.self, forKey: .body) + timestamp = try values.decode(Date.self, forKey: .timestamp) + direction = try values.decode(Direction.self, forKey: .direction) + delivery = try values.decode(Delivery.self, forKey: .delivery) + security = try values.decode(Security.self, forKey: .security) + kind = try values.decode(Kind.self, forKey: .kind) + remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL) + localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename) + mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType) + duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration) + byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount) + encryptionFingerprint = try values.decodeIfPresent(String.self, forKey: .encryptionFingerprint) + editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt) + replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID) + replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID).map(Self.normalizedSenderJID) + replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview) + forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom) + retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt) + stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID) + senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName) + isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false + callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory) + reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? [] + } + + var canBeEdited: Bool { + !isRetracted + && !isGroupMessage + && direction == .outgoing + && kind == .text + && (delivery == .sent || delivery == .delivered) + } + + var isRetracted: Bool { + retractedAt != nil + } + + var canBeRepliedTo: Bool { + !isRetracted + && kind != .system + && (!isGroupMessage || stanzaID != nil) + } + + var canBeForwarded: Bool { + !isRetracted && kind != .system + } + + var canBeRetracted: Bool { + direction == .outgoing + && !isGroupMessage + && !isRetracted + && kind != .system + && (delivery == .sent || delivery == .delivered) + } + + var replyIdentifier: String? { + isGroupMessage ? stanzaID : id + } + + var quotePreview: String { + if isRetracted { return "Сообщение удалено" } + let value = kind == .text ? body : previewText + let normalized = value + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count > 140 else { return normalized } + return String(normalized.prefix(137)) + "…" + } + + var previewText: String { + if isRetracted { return "🚫 Сообщение удалено" } + if let callHistory { + let icon = callHistory.isVideo ? "🎥" : "📞" + return "\(icon) \(callTitle)" + } + switch kind { + case .attachment: + return "📎 \(localFilename ?? "Вложение")" + case .photo: + return "📷 Фото" + case .video: + return "🎬 Видео" + case .audio: + return "🎵 \(localFilename ?? "Аудио")" + case .voice: + return "🎙 Голосовое сообщение" + case .videoNote: + return "⭕️ Видеосообщение" + case .location: + return "📍 Геопозиция" + case .text, .system: + return body + } + } + + var callTitle: String { + guard let callHistory else { return body } + let media = callHistory.isVideo ? "видеозвонок" : "аудиозвонок" + switch callHistory.outcome { + case .completed: + return direction == .incoming ? "Входящий \(media)" : "Исходящий \(media)" + case .declined: + return "Отклонённый \(media)" + case .missed: + return "Пропущенный \(media)" + case .cancelled: + return "Отменённый \(media)" + case .unanswered: + return "Нет ответа" + case .failed: + return "Неудачный \(media)" + case .answeredElsewhere: + return "Звонок принят на другом устройстве" + } + } + + var callSubtitle: String? { + guard let callHistory else { return nil } + switch callHistory.outcome { + case .completed: + guard let duration else { return "Звонок завершён" } + return "Длительность \(Self.formattedCallDuration(duration))" + case .declined: + return direction == .incoming + ? "Вы отклонили звонок" + : "Собеседник отклонил звонок" + case .missed: + return "Вы не ответили" + case .cancelled: + return direction == .outgoing + ? "Вы отменили вызов" + : "Собеседник отменил вызов" + case .unanswered: + return "Собеседник не ответил" + case .failed: + return "Соединение не установлено" + case .answeredElsewhere: + return "Ответ с другого устройства" + } + } + + private static func formattedCallDuration(_ value: TimeInterval) -> String { + let seconds = max(0, Int(value.rounded())) + if seconds >= 3_600 { + return String( + format: "%d:%02d:%02d", + seconds / 3_600, + (seconds % 3_600) / 60, + seconds % 60 + ) + } + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + private static func normalizedSenderJID(_ value: String) -> String { + guard let slash = value.firstIndex(of: "/") else { return value.lowercased() } + let bare = value[.. Bool { + guard bottomY.isFinite, viewportHeight.isFinite, viewportHeight > 0 else { + return true + } + return bottomY <= viewportHeight + nearBottomThreshold + } + + static func isNearBottom(distanceFromBottom: CGFloat) -> Bool { + guard distanceFromBottom.isFinite else { return true } + return distanceFromBottom <= nearBottomThreshold + } +} diff --git a/Sources/Shared/Models/ChatTimelineEntry.swift b/Sources/Shared/Models/ChatTimelineEntry.swift new file mode 100644 index 0000000..aa15796 --- /dev/null +++ b/Sources/Shared/Models/ChatTimelineEntry.swift @@ -0,0 +1,28 @@ +import Foundation + +/// A cached presentation row for the selected chat. Building day boundaries +/// once per message mutation avoids walking the full history during every +/// scroll-position or composer-state update. +struct ChatTimelineEntry: Identifiable, Hashable, Sendable { + let message: ChatMessage + let startsNewDay: Bool + + var id: String { message.id } + + static func make( + from messages: [ChatMessage], + calendar: Calendar = .autoupdatingCurrent + ) -> [ChatTimelineEntry] { + var previousTimestamp: Date? + return messages.map { message in + let startsNewDay = previousTimestamp.map { + !calendar.isDate($0, inSameDayAs: message.timestamp) + } ?? true + previousTimestamp = message.timestamp + return ChatTimelineEntry( + message: message, + startsNewDay: startsNewDay + ) + } + } +} diff --git a/Sources/Shared/Models/ChatTypingPolicy.swift b/Sources/Shared/Models/ChatTypingPolicy.swift new file mode 100644 index 0000000..ba303ed --- /dev/null +++ b/Sources/Shared/Models/ChatTypingPolicy.swift @@ -0,0 +1,29 @@ +import Foundation + +enum ChatTypingState: String, Sendable { + case active + case composing + case paused + case inactive + case gone +} + +enum ChatTypingPolicy { + static let pauseDelayNanoseconds: UInt64 = 5_000_000_000 + static let remoteExpiryNanoseconds: UInt64 = 8_000_000_000 + + static func displayText(names: [String], isGroup: Bool) -> String? { + let uniqueNames = Array(Set(names.filter { !$0.isEmpty })).sorted() + guard !uniqueNames.isEmpty else { return nil } + guard isGroup else { return "печатает…" } + + switch uniqueNames.count { + case 1: + return "\(uniqueNames[0]) печатает…" + case 2: + return "\(uniqueNames[0]) и \(uniqueNames[1]) печатают…" + default: + return "Несколько участников печатают…" + } + } +} diff --git a/Sources/Shared/Models/Conversation.swift b/Sources/Shared/Models/Conversation.swift new file mode 100644 index 0000000..dab4911 --- /dev/null +++ b/Sources/Shared/Models/Conversation.swift @@ -0,0 +1,122 @@ +import Foundation + +struct Conversation: Codable, Identifiable, Hashable, Sendable { + enum Kind: String, Codable, Sendable { + case direct + case group + } + + let id: String + var jid: String + var displayName: String + var lastMessage: String + var lastActivity: Date + var unreadCount: Int + var isOnline: Bool + var isPinned: Bool + var encryptionPreference: EncryptionPreference + var kind: Kind + var groupNickname: String? + var isGroupJoined: Bool + var shouldAutojoin: Bool + var occupantCount: Int + var invitedBy: String? + + init( + jid: String, + displayName: String? = nil, + lastMessage: String = "", + lastActivity: Date = .distantPast, + unreadCount: Int = 0, + isOnline: Bool = false, + isPinned: Bool = false, + encryptionPreference: EncryptionPreference = .inheritGlobal, + kind: Kind = .direct, + groupNickname: String? = nil, + isGroupJoined: Bool = false, + shouldAutojoin: Bool = false, + occupantCount: Int = 0, + invitedBy: String? = nil + ) { + let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self.id = normalized + self.jid = normalized + self.displayName = displayName?.nilIfBlank ?? normalized + self.lastMessage = lastMessage + self.lastActivity = lastActivity + self.unreadCount = unreadCount + self.isOnline = isOnline + self.isPinned = isPinned + self.encryptionPreference = encryptionPreference + self.kind = kind + self.groupNickname = groupNickname?.nilIfBlank + self.isGroupJoined = isGroupJoined + self.shouldAutojoin = shouldAutojoin + self.occupantCount = max(0, occupantCount) + self.invitedBy = invitedBy?.nilIfBlank + } + + private enum CodingKeys: String, CodingKey { + case id + case jid + case displayName + case lastMessage + case lastActivity + case unreadCount + case isOnline + case isPinned + case encryptionPreference + case kind + case groupNickname + case isGroupJoined + case shouldAutojoin + case occupantCount + case invitedBy + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + jid = try values.decode(String.self, forKey: .jid) + id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased() + displayName = try values.decode(String.self, forKey: .displayName) + lastMessage = try values.decode(String.self, forKey: .lastMessage) + lastActivity = try values.decode(Date.self, forKey: .lastActivity) + unreadCount = try values.decode(Int.self, forKey: .unreadCount) + isOnline = try values.decode(Bool.self, forKey: .isOnline) + isPinned = try values.decode(Bool.self, forKey: .isPinned) + encryptionPreference = try values.decodeIfPresent( + EncryptionPreference.self, + forKey: .encryptionPreference + ) ?? .inheritGlobal + kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct + groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname) + isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false + shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false + occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0) + invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy) + } + + var isGroup: Bool { kind == .group } + + var initials: String { + let source = displayName == jid ? (jid.split(separator: "@").first.map(String.init) ?? jid) : displayName + let words = source.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }) + let letters = words.prefix(2).compactMap(\.first) + if letters.isEmpty { + return String(source.prefix(1)).uppercased() + } + return String(letters).uppercased() + } + + var colorSeed: UInt64 { + jid.utf8.reduce(14_695_981_039_346_656_037) { partial, byte in + (partial ^ UInt64(byte)) &* 1_099_511_628_211 + } + } +} + +private extension String { + var nilIfBlank: String? { + trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Sources/Shared/Models/EncryptionPreference.swift b/Sources/Shared/Models/EncryptionPreference.swift new file mode 100644 index 0000000..29d41ad --- /dev/null +++ b/Sources/Shared/Models/EncryptionPreference.swift @@ -0,0 +1,29 @@ +import Foundation + +enum EncryptionPreference: String, Codable, CaseIterable, Equatable, Hashable, Sendable { + case inheritGlobal + case enabled + case disabled + + func resolved(globalEnabled: Bool) -> Bool { + switch self { + case .inheritGlobal: + return globalEnabled + case .enabled: + return true + case .disabled: + return false + } + } + + var title: String { + switch self { + case .inheritGlobal: + return "Как в настройках" + case .enabled: + return "Всегда включено" + case .disabled: + return "Выключено для чата" + } + } +} diff --git a/Sources/Shared/Models/GeoLocation.swift b/Sources/Shared/Models/GeoLocation.swift new file mode 100644 index 0000000..b038358 --- /dev/null +++ b/Sources/Shared/Models/GeoLocation.swift @@ -0,0 +1,61 @@ +import Foundation + +struct GeoLocation: Equatable, Sendable { + let latitude: Double + let longitude: Double + let uncertainty: Double? + + init?(latitude: Double, longitude: Double, uncertainty: Double? = nil) { + guard latitude.isFinite, + longitude.isFinite, + (-90...90).contains(latitude), + (-180...180).contains(longitude) else { return nil } + if let uncertainty, (!uncertainty.isFinite || uncertainty < 0) { + return nil + } + self.latitude = latitude + self.longitude = longitude + self.uncertainty = uncertainty + } + + init?(uri: String) { + let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("geo:") else { return nil } + let payload = trimmed.dropFirst(4) + let components = payload.split(separator: ";", omittingEmptySubsequences: false) + guard let coordinatePart = components.first else { return nil } + let coordinates = coordinatePart.split(separator: ",", omittingEmptySubsequences: false) + guard coordinates.count == 2 || coordinates.count == 3, + let latitude = Double(coordinates[0]), + let longitude = Double(coordinates[1]) else { return nil } + + if coordinates.count == 3, Double(coordinates[2]) == nil { + return nil + } + var uncertainty: Double? + for component in components.dropFirst() { + let pair = component.split(separator: "=", maxSplits: 1) + guard pair.count == 2 else { continue } + if pair[0].lowercased() == "u" { + guard let value = Double(pair[1]) else { return nil } + uncertainty = value + } + } + self.init(latitude: latitude, longitude: longitude, uncertainty: uncertainty) + } + + var uriString: String { + let coordinates = String( + format: "geo:%.6f,%.6f", + locale: Locale(identifier: "en_US_POSIX"), + latitude, + longitude + ) + guard let uncertainty else { return coordinates } + return coordinates + String( + format: ";u=%.0f", + locale: Locale(identifier: "en_US_POSIX"), + uncertainty + ) + } +} diff --git a/Sources/Shared/Models/MediaMetadata.swift b/Sources/Shared/Models/MediaMetadata.swift new file mode 100644 index 0000000..0e18c40 --- /dev/null +++ b/Sources/Shared/Models/MediaMetadata.swift @@ -0,0 +1,76 @@ +import Foundation +import UniformTypeIdentifiers + +enum MediaMetadata { + struct Inferred: Equatable, Sendable { + let kind: ChatMessage.Kind + let mimeType: String? + let duration: TimeInterval? + } + + static func transportFilename( + originalFilename: String, + kind: ChatMessage.Kind, + duration: TimeInterval? + ) -> String { + let milliseconds = duration.flatMap { value -> Int? in + guard value.isFinite, + value > 0, + value <= Double(Int.max) / 1_000 else { return nil } + return Int((value * 1_000).rounded()) + } + guard let milliseconds else { return originalFilename } + + switch kind { + case .voice: + return "luma-voice-\(milliseconds)-\(originalFilename)" + case .videoNote: + return "luma-videonote-\(milliseconds)-\(originalFilename)" + default: + return originalFilename + } + } + + static func infer(from transportFilename: String) -> Inferred { + let contentType = UTType( + filenameExtension: URL(fileURLWithPath: transportFilename).pathExtension + ) + let kind: ChatMessage.Kind + if transportFilename.hasPrefix("luma-voice-") { + kind = .voice + } else if transportFilename.hasPrefix("luma-videonote-") { + kind = .videoNote + } else if contentType?.conforms(to: .image) == true { + kind = .photo + } else if contentType?.conforms(to: .movie) == true { + kind = .video + } else if contentType?.conforms(to: .audio) == true { + kind = .audio + } else { + kind = .attachment + } + + return Inferred( + kind: kind, + mimeType: contentType?.preferredMIMEType, + duration: encodedDuration(from: transportFilename) + ) + } + + private static func encodedDuration(from filename: String) -> TimeInterval? { + let prefix: String + if filename.hasPrefix("luma-voice-") { + prefix = "luma-voice-" + } else if filename.hasPrefix("luma-videonote-") { + prefix = "luma-videonote-" + } else { + return nil + } + + let remainder = filename.dropFirst(prefix.count) + guard let separator = remainder.firstIndex(of: "-"), + let milliseconds = Int(remainder[.. 0 else { return nil } + return TimeInterval(milliseconds) / 1_000 + } +} diff --git a/Sources/Shared/Models/MediaPickerSelectionPolicy.swift b/Sources/Shared/Models/MediaPickerSelectionPolicy.swift new file mode 100644 index 0000000..4eb00f6 --- /dev/null +++ b/Sources/Shared/Models/MediaPickerSelectionPolicy.swift @@ -0,0 +1,18 @@ +enum MediaPickerRepresentation: Equatable, Sendable { + case photo + case video +} + +enum MediaPickerSelectionPolicy { + static func preferredOrder( + supportsImage: Bool, + supportsVideo: Bool + ) -> [MediaPickerRepresentation] { + // Live Photos can expose both types. Their still image is the most + // predictable cross-client representation; a video-only asset keeps + // movie first. The second entry is an explicit provider fallback. + supportsVideo && !supportsImage + ? [.video, .photo] + : [.photo, .video] + } +} diff --git a/Sources/Shared/Models/MediaSendActivityTracker.swift b/Sources/Shared/Models/MediaSendActivityTracker.swift new file mode 100644 index 0000000..09425c0 --- /dev/null +++ b/Sources/Shared/Models/MediaSendActivityTracker.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Tracks overlapping media operations without letting an older operation +/// restore a stale busy state after a newer one has already finished. +struct MediaSendActivityTracker: Sendable { + private(set) var activeTokens: Set = [] + + var isActive: Bool { + !activeTokens.isEmpty + } + + mutating func begin() -> UUID { + let token = UUID() + activeTokens.insert(token) + return token + } + + mutating func end(_ token: UUID) { + activeTokens.remove(token) + } + + mutating func reset() { + activeTokens.removeAll() + } +} diff --git a/Sources/Shared/Models/MediaViewerItem.swift b/Sources/Shared/Models/MediaViewerItem.swift new file mode 100644 index 0000000..abfb2b3 --- /dev/null +++ b/Sources/Shared/Models/MediaViewerItem.swift @@ -0,0 +1,8 @@ +import Foundation + +struct MediaViewerItem: Identifiable, Equatable, Sendable { + let id: String + let url: URL + let kind: ChatMessage.Kind + let title: String +} diff --git a/Sources/Shared/Models/MessageReaction.swift b/Sources/Shared/Models/MessageReaction.swift new file mode 100644 index 0000000..6090b53 --- /dev/null +++ b/Sources/Shared/Models/MessageReaction.swift @@ -0,0 +1,103 @@ +import Foundation + +struct MessageReaction: Codable, Identifiable, Hashable, Sendable { + let senderJID: String + var emojis: [String] + var updatedAt: Date + + var id: String { senderJID } + + init(senderJID: String, emojis: [String], updatedAt: Date = Date()) { + self.senderJID = Self.normalizedJID(senderJID) + self.emojis = MessageReactionPolicy.sanitized(emojis) + self.updatedAt = updatedAt + } + + private static func normalizedJID(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +struct MessageReactionSummary: Identifiable, Hashable, Sendable { + let emoji: String + let count: Int + let includesOwnReaction: Bool + + var id: String { emoji } +} + +enum MessageReactionPolicy { + static let quickChoices = ["👍", "❤️", "😂", "😮", "😢", "🔥"] + + static func sanitized(_ values: [String]) -> [String] { + var seen: Set = [] + return values.compactMap { rawValue in + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.count == 1, + value.utf8.count <= 64, + value.unicodeScalars.contains(where: { $0.properties.isEmoji }), + seen.insert(value).inserted else { return nil } + return value + } + } + + static func summaries( + reactions: [MessageReaction], + ownJID: String? + ) -> [MessageReactionSummary] { + let normalizedOwnJID = ownJID.map(bareJID) + var counts: [String: Int] = [:] + var ownValues: Set = [] + + for reaction in reactions { + let values = sanitized(reaction.emojis) + for emoji in values { + counts[emoji, default: 0] += 1 + } + if normalizedOwnJID == bareJID(reaction.senderJID) { + ownValues.formUnion(values) + } + } + + return counts.keys.sorted().map { emoji in + MessageReactionSummary( + emoji: emoji, + count: counts[emoji, default: 0], + includesOwnReaction: ownValues.contains(emoji) + ) + } + } + + static func bareJID(_ value: String) -> String { + value + .split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true) + .first + .map(String.init)? + .lowercased() + ?? value.lowercased() + } +} + +extension ChatMessage { + var reactionIdentifier: String? { + isGroupMessage ? stanzaID : id + } + + var canBeReactedTo: Bool { + guard !isRetracted, + kind != .system, + direction == .incoming || delivery != .sending else { return false } + return !isGroupMessage || stanzaID != nil + } + + func reactionSummaries(ownJID: String?) -> [MessageReactionSummary] { + MessageReactionPolicy.summaries(reactions: reactions, ownJID: ownJID) + } + + func reactionEmojis(from senderJID: String) -> [String] { + let sender = MessageReactionPolicy.bareJID(senderJID) + return reactions.first { + MessageReactionPolicy.bareJID($0.senderJID) == sender + }?.emojis ?? [] + } +} diff --git a/Sources/Shared/Models/MessageReplyFallback.swift b/Sources/Shared/Models/MessageReplyFallback.swift new file mode 100644 index 0000000..b2342a5 --- /dev/null +++ b/Sources/Shared/Models/MessageReplyFallback.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Builds and removes the plain-text quote used when a peer does not render +/// XEP-0461 replies. XEP-0428 offsets are Unicode scalar offsets, not UTF-16. +enum MessageReplyFallback { + struct Parsed: Equatable, Sendable { + let body: String + let preview: String? + } + + static func make(author: String, preview: String) -> (prefix: String, scalarCount: Int) { + let safeAuthor = author + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + let quoteLines = preview + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "\n", omittingEmptySubsequences: false) + .prefix(8) + .map { "> \($0)" } + let prefix = (["> \(safeAuthor) написал(а):"] + quoteLines).joined(separator: "\n") + "\n\n" + return (prefix, prefix.unicodeScalars.count) + } + + static func parse( + body: String, + fallbackStart: Int?, + fallbackEnd: Int? + ) -> Parsed { + if let fallbackStart, + let fallbackEnd, + let fallback = scalarSubstring(body, start: fallbackStart, end: fallbackEnd), + let stripped = removingScalarRange(body, start: fallbackStart, end: fallbackEnd) { + return Parsed( + body: stripped.trimmingCharacters(in: .whitespacesAndNewlines), + preview: quotePreview(from: fallback) + ) + } + return parseLegacyQuote(body) ?? Parsed(body: body, preview: nil) + } + + static func parseLegacyQuote(_ body: String) -> Parsed? { + let lines = body.components(separatedBy: .newlines) + guard let first = lines.first, + first.trimmingCharacters(in: .whitespaces).hasPrefix(">") else { return nil } + + var quoteLines: [String] = [] + var bodyStart = 0 + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix(">") { + quoteLines.append(String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces)) + bodyStart = index + 1 + continue + } + if trimmed.isEmpty, !quoteLines.isEmpty { + bodyStart = index + 1 + continue + } + break + } + + guard !quoteLines.isEmpty, bodyStart < lines.count else { return nil } + let remaining = lines[bodyStart...] + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !remaining.isEmpty else { return nil } + return Parsed(body: remaining, preview: normalizedPreview(quoteLines)) + } + + private static func quotePreview(from fallback: String) -> String? { + let lines = fallback.components(separatedBy: .newlines).compactMap { line -> String? in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix(">") else { return nil } + return String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) + } + return normalizedPreview(lines) + } + + private static func normalizedPreview(_ lines: [String]) -> String? { + var values = lines.filter { !$0.isEmpty } + if values.count > 1, + let first = values.first?.lowercased(), + first.hasSuffix(":") || first.contains(" wrote") || first.contains("написал") { + values.removeFirst() + } + let value = values.joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return nil } + return value.count > 280 ? String(value.prefix(277)) + "…" : value + } + + private static func scalarSubstring(_ value: String, start: Int, end: Int) -> String? { + let scalars = value.unicodeScalars + guard start >= 0, end >= start, end <= scalars.count else { return nil } + let lower = scalars.index(scalars.startIndex, offsetBy: start) + let upper = scalars.index(scalars.startIndex, offsetBy: end) + return String(value[lower.. String? { + let scalars = value.unicodeScalars + guard start >= 0, end >= start, end <= scalars.count else { return nil } + let lower = scalars.index(scalars.startIndex, offsetBy: start) + let upper = scalars.index(scalars.startIndex, offsetBy: end) + return String(value[.. CGFloat { + guard isHorizontal(translation) else { return 0 } + let distance = min(abs(translation.width) * 0.72, maximumOffset) + return translation.width < 0 ? -distance : distance + } + + static func shouldReply( + translation: CGSize, + predictedEndTranslation: CGSize + ) -> Bool { + let candidate = abs(predictedEndTranslation.width) > abs(translation.width) + ? predictedEndTranslation + : translation + return isHorizontal(candidate) && abs(candidate.width) >= triggerDistance + } + + static func progress(for offset: CGFloat) -> CGFloat { + min(1, abs(offset) / triggerDistance) + } + + private static func isHorizontal(_ translation: CGSize) -> Bool { + abs(translation.width) > abs(translation.height) * horizontalDominance + } +} diff --git a/Sources/Shared/Models/NotificationPolicy.swift b/Sources/Shared/Models/NotificationPolicy.swift new file mode 100644 index 0000000..aa74cce --- /dev/null +++ b/Sources/Shared/Models/NotificationPolicy.swift @@ -0,0 +1,15 @@ +import Foundation + +enum NotificationPolicy { + static func shouldPresentMessage( + inserted: Bool, + isOutgoing: Bool, + appIsActive: Bool, + selectedConversationID: String?, + conversationID: String + ) -> Bool { + guard inserted, !isOutgoing else { return false } + guard appIsActive else { return true } + return selectedConversationID?.lowercased() != conversationID.lowercased() + } +} diff --git a/Sources/Shared/Models/OMEMODevice.swift b/Sources/Shared/Models/OMEMODevice.swift new file mode 100644 index 0000000..be68db1 --- /dev/null +++ b/Sources/Shared/Models/OMEMODevice.swift @@ -0,0 +1,38 @@ +import Foundation + +struct OMEMODevice: Identifiable, Hashable, Sendable { + enum Trust: String, Sendable { + case undecided + case trusted + case verified + case compromised + } + + let jid: String + let deviceID: Int32 + let fingerprint: String + let trust: Trust + let isActive: Bool + let isOwn: Bool + + var id: String { "\(jid)|\(deviceID)" } + + var formattedFingerprint: String { + fingerprint.chunked(every: 8).joined(separator: " ") + } +} + +private extension String { + func chunked(every size: Int) -> [String] { + guard size > 0 else { return [self] } + var chunks: [String] = [] + var start = startIndex + while start < endIndex { + let end = index(start, offsetBy: size, limitedBy: endIndex) ?? endIndex + chunks.append(String(self[start.. Bool { + guard let error else { return true } + let value = error as NSError + if value.domain == AVFoundationErrorDomain, + value.code == AVError.Code.maximumDurationReached.rawValue { + return true + } + if let finished = value.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? Bool { + return finished + } + return (value.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? NSNumber)?.boolValue + == true + } + + /// A camera interruption may still leave a playable partial movie. That + /// file is useful for recovery, but it must not be sent as though the user + /// released the record button. The 60-second capture limit is the only + /// completion that is expected without an explicit stop request. + static func wasRequestedOrReachedLimit( + stopRequested: Bool, + error: Error?, + recordedDuration: TimeInterval, + maximumDuration: TimeInterval + ) -> Bool { + if stopRequested { return true } + + if let error, + (error as NSError).domain == AVFoundationErrorDomain, + (error as NSError).code == AVError.Code.maximumDurationReached.rawValue { + return true + } + + guard recordedDuration.isFinite, + maximumDuration.isFinite, + maximumDuration > 0 else { return false } + return recordedDuration >= maximumDuration - 0.25 + } +} diff --git a/Sources/Shared/Models/VideoNoteRecordingLifecycle.swift b/Sources/Shared/Models/VideoNoteRecordingLifecycle.swift new file mode 100644 index 0000000..f895f0f --- /dev/null +++ b/Sources/Shared/Models/VideoNoteRecordingLifecycle.swift @@ -0,0 +1,38 @@ +import Foundation + +enum VideoNoteRecordingLifecycle: Equatable, Sendable { + case idle + case prepared + case starting + case recording + case stopping + + var isBusy: Bool { + switch self { + case .starting, .recording, .stopping: + return true + case .idle, .prepared: + return false + } + } + + static func acceptsCompletion(activeURL: URL?, outputURL: URL) -> Bool { + guard let activeURL else { return false } + return normalizedRecordingPath(activeURL) == normalizedRecordingPath(outputURL) + } + + /// AVFoundation can return a temporary file through its canonical macOS + /// path (`/private/var/...`) even when the caller supplied the equivalent + /// user-facing path (`/var/...`). Treat those aliases as one recording so + /// a valid delegate callback is not mistaken for a stale attempt. + private static func normalizedRecordingPath(_ url: URL) -> String { + var path = url.standardizedFileURL.resolvingSymlinksInPath().path + for privateAlias in ["/private/var", "/private/tmp"] { + if path == privateAlias || path.hasPrefix(privateAlias + "/") { + path.removeFirst("/private".count) + break + } + } + return path + } +} diff --git a/Sources/Shared/Persistence/AccountPreferences.swift b/Sources/Shared/Persistence/AccountPreferences.swift new file mode 100644 index 0000000..5485161 --- /dev/null +++ b/Sources/Shared/Persistence/AccountPreferences.swift @@ -0,0 +1,48 @@ +import Foundation + +final class AccountPreferences { + private let defaults: UserDefaults + private let accountKey = "luma.active-account.v1" + private let encryptionSettingsKey = "luma.encryption-settings.v1" + private let chatStateSettingsKey = "luma.chat-state-settings.v1" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> AccountConfiguration? { + guard let data = defaults.data(forKey: accountKey) else { return nil } + return try? JSONDecoder().decode(AccountConfiguration.self, from: data) + } + + func save(_ account: AccountConfiguration) throws { + let data = try JSONEncoder().encode(account) + defaults.set(data, forKey: accountKey) + } + + func clear() { + defaults.removeObject(forKey: accountKey) + } + + func encryptionEnabled(for accountJID: String) -> Bool { + let settings = defaults.dictionary(forKey: encryptionSettingsKey) as? [String: Bool] + return settings?[accountJID.lowercased()] ?? true + } + + func setEncryptionEnabled(_ enabled: Bool, for accountJID: String) { + var settings = (defaults.dictionary(forKey: encryptionSettingsKey) as? [String: Bool]) ?? [:] + settings[accountJID.lowercased()] = enabled + defaults.set(settings, forKey: encryptionSettingsKey) + } + + func chatStatesEnabled(for accountJID: String) -> Bool { + let settings = defaults.dictionary(forKey: chatStateSettingsKey) as? [String: Bool] + return settings?[accountJID.lowercased()] ?? true + } + + func setChatStatesEnabled(_ enabled: Bool, for accountJID: String) { + var settings = (defaults.dictionary(forKey: chatStateSettingsKey) as? [String: Bool]) ?? [:] + settings[accountJID.lowercased()] = enabled + defaults.set(settings, forKey: chatStateSettingsKey) + } +} diff --git a/Sources/Shared/Persistence/AvatarCache.swift b/Sources/Shared/Persistence/AvatarCache.swift new file mode 100644 index 0000000..100aa76 --- /dev/null +++ b/Sources/Shared/Persistence/AvatarCache.swift @@ -0,0 +1,38 @@ +import Foundation + +actor AvatarCache { + private let directory: URL + + init() { + let fileManager = FileManager.default + let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + directory = base.appendingPathComponent("Luma/Avatars", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + + func data(for jid: String) -> Data? { + try? Data(contentsOf: fileURL(for: jid)) + } + + func store(_ data: Data, for jid: String) throws { + try data.write(to: fileURL(for: jid), options: [.atomic]) +#if os(iOS) + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL(for: jid).path + ) +#endif + } + + private func fileURL(for jid: String) -> URL { + directory.appendingPathComponent("\(Self.stableHash(jid)).avatar") + } + + private static func stableHash(_ value: String) -> String { + let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in + (partial ^ UInt64(byte)) &* 1_099_511_628_211 + } + return String(hash, radix: 16, uppercase: false) + } +} diff --git a/Sources/Shared/Persistence/ChatArchive.swift b/Sources/Shared/Persistence/ChatArchive.swift new file mode 100644 index 0000000..4ffc700 --- /dev/null +++ b/Sources/Shared/Persistence/ChatArchive.swift @@ -0,0 +1,118 @@ +import Foundation + +actor ChatArchive { + struct Snapshot: Codable, Sendable { + static let currentSchemaVersion = 5 + + var schemaVersion: Int + var conversations: [Conversation] + var messages: [ChatMessage] + var locallyDeletedMessageIDs: Set + var rosterContactJIDs: Set + var lastSuccessfulMAMSync: Date? + var lastSuccessfulMAMCursor: String? + + init( + conversations: [Conversation], + messages: [ChatMessage], + locallyDeletedMessageIDs: Set = [], + rosterContactJIDs: Set = [], + lastSuccessfulMAMSync: Date? = nil, + lastSuccessfulMAMCursor: String? = nil + ) { + self.schemaVersion = Self.currentSchemaVersion + self.conversations = conversations + self.messages = messages + self.locallyDeletedMessageIDs = locallyDeletedMessageIDs + self.rosterContactJIDs = rosterContactJIDs + self.lastSuccessfulMAMSync = lastSuccessfulMAMSync + self.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor( + lastSuccessfulMAMCursor + ) + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case conversations + case messages + case locallyDeletedMessageIDs + case rosterContactJIDs + case lastSuccessfulMAMSync + case lastSuccessfulMAMCursor + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 + conversations = try values.decode([Conversation].self, forKey: .conversations) + messages = try values.decode([ChatMessage].self, forKey: .messages) + locallyDeletedMessageIDs = try values.decodeIfPresent( + Set.self, + forKey: .locallyDeletedMessageIDs + ) ?? [] + rosterContactJIDs = try values.decodeIfPresent( + Set.self, + forKey: .rosterContactJIDs + ) ?? [] + lastSuccessfulMAMSync = try values.decodeIfPresent( + Date.self, + forKey: .lastSuccessfulMAMSync + ) + lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor( + try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor) + ) + } + + static let empty = Snapshot(conversations: [], messages: []) + } + + private let fileURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(accountJID: String) { + let fileManager = FileManager.default + let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json") + + encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .millisecondsSince1970 + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + } + + func load() -> Snapshot { + guard let data = try? Data(contentsOf: fileURL), + let snapshot = try? decoder.decode(Snapshot.self, from: data) else { + return .empty + } + return snapshot + } + + func save(_ snapshot: Snapshot) throws { + let data = try encoder.encode(snapshot) + try data.write(to: fileURL, options: [.atomic]) +#if os(iOS) + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) +#endif + } + + func erase() throws { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + try FileManager.default.removeItem(at: fileURL) + } + + private static func stableHash(_ value: String) -> String { + let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in + (partial ^ UInt64(byte)) &* 1_099_511_628_211 + } + return String(hash, radix: 16, uppercase: false) + } +} diff --git a/Sources/Shared/Security/CertificateTrustEvaluator.swift b/Sources/Shared/Security/CertificateTrustEvaluator.swift new file mode 100644 index 0000000..0d8161c --- /dev/null +++ b/Sources/Shared/Security/CertificateTrustEvaluator.swift @@ -0,0 +1,24 @@ +import Foundation +import Security + +enum CertificateTrustEvaluator { + /// Evaluates the peer as a TLS server for the XMPP service domain. + /// + /// Martin 3.2.4's legacy SocketConnector creates an SSL client-certificate + /// policy in its default path. Luma supplies the correct server policy + /// without weakening hostname or certificate-chain validation. + static func evaluateServerTrust(_ trust: SecTrust, expectedDomain: String) -> Bool { + let policy = SecPolicyCreateSSL(true, expectedDomain as CFString) + guard SecTrustSetPolicies(trust, policy) == errSecSuccess else { + return false + } + + var evaluationError: CFError? + let trusted = SecTrustEvaluateWithError(trust, &evaluationError) + if !trusted, let evaluationError { + let description = CFErrorCopyDescription(evaluationError) as String + print("Luma TLS trust failed for \(expectedDomain): \(description)") + } + return trusted + } +} diff --git a/Sources/Shared/Security/CredentialVault.swift b/Sources/Shared/Security/CredentialVault.swift new file mode 100644 index 0000000..051140d --- /dev/null +++ b/Sources/Shared/Security/CredentialVault.swift @@ -0,0 +1,85 @@ +import Foundation +import Security + +final class CredentialVault { + private let service = "app.luma.chat.xmpp" + + func save(password: String, for jid: String) throws { + let account = jid.lowercased() + let passwordData = Data(password.utf8) + let baseQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account + ] + + let updateStatus = SecItemUpdate( + baseQuery as CFDictionary, + [kSecValueData: passwordData] as CFDictionary + ) + + if updateStatus == errSecSuccess { + return + } + guard updateStatus == errSecItemNotFound else { + throw CredentialVaultError.unhandled(updateStatus) + } + + var insertion = baseQuery + insertion[kSecValueData] = passwordData +#if !os(macOS) + insertion[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +#endif + let addStatus = SecItemAdd(insertion as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw CredentialVaultError.unhandled(addStatus) + } + } + + func password(for jid: String) throws -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: jid.lowercased(), + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8) else { + throw CredentialVaultError.unhandled(status) + } + return value + } + + func deletePassword(for jid: String) throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: jid.lowercased() + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CredentialVaultError.unhandled(status) + } + } +} + +enum CredentialVaultError: LocalizedError { + case unhandled(OSStatus) + + var errorDescription: String? { + switch self { + case .unhandled(let status): + let message = SecCopyErrorMessageString(status, nil) as String? + return message ?? "Не удалось обратиться к Keychain (\(status))." + } + } +} + diff --git a/Sources/Shared/Services/AudioMessageRecorder.swift b/Sources/Shared/Services/AudioMessageRecorder.swift new file mode 100644 index 0000000..1635fe5 --- /dev/null +++ b/Sources/Shared/Services/AudioMessageRecorder.swift @@ -0,0 +1,147 @@ +import AVFoundation +import Combine +import Foundation + +@MainActor +final class AudioMessageRecorder: NSObject, ObservableObject { + struct Recording { + let url: URL + let duration: TimeInterval + } + + @Published private(set) var isRecording = false + @Published private(set) var elapsed: TimeInterval = 0 + + private var recorder: AVAudioRecorder? + private var timer: Timer? + private var startedAt: Date? + + func start() async throws { + guard !isRecording else { return } + guard await requestPermission() else { throw AudioRecorderError.permissionDenied } + guard !Task.isCancelled else { throw CancellationError() } + var didStart = false + defer { +#if os(iOS) + if !didStart { + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } +#endif + } + +#if os(iOS) + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP]) + try session.setActive(true) +#endif + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaRecordings", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("voice-\(UUID().uuidString).m4a") + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44_100.0, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + let recorder = try AVAudioRecorder(url: url, settings: settings) + recorder.prepareToRecord() + guard recorder.record() else { throw AudioRecorderError.cannotStart } + + self.recorder = recorder + startedAt = Date() + elapsed = 0 + isRecording = true + didStart = true + timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, let startedAt = self.startedAt else { return } + self.elapsed = Date().timeIntervalSince(startedAt) + } + } + } + + func finish() throws -> Recording { + guard let recorder, isRecording else { throw AudioRecorderError.notRecording } + let duration = max(recorder.currentTime, elapsed) + let url = recorder.url + recorder.stop() + stopState() + guard duration >= 0.4 else { + try? FileManager.default.removeItem(at: url) + throw AudioRecorderError.tooShort + } + return Recording(url: url, duration: duration) + } + + func cancel() { + let url = recorder?.url + recorder?.stop() + stopState() + if let url { + try? FileManager.default.removeItem(at: url) + } + } + + private func stopState() { + timer?.invalidate() + timer = nil + recorder = nil + startedAt = nil + elapsed = 0 + isRecording = false +#if os(iOS) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) +#endif + } + + private func requestPermission() async -> Bool { +#if os(iOS) + let session = AVAudioApplication.shared + switch session.recordPermission { + case .granted: + return true + case .denied: + return false + case .undetermined: + return await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { continuation.resume(returning: $0) } + } + @unknown default: + return false + } +#else + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return true + case .denied, .restricted: + return false + case .notDetermined: + return await AVCaptureDevice.requestAccess(for: .audio) + @unknown default: + return false + } +#endif + } +} + +private enum AudioRecorderError: LocalizedError { + case permissionDenied + case cannotStart + case notRecording + case tooShort + + var errorDescription: String? { + switch self { + case .permissionDenied: + return "Разрешите Luma доступ к микрофону в системных настройках." + case .cannotStart: + return "Не удалось начать запись голоса." + case .notRecording: + return "Запись голоса не запущена." + case .tooShort: + return "Голосовое сообщение получилось слишком коротким." + } + } +} diff --git a/Sources/Shared/Services/AvatarImageProcessor.swift b/Sources/Shared/Services/AvatarImageProcessor.swift new file mode 100644 index 0000000..116c095 --- /dev/null +++ b/Sources/Shared/Services/AvatarImageProcessor.swift @@ -0,0 +1,49 @@ +import Foundation +import ImageIO +import UniformTypeIdentifiers + +enum AvatarImageProcessor { + static func pngData(from sourceData: Data) throws -> Data { + guard let source = CGImageSourceCreateWithData(sourceData as CFData, nil), + let image = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 512 + ] as CFDictionary + ) else { + throw AvatarImageError.invalidImage + } + + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output, + UTType.png.identifier as CFString, + 1, + nil + ) else { + throw AvatarImageError.encodingFailed + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw AvatarImageError.encodingFailed + } + return output as Data + } +} + +private enum AvatarImageError: LocalizedError { + case invalidImage + case encodingFailed + + var errorDescription: String? { + switch self { + case .invalidImage: + return "Не удалось прочитать выбранное изображение." + case .encodingFailed: + return "Не удалось подготовить аватар в формате PNG." + } + } +} diff --git a/Sources/Shared/Services/ChatMediaImageCache.swift b/Sources/Shared/Services/ChatMediaImageCache.swift new file mode 100644 index 0000000..e1b9b0e --- /dev/null +++ b/Sources/Shared/Services/ChatMediaImageCache.swift @@ -0,0 +1,35 @@ +import Foundation + +#if os(iOS) +import UIKit +typealias ChatMediaPlatformImage = UIImage +#elseif os(macOS) +import AppKit +typealias ChatMediaPlatformImage = NSImage +#endif + +/// SwiftUI can evaluate every visible bubble repeatedly during scrolling. +/// Keep decoded thumbnails in a bounded native-image cache so those passes do +/// not repeatedly decompress JPEG/HEIC data on the main actor. +@MainActor +enum ChatMediaImageCache { + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 160 + cache.totalCostLimit = 48 * 1_024 * 1_024 + return cache + }() + + static func image( + for message: ChatMessage, + data: Data + ) -> ChatMediaPlatformImage? { + let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString + if let cached = cache.object(forKey: cacheKey) { + return cached + } + guard let image = ChatMediaPlatformImage(data: data) else { return nil } + cache.setObject(image, forKey: cacheKey, cost: data.count) + return image + } +} diff --git a/Sources/Shared/Services/LocationProvider.swift b/Sources/Shared/Services/LocationProvider.swift new file mode 100644 index 0000000..59437f3 --- /dev/null +++ b/Sources/Shared/Services/LocationProvider.swift @@ -0,0 +1,94 @@ +import CoreLocation +import Combine +import Foundation + +@MainActor +final class LocationProvider: NSObject, ObservableObject { + @Published private(set) var location: CLLocation? + @Published private(set) var authorizationStatus: CLAuthorizationStatus + @Published private(set) var errorMessage: String? + @Published private(set) var isLocating = false + + private let manager: CLLocationManager + + override init() { + let manager = CLLocationManager() + self.manager = manager + authorizationStatus = manager.authorizationStatus + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + } + + func requestCurrentLocation() { + errorMessage = nil + switch manager.authorizationStatus { + case .notDetermined: + isLocating = true + manager.requestWhenInUseAuthorization() + case .authorizedAlways, .authorizedWhenInUse: + isLocating = true + manager.requestLocation() + case .denied, .restricted: + isLocating = false + errorMessage = "Доступ к геопозиции выключен. Можно выбрать точку на карте вручную." + @unknown default: + isLocating = false + errorMessage = "Не удалось определить статус доступа к геопозиции." + } + } +} + +extension LocationProvider: CLLocationManagerDelegate { + nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + Task { @MainActor [weak self] in + guard let self else { return } + authorizationStatus = status + switch status { + case .authorizedAlways, .authorizedWhenInUse: + isLocating = true + self.manager.requestLocation() + case .denied, .restricted: + isLocating = false + errorMessage = "Доступ к геопозиции выключен. Можно выбрать точку на карте вручную." + case .notDetermined: + break + @unknown default: + isLocating = false + } + } + } + + nonisolated func locationManager( + _ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation] + ) { + guard let newest = locations.last else { return } + let latitude = newest.coordinate.latitude + let longitude = newest.coordinate.longitude + let altitude = newest.altitude + let horizontalAccuracy = newest.horizontalAccuracy + let verticalAccuracy = newest.verticalAccuracy + let timestamp = newest.timestamp + Task { @MainActor [weak self] in + self?.location = CLLocation( + coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + altitude: altitude, + horizontalAccuracy: horizontalAccuracy, + verticalAccuracy: verticalAccuracy, + timestamp: timestamp + ) + self?.isLocating = false + self?.errorMessage = nil + } + } + + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + let description = error.localizedDescription + Task { @MainActor [weak self] in + self?.isLocating = false + self?.errorMessage = "Не удалось получить текущую геопозицию: \(description)" + } + } +} diff --git a/Sources/Shared/Services/MediaFileIO.swift b/Sources/Shared/Services/MediaFileIO.swift new file mode 100644 index 0000000..6f6a596 --- /dev/null +++ b/Sources/Shared/Services/MediaFileIO.swift @@ -0,0 +1,162 @@ +import Foundation +import UniformTypeIdentifiers + +struct LoadedMediaFile: Sendable { + let data: Data + let filename: String + let mimeType: String + let inferredKind: ChatMessage.Kind +} + +struct StagedMediaFile: Sendable { + let url: URL + let filename: String + let contentTypeIdentifier: String? + let byteCount: Int +} + +enum MediaFileIOError: LocalizedError, Sendable { + case coordinatedReadFailed(String) + case emptyFile(String) + case unreadableFile(String) + + var errorDescription: String? { + switch self { + case .coordinatedReadFailed(let message): + return "Не удалось получить выбранный файл: \(message)" + case .emptyFile(let filename): + return "Файл «\(filename)» пуст или ещё не загружен из iCloud." + case .unreadableFile(let filename): + return "Не удалось подготовить «\(filename)» для отправки." + } + } +} + +/// Serializes potentially large media file reads and writes away from the +/// main actor so finishing a video note cannot freeze the composer. +actor MediaFileIO { + /// Monal immediately copies an item-provider URL into its own cache under + /// NSFileCoordinator before preview or upload work starts. Do the same for + /// every Luma draft so Photos/iCloud temporary URLs can disappear safely. + func stageAttachmentDraft(from source: URL) throws -> StagedMediaFile { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory + .appendingPathComponent("LumaAttachmentDrafts", isDirectory: true) + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + + let accessed = source.startAccessingSecurityScopedResource() + defer { + if accessed { source.stopAccessingSecurityScopedResource() } + } + + var coordinatedError: NSError? + var stagedResult: Result? + let coordinator = NSFileCoordinator(filePresenter: nil) + coordinator.coordinate( + readingItemAt: source, + options: [], + error: &coordinatedError + ) { coordinatedURL in + stagedResult = Result { + let originalValues = try? source.resourceValues( + forKeys: [.nameKey, .contentTypeKey] + ) + let coordinatedValues = try coordinatedURL.resourceValues( + forKeys: [.fileSizeKey, .contentTypeKey] + ) + let sourceName = originalValues?.name ?? source.lastPathComponent + let filename = sourceName.isEmpty + ? coordinatedURL.lastPathComponent + : sourceName + let safeComponent = Self.safePathComponent(filename) + let destination = directory.appendingPathComponent( + "\(UUID().uuidString)-\(safeComponent)" + ) + + do { + try fileManager.copyItem(at: coordinatedURL, to: destination) + let stagedValues = try destination.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ) + let byteCount = stagedValues.fileSize ?? coordinatedValues.fileSize ?? 0 + guard stagedValues.isRegularFile == true, + fileManager.isReadableFile(atPath: destination.path) else { + throw MediaFileIOError.unreadableFile(filename) + } + guard byteCount > 0 else { + throw MediaFileIOError.emptyFile(filename) + } + let contentType = originalValues?.contentType + ?? coordinatedValues.contentType + ?? UTType(filenameExtension: source.pathExtension) + return StagedMediaFile( + url: destination, + filename: filename, + contentTypeIdentifier: contentType?.identifier, + byteCount: byteCount + ) + } catch { + try? fileManager.removeItem(at: destination) + throw error + } + } + } + + if let stagedResult { + return try stagedResult.get() + } + throw MediaFileIOError.coordinatedReadFailed( + coordinatedError?.localizedDescription ?? "неизвестная ошибка" + ) + } + + func load( + from url: URL, + preferredKind: ChatMessage.Kind? + ) throws -> LoadedMediaFile { + let values = try url.resourceValues(forKeys: [.nameKey, .contentTypeKey]) + let filename = values.name ?? url.lastPathComponent + let contentType = values.contentType ?? UTType(filenameExtension: url.pathExtension) + let kind = preferredKind ?? Self.mediaKind( + for: contentType, + filename: filename + ) + return LoadedMediaFile( + data: try Data(contentsOf: url, options: [.mappedIfSafe]), + filename: filename, + mimeType: contentType?.preferredMIMEType ?? "application/octet-stream", + inferredKind: kind + ) + } + + func write(_ data: Data, to url: URL) throws { + try data.write(to: url, options: [.atomic]) + } + + private static func safePathComponent(_ value: String) -> String { + let forbidden = CharacterSet(charactersIn: "/\\:") + .union(.controlCharacters) + let cleaned = value.components(separatedBy: forbidden).joined(separator: "_") + return cleaned.isEmpty ? "attachment" : cleaned + } + + private static func mediaKind( + for contentType: UTType?, + filename: String + ) -> ChatMessage.Kind { + if contentType?.conforms(to: .image) == true { return .photo } + if contentType?.conforms(to: .movie) == true { return .video } + if contentType?.conforms(to: .audio) == true { return .audio } + + let fallback = UTType( + filenameExtension: URL(fileURLWithPath: filename).pathExtension + ) + if fallback?.conforms(to: .image) == true { return .photo } + if fallback?.conforms(to: .movie) == true { return .video } + if fallback?.conforms(to: .audio) == true { return .audio } + return .attachment + } +} diff --git a/Sources/Shared/Services/MediaPlaybackCoordinator.swift b/Sources/Shared/Services/MediaPlaybackCoordinator.swift new file mode 100644 index 0000000..e7008ee --- /dev/null +++ b/Sources/Shared/Services/MediaPlaybackCoordinator.swift @@ -0,0 +1,207 @@ +import AVFoundation +import Combine +import Foundation + +extension Notification.Name { + static let lumaExclusiveMediaPlayback = Notification.Name("app.luma.exclusiveMediaPlayback") +} + +@MainActor +final class MediaPlaybackCoordinator: ObservableObject { + @Published private(set) var currentMessageID: String? + @Published private(set) var isPlaying = false + @Published private(set) var currentTime: TimeInterval = 0 + @Published private(set) var duration: TimeInterval = 0 + @Published private(set) var voiceRate: Double = 1 + + private var player: AVPlayer? + private var timeObserver: Any? + private var endObserver: NSObjectProtocol? + private var currentIsVoice = false + private var durationTask: Task? + + deinit { + durationTask?.cancel() + if let timeObserver, let player { + player.removeTimeObserver(timeObserver) + } + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + } + + func toggle( + messageID: String, + url: URL, + durationHint: TimeInterval?, + isVoice: Bool + ) { + if currentMessageID != messageID { + prepare( + messageID: messageID, + url: url, + durationHint: durationHint, + isVoice: isVoice + ) + } + guard player != nil else { return } + isPlaying ? pause() : play() + } + + func seek( + messageID: String, + url: URL, + durationHint: TimeInterval?, + isVoice: Bool, + fraction: Double + ) { + if currentMessageID != messageID { + prepare( + messageID: messageID, + url: url, + durationHint: durationHint, + isVoice: isVoice + ) + } + guard let player else { return } + let targetDuration = max(duration, durationHint ?? 0) + guard targetDuration > 0 else { return } + let seconds = max(0, min(1, fraction)) * targetDuration + currentTime = seconds + player.seek( + to: CMTime(seconds: seconds, preferredTimescale: 600), + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + + func cycleVoiceRate() { + if voiceRate < 1.25 { + voiceRate = 1.5 + } else if voiceRate < 1.75 { + voiceRate = 2 + } else { + voiceRate = 1 + } + guard currentIsVoice, isPlaying else { return } + player?.playImmediately(atRate: Float(voiceRate)) + } + + func stop() { + durationTask?.cancel() + durationTask = nil + player?.pause() + removeObservers() + player = nil + currentMessageID = nil + isPlaying = false + currentTime = 0 + duration = 0 + currentIsVoice = false + } + + private func prepare( + messageID: String, + url: URL, + durationHint: TimeInterval?, + isVoice: Bool + ) { + stop() + let player = AVPlayer(url: url) + player.actionAtItemEnd = .pause + self.player = player + currentMessageID = messageID + currentIsVoice = isVoice + currentTime = 0 + duration = Self.validDuration(durationHint) ?? 0 + installObservers(for: player) + + if duration == 0 { + durationTask = Task { [weak self] in + let asset = AVURLAsset(url: url) + guard let time = try? await asset.load(.duration), + !Task.isCancelled else { return } + let seconds = time.seconds + guard seconds.isFinite, seconds > 0, + self?.currentMessageID == messageID else { return } + self?.duration = seconds + } + } + } + + private func play() { + guard let player, let currentMessageID else { return } + configureAudioSession() + NotificationCenter.default.post( + name: .lumaExclusiveMediaPlayback, + object: currentMessageID + ) + if currentIsVoice { + player.playImmediately(atRate: Float(voiceRate)) + } else { + player.play() + } + isPlaying = true + } + + private func pause() { + player?.pause() + isPlaying = false + } + + private func installObservers(for player: AVPlayer) { + timeObserver = player.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.1, preferredTimescale: 600), + queue: .main + ) { [weak self, weak player] time in + Task { @MainActor in + guard let self, let player else { return } + let seconds = time.seconds + if seconds.isFinite { + self.currentTime = max(0, seconds) + } + if let itemDuration = player.currentItem?.duration.seconds, + itemDuration.isFinite, + itemDuration > 0 { + self.duration = itemDuration + } + } + } + endObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem, + queue: .main + ) { [weak self, weak player] _ in + Task { @MainActor in + player?.seek(to: .zero) + self?.currentTime = 0 + self?.isPlaying = false + } + } + } + + private func removeObservers() { + if let timeObserver, let player { + player.removeTimeObserver(timeObserver) + } + timeObserver = nil + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + endObserver = nil + } + + private func configureAudioSession() { +#if os(iOS) + let session = AVAudioSession.sharedInstance() + let mode: AVAudioSession.Mode = currentIsVoice ? .spokenAudio : .default + try? session.setCategory(.playback, mode: mode, options: [.allowAirPlay, .allowBluetoothA2DP]) + try? session.setActive(true) +#endif + } + + private static func validDuration(_ value: TimeInterval?) -> TimeInterval? { + guard let value, value.isFinite, value > 0 else { return nil } + return value + } +} diff --git a/Sources/Shared/Services/MediaPreviewProcessor.swift b/Sources/Shared/Services/MediaPreviewProcessor.swift new file mode 100644 index 0000000..9a004ec --- /dev/null +++ b/Sources/Shared/Services/MediaPreviewProcessor.swift @@ -0,0 +1,195 @@ +import AVFoundation +import Foundation +import ImageIO +import UniformTypeIdentifiers + +actor MediaPreviewProcessor { + struct Analysis: Sendable { + let duration: TimeInterval? + let thumbnailData: Data? + let waveform: [Float]? + } + + static let placeholderWaveform: [Float] = [ + 0.24, 0.42, 0.30, 0.58, 0.76, 0.48, 0.34, 0.67, + 0.88, 0.61, 0.40, 0.73, 0.52, 0.31, 0.64, 0.82, + 0.45, 0.69, 0.91, 0.56, 0.38, 0.71, 0.49, 0.79, + 0.57, 0.35, 0.62, 0.86, 0.53, 0.72, 0.43, 0.65, + 0.84, 0.51, 0.32, 0.59, 0.75, 0.46, 0.68, 0.39, + 0.80, 0.55, 0.36, 0.70, 0.47, 0.63, 0.78, 0.44 + ] + + func analyze( + url: URL, + kind: ChatMessage.Kind, + mimeType: String? = nil + ) async -> Analysis { + let asset = Self.mediaAsset(url: url, mimeType: mimeType) + let duration: TimeInterval? + if [.video, .audio, .voice, .videoNote].contains(kind) { + duration = await mediaDuration(for: asset) + } else { + duration = nil + } + let thumbnailData: Data? + if kind == .photo { + thumbnailData = imageThumbnailData(for: url) + } else if kind == .video || kind == .videoNote { + thumbnailData = await videoThumbnailData( + for: asset, + duration: duration + ) + } else { + thumbnailData = nil + } + + let waveform = kind == .voice ? voiceWaveform(for: url) : nil + return Analysis( + duration: duration, + thumbnailData: thumbnailData, + waveform: waveform + ) + } + + private func mediaDuration(for asset: AVURLAsset) async -> TimeInterval? { + guard let time = try? await asset.load(.duration) else { return nil } + let seconds = time.seconds + return seconds.isFinite && seconds > 0 ? seconds : nil + } + + private func videoThumbnailData( + for asset: AVURLAsset, + duration: TimeInterval? + ) async -> Data? { + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: 960, height: 960) + generator.requestedTimeToleranceBefore = CMTime( + seconds: 0.35, + preferredTimescale: 600 + ) + generator.requestedTimeToleranceAfter = CMTime( + seconds: 0.35, + preferredTimescale: 600 + ) + + let safeDuration = duration.flatMap { value in + value.isFinite && value > 0 ? value : nil + } + let preferredSecond = safeDuration.map { + min(1, max(0.01, $0 * 0.25)) + } ?? 0.5 + let earlySecond = safeDuration.map { + min(0.05, max(0, $0 * 0.1)) + } ?? 0.05 + var attemptedMilliseconds: Set = [] + for second in [preferredSecond, earlySecond, 0] { + let key = Int((second * 1_000).rounded()) + guard attemptedMilliseconds.insert(key).inserted else { continue } + do { + let result = try await generator.image( + at: CMTime(seconds: second, preferredTimescale: 600) + ) + if let data = jpegData(from: result.image) { + return data + } + } catch { + continue + } + } + return nil + } + + private func imageThumbnailData(for url: URL) -> Data? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 1_200, + kCGImageSourceShouldCacheImmediately: true + ] + guard let image = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + options as CFDictionary + ) else { return nil } + return jpegData(from: image) + } + + private func jpegData(from image: CGImage) -> Data? { + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output as CFMutableData, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { return nil } + let options = [kCGImageDestinationLossyCompressionQuality: 0.82] as CFDictionary + CGImageDestinationAddImage(destination, image, options) + guard CGImageDestinationFinalize(destination) else { return nil } + return output as Data + } + + private static func mediaAsset(url: URL, mimeType: String?) -> AVURLAsset { + guard let mimeType, !mimeType.isEmpty else { + return AVURLAsset(url: url) + } + // Supplying the MIME type is important for picker files whose temporary + // extension does not describe the real QuickTime/HEVC container. + return AVURLAsset( + url: url, + options: ["AVURLAssetOutOfBandMIMETypeKey": mimeType] + ) + } + + private func voiceWaveform(for url: URL, sampleCount: Int = 48) -> [Float]? { + do { + let file = try AVAudioFile( + forReading: url, + commonFormat: .pcmFormatFloat32, + interleaved: false + ) + let totalFrames = max(1, file.length) + let capacity: AVAudioFrameCount = 4_096 + guard let buffer = AVAudioPCMBuffer( + pcmFormat: file.processingFormat, + frameCapacity: capacity + ) else { return nil } + + var peaks = Array(repeating: Float.zero, count: sampleCount) + var processedFrames: AVAudioFramePosition = 0 + + while processedFrames < totalFrames { + let remaining = totalFrames - processedFrames + let requested = AVAudioFrameCount(min(AVAudioFramePosition(capacity), remaining)) + try file.read(into: buffer, frameCount: requested) + let frameLength = Int(buffer.frameLength) + guard frameLength > 0, let channels = buffer.floatChannelData else { break } + let channelCount = Int(buffer.format.channelCount) + + for frame in 0.. 0 else { + return Self.placeholderWaveform + } + return peaks.map { peak in + max(0.08, min(1, sqrt(peak / maximum))) + } + } catch { + return Self.placeholderWaveform + } + } +} diff --git a/Sources/Shared/Services/NotificationCoordinator.swift b/Sources/Shared/Services/NotificationCoordinator.swift new file mode 100644 index 0000000..ca5e6c9 --- /dev/null +++ b/Sources/Shared/Services/NotificationCoordinator.swift @@ -0,0 +1,37 @@ +import Foundation +import UserNotifications + +final class NotificationCoordinator: NSObject, UNUserNotificationCenterDelegate { + override init() { + super.init() + // UNUserNotificationCenter keeps a weak delegate. AppModel retains this + // coordinator for the lifetime of the application. + UNUserNotificationCenter.current().delegate = self + } + + func requestAuthorization() async { + _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) + } + + func showIncomingMessage(id: String, sender: String, body: String, conversationID: String) { + let content = UNMutableNotificationContent() + content.title = sender + content.body = body + content.sound = .default + content.threadIdentifier = conversationID + content.userInfo = ["conversation": conversationID] + + let request = UNNotificationRequest(identifier: id, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + // System notifications are normally hidden while an app is in the + // foreground. Explicitly request the same banner/list/sound treatment + // used when Luma is backgrounded. + [.banner, .list, .sound] + } +} diff --git a/Sources/Shared/Services/PhoneWatchBridge.swift b/Sources/Shared/Services/PhoneWatchBridge.swift new file mode 100644 index 0000000..523e1b8 --- /dev/null +++ b/Sources/Shared/Services/PhoneWatchBridge.swift @@ -0,0 +1,405 @@ +import Foundation + +struct PhoneWatchSnapshot: Codable { + struct Chat: Codable { + let jid: String + let name: String + let unread: Int + let messages: [Message] + } + + struct Message: Codable, Identifiable { + let id: String + let body: String + let timestamp: Date + let outgoing: Bool + let encrypted: Bool + } + + let chats: [Chat] +} + +struct WatchVoiceMessage: Sendable { + let transferID: String + let jid: String + let filename: String + let duration: TimeInterval? + let data: Data + + var stableMessageID: String { + "watch-\(transferID)" + } +} + +#if os(iOS) && canImport(WatchConnectivity) +import WatchConnectivity + +final class PhoneWatchBridge: NSObject, WCSessionDelegate { + var onReply: ((String, String) -> Void)? + var onVoiceMessage: ((WatchVoiceMessage) -> Void)? { + didSet { deliverPendingVoiceMessages() } + } + private let session: WCSession? + private let snapshotQueue = DispatchQueue( + label: "app.luma.watch-snapshot", + qos: .utility + ) + private var latestSnapshot: Data? + private var pendingVoiceMessages: [WatchVoiceMessage] = [] + private var deliveredVoiceTransferIDs: Set = [] + + override init() { + session = WCSession.isSupported() ? .default : nil + super.init() + pendingVoiceMessages = Self.loadPersistedVoiceMessages() + session?.delegate = self + session?.activate() + } + + func update(conversations: [Conversation], messages: [ChatMessage]) { + guard let session else { return } + // Copy-on-write snapshots are cheap here; the filtering, sorting and + // JSON encoding are not. Keep that work away from SwiftUI's main run + // loop, especially immediately after a MAM page is committed. + snapshotQueue.async { [weak self, weak session] in + guard let self, + let session, + let data = Self.makeSnapshotData( + conversations: conversations, + messages: messages + ), + data != self.latestSnapshot else { return } + self.latestSnapshot = data + self.publishLatestSnapshot(using: session) + } + } + + private static func makeSnapshotData( + conversations: [Conversation], + messages: [ChatMessage] + ) -> Data? { + let visibleConversations = Array(conversations.prefix(20)) + let visibleConversationIDs = Set(visibleConversations.map(\.id)) + var recentMessagesByConversation: [String: [ChatMessage]] = [:] + + // Keep a bounded, chronologically sorted window. MAM can append an old + // overlapping stanza after a newer live message, so array order alone + // cannot identify the latest messages reliably. + for message in messages where visibleConversationIDs.contains(message.conversationID) { + var recent = recentMessagesByConversation[message.conversationID] ?? [] + let insertionIndex = Self.insertionIndex(for: message, in: recent) + recent.insert(message, at: insertionIndex) + if recent.count > 20 { + recent.removeFirst(recent.count - 20) + } + recentMessagesByConversation[message.conversationID] = recent + } + + let chats = visibleConversations.map { conversation in + PhoneWatchSnapshot.Chat( + jid: conversation.jid, + name: conversation.displayName, + unread: conversation.unreadCount, + messages: (recentMessagesByConversation[conversation.id] ?? []) + .map { + PhoneWatchSnapshot.Message( + id: $0.id, + body: $0.previewText, + timestamp: $0.timestamp, + outgoing: $0.direction == .outgoing, + encrypted: $0.security == .omemo + ) + } + ) + } + return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats)) + } + + private static func insertionIndex( + for message: ChatMessage, + in sortedMessages: [ChatMessage] + ) -> Int { + var lowerBound = 0 + var upperBound = sortedMessages.count + while lowerBound < upperBound { + let middle = lowerBound + (upperBound - lowerBound) / 2 + let existing = sortedMessages[middle] + let existingComesFirst = existing.timestamp < message.timestamp + || (existing.timestamp == message.timestamp && existing.id < message.id) + if existingComesFirst { + lowerBound = middle + 1 + } else { + upperBound = middle + } + } + return lowerBound + } + + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + guard activationState == .activated else { return } + snapshotQueue.async { [weak self, weak session] in + guard let self, let session else { return } + self.publishLatestSnapshot(using: session) + } + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + consume(message) + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + consume(userInfo) + } + + func session(_ session: WCSession, didReceive file: WCSessionFile) { + guard let metadata = file.metadata, + metadata["action"] as? String == "voice", + let transferID = metadata["transferID"] as? String, + UUID(uuidString: transferID) != nil, + let jid = metadata["jid"] as? String, + !jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + do { + // WCSession owns this URL only for the duration of the callback. + // Copy the bytes before handing the recording to the async upload + // pipeline so the temporary file cannot disappear underneath it. + let data = try Data(contentsOf: file.fileURL) + guard !data.isEmpty else { throw WatchVoiceBridgeError.emptyRecording } + let duration = (metadata["duration"] as? NSNumber)?.doubleValue + let filename = (metadata["filename"] as? String).flatMap { $0.isEmpty ? nil : $0 } + ?? file.fileURL.lastPathComponent + let message = WatchVoiceMessage( + transferID: transferID, + jid: jid, + filename: filename, + duration: duration, + data: data + ) + try Self.persistVoiceMessage(message) + DispatchQueue.main.async { [weak self] in + self?.deliverVoiceMessage(message) + } + } catch { + reportVoiceResult( + transferID: transferID, + success: false, + error: "iPhone не смог прочитать запись: \(error.localizedDescription)", + jid: jid + ) + } + } + + func reportVoiceResult( + transferID: String, + success: Bool, + error: String? = nil, + jid explicitJID: String? = nil + ) { + guard let session else { return } + let jid = explicitJID ?? Self.persistedVoiceJID(for: transferID) + Self.removePersistedVoiceMessage(transferID: transferID) + deliveredVoiceTransferIDs.remove(transferID) + var payload: [String: Any] = [ + "action": "voiceResult", + "transferID": transferID, + "success": success + ] + if let jid, !jid.isEmpty { + payload["jid"] = jid + } + if let error, !error.isEmpty { + payload["error"] = error + } + + if session.isReachable { + session.sendMessage(payload, replyHandler: nil) { _ in + session.transferUserInfo(payload) + } + } else { + session.transferUserInfo(payload) + } + } + + private func consume(_ payload: [String: Any]) { + guard payload["action"] as? String == "reply", + let jid = payload["jid"] as? String, + let text = payload["text"] as? String, + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + onReply?(jid, text) + } + + private func publishLatestSnapshot(using session: WCSession) { + guard let latestSnapshot else { return } + try? session.updateApplicationContext(["snapshot": latestSnapshot]) + } + + private func deliverVoiceMessage(_ message: WatchVoiceMessage) { + if let onVoiceMessage { + guard deliveredVoiceTransferIDs.insert(message.transferID).inserted else { return } + onVoiceMessage(message) + } else if !pendingVoiceMessages.contains(where: { $0.transferID == message.transferID }) { + pendingVoiceMessages.append(message) + } + } + + private func deliverPendingVoiceMessages() { + guard let onVoiceMessage, !pendingVoiceMessages.isEmpty else { return } + let pending = pendingVoiceMessages + pendingVoiceMessages.removeAll() + for message in pending where deliveredVoiceTransferIDs.insert(message.transferID).inserted { + onVoiceMessage(message) + } + } + + private static func persistVoiceMessage(_ message: WatchVoiceMessage) throws { + let directory = try watchVoiceOutboxDirectory() + let audioFilename = "\(message.transferID).m4a" + let audioURL = directory.appendingPathComponent(audioFilename) + let metadataURL = directory.appendingPathComponent("\(message.transferID).json") + let stored = StoredWatchVoiceMessage( + transferID: message.transferID, + jid: message.jid, + filename: message.filename, + duration: message.duration, + audioFilename: audioFilename, + createdAt: Date() + ) + + do { + try message.data.write(to: audioURL, options: [.atomic]) + try JSONEncoder().encode(stored).write(to: metadataURL, options: [.atomic]) + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: audioURL.path + ) + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: metadataURL.path + ) + } catch { + try? FileManager.default.removeItem(at: audioURL) + try? FileManager.default.removeItem(at: metadataURL) + throw error + } + } + + private static func loadPersistedVoiceMessages() -> [WatchVoiceMessage] { + guard let directory = try? watchVoiceOutboxDirectory() else { return [] } + let metadataURLs = (try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + )) ?? [] + let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60) + + return metadataURLs + .filter { $0.pathExtension.lowercased() == "json" } + .compactMap { metadataURL in + guard let metadataData = try? Data(contentsOf: metadataURL), + let stored = try? JSONDecoder().decode( + StoredWatchVoiceMessage.self, + from: metadataData + ), + stored.createdAt >= expiration else { + removePersistedVoiceMessage( + transferID: metadataURL.deletingPathExtension().lastPathComponent + ) + return nil + } + let audioURL = directory.appendingPathComponent(stored.audioFilename) + guard let data = try? Data(contentsOf: audioURL), !data.isEmpty else { + removePersistedVoiceMessage(transferID: stored.transferID) + return nil + } + return WatchVoiceMessage( + transferID: stored.transferID, + jid: stored.jid, + filename: stored.filename, + duration: stored.duration, + data: data + ) + } + .sorted { $0.transferID < $1.transferID } + } + + private static func persistedVoiceJID(for transferID: String) -> String? { + guard let directory = try? watchVoiceOutboxDirectory(), + let data = try? Data(contentsOf: directory.appendingPathComponent("\(transferID).json")), + let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) else { + return nil + } + return stored.jid + } + + private static func removePersistedVoiceMessage(transferID: String) { + guard UUID(uuidString: transferID) != nil, + let directory = try? watchVoiceOutboxDirectory() else { return } + let metadataURL = directory.appendingPathComponent("\(transferID).json") + if let data = try? Data(contentsOf: metadataURL), + let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) { + try? FileManager.default.removeItem( + at: directory.appendingPathComponent(stored.audioFilename) + ) + } else { + try? FileManager.default.removeItem( + at: directory.appendingPathComponent("\(transferID).m4a") + ) + } + try? FileManager.default.removeItem(at: metadataURL) + } + + private static func watchVoiceOutboxDirectory() throws -> URL { + let base = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + let directory = base.appendingPathComponent("Luma/WatchVoiceOutbox", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} + +private struct StoredWatchVoiceMessage: Codable { + let transferID: String + let jid: String + let filename: String + let duration: TimeInterval? + let audioFilename: String + let createdAt: Date +} + +#else + +final class PhoneWatchBridge { + var onReply: ((String, String) -> Void)? + var onVoiceMessage: ((WatchVoiceMessage) -> Void)? + func update(conversations: [Conversation], messages: [ChatMessage]) {} + func reportVoiceResult(transferID: String, success: Bool, error: String? = nil) {} +} + +#endif + +private extension JSONEncoder { + static var watchEncoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + return encoder + } +} + +private enum WatchVoiceBridgeError: LocalizedError { + case emptyRecording + + var errorDescription: String? { + "Файл голосового сообщения пуст." + } +} diff --git a/Sources/Shared/Services/VideoNoteRecorder.swift b/Sources/Shared/Services/VideoNoteRecorder.swift new file mode 100644 index 0000000..aa96fb1 --- /dev/null +++ b/Sources/Shared/Services/VideoNoteRecorder.swift @@ -0,0 +1,930 @@ +@preconcurrency import AVFoundation +import Combine +import Foundation + +#if os(iOS) +import UIKit +#endif + +@MainActor +final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRecordingDelegate { + struct Recording { + let url: URL + let duration: TimeInterval + } + + @Published private(set) var isPrepared = false + @Published private(set) var isRecording = false + @Published private(set) var elapsed: TimeInterval = 0 + @Published private(set) var isUsingFrontCamera = true + @Published private(set) var isMicrophoneMuted = false + + let session = AVCaptureSession() + var onFinished: ((Result) -> Void)? + + var hasRequiredAuthorization: Bool { + AVCaptureDevice.authorizationStatus(for: .video) == .authorized + && AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + } + + private let movieOutput = AVCaptureMovieFileOutput() + private let sessionQueue = DispatchQueue( + label: "app.luma.video-note.capture-session", + qos: .userInitiated + ) + private let fileInspector = VideoNoteFileInspector() + private var cameraInput: AVCaptureDeviceInput? + private var microphoneInput: AVCaptureDeviceInput? + private var timer: Timer? + private var startedAt: Date? + private var discardCurrentRecording = false + private var stopRequested = false + private var requestedMinimumDuration: TimeInterval = 0 + private var sessionGeneration = UUID() + private var lifecycle: VideoNoteRecordingLifecycle = .idle + private var activeRecordingURL: URL? + private var stopRequestTask: Task? + private var startupTimeoutTask: Task? + private var finalizationTimeoutTask: Task? + private var fileValidationTask: Task? + + private struct CaptureGraph { + let cameraInput: AVCaptureDeviceInput + let microphoneInput: AVCaptureDeviceInput + let usesFrontCamera: Bool + } + + private struct MovieOutputSnapshot: Sendable { + let isRecording: Bool + let recordedDuration: TimeInterval + } + + func prepare() async throws { + try await waitForPreviousRecordingToFinish() + if lifecycle == .prepared, isPrepared { return } + guard lifecycle == .idle, !isPrepared else { + throw VideoNoteRecorderError.recordingBusy + } + + let generation = UUID() + sessionGeneration = generation + await waitForPendingSessionOperations() + guard !Task.isCancelled, sessionGeneration == generation else { + throw CancellationError() + } + guard await Self.requestAccess(for: .video) else { + throw VideoNoteRecorderError.cameraPermissionDenied + } + guard !Task.isCancelled, sessionGeneration == generation else { + throw CancellationError() + } + guard await Self.requestAccess(for: .audio) else { + throw VideoNoteRecorderError.microphonePermissionDenied + } + guard !Task.isCancelled, sessionGeneration == generation else { + throw CancellationError() + } + + var didPrepare = false + defer { + if !didPrepare { + performShutdown() + } + } + +#if os(iOS) + try AVAudioSession.sharedInstance().setCategory( + .playAndRecord, + mode: .videoRecording, + options: [.defaultToSpeaker, .allowBluetoothHFP] + ) + try AVAudioSession.sharedInstance().setActive(true) +#endif + + let graph = try await configureCaptureGraph() + guard !Task.isCancelled, sessionGeneration == generation else { + throw CancellationError() + } + cameraInput = graph.cameraInput + microphoneInput = graph.microphoneInput + isUsingFrontCamera = graph.usesFrontCamera + + configureVideoConnection() + try await startCaptureSession(generation: generation) + isPrepared = true + lifecycle = .prepared + didPrepare = true + } + + func start() throws { + guard lifecycle == .prepared, + isPrepared, + session.isRunning else { throw VideoNoteRecorderError.notPrepared } + guard !movieOutput.isRecording, + activeRecordingURL == nil else { + throw VideoNoteRecorderError.recordingBusy + } + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaRecordings", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("video-note-\(UUID().uuidString).mov") + + discardCurrentRecording = false + stopRequested = false + requestedMinimumDuration = 0 + activeRecordingURL = url + lifecycle = .starting + startedAt = nil + elapsed = 0 + isRecording = true + + let output = movieOutput + let delegate = self + sessionQueue.async { + output.startRecording(to: url, recordingDelegate: delegate) + } + scheduleStartupTimeout(for: url) + } + + func stop() { + requestStop(afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration) + } + + func stop(afterMinimumDuration minimumDuration: TimeInterval) async { + requestStop(afterMinimumDuration: minimumDuration) + } + + func cancel() { + discardCurrentRecording = true + stopRequested = true + requestedMinimumDuration = 0 + + switch lifecycle { + case .starting, .recording: + lifecycle = .stopping + stopRequestTask?.cancel() + stopRequestTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.stopMovieOutput() + guard !Task.isCancelled, + self.lifecycle == .stopping, + let url = self.activeRecordingURL else { return } + self.scheduleFinalizationTimeout(for: url) + } + case .stopping: + break + case .idle, .prepared: + performShutdown() + } + } + + func switchCamera() async throws { +#if os(iOS) + guard isPrepared, + lifecycle == .prepared, + let currentInput = cameraInput else { + throw VideoNoteRecorderError.notPrepared + } + let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front + guard let camera = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: nextPosition + ) else { + throw VideoNoteRecorderError.alternateCameraUnavailable + } + + let nextInput = try AVCaptureDeviceInput(device: camera) + let session = session + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + sessionQueue.async { + session.beginConfiguration() + session.removeInput(currentInput) + guard session.canAddInput(nextInput) else { + if session.canAddInput(currentInput) { + session.addInput(currentInput) + } + session.commitConfiguration() + continuation.resume(throwing: VideoNoteRecorderError.configurationFailed) + return + } + session.addInput(nextInput) + session.commitConfiguration() + continuation.resume() + } + } + + cameraInput = nextInput + isUsingFrontCamera = nextPosition == .front + configureVideoConnection() +#else + throw VideoNoteRecorderError.alternateCameraUnavailable +#endif + } + + func toggleMicrophoneMuted() { + guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return } + isMicrophoneMuted.toggle() + connection.isEnabled = !isMicrophoneMuted + } + + func shutdown() { + if lifecycle.isBusy || movieOutput.isRecording { + cancel() + return + } + performShutdown() + } + + nonisolated func fileOutput( + _ output: AVCaptureFileOutput, + didStartRecordingTo fileURL: URL, + from connections: [AVCaptureConnection] + ) { + Task { @MainActor [weak self] in + self?.recordingDidStart(url: fileURL) + } + } + + nonisolated func fileOutput( + _ output: AVCaptureFileOutput, + didFinishRecordingTo outputFileURL: URL, + from connections: [AVCaptureConnection], + error: Error? + ) { + Task { @MainActor [weak self] in + self?.recordingDidFinish(url: outputFileURL, error: error) + } + } + + private func requestStop(afterMinimumDuration minimumDuration: TimeInterval) { + guard isRecording, + lifecycle == .starting || lifecycle == .recording else { return } + stopRequested = true + requestedMinimumDuration = max( + requestedMinimumDuration, + max(0, minimumDuration) + ) + scheduleStopAfterActualStartIfNeeded() + } + + private func recordingDidStart(url: URL) { + guard VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: activeRecordingURL, + outputURL: url + ), isRecording, + lifecycle == .starting || lifecycle == .stopping else { return } + + startupTimeoutTask?.cancel() + startupTimeoutTask = nil + if startedAt == nil { + startedAt = Date() + } + + if lifecycle == .stopping { + // Cancellation can be requested while startRecording is still + // crossing the capture queue. If the first stop observed + // isRecording == false, stop again after the real didStart rather + // than leaving an orphaned capture running until the watchdog. + stopRequestTask?.cancel() + stopRequestTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.stopMovieOutput() + guard !Task.isCancelled, + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + self.scheduleFinalizationTimeout(for: url) + } + return + } + + lifecycle = .recording + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, let startedAt = self.startedAt else { return } + self.elapsed = min( + VideoNoteStopPolicy.maximumCaptureDuration, + Date().timeIntervalSince(startedAt) + ) + } + } + scheduleStopAfterActualStartIfNeeded() + } + + private func scheduleStopAfterActualStartIfNeeded() { + guard stopRequested, + lifecycle == .recording, + startedAt != nil, + let url = activeRecordingURL else { return } + + stopRequestTask?.cancel() + let minimumDuration = requestedMinimumDuration + let mediaDeadline = Date().addingTimeInterval( + VideoNoteStopPolicy.recordedDurationWaitTimeout + ) + stopRequestTask = Task { @MainActor [weak self] in + while true { + guard !Task.isCancelled, + let self, + self.lifecycle == .recording, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + + let snapshot = await self.movieOutputSnapshot() + guard !Task.isCancelled, + self.lifecycle == .recording, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + + if !snapshot.isRecording { + // AVFoundation stopped without the user's stop reaching + // the output. Wait for didFinish, but never let this state + // remain without a finalization watchdog. + self.lifecycle = .stopping + self.scheduleFinalizationTimeout(for: url) + return + } + + let remaining = VideoNoteStopPolicy.remainingRecordedDuration( + recordedDuration: snapshot.recordedDuration, + minimumDuration: minimumDuration + ) + if remaining <= 0 || Date() >= mediaDeadline { break } + + do { + try await Task.sleep( + nanoseconds: min( + VideoNoteStopPolicy.recordedDurationPollNanoseconds, + UInt64((remaining * 1_000_000_000).rounded(.up)) + ) + ) + } catch { + return + } + } + + guard !Task.isCancelled, + let self, + self.lifecycle == .recording, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + + self.lifecycle = .stopping + await self.stopMovieOutput() + guard !Task.isCancelled, + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + self.scheduleFinalizationTimeout(for: url) + } + } + + private func recordingDidFinish(url: URL, error: Error?) { + guard VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: activeRecordingURL, + outputURL: url + ) else { + // A delayed callback from an earlier attempt must not tear down a + // newer capture graph. Do not delete it here: macOS can spell the + // same temporary URL through a filesystem alias, and preserving a + // doubtful callback is safer than deleting the active movie. + return + } + + stopRequestTask?.cancel() + stopRequestTask = nil + startupTimeoutTask?.cancel() + startupTimeoutTask = nil + finalizationTimeoutTask?.cancel() + finalizationTimeoutTask = nil + timer?.invalidate() + timer = nil + isRecording = false + lifecycle = .stopping + + let fallbackDuration = measuredWallClockDuration + let shouldDiscard = discardCurrentRecording + let wasStopRequested = stopRequested + if shouldDiscard { + try? FileManager.default.removeItem(at: url) + finishAndReset(nil) + return + } + + fileValidationTask?.cancel() + fileValidationTask = Task { @MainActor [weak self] in + guard let self else { return } + let inspection = await self.fileInspector.inspect( + url: url, + fallbackDuration: fallbackDuration + ) + guard !Task.isCancelled, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + self.fileValidationTask = nil + + if self.discardCurrentRecording { + try? FileManager.default.removeItem(at: url) + self.finishAndReset(nil) + return + } + + if let inspection, + VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( + stopRequested: wasStopRequested, + error: error, + recordedDuration: inspection.duration, + maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration + ), + VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) { + self.finishAndReset(.success(Recording( + url: url, + duration: inspection.duration + ))) + return + } + + try? FileManager.default.removeItem(at: url) + let failure: Error + if !wasStopRequested, + !VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( + stopRequested: false, + error: error, + recordedDuration: inspection?.duration ?? fallbackDuration, + maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration + ) { + failure = VideoNoteRecorderError.recordingInterrupted + } else if !VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error), let error { + failure = error + } else { + failure = VideoNoteRecorderError.emptyRecording + } + self.finishAndReset(.failure(failure)) + } + } + + private var measuredWallClockDuration: TimeInterval { + guard let startedAt else { return max(0, elapsed) } + return max(elapsed, Date().timeIntervalSince(startedAt)) + } + + private func stopMovieOutput() async { + let output = movieOutput + await withCheckedContinuation { (continuation: CheckedContinuation) in + sessionQueue.async { + if output.isRecording { + output.stopRecording() + } + continuation.resume() + } + } + } + + private func movieOutputSnapshot() async -> MovieOutputSnapshot { + let output = movieOutput + return await withCheckedContinuation { continuation in + sessionQueue.async { + let seconds = CMTimeGetSeconds(output.recordedDuration) + continuation.resume(returning: MovieOutputSnapshot( + isRecording: output.isRecording, + recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0 + )) + } + } + } + + private func flushCaptureSessionForFinalization() async { + let output = movieOutput + let session = session + await withCheckedContinuation { (continuation: CheckedContinuation) in + sessionQueue.async { + if output.isRecording { + output.stopRecording() + } + if session.isRunning { + session.stopRunning() + } + continuation.resume() + } + } + } + + private func scheduleStartupTimeout(for url: URL) { + startupTimeoutTask?.cancel() + startupTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep( + nanoseconds: VideoNoteStopPolicy.startupTimeoutNanoseconds + ) + guard !Task.isCancelled, + let self, + self.lifecycle == .starting, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + + self.lifecycle = .stopping + await self.stopMovieOutput() + guard !Task.isCancelled, + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + self.scheduleFinalizationTimeout(for: url) + } + } + + private func scheduleFinalizationTimeout(for url: URL) { + finalizationTimeoutTask?.cancel() + finalizationTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep( + nanoseconds: VideoNoteStopPolicy.finalizationTimeoutNanoseconds + ) + guard !Task.isCancelled, + let self, + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + + // A few macOS camera drivers stop the file output but delay its + // delegate callback until the capture session itself is flushed. + // This is recovery-only; the normal path still waits for + // didFinishRecording before touching the session. + await self.flushCaptureSessionForFinalization() + try? await Task.sleep( + nanoseconds: VideoNoteStopPolicy.finalizationRecoveryGraceNanoseconds + ) + guard !Task.isCancelled, + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + let fallbackDuration = self.measuredWallClockDuration + let inspection = await self.fileInspector.inspect( + url: url, + fallbackDuration: fallbackDuration + ) + guard !Task.isCancelled, + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) else { return } + + self.finalizationTimeoutTask = nil + if self.discardCurrentRecording { + try? FileManager.default.removeItem(at: url) + self.finishAndReset(nil) + } else if let inspection, + VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( + stopRequested: self.stopRequested, + error: nil, + recordedDuration: inspection.duration, + maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration + ), + VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) { + // Some macOS camera drivers finalize the .mov but omit the + // delegate callback. A playable file is more authoritative + // than the missing callback and remains safe to send. + self.finishAndReset(.success(Recording( + url: url, + duration: inspection.duration + ))) + } else if !self.stopRequested { + try? FileManager.default.removeItem(at: url) + self.finishAndReset(.failure(VideoNoteRecorderError.recordingInterrupted)) + } else { + try? FileManager.default.removeItem(at: url) + self.finishAndReset(.failure(VideoNoteRecorderError.finalizationTimedOut)) + } + } + } + + private func finishAndReset(_ result: Result?) { + fileValidationTask = nil + let completion = onFinished + performShutdown() + if let result { + completion?(result) + } + } + + private func performShutdown() { + sessionGeneration = UUID() + stopRequestTask?.cancel() + stopRequestTask = nil + startupTimeoutTask?.cancel() + startupTimeoutTask = nil + finalizationTimeoutTask?.cancel() + finalizationTimeoutTask = nil + fileValidationTask?.cancel() + fileValidationTask = nil + timer?.invalidate() + timer = nil + resetCaptureGraph() + lifecycle = .idle + activeRecordingURL = nil + discardCurrentRecording = false + stopRequested = false + requestedMinimumDuration = 0 + startedAt = nil + elapsed = 0 + isPrepared = false + isRecording = false + isUsingFrontCamera = true + isMicrophoneMuted = false +#if os(iOS) + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) +#endif + } + + private func waitForPreviousRecordingToFinish() async throws { + guard lifecycle != .starting, lifecycle != .recording else { + throw VideoNoteRecorderError.recordingBusy + } + let deadline = Date().addingTimeInterval(10) + while lifecycle == .stopping, Date() < deadline { + try await Task.sleep(nanoseconds: 25_000_000) + } + guard lifecycle != .stopping else { + throw VideoNoteRecorderError.finalizationTimedOut + } + } + + private func configureVideoConnection() { + guard let connection = movieOutput.connection(with: .video) else { return } +#if os(iOS) + configureInteroperableVideoCodec(for: connection) +#endif + if connection.isVideoMirroringSupported { + connection.automaticallyAdjustsVideoMirroring = false + connection.isVideoMirrored = isUsingFrontCamera + } +#if os(iOS) + if connection.isVideoOrientationSupported { + connection.videoOrientation = Self.currentVideoOrientation + } +#endif + } + +#if os(iOS) + private func configureInteroperableVideoCodec(for connection: AVCaptureConnection) { + guard movieOutput.availableVideoCodecTypes.contains(.h264) else { return } + movieOutput.setOutputSettings( + [AVVideoCodecKey: AVVideoCodecType.h264], + for: connection + ) + } +#endif + + private func resetCaptureGraph() { + let session = session + sessionQueue.async { + if session.isRunning { + session.stopRunning() + } + guard !session.inputs.isEmpty || !session.outputs.isEmpty else { return } + session.beginConfiguration() + session.inputs.forEach { session.removeInput($0) } + session.outputs.forEach { session.removeOutput($0) } + session.commitConfiguration() + } + cameraInput = nil + microphoneInput = nil + } + + private func waitForPendingSessionOperations() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + sessionQueue.async { + continuation.resume() + } + } + } + + private func configureCaptureGraph() async throws -> CaptureGraph { + let session = session + let movieOutput = movieOutput + return try await withCheckedThrowingContinuation { continuation in + sessionQueue.async { + session.beginConfiguration() + do { + session.sessionPreset = session.canSetSessionPreset(.vga640x480) + ? .vga640x480 + : .medium + +#if os(iOS) + let camera = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: .front + ) ?? AVCaptureDevice.default(for: .video) +#else + let camera = AVCaptureDevice.default(for: .video) +#endif + guard let camera else { + throw VideoNoteRecorderError.cameraUnavailable + } + let cameraInput = try AVCaptureDeviceInput(device: camera) + guard session.canAddInput(cameraInput) else { + throw VideoNoteRecorderError.configurationFailed + } + session.addInput(cameraInput) + + guard let microphone = AVCaptureDevice.default(for: .audio) else { + throw VideoNoteRecorderError.microphoneUnavailable + } + let microphoneInput = try AVCaptureDeviceInput(device: microphone) + guard session.canAddInput(microphoneInput) else { + throw VideoNoteRecorderError.configurationFailed + } + session.addInput(microphoneInput) + + guard session.canAddOutput(movieOutput) else { + throw VideoNoteRecorderError.configurationFailed + } + session.addOutput(movieOutput) + movieOutput.maxRecordedDuration = CMTime( + seconds: VideoNoteStopPolicy.maximumCaptureDuration, + preferredTimescale: 600 + ) + session.commitConfiguration() + continuation.resume(returning: CaptureGraph( + cameraInput: cameraInput, + microphoneInput: microphoneInput, + usesFrontCamera: camera.position == .front + )) + } catch { + session.commitConfiguration() + continuation.resume(throwing: error) + } + } + } + } + + private func startCaptureSession(generation: UUID) async throws { + let session = session + await withCheckedContinuation { (continuation: CheckedContinuation) in + sessionQueue.async { + if !session.isRunning { + session.startRunning() + } + continuation.resume() + } + } + guard !Task.isCancelled, + sessionGeneration == generation, + session.isRunning else { + throw CancellationError() + } + } + + private static func requestAccess(for mediaType: AVMediaType) async -> Bool { + switch AVCaptureDevice.authorizationStatus(for: mediaType) { + case .authorized: + return true + case .denied, .restricted: + return false + case .notDetermined: + return await AVCaptureDevice.requestAccess(for: mediaType) + @unknown default: + return false + } + } +} + +private actor VideoNoteFileInspector { + struct Inspection: Sendable { + let duration: TimeInterval + } + + func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? { + let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000] + for delay in retryDelays { + if delay > 0 { + try? await Task.sleep(nanoseconds: delay) + } + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, + let size = values.fileSize, + size > 1_024 else { continue } + + let asset = AVURLAsset(url: url) + guard let duration = try? await asset.load(.duration), + duration.seconds.isFinite, + duration.seconds > 0.04, + let tracks = try? await asset.loadTracks(withMediaType: .video), + !tracks.isEmpty else { continue } + return Inspection(duration: duration.seconds) + } + + // Keep a non-empty file only when AVFoundation reported enough wall + // clock time to make a useful fallback. This branch covers a small set + // of macOS camera drivers that delay movie metadata longer than the + // delegate callback itself. + guard fallbackDuration > 0.25, + let values = try? url.resourceValues(forKeys: [.fileSizeKey]), + let size = values.fileSize, + size > 16_384 else { return nil } + return Inspection(duration: fallbackDuration) + } +} + +enum VideoNoteStopPolicy { + static let minimumCaptureDuration: TimeInterval = 1.2 + static let minimumValidFileDuration: TimeInterval = 0.8 + static let maximumCaptureDuration: TimeInterval = 60 + static let recordedDurationWaitTimeout: TimeInterval = 5 + static let recordedDurationPollNanoseconds: UInt64 = 80_000_000 + static let startupTimeoutNanoseconds: UInt64 = 6_000_000_000 + static let finalizationTimeoutNanoseconds: UInt64 = 12_000_000_000 + static let finalizationRecoveryGraceNanoseconds: UInt64 = 1_000_000_000 + + static func remainingRecordedDuration( + recordedDuration: TimeInterval, + minimumDuration: TimeInterval + ) -> TimeInterval { + max(0, max(0, minimumDuration) - max(0, recordedDuration)) + } + + static func isValidFinalDuration(_ duration: TimeInterval) -> Bool { + duration.isFinite && duration >= minimumValidFileDuration + } +} + +#if os(iOS) +private extension VideoNoteRecorder { + static var currentVideoOrientation: AVCaptureVideoOrientation { + switch UIDevice.current.orientation { + case .portraitUpsideDown: + return .portraitUpsideDown + case .landscapeLeft: + return .landscapeRight + case .landscapeRight: + return .landscapeLeft + default: + return .portrait + } + } +} +#endif + +private enum VideoNoteRecorderError: LocalizedError { + case cameraPermissionDenied + case microphonePermissionDenied + case cameraUnavailable + case alternateCameraUnavailable + case microphoneUnavailable + case configurationFailed + case notPrepared + case recordingBusy + case recordingInterrupted + case finalizationTimedOut + case emptyRecording + + var errorDescription: String? { + switch self { + case .cameraPermissionDenied: + return "Разрешите Luma доступ к камере в системных настройках." + case .microphonePermissionDenied: + return "Разрешите Luma доступ к микрофону в системных настройках." + case .cameraUnavailable: + return "Камера недоступна." + case .alternateCameraUnavailable: + return "Вторая камера недоступна." + case .microphoneUnavailable: + return "Микрофон недоступен." + case .configurationFailed: + return "Не удалось настроить запись видеосообщения." + case .notPrepared: + return "Камера ещё не готова." + case .recordingBusy: + return "Предыдущая запись ещё завершается. Подождите секунду и повторите." + case .recordingInterrupted: + return "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись." + case .finalizationTimedOut: + return "Камера не смогла завершить видеофайл. Повторите запись." + case .emptyRecording: + return "Камера создала пустой или нечитаемый видеофайл. Повторите запись." + } + } +} diff --git a/Sources/Shared/UI/AttachmentPreviewView.swift b/Sources/Shared/UI/AttachmentPreviewView.swift new file mode 100644 index 0000000..6bc5525 --- /dev/null +++ b/Sources/Shared/UI/AttachmentPreviewView.swift @@ -0,0 +1,293 @@ +import Foundation +import SwiftUI + +#if os(iOS) +import UIKit +private typealias DraftThumbnailImage = UIImage +#elseif os(macOS) +import AppKit +private typealias DraftThumbnailImage = NSImage +#endif + +@MainActor +struct AttachmentPreviewView: View { + @ObservedObject var model: AppModel + let conversation: Conversation + @Binding var drafts: [AttachmentDraft] + + @Environment(\.dismiss) private var dismiss + @State private var caption = "" + @State private var isSending = false + @State private var sendStatusMessage: String? + @State private var previewItem: MediaViewerItem? + @State private var thumbnailImages: [UUID: DraftThumbnailImage] = [:] + + private let columns = [GridItem(.adaptive(minimum: 128, maximum: 190), spacing: 18)] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + ScrollView { + LazyVGrid(columns: columns, spacing: 22) { + ForEach(drafts) { draft in + draftCard(draft) + } + } + .padding(16) + } + + Divider() + VStack(spacing: 10) { + TextField("Добавить подпись…", text: $caption, axis: .vertical) + .lineLimit(1...4) + .textFieldStyle(.plain) + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16)) + .disabled(isSending) + + if let sendStatusMessage { + Label(sendStatusMessage, systemImage: "arrow.clockwise.circle.fill") + .font(.caption) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { + send() + } label: { + Label(isSending ? "Отправка…" : "Отправить", systemImage: "arrow.up.circle.fill") + .fontWeight(.semibold) + } + .buttonStyle(.borderedProminent) + .disabled(drafts.isEmpty || isSending) + } + } + .padding(14) + .background(.ultraThinMaterial) + } + .navigationTitle("Перед отправкой") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { cancel() } + .disabled(isSending) + } + } + } +#if os(macOS) + .frame(minWidth: 560, minHeight: 560) +#endif + .interactiveDismissDisabled(isSending || previewItem != nil) + .task(id: drafts.map(\.id)) { + await cacheDraftThumbnails() + } + .overlay { + if let previewItem { + MediaViewer(item: previewItem) { + self.previewItem = nil + } + .zIndex(100) + } + } + .onDisappear { + guard !isSending, !drafts.isEmpty else { return } + model.discardAttachmentDrafts(drafts) + drafts = [] + } + } + + private func draftCard(_ draft: AttachmentDraft) -> some View { + VStack(alignment: .leading, spacing: 7) { + ZStack(alignment: .topTrailing) { + Button { + presentPreview(for: draft) + } label: { + ZStack { + Color.secondary.opacity(0.1) + thumbnail(for: draft) + .padding(6) + + if draft.kind == .video { + Image(systemName: "play.fill") + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + .padding(11) + .background(.black.opacity(0.45), in: Circle()) + } + } + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 15, style: .continuous) + .stroke(Color.secondary.opacity(0.13), lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(!canPreview(draft) || isSending) + .accessibilityLabel("Предпросмотр \(draft.filename)") + + Button { + remove(draft) + } label: { + Image(systemName: "xmark") + .font(.caption.bold()) + .foregroundStyle(.white) + .frame(width: 25, height: 25) + .background(.black.opacity(0.62), in: Circle()) + } + .buttonStyle(.plain) + .padding(7) + .accessibilityLabel("Убрать \(draft.filename)") + } + + Text(draft.filename) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Text(detail(for: draft)) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(9) + .background(.background, in: RoundedRectangle(cornerRadius: 19, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 19, style: .continuous) + .stroke(Color.secondary.opacity(0.15), lineWidth: 1) + } + .shadow(color: .black.opacity(0.045), radius: 5, y: 2) + } + + @ViewBuilder + private func thumbnail(for draft: AttachmentDraft) -> some View { +#if os(iOS) + if let image = thumbnailImages[draft.id] { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else { + filePlaceholder(for: draft) + } +#elseif os(macOS) + if let image = thumbnailImages[draft.id] { + Image(nsImage: image) + .resizable() + .scaledToFit() + } else { + filePlaceholder(for: draft) + } +#endif + } + + private func filePlaceholder(for draft: AttachmentDraft) -> some View { + ZStack { + LinearGradient( + colors: [Color.accentColor.opacity(0.18), Color.secondary.opacity(0.08)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Image(systemName: icon(for: draft.kind)) + .font(.system(size: 38, weight: .medium)) + .foregroundStyle(.tint) + } + } + + private var summary: String { + let bytes = drafts.reduce(0) { $0 + $1.byteCount } + return "\(drafts.count) · \(ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))" + } + + private func detail(for draft: AttachmentDraft) -> String { + var values = [ByteCountFormatter.string(fromByteCount: Int64(draft.byteCount), countStyle: .file)] + if let duration = draft.duration { + let seconds = max(0, Int(duration.rounded())) + values.insert(String(format: "%d:%02d", seconds / 60, seconds % 60), at: 0) + } + return values.joined(separator: " · ") + } + + private func icon(for kind: ChatMessage.Kind) -> String { + switch kind { + case .photo: return "photo.fill" + case .video: return "video.fill" + case .audio: return "music.note" + case .voice: return "waveform" + case .videoNote: return "video.circle.fill" + case .attachment: return "doc.fill" + case .location: return "location.fill" + case .text, .system: return "doc" + } + } + + private func remove(_ draft: AttachmentDraft) { + model.discardAttachmentDrafts([draft]) + drafts.removeAll { $0.id == draft.id } + thumbnailImages.removeValue(forKey: draft.id) + if drafts.isEmpty { dismiss() } + } + + private func cacheDraftThumbnails() async { + let retainedIDs = Set(drafts.map(\.id)) + thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) } + for draft in drafts where thumbnailImages[draft.id] == nil { + guard let data = draft.thumbnailData, + let image = DraftThumbnailImage(data: data) else { continue } + thumbnailImages[draft.id] = image + await Task.yield() + } + } + + private func canPreview(_ draft: AttachmentDraft) -> Bool { + draft.kind == .photo || draft.kind == .video + } + + private func presentPreview(for draft: AttachmentDraft) { + guard canPreview(draft), FileManager.default.fileExists(atPath: draft.url.path) else { return } + previewItem = MediaViewerItem( + id: draft.id.uuidString, + url: draft.url, + kind: draft.kind, + title: draft.filename + ) + } + + private func cancel() { + model.discardAttachmentDrafts(drafts) + drafts = [] + dismiss() + } + + private func send() { + guard !isSending, !drafts.isEmpty else { return } + let outgoingDrafts = drafts + sendStatusMessage = nil + isSending = true + Task { + let result = await model.sendAttachmentBatch( + outgoingDrafts, + caption: caption, + to: conversation.jid + ) + isSending = false + if result.failedDrafts.isEmpty { + drafts = [] + dismiss() + } else { + drafts = result.failedDrafts + if result.sentCount > 0 { + sendStatusMessage = "Отправлено: \(result.sentCount). Не удалось: \(result.failedDrafts.count). Нажмите «Отправить», чтобы повторить оставшиеся." + } else { + sendStatusMessage = "Не удалось отправить. Файлы сохранены здесь — проверьте соединение и повторите попытку." + } + } + } + } +} diff --git a/Sources/Shared/UI/CallView.swift b/Sources/Shared/UI/CallView.swift new file mode 100644 index 0000000..4ea1957 --- /dev/null +++ b/Sources/Shared/UI/CallView.swift @@ -0,0 +1,243 @@ +import Foundation +import SwiftUI +import WebRTC + +@MainActor +struct CallView: View { + @ObservedObject var model: AppModel + let call: CallSnapshot + + private var conversation: Conversation { + model.conversations.first(where: { $0.id == call.peerJID.lowercased() }) + ?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID)) + } + + private var remoteTrack: RTCVideoTrack? { + model.remoteCallVideoTrack(for: call.id) + } + + private var localTrack: RTCVideoTrack? { + model.localCallVideoTrack(for: call.id) + } + + var body: some View { + ZStack { + background + + VStack(spacing: 24) { + callHeader + .padding(.top, 22) + + Spacer(minLength: 12) + + if remoteTrack == nil { + contactIdentity + } + + Spacer(minLength: 20) + + controls + .padding(.bottom, 28) + } + .padding(.horizontal, 24) + + if call.isVideoCall, let localTrack, call.isCameraEnabled { + RTCVideoRendererView(track: localTrack, mirrored: true) + .frame(width: 118, height: 168) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(.white.opacity(0.24), lineWidth: 1) + } + .shadow(color: .black.opacity(0.38), radius: 16, y: 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.top, 92) + .padding(.trailing, 18) + } + } + .foregroundStyle(.white) + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private var background: some View { + if call.isVideoCall, let remoteTrack { + RTCVideoRendererView(track: remoteTrack) + .ignoresSafeArea() + .overlay { + LinearGradient( + colors: [ + .black.opacity(0.48), + .clear, + .black.opacity(0.62) + ], + startPoint: .top, + endPoint: .bottom + ) + } + } else { + LinearGradient( + colors: [Color(red: 0.11, green: 0.17, blue: 0.24), .black], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .overlay { + Circle() + .fill(Color.accentColor.opacity(0.24)) + .frame(width: 440, height: 440) + .blur(radius: 90) + .offset(x: 170, y: -260) + } + .ignoresSafeArea() + } + } + + private var callHeader: some View { + VStack(spacing: 5) { + Text(conversation.displayName) + .font(.headline) + .lineLimit(1) + TimelineView(.periodic(from: .now, by: 1)) { context in + Text(statusText(at: context.date)) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.white.opacity(0.72)) + } + } + .frame(maxWidth: .infinity) + } + + private var contactIdentity: some View { + VStack(spacing: 18) { + AvatarView( + conversation: conversation, + imageData: model.avatarData(for: call.peerJID), + size: 132 + ) + .overlay { + Circle().stroke(.white.opacity(0.14), lineWidth: 1) + } + .shadow(color: .black.opacity(0.32), radius: 28, y: 14) + + Text(conversation.displayName) + .font(.system(size: 30, weight: .semibold, design: .rounded)) + .multilineTextAlignment(.center) + .lineLimit(2) + + Text(call.isVideoCall ? "Видеозвонок Luma" : "Аудиозвонок Luma") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.66)) + } + } + + @ViewBuilder + private var controls: some View { + if call.isIncomingRinging { + HStack(spacing: 52) { + CallControlButton( + title: "Отклонить", + systemImage: "phone.down.fill", + color: .red, + action: model.rejectCall + ) + CallControlButton( + title: "Ответить", + systemImage: call.isVideoCall ? "video.fill" : "phone.fill", + color: .green + ) { + Task { await model.answerCall() } + } + } + } else { + VStack(spacing: 22) { + HStack(spacing: call.isVideoCall ? 10 : 24) { + CallControlButton( + title: call.isMuted ? "Включить" : "Микрофон", + systemImage: call.isMuted ? "mic.slash.fill" : "mic.fill", + color: call.isMuted ? .white.opacity(0.34) : .white.opacity(0.18) + ) { + model.setCallMuted(!call.isMuted) + } + +#if os(iOS) + CallControlButton( + title: "Динамик", + systemImage: call.isSpeakerEnabled ? "speaker.wave.3.fill" : "speaker.fill", + color: call.isSpeakerEnabled ? .white.opacity(0.34) : .white.opacity(0.18) + ) { + model.setCallSpeakerEnabled(!call.isSpeakerEnabled) + } +#endif + + if call.isVideoCall { + CallControlButton( + title: call.isCameraEnabled ? "Камера" : "Включить", + systemImage: call.isCameraEnabled ? "video.fill" : "video.slash.fill", + color: call.isCameraEnabled ? .white.opacity(0.18) : .white.opacity(0.34) + ) { + model.setCallCameraEnabled(!call.isCameraEnabled) + } + CallControlButton( + title: "Повернуть", + systemImage: "camera.rotate.fill", + color: .white.opacity(0.18), + action: model.switchCallCamera + ) + } + } + + CallControlButton( + title: "Завершить", + systemImage: "phone.down.fill", + color: .red, + action: model.endCall + ) + } + } + } + + private func statusText(at date: Date) -> String { + switch call.phase { + case .ringing: + return call.direction == .incoming ? "Входящий звонок" : "Вызов…" + case .connecting: + return "Соединение…" + case .connected: + guard let connectedAt = call.connectedAt else { return "Соединено" } + let total = max(0, Int(date.timeIntervalSince(connectedAt))) + let hours = total / 3_600 + let minutes = (total % 3_600) / 60 + let seconds = total % 60 + return hours > 0 + ? String(format: "%d:%02d:%02d", hours, minutes, seconds) + : String(format: "%02d:%02d", minutes, seconds) + } + } +} + +private struct CallControlButton: View { + let title: String + let systemImage: String + let color: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Circle() + .fill(color) + .frame(width: 58, height: 58) + .overlay { + Image(systemName: systemImage) + .font(.system(size: 24, weight: .semibold)) + } + Text(title) + .font(.caption) + .lineLimit(1) + } + .frame(minWidth: 58) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .accessibilityLabel(title) + } +} diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift new file mode 100644 index 0000000..748dd32 --- /dev/null +++ b/Sources/Shared/UI/ChatView.swift @@ -0,0 +1,2240 @@ +import CoreTransferable +import Foundation +import ImageIO +import PhotosUI +import SwiftUI +import UniformTypeIdentifiers + +#if os(iOS) +import UIKit +#endif + +@MainActor +struct ChatView: View { + private static let bottomAnchorID = "luma-chat-timeline-bottom" + private static let timelineCoordinateSpace = "luma-chat-timeline-space" + + @ObservedObject var model: AppModel + let conversation: Conversation + + @StateObject private var audioRecorder = AudioMessageRecorder() + @FocusState private var isComposerFocused: Bool + @State private var draft = "" + @State private var showingFileImporter = false + @State private var fileImportMode: FileImportMode = .files + @State private var showingEncryption = false + @State private var showingVideoNoteRecorder = false + @State private var videoNoteIsSending = false + @State private var showingMediaPicker = false + @State private var showingPhotoCamera = false + @State private var photoCameraIsPreparingResult = false + @State private var showingLocationPicker = false + @State private var showingGroupInfo = false + @State private var showingAttachmentPreview = false + @State private var attachmentPreviewPresentationPending = false + @State private var attachmentPreviewPresentationTask: Task? + @State private var pickedMediaItems: [PhotosPickerItem] = [] + @State private var attachmentDrafts: [AttachmentDraft] = [] + @State private var isPreparingAttachments = false + @State private var editingMessageID: String? + @State private var replyingToMessageID: String? + @State private var forwardingSelection: MessageForwardSelection? + @State private var selectedMessageIDs: Set = [] + @State private var destructiveAction: MessageDestructiveAction? + @State private var replyThreadSelection: ReplyThreadSelection? + @State private var hasCompletedInitialScroll = false + @State private var isNearTimelineBottom = true + @State private var activeCaptureMode: ComposerCaptureMode? + @State private var preparingCaptureMode: ComposerCaptureMode? + @State private var captureGestureIsActive = false + @State private var captureIsLocked = false + @State private var captureDragTranslation: CGSize = .zero + @State private var captureAttemptID = UUID() + @State private var captureStartTask: Task? + @State private var captureSuspendsArchiveSync = false + + var body: some View { + VStack(spacing: 0) { + if conversation.isGroup, !liveConversation.isGroupJoined { + groupJoinBanner + } + messageTimeline + .safeAreaInset(edge: .bottom, spacing: 0) { + if isSelectingMessages { + selectionActionBar + } else { + composer + } + } + } + .background(Color.secondary.opacity(0.035)) + .navigationTitle(conversation.displayName) +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + .toolbar { + ToolbarItem(placement: .principal) { + chatNavigationTitle + } + if isSelectingMessages { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена", action: clearMessageSelection) + } + ToolbarItem(placement: .primaryAction) { + Text("Выбрано: \(selectedTimelineMessages.count)") + .font(.subheadline.weight(.semibold)) + } + } else { + ToolbarItemGroup(placement: .primaryAction) { + if !conversation.isGroup { + Button { + Task { await model.startCall(to: conversation.jid, withVideo: false) } + } label: { + Image(systemName: "phone") + } + .disabled(!canStartCall) + .help("Аудиозвонок") + + Button { + Task { await model.startCall(to: conversation.jid, withVideo: true) } + } label: { + Image(systemName: "video") + } + .disabled(!canStartCall) + .help("Видеозвонок") + } + AvatarView( + conversation: liveConversation, + imageData: model.avatarData(for: conversation.jid), + size: 28 + ) + if conversation.isGroup { + Button { showingGroupInfo = true } label: { + Image(systemName: "person.3") + } + .help("Информация о группе") + } + encryptionMenu + } + } + } +#if os(iOS) + .toolbar(replyThreadSelection == nil ? .visible : .hidden, for: .navigationBar) +#endif + .fileImporter( + isPresented: $showingFileImporter, + allowedContentTypes: fileImportMode.allowedContentTypes, + allowsMultipleSelection: true + ) { result in + guard case .success(let urls) = result else { return } + let preferredKind = fileImportMode.preferredKind + Task { await stageImportedFiles(urls, preferredKind: preferredKind) } + } + .photosPicker( + isPresented: $showingMediaPicker, + selection: $pickedMediaItems, + maxSelectionCount: 20, + matching: .any(of: [.images, .videos]), + preferredItemEncoding: .current + ) + .onChange(of: showingMediaPicker) { _, isPresented in + if !isPresented { + schedulePendingAttachmentPreview() + } + } +#if os(iOS) + .fullScreenCover( + isPresented: $showingPhotoCamera, + onDismiss: { + schedulePendingAttachmentPreview() + if !photoCameraIsPreparingResult { + releaseArchiveSyncAfterCapture() + } + } + ) { + SystemPhotoCameraView( + onMedia: { result in + showingPhotoCamera = false + switch result { + case .success(let media): + photoCameraIsPreparingResult = true + Task { await stageCapturedMedia(media) } + case .failure(let error): + model.errorMessage = error.localizedDescription + } + }, + onCancel: { + showingPhotoCamera = false + } + ) + .ignoresSafeArea() + } +#endif + .onChange(of: pickedMediaItems) { _, items in + guard !items.isEmpty else { return } + Task { await stagePickedMedia(items) } + } + .onChange(of: draft) { _, text in + model.updateComposerActivity(text, in: liveConversation) + } + .sheet(isPresented: $showingEncryption) { + EncryptionDevicesView(model: model, conversation: conversation) + } + .sheet( + isPresented: $showingVideoNoteRecorder, + onDismiss: { + if !videoNoteIsSending { + releaseArchiveSyncAfterCapture() + } + } + ) { + VideoNoteCaptureView { recording in + sendRecordedVideoNote(recording) + } + } + .sheet(isPresented: $showingLocationPicker) { + LocationPickerView { location in + Task { + await model.sendLocation(location, to: conversation.jid) + } + } + } + .sheet( + isPresented: $showingAttachmentPreview, + onDismiss: { + attachmentPreviewPresentationPending = false + attachmentPreviewPresentationTask?.cancel() + attachmentPreviewPresentationTask = nil + } + ) { + AttachmentPreviewView( + model: model, + conversation: liveConversation, + drafts: $attachmentDrafts + ) + } + .sheet(isPresented: $showingGroupInfo) { + GroupInfoView(model: model, conversation: liveConversation) + } + .sheet(item: $forwardingSelection) { selection in + ForwardMessageView(model: model, messages: selection.messages) { + clearMessageSelection() + } + } + .alert(item: $destructiveAction) { action in + Alert( + title: Text(action.title), + message: Text(action.explanation), + primaryButton: .destructive(Text(action.confirmationTitle)) { + perform(action) + }, + secondaryButton: .cancel(Text("Отмена")) + ) + } + .overlay { + replyThreadOverlay + } + .onChange(of: conversation.id) { _, _ in + hasCompletedInitialScroll = false + isNearTimelineBottom = true + } + .onDisappear { + model.endComposerActivity(in: liveConversation) + attachmentPreviewPresentationTask?.cancel() + attachmentPreviewPresentationTask = nil + attachmentPreviewPresentationPending = false + replyThreadSelection = nil + clearMessageSelection() + cancelComposerRecording(feedback: false) + if !showingVideoNoteRecorder, + !showingPhotoCamera, + !videoNoteIsSending, + !photoCameraIsPreparingResult { + releaseArchiveSyncAfterCapture() + } + if !showingAttachmentPreview { + model.discardAttachmentDrafts(attachmentDrafts) + attachmentDrafts = [] + } + } + } + + @ViewBuilder + private var replyThreadOverlay: some View { + if let selection = replyThreadSelection, + let root = model.message(withID: selection.rootID, in: conversation.id) { + ReplyThreadOverlay( + model: model, + rootMessage: root, + replies: replyThreadReplies( + for: root, + selectedReplyID: selection.selectedReplyID + ), + selectedReplyID: selection.selectedReplyID, + onDismiss: dismissReplyThread + ) + .zIndex(100) + } + } + + private var encryptionMenu: some View { + Menu { + Section(conversation.isGroup ? "Шифрование этой группы" : "Шифрование этого чата") { + ForEach(EncryptionPreference.allCases, id: \.rawValue) { preference in + Button { + model.setEncryptionPreference(preference, for: conversation.jid) + } label: { + if model.encryptionPreference(for: conversation.jid) == preference { + Label(preference.title, systemImage: "checkmark") + } else { + Text(preference.title) + } + } + } + } + if !conversation.isGroup { + Divider() + Button { showingEncryption = true } label: { + Label("Устройства и отпечатки", systemImage: "checkmark.shield") + } + } + } label: { + Image(systemName: encryptionIcon) + .foregroundStyle(encryptionEnabled ? Color.primary : Color.orange) + } + .help(encryptionEnabled ? "OMEMO включено" : "Сообщения отправляются без OMEMO") + } + + private var canStartCall: Bool { + model.connectionStatus == .connected && model.activeCall == nil + } + + private var selectedTimelineMessages: [ChatMessage] { + model.selectedMessages.filter { selectedMessageIDs.contains($0.id) } + } + + private var isSelectingMessages: Bool { + !selectedMessageIDs.isEmpty + } + + private var messageTimeline: some View { + ScrollViewReader { proxy in + let timelineEntries = model.selectedTimelineEntries + GeometryReader { viewport in + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack(spacing: 5) { + if timelineEntries.isEmpty { + ContentUnavailableView( + emptyChatTitle, + systemImage: conversation.isGroup + ? "person.3.fill" + : (encryptionEnabled ? "lock.shield" : "lock.open"), + description: Text(emptyChatDescription) + ) + .padding(.top, 80) + } else { + ForEach(timelineEntries) { entry in + let message = entry.message + if entry.startsNewDay { + datePill(message.timestamp) + } + MessageBubble( + model: model, + message: message, + isSelectionMode: isSelectingMessages, + isSelected: selectedMessageIDs.contains(message.id), + onAttachmentTap: { + Task { await model.previewAttachment(message) } + }, + onEdit: { startEditing(message) }, + onReply: { startReplying(to: message) }, + onForward: { presentForwarding([message]) }, + onRetry: { + Task { await model.retryMediaMessage(message) } + }, + onToggleSelection: { toggleMessageSelection(message) }, + onBeginSelection: { beginMessageSelection(with: message) }, + onRetract: { + destructiveAction = .init(kind: .retract, messages: [message]) + }, + onDelete: { + destructiveAction = .init(kind: .localDelete, messages: [message]) + }, + onReplyTap: { targetID in + presentReplyThread( + rootID: targetID, + selectedReplyID: message.id + ) + }, + onReact: { emoji in + Task { await model.toggleReaction(emoji, on: message) } + } + ) + .id(message.id) + } + } + } + .padding(.vertical, 10) + + timelineBottomSentinel +#if os(iOS) + ChatScrollMetricsObserver(identity: conversation.id) { isNearBottom in + guard isNearTimelineBottom != isNearBottom else { return } + isNearTimelineBottom = isNearBottom + } + .frame(height: 0) + .accessibilityHidden(true) +#endif + } + .defaultScrollAnchor(.bottom) +#if os(iOS) + .scrollDismissesKeyboard(.interactively) +#endif + .coordinateSpace(name: Self.timelineCoordinateSpace) +#if os(macOS) + .onPreferenceChange(TimelineBottomYPreferenceKey.self) { bottomY in + updateTimelineBottomProximity( + bottomY: bottomY, + viewportHeight: viewport.size.height + ) + } +#endif + .task(id: timelineEntries.last?.id) { + await performInitialScrollAfterLayout(using: proxy) + } + .onChange(of: timelineEntries.last?.id) { oldID, newID in + guard hasCompletedInitialScroll, + let newID, + newID != oldID, + isNearTimelineBottom + || timelineEntries.last?.message.direction == .outgoing else { + return + } + scrollToBottom(proxy, animated: true) + } + .onChange(of: timelineEntries.count) { _, _ in + selectedMessageIDs.formIntersection( + Set(timelineEntries.map(\.id)) + ) + } + + if hasCompletedInitialScroll, + !timelineEntries.isEmpty, + !isNearTimelineBottom { + jumpToLatestButton(using: proxy) + .padding(.trailing, 14) + .padding(.bottom, 12) + .transition(.scale(scale: 0.82).combined(with: .opacity)) + .zIndex(10) + } + } + .animation( + .easeInOut(duration: 0.16), + value: isNearTimelineBottom + ) + } + } + } + + @ViewBuilder + private var timelineBottomSentinel: some View { +#if os(iOS) + Color.clear + .frame(height: 1) + .accessibilityHidden(true) + .id(Self.bottomAnchorID) +#else + Color.clear + .frame(height: 1) + .background { + GeometryReader { geometry in + Color.clear.preference( + key: TimelineBottomYPreferenceKey.self, + value: geometry.frame( + in: .named(Self.timelineCoordinateSpace) + ).maxY + ) + } + } + .accessibilityHidden(true) + .id(Self.bottomAnchorID) +#endif + } + + private var selectionActionBar: some View { + HStack(spacing: 18) { + Text("Выбрано: \(selectedTimelineMessages.count)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer() + + Button { + presentForwarding(selectedTimelineMessages) + } label: { + Image(systemName: "arrowshape.turn.up.right.fill") + .font(.system(size: 18, weight: .semibold)) + .frame(width: 36, height: 36) + } + .buttonStyle(.plain) + .disabled( + selectedTimelineMessages.isEmpty + || !selectedTimelineMessages.allSatisfy(\.canBeForwarded) + ) + .accessibilityLabel("Переслать выбранные сообщения") + + Menu { + Button(role: .destructive) { + destructiveAction = .init( + kind: .localDelete, + messages: selectedTimelineMessages + ) + } label: { + Label("Удалить у меня", systemImage: "trash") + } + + if !selectedTimelineMessages.isEmpty, + selectedTimelineMessages.allSatisfy(\.canBeRetracted) { + Button(role: .destructive) { + destructiveAction = .init( + kind: .retract, + messages: selectedTimelineMessages + ) + } label: { + Label("Удалить у всех", systemImage: "trash.slash") + } + } + } label: { + Image(systemName: "trash.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.red) + .frame(width: 36, height: 36) + } + .disabled(selectedTimelineMessages.isEmpty) + .accessibilityLabel("Удалить выбранные сообщения") + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + } + + private var composer: some View { + VStack(spacing: 8) { + if let editingMessage { + editingBanner(editingMessage) + } else if let replyingToMessage { + replyBanner(replyingToMessage) + } + + HStack(alignment: .bottom, spacing: 8) { + Group { + if isCapturePresentationVisible { + recordingStatusStrip + } else { + HStack(alignment: .bottom, spacing: 8) { + attachmentButton + .disabled(!canOpenAttachments) + messageInput + } + } + } + .frame(maxWidth: .infinity) + + composerActionControl + } + .padding(.horizontal, 10) + .padding(.bottom, 8) + } + .padding(.top, 6) + .background(Color.clear) + .animation(.easeInOut(duration: 0.18), value: isCapturePresentationVisible) + } + + private var messageInput: some View { + HStack(alignment: .bottom, spacing: 8) { + TextField( + editingMessageID == nil ? "Сообщение" : "Изменить сообщение", + text: $draft, + axis: .vertical + ) + .focused($isComposerFocused) + .lineLimit(1...6) + .textFieldStyle(.plain) + .submitLabel(.send) + .onSubmit { sendDraft() } + + Image(systemName: "face.smiling") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(.secondary) + .padding(.bottom, 1) + .accessibilityHidden(true) + } + .padding(.leading, 14) + .padding(.trailing, 12) + .padding(.vertical, 9) + .frame(minHeight: 42) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 21, style: .continuous) + .stroke( + LinearGradient( + colors: [Color.white.opacity(0.3), Color.secondary.opacity(0.18)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.75 + ) + ) + .shadow(color: .black.opacity(0.075), radius: 8, y: 2) + .contentShape(RoundedRectangle(cornerRadius: 21, style: .continuous)) + } + + @ViewBuilder + private var composerActionControl: some View { + if editingMessageID != nil || !draftIsEmpty { + Button(action: sendDraft) { + Image(systemName: "arrow.up") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(canUseComposer ? Color.accentColor : Color.secondary) + .frame(width: 42, height: 42) + .background(.ultraThinMaterial, in: Circle()) + .overlay( + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 0.75) + ) + .shadow(color: .black.opacity(0.075), radius: 8, y: 2) + } + .buttonStyle(.plain) + .disabled(!canUseComposer) + .accessibilityLabel(editingMessageID == nil ? "Отправить" : "Сохранить исправление") + } else { + TelegramRecordButton( + mode: .voice, + isRecording: isCapturePresentationVisible, + isLocked: captureIsLocked, + isFinalizing: false, + isEnabled: canUseComposer, + onTap: captureButtonTapped, + onLongPressBegan: beginCaptureGesture, + onLongPressChanged: updateCaptureGesture, + onLongPressEnded: endCaptureGesture, + onLongPressCancelled: { + cancelComposerRecording(feedback: false) + } + ) + .overlay(alignment: .top) { + if isCapturePresentationVisible, !captureIsLocked { + recordingLockIndicator + .offset(y: -74 + min(12, max(-14, captureDragTranslation.height * 0.12))) + .transition(.scale.combined(with: .opacity)) + } + } + } + } + + private var attachmentButton: some View { + Menu { +#if os(iOS) + Button { + presentCamera() + } label: { + Label("Камера", systemImage: "camera.fill") + } + .disabled(!SystemPhotoCameraView.isAvailable) +#endif + Button { + dismissKeyboard() + showingMediaPicker = true + } label: { + Label("Фото или видео (несколько)", systemImage: "photo.on.rectangle") + } + Button { presentFileImporter(.audio) } label: { + Label("Музыка", systemImage: "music.note") + } + Button { + presentVideoNoteRecorder() + } label: { + Label("Видеосообщение", systemImage: "video.circle") + } + Button { + dismissKeyboard() + model.audioPlayback.stop() + showingLocationPicker = true + } label: { + Label("Отправить геопозицию", systemImage: "location.fill") + } + Divider() + Button { presentFileImporter(.files) } label: { + Label("Файлы (несколько)", systemImage: "doc.on.doc") + } + } label: { + ZStack { + Circle() + .fill(.ultraThinMaterial) + .overlay( + Circle() + .stroke(Color.secondary.opacity(0.18), lineWidth: 0.75) + ) + if model.isSendingAttachment || isPreparingAttachments { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "paperclip") + .font(.system(size: 21, weight: .semibold)) + .foregroundStyle(.primary) + } + } + .frame(width: 42, height: 42) + .shadow(color: .black.opacity(0.075), radius: 8, y: 2) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Добавить вложение") + .help("Открыть камеру или добавить фото, видео, файлы, музыку и геопозицию") + } + + private var editingMessage: ChatMessage? { + guard let editingMessageID else { return nil } + return model.selectedMessages.first { $0.id == editingMessageID } + } + + private var replyingToMessage: ChatMessage? { + guard let replyingToMessageID else { return nil } + return model.selectedMessages.first { + $0.id == replyingToMessageID && $0.canBeRepliedTo + } + } + + private var requiresOMEMO: Bool { + editingMessage?.security == .omemo || (editingMessageID == nil && encryptionEnabled) + } + + private var encryptionEnabled: Bool { + model.encryptionEnabled(for: conversation.jid) + } + + private var encryptionIcon: String { + if !encryptionEnabled { return "lock.open" } + return model.isOMEMOReady ? "lock.fill" : "lock.trianglebadge.exclamationmark" + } + + private var liveConversation: Conversation { + model.conversations.first(where: { $0.id == conversation.id }) ?? conversation + } + + private var chatNavigationTitle: some View { + VStack(spacing: 0) { + Text(liveConversation.displayName) + .font(.headline) + .lineLimit(1) + if let typingText = model.typingText(for: liveConversation) { + Text(typingText) + .font(.caption2.weight(.medium)) + .foregroundStyle(.tint) + .lineLimit(1) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.16), value: model.typingText(for: liveConversation)) + .accessibilityElement(children: .combine) + } + + private var canSendToConversation: Bool { + !conversation.isGroup || liveConversation.isGroupJoined + } + + private var emptyChatTitle: String { + if conversation.isGroup { return "Групповая комната" } + return encryptionEnabled ? "Новый защищённый чат" : "Новый чат без шифрования" + } + + private var emptyChatDescription: String { + if conversation.isGroup { + return liveConversation.isGroupJoined + ? "Напишите первое сообщение участникам комнаты." + : "Войдите в комнату, чтобы отправлять сообщения." + } + return encryptionEnabled + ? "Сообщения этому контакту будут отправляться с OMEMO." + : "Сообщения этому контакту будут передаваться открытым текстом внутри TLS-соединения." + } + + private var groupJoinBanner: some View { + HStack(spacing: 10) { + Image(systemName: liveConversation.invitedBy == nil ? "person.3" : "envelope.badge") + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text(liveConversation.invitedBy == nil ? "Комната не подключена" : "Вас пригласили в комнату") + .font(.subheadline.weight(.semibold)) + Text(liveConversation.invitedBy ?? conversation.jid) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button(liveConversation.invitedBy == nil ? "Войти" : "Принять") { + Task { await model.joinGroup(jid: conversation.jid) } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(Color.accentColor.opacity(0.09)) + } + + private var draftIsEmpty: Bool { + draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var canUseComposer: Bool { + canSendToConversation + && (!requiresOMEMO || model.isOMEMOReady) + && !model.isSendingAttachment + && !isPreparingAttachments + && (editingMessageID == nil || !draftIsEmpty) + } + + private var canOpenAttachments: Bool { + canUseComposer + && !isCapturePresentationVisible + && editingMessageID == nil + } + + private var isCapturePresentationVisible: Bool { + activeCaptureMode != nil + || preparingCaptureMode != nil + || audioRecorder.isRecording + } + + private var captureElapsed: TimeInterval { + audioRecorder.elapsed + } + + private var recordingCancelHintOpacity: Double { + ComposerRecordingGesturePolicy.cancelHintOpacity(for: captureDragTranslation) + } + + private var recordingStatusStrip: some View { + HStack(spacing: 9) { + Circle() + .fill(Color.red) + .frame(width: 9, height: 9) + + Text(formatDuration(captureElapsed)) + .font(.system(.body, design: .monospaced).weight(.semibold)) + .contentTransition(.numericText()) + + Spacer(minLength: 6) + + if captureIsLocked { + Button("Отмена") { + cancelComposerRecording() + } + .buttonStyle(.plain) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.red) + } else { + Label("Влево — отмена", systemImage: "chevron.left") + .labelStyle(.titleAndIcon) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .offset(x: min(0, captureDragTranslation.width * 0.18)) + .opacity(recordingCancelHintOpacity) + } + } + .padding(.horizontal, 13) + .frame(minHeight: 42) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 21, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 21, style: .continuous) + .stroke(Color.secondary.opacity(0.2), lineWidth: 0.75) + ) + } + + private var recordingLockIndicator: some View { + VStack(spacing: 1) { + Image(systemName: "lock.open.fill") + .font(.system(size: 18, weight: .medium)) + Image(systemName: "chevron.up") + .font(.system(size: 10, weight: .bold)) + } + .foregroundStyle(.primary) + .frame(width: 38, height: 55) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)) + .accessibilityLabel("Проведите вверх, чтобы зафиксировать запись") + } + + private func captureButtonTapped() { + if captureIsLocked { + finishComposerRecording() + } + } + + private func beginCaptureGesture() { + guard canUseComposer, + draftIsEmpty, + editingMessageID == nil, + !isCapturePresentationVisible else { return } + + dismissKeyboard() + model.audioPlayback.stop() + captureStartTask?.cancel() + let attemptID = UUID() + captureAttemptID = attemptID + captureGestureIsActive = true + captureIsLocked = false + captureDragTranslation = .zero + preparingCaptureMode = .voice + activeCaptureMode = nil + impactFeedback() + + captureStartTask = Task { @MainActor in + do { + try await audioRecorder.start() + guard !Task.isCancelled, + captureAttemptID == attemptID, + captureGestureIsActive || captureIsLocked else { + audioRecorder.cancel() + return + } + + guard captureAttemptID == attemptID else { return } + preparingCaptureMode = nil + activeCaptureMode = .voice + } catch { + audioRecorder.cancel() + guard captureAttemptID == attemptID else { return } + resetCaptureInterface() + if !(error is CancellationError) { + model.errorMessage = error.localizedDescription + } + } + } + } + + private func updateCaptureGesture(_ translation: CGSize) { + guard isCapturePresentationVisible, !captureIsLocked else { return } + captureDragTranslation = translation + switch ComposerRecordingGesturePolicy.resolution(for: translation) { + case .continueRecording: + break + case .cancel: + cancelComposerRecording() + case .lock: + captureIsLocked = true + captureGestureIsActive = false + captureDragTranslation = .zero + impactFeedback() + } + } + + private func endCaptureGesture(_ translation: CGSize) { + guard isCapturePresentationVisible else { return } + updateCaptureGesture(translation) + guard isCapturePresentationVisible, !captureIsLocked else { return } + captureGestureIsActive = false + + guard activeCaptureMode != nil else { + // A first-run permission prompt can outlive the physical press. + // Do not send a surprise recording after the user releases it. + cancelComposerRecording(feedback: false) + return + } + finishComposerRecording() + } + + private func finishComposerRecording() { + captureGestureIsActive = false + captureDragTranslation = .zero + + switch activeCaptureMode { + case .voice: + do { + let recording = try audioRecorder.finish() + resetCaptureInterface() + Task { + let sentID = await model.sendAttachment( + from: recording.url, + preferredKind: .voice, + duration: recording.duration, + to: conversation.jid + ) + if sentID != nil { + try? FileManager.default.removeItem(at: recording.url) + } + } + } catch { + resetCaptureInterface() + model.errorMessage = error.localizedDescription + } + case nil: + cancelComposerRecording(feedback: false) + } + } + + private func cancelComposerRecording(feedback: Bool = true) { + let hadCapture = isCapturePresentationVisible + captureStartTask?.cancel() + captureStartTask = nil + captureAttemptID = UUID() + if audioRecorder.isRecording { + audioRecorder.cancel() + } + resetCaptureInterface() + if feedback, hadCapture { + impactFeedback() + } + } + + private func resetCaptureInterface() { + captureStartTask?.cancel() + captureStartTask = nil + captureGestureIsActive = false + captureIsLocked = false + captureDragTranslation = .zero + preparingCaptureMode = nil + activeCaptureMode = nil + } + + private func releaseArchiveSyncAfterCapture() { + guard captureSuspendsArchiveSync else { return } + captureSuspendsArchiveSync = false + model.setVideoNoteCaptureActive(false) + } + + private func dismissKeyboard() { + isComposerFocused = false +#if os(iOS) + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, + from: nil, + for: nil + ) +#endif + } + + private func impactFeedback() { +#if os(iOS) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() +#endif + } + + private func sendDraft() { + let outgoing = draft + guard canSendToConversation, + !outgoing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + draft = "" + if let editingMessageID { + self.editingMessageID = nil + Task { await model.editMessage(id: editingMessageID, newBody: outgoing) } + } else { + let replyTarget = replyingToMessage + replyingToMessageID = nil + Task { + await model.sendText( + outgoing, + to: conversation.jid, + replyingTo: replyTarget + ) + } + } + } + + private func stageImportedFiles( + _ urls: [URL], + preferredKind: ChatMessage.Kind? = nil + ) async { + guard !urls.isEmpty else { return } + isPreparingAttachments = true + defer { isPreparingAttachments = false } + let prepared = await model.prepareAttachmentDrafts( + from: urls, + preferredKind: preferredKind, + for: conversation.jid + ) + guard !prepared.isEmpty else { return } + model.discardAttachmentDrafts(attachmentDrafts) + attachmentDrafts = prepared + presentAttachmentPreviewAfterPickerDismissal() + } + + private func stageCapturedMedia(_ media: CapturedCameraMedia) async { + defer { + try? FileManager.default.removeItem(at: media.url) + photoCameraIsPreparingResult = false + releaseArchiveSyncAfterCapture() + } + await stageImportedFiles([media.url], preferredKind: media.kind) + } + + private func presentCamera() { +#if os(iOS) + guard SystemPhotoCameraView.isAvailable else { + model.errorMessage = "Камера недоступна на этом устройстве." + return + } + dismissKeyboard() + model.audioPlayback.stop() + photoCameraIsPreparingResult = false + if !captureSuspendsArchiveSync { + captureSuspendsArchiveSync = true + model.setVideoNoteCaptureActive(true) + } + Task { @MainActor in + await Task.yield() + showingPhotoCamera = true + } +#endif + } + + private func presentVideoNoteRecorder() { + guard !showingVideoNoteRecorder, !videoNoteIsSending else { return } + dismissKeyboard() + model.audioPlayback.stop() + if !captureSuspendsArchiveSync { + captureSuspendsArchiveSync = true + model.setVideoNoteCaptureActive(true) + } + Task { @MainActor in + // Let the attachment menu finish dismissing before presenting the + // dedicated camera. This keeps the menu tap separate from the + // voice-message touch surface. + await Task.yield() + showingVideoNoteRecorder = true + } + } + + private func sendRecordedVideoNote(_ recording: VideoNoteRecorder.Recording) { + videoNoteIsSending = true + Task { @MainActor in + defer { + videoNoteIsSending = false + releaseArchiveSyncAfterCapture() + } + let sentID = await model.sendAttachment( + from: recording.url, + preferredKind: .videoNote, + duration: recording.duration, + to: conversation.jid + ) + if sentID != nil { + try? FileManager.default.removeItem(at: recording.url) + } + } + } + + private func presentFileImporter(_ mode: FileImportMode) { + dismissKeyboard() + fileImportMode = mode + // Let Menu finish dismissing before SwiftUI presents the native file + // panel. A single importer owns both modes, so presentations no longer + // compete with each other. + Task { @MainActor in + await Task.yield() + showingFileImporter = true + } + } + + private func presentReplyThread(rootID: String, selectedReplyID: String) { + guard model.message(withID: rootID, in: conversation.id) != nil else { + model.errorMessage = "Исходное сообщение ещё не загружено в локальную историю." + return + } + withAnimation(.easeOut(duration: 0.2)) { + replyThreadSelection = ReplyThreadSelection( + rootID: rootID, + selectedReplyID: selectedReplyID + ) + } + } + + private func dismissReplyThread() { + withAnimation(.easeIn(duration: 0.18)) { + replyThreadSelection = nil + } + } + + private func replyThreadReplies( + for root: ChatMessage, + selectedReplyID: String + ) -> [ChatMessage] { + var sourceIdentifiers: Set = [root.id] + if let stanzaID = root.stanzaID { sourceIdentifiers.insert(stanzaID) } + if let replyIdentifier = root.replyIdentifier { + sourceIdentifiers.insert(replyIdentifier) + } + + var replies = model.selectedMessages.filter { candidate in + guard let replyToID = candidate.replyToID else { return false } + return sourceIdentifiers.contains(replyToID) + } + if !replies.contains(where: { $0.id == selectedReplyID }), + let selected = model.message(withID: selectedReplyID, in: conversation.id) { + replies.append(selected) + } + return replies.sorted { lhs, rhs in + if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } + return lhs.timestamp < rhs.timestamp + } + } + + private func stagePickedMedia(_ items: [PhotosPickerItem]) async { + guard !items.isEmpty, !isPreparingAttachments else { return } + isPreparingAttachments = true + var sourceURLs: [URL] = [] + var failedItemCount = 0 + defer { + pickedMediaItems = [] + isPreparingAttachments = false + sourceURLs.forEach(discardPickedMediaFile) + } + model.clearError() + + let prepared = await model.withMediaPreparationActivity { + for item in items.prefix(20) { + do { + sourceURLs.append(try await loadPickedMediaFile(item)) + } catch { + failedItemCount += 1 + } + } + guard !sourceURLs.isEmpty else { return [AttachmentDraft]() } + return await model.prepareAttachmentDrafts( + from: sourceURLs, + for: conversation.jid + ) + } + + guard !sourceURLs.isEmpty else { + model.errorMessage = MediaPickerError.cannotReadSelection.errorDescription + return + } + failedItemCount += max(0, sourceURLs.count - prepared.count) + guard !prepared.isEmpty else { return } + model.discardAttachmentDrafts(attachmentDrafts) + attachmentDrafts = prepared + presentAttachmentPreviewAfterPickerDismissal() + if failedItemCount > 0 { + model.errorMessage = "Не удалось подготовить \(failedItemCount) из \(items.prefix(20).count) выбранных фото или видео. Остальные готовы к отправке." + } + } + + private func presentAttachmentPreviewAfterPickerDismissal() { + guard !attachmentDrafts.isEmpty else { return } + attachmentPreviewPresentationPending = true + schedulePendingAttachmentPreview() + } + + private func schedulePendingAttachmentPreview() { + guard attachmentPreviewPresentationPending else { return } + attachmentPreviewPresentationTask?.cancel() + attachmentPreviewPresentationTask = Task { @MainActor in + // `isPresented` becomes false at the beginning of UIKit's + // dismissal animation. Starting another sheet in that same frame + // is ignored on iOS. Leave a short hand-off window, then keep the + // request pending until every system picker has really gone away. + do { + try await Task.sleep(nanoseconds: 350_000_000) + } catch { + return + } + + for _ in 0..<120 { + guard !Task.isCancelled, + attachmentPreviewPresentationPending, + !attachmentDrafts.isEmpty else { return } + if !showingMediaPicker, + !showingPhotoCamera, + !showingFileImporter, + !isPreparingAttachments, + !showingAttachmentPreview { + attachmentPreviewPresentationPending = false + showingAttachmentPreview = true + attachmentPreviewPresentationTask = nil + return + } + do { + try await Task.sleep(nanoseconds: 50_000_000) + } catch { + return + } + } + attachmentPreviewPresentationTask = nil + } + } + + private func loadPickedMediaFile(_ item: PhotosPickerItem) async throws -> URL { + let supportsImage = item.supportedContentTypes.contains { + $0.conforms(to: .image) + } + let supportsVideo = item.supportedContentTypes.contains { + $0.conforms(to: .movie) + } + // A Live Photo can advertise both representations. Prefer its still + // image, while a video-only asset starts with the movie representation. + let order = MediaPickerSelectionPolicy.preferredOrder( + supportsImage: supportsImage, + supportsVideo: supportsVideo + ) + var lastError: Error? + + for representation in order { + do { + switch representation { + case .photo: + return try await loadPickedPhoto(item) + case .video: + return try await loadPickedVideo(item) + } + } catch { + lastError = error + } + } + throw lastError ?? MediaPickerError.cannotReadSelection + } + + private func loadPickedPhoto(_ item: PhotosPickerItem) async throws -> URL { + let fileRepresentation = try? await item.loadTransferable(type: PickedPhoto.self) + if let photo = fileRepresentation { + return photo.url + } + + let dataRepresentation = try await item.loadTransferable(type: Data.self) + guard let data = dataRepresentation, !data.isEmpty else { + throw MediaPickerError.cannotReadSelection + } + return try await Task.detached(priority: .userInitiated) { + try writePickedImageData(data) + }.value + } + + private func loadPickedVideo(_ item: PhotosPickerItem) async throws -> URL { + guard let video = try await item.loadTransferable(type: PickedVideo.self) else { + throw MediaPickerError.cannotReadSelection + } + return video.url + } + + private func discardPickedMediaFile(_ url: URL) { + let pickerRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaPhotoPicker", isDirectory: true) + .standardizedFileURL + let parent = url.deletingLastPathComponent().standardizedFileURL + if parent.deletingLastPathComponent() == pickerRoot { + try? FileManager.default.removeItem(at: parent) + } else { + try? FileManager.default.removeItem(at: url) + } + } + + private func startEditing(_ message: ChatMessage) { + guard message.canBeEdited else { return } + cancelComposerRecording(feedback: false) + replyingToMessageID = nil + editingMessageID = message.id + draft = message.body + isComposerFocused = true + } + + private func beginMessageSelection(with message: ChatMessage) { + cancelComposerRecording(feedback: false) + dismissKeyboard() + if editingMessageID != nil { cancelEditing() } + cancelReplying() + selectedMessageIDs.insert(message.id) + } + + private func toggleMessageSelection(_ message: ChatMessage) { + if selectedMessageIDs.contains(message.id) { + selectedMessageIDs.remove(message.id) + } else { + selectedMessageIDs.insert(message.id) + } + } + + private func clearMessageSelection() { + selectedMessageIDs.removeAll() + } + + private func presentForwarding(_ messages: [ChatMessage]) { + let forwardable = messages.filter(\.canBeForwarded) + guard !forwardable.isEmpty else { return } + forwardingSelection = MessageForwardSelection(messages: forwardable) + } + + private func startReplying(to message: ChatMessage) { + guard message.canBeRepliedTo else { return } + cancelComposerRecording(feedback: false) + if editingMessageID != nil { + editingMessageID = nil + draft = "" + } + replyingToMessageID = message.id + isComposerFocused = true + } + + private func cancelEditing() { + editingMessageID = nil + draft = "" + } + + private func cancelReplying() { + replyingToMessageID = nil + } + + private func editingBanner(_ message: ChatMessage) -> some View { + HStack(spacing: 10) { + Capsule() + .fill(Color.accentColor) + .frame(width: 3, height: 34) + Image(systemName: "pencil") + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 1) { + Text("Редактирование") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + Text(message.body) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button(action: cancelEditing) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Отменить редактирование") + } + .padding(.horizontal, 14) + .padding(.top, 8) + } + + private func replyBanner(_ message: ChatMessage) -> some View { + HStack(spacing: 10) { + Capsule() + .fill(Color.accentColor) + .frame(width: 3, height: 36) + VStack(alignment: .leading, spacing: 1) { + Text("Ответ: \(message.senderDisplayName ?? model.displayName(for: message.senderJID))") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tint) + Text(message.quotePreview) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button(action: cancelReplying) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Отменить ответ") + } + .padding(.horizontal, 14) + .padding(.top, 8) + .padding(.bottom, 3) + } + + private func perform(_ action: MessageDestructiveAction) { + let ids = Set(action.messages.map(\.id)) + if let editingMessageID, ids.contains(editingMessageID) { + cancelEditing() + } + if let replyingToMessageID, ids.contains(replyingToMessageID) { + cancelReplying() + } + selectedMessageIDs.subtract(ids) + switch action.kind { + case .localDelete: + model.deleteMessagesLocally(ids: ids, in: conversation.id) + case .retract: + Task { + for message in action.messages where message.canBeRetracted { + await model.retractMessage(id: message.id) + } + } + } + } + + private func datePill(_ date: Date) -> some View { + Text(date, format: .dateTime.day().month(.wide).year()) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 11) + .padding(.vertical, 5) + .background(.thinMaterial, in: Capsule()) + .padding(.vertical, 10) + } + + private func jumpToLatestButton(using proxy: ScrollViewProxy) -> some View { + Button { + scrollToBottom(proxy, animated: true) + } label: { + Image(systemName: "chevron.down") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 46, height: 46) + .background(.ultraThinMaterial, in: Circle()) + .overlay { + Circle() + .stroke(Color.accentColor.opacity(0.42), lineWidth: 1) + } + .shadow(color: .black.opacity(0.16), radius: 10, y: 4) + } + .buttonStyle(.plain) + .contentShape(Circle()) + .accessibilityLabel("Перейти к последнему сообщению") + .help("Перейти в конец истории") + } + + private func scrollToBottom(_ proxy: ScrollViewProxy, animated: Bool) { + guard !model.selectedMessages.isEmpty else { return } + let action = { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } + if animated { + withAnimation(.easeOut(duration: 0.2)) { action() } + } else { + action() + } + } + + private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy) async { + guard !hasCompletedInitialScroll, + !model.selectedMessages.isEmpty else { return } + + // ScrollViewReader can receive its first command before a long + // LazyVStack has published the bottom anchor. Yield once for layout, + // then repeat on the next frame to make opening at the latest message + // deterministic on iOS, iPadOS and macOS. + await Task.yield() + guard !Task.isCancelled else { return } + scrollToBottom(proxy, animated: false) + + try? await Task.sleep(nanoseconds: 50_000_000) + guard !Task.isCancelled else { return } + scrollToBottom(proxy, animated: false) + hasCompletedInitialScroll = true + } + + private func updateTimelineBottomProximity( + bottomY: CGFloat, + viewportHeight: CGFloat + ) { + let isNearBottom = ChatScrollPositionPolicy.isNearBottom( + bottomY: bottomY, + viewportHeight: viewportHeight + ) + guard isNearBottom != isNearTimelineBottom else { return } + isNearTimelineBottom = isNearBottom + } + + private func formatDuration(_ duration: TimeInterval) -> String { + let seconds = max(0, Int(duration.rounded(.down))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } +} + +private struct TimelineBottomYPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + +#if os(iOS) +/// Reads the real UIScrollView offset without replacing its delegate or +/// installing another pan recognizer. SwiftUI geometry preferences can stop +/// updating after a programmatic ScrollViewReader jump on some iOS releases. +private struct ChatScrollMetricsObserver: UIViewRepresentable { + let identity: String + let onNearBottomChange: (Bool) -> Void + + init(identity: String, onNearBottomChange: @escaping (Bool) -> Void) { + self.identity = identity + self.onNearBottomChange = onNearBottomChange + } + + func makeUIView(context: Context) -> ObserverView { + let view = ObserverView(frame: .zero) + view.identity = identity + view.onNearBottomChange = onNearBottomChange + return view + } + + func updateUIView(_ uiView: ObserverView, context: Context) { + uiView.onNearBottomChange = onNearBottomChange + if uiView.identity != identity { + uiView.identity = identity + uiView.resetReportedState() + } + uiView.attachToAncestorScrollViewIfNeeded() + } + + static func dismantleUIView(_ uiView: ObserverView, coordinator: ()) { + uiView.detach() + } + + final class ObserverView: UIView { + var identity = "" + var onNearBottomChange: ((Bool) -> Void)? + + private weak var observedScrollView: UIScrollView? + private var observations: [NSKeyValueObservation] = [] + private var lastNearBottom: Bool? + private var attachmentScheduled = false + private var metricsReportScheduled = false + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + backgroundColor = .clear + } + + required init?(coder: NSCoder) { + nil + } + + override func didMoveToWindow() { + super.didMoveToWindow() + attachToAncestorScrollViewIfNeeded() + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + attachToAncestorScrollViewIfNeeded() + } + + func attachToAncestorScrollViewIfNeeded() { + guard window != nil, + !attachmentScheduled else { return } + attachmentScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.attachmentScheduled = false + var ancestor = self.superview + while let view = ancestor, !(view is UIScrollView) { + ancestor = view.superview + } + guard let scrollView = ancestor as? UIScrollView else { + self.detach() + return + } + self.observe(scrollView) + } + } + + func resetReportedState() { + lastNearBottom = nil + scheduleMetricsReport() + } + + func detach() { + observations.forEach { $0.invalidate() } + observations.removeAll(keepingCapacity: false) + observedScrollView = nil + metricsReportScheduled = false + } + + private func observe(_ scrollView: UIScrollView) { + guard observedScrollView !== scrollView else { return } + detach() + observedScrollView = scrollView + observations = [ + scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] _, _ in + self?.scheduleMetricsReport() + }, + scrollView.observe(\.contentSize, options: [.new]) { [weak self] _, _ in + self?.scheduleMetricsReport() + }, + scrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in + self?.scheduleMetricsReport() + } + ] + } + + private func scheduleMetricsReport() { + guard !metricsReportScheduled else { return } + metricsReportScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.metricsReportScheduled = false + self.reportMetricsNow() + } + } + + private func reportMetricsNow() { + guard let scrollView = observedScrollView else { return } + let maximumOffsetY = max( + -scrollView.adjustedContentInset.top, + scrollView.contentSize.height + - scrollView.bounds.height + + scrollView.adjustedContentInset.bottom + ) + let distance = maximumOffsetY - scrollView.contentOffset.y + let isNearBottom = ChatScrollPositionPolicy.isNearBottom( + distanceFromBottom: distance + ) + guard lastNearBottom != isNearBottom else { return } + lastNearBottom = isNearBottom + onNearBottomChange?(isNearBottom) + } + } +} +#endif + +enum ComposerRecordingGestureResolution: Equatable { + case continueRecording + case cancel + case lock +} + +struct ComposerRecordingGesturePolicy { + static let cancelDistance: CGFloat = 92 + static let lockDistance: CGFloat = 78 + static let cancelHintFadeDistance: CGFloat = 150 + static let maximumCancelHintFade: CGFloat = 0.72 + static let longPressDelayNanoseconds: UInt64 = 240_000_000 + static let minimumLongPressDuration: TimeInterval = 0.22 + + static func resolution(for translation: CGSize) -> ComposerRecordingGestureResolution { + let cancelProgress = max(0, -translation.width) / cancelDistance + let lockProgress = max(0, -translation.height) / lockDistance + if cancelProgress >= 1, cancelProgress >= lockProgress { + return .cancel + } + if lockProgress >= 1 { + return .lock + } + return .continueRecording + } + + static func cancelHintOpacity(for translation: CGSize) -> Double { + let leftwardDistance = max(CGFloat.zero, -translation.width) + let fadeAmount = min(maximumCancelHintFade, leftwardDistance / cancelHintFadeDistance) + return Double(CGFloat(1) - fadeAmount) + } + + static func isLongPress(elapsed: TimeInterval) -> Bool { + elapsed >= minimumLongPressDuration + } +} + +private enum ComposerCaptureMode: Equatable { + case voice + + var systemImage: String { + "mic.fill" + } + + var recordingAccessibilityLabel: String { + "Запись голосового сообщения" + } + + var idleAccessibilityLabel: String { + "Удерживайте для голосового сообщения" + } +} + +@MainActor +private struct TelegramRecordButton: View { + let mode: ComposerCaptureMode + let isRecording: Bool + let isLocked: Bool + let isFinalizing: Bool + let isEnabled: Bool + let onTap: () -> Void + let onLongPressBegan: () -> Void + let onLongPressChanged: (CGSize) -> Void + let onLongPressEnded: (CGSize) -> Void + let onLongPressCancelled: () -> Void + + @State private var isPressing = false + @State private var didBeginLongPress = false + @State private var pressStartedAt: Date? + @State private var longPressTask: Task? + + var body: some View { + ZStack { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .fill(isRecording ? Color.red.opacity(0.13) : Color.clear) + } + .overlay( + Circle() + .stroke( + isRecording + ? Color.red.opacity(0.42) + : Color.secondary.opacity(0.2), + lineWidth: 0.75 + ) + ) + + if isFinalizing { + ProgressView() + .controlSize(.small) + .tint(iconColor) + } else { + Image(systemName: isLocked ? "arrow.up" : mode.systemImage) + .font(.system(size: isLocked ? 18 : 20, weight: .bold)) + .foregroundStyle(iconColor) + .contentTransition(.symbolEffect(.replace)) + } + } + .frame(width: 42, height: 42) + .scaleEffect(isRecording && !isLocked ? 1.34 : (isPressing ? 1.08 : 1)) + .shadow( + color: isRecording ? Color.black.opacity(0.2) : Color.black.opacity(0.075), + radius: 8, + y: isRecording ? 4 : 2 + ) + .contentShape(Circle()) +#if os(iOS) + .overlay { + ComposerRecordTouchSurface( + onBegan: { beginPhysicalPress(at: $0) }, + onMoved: movePhysicalPress, + onEnded: { translation, eventTime in + endPhysicalPress(translation, at: eventTime) + }, + onCancelled: cancelPhysicalPress + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityHidden(true) + } +#else + .gesture(recordGesture) +#endif + .opacity(isEnabled ? 1 : 0.45) + .allowsHitTesting(isEnabled && !isFinalizing) + .animation(.spring(response: 0.24, dampingFraction: 0.78), value: isRecording) + .animation(.easeOut(duration: 0.12), value: isPressing) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint("Во время записи проведите влево для отмены или вверх для фиксации") + .onDisappear { + longPressTask?.cancel() + longPressTask = nil + pressStartedAt = nil + } + } + + private var iconColor: Color { + isRecording ? .red : .primary + } + + private var accessibilityLabel: String { + if isLocked { return "Отправить записанное сообщение" } + return isRecording ? mode.recordingAccessibilityLabel : mode.idleAccessibilityLabel + } + + private var recordGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + guard isEnabled, !isFinalizing else { return } + if !isPressing { + // DragGesture timestamps belong to the physical input + // events. Date() here would measure when the main actor + // finally handled the event and could turn a real hold + // into a tap after a busy MAM batch. + beginPhysicalPress(at: value.time) + } + if didBeginLongPress { + movePhysicalPress(value.translation) + } + } + .onEnded { value in + endPhysicalPress(value.translation, at: value.time) + } + } + + private func beginPhysicalPress(at eventTime: Date) { + guard isEnabled, !isFinalizing, !isPressing else { return } + isPressing = true + didBeginLongPress = false + pressStartedAt = eventTime + scheduleLongPress() + } + + private func movePhysicalPress(_ translation: CGSize) { + guard didBeginLongPress else { return } + onLongPressChanged(translation) + } + + private func endPhysicalPress(_ translation: CGSize, at eventTime: Date) { + guard isPressing || didBeginLongPress else { return } + longPressTask?.cancel() + longPressTask = nil + let wasLongPress = didBeginLongPress + let elapsed = pressStartedAt.map { + eventTime.timeIntervalSince($0) + } ?? 0 + isPressing = false + didBeginLongPress = false + pressStartedAt = nil + if wasLongPress { + onLongPressEnded(translation) + } else if ComposerRecordingGesturePolicy.isLongPress(elapsed: elapsed) { + // If MAM decryption delayed the main actor, the scheduled + // recognizer callback may arrive after the finger lifts. Recover + // the physical press duration instead of turning it into a tap. + onLongPressBegan() + onLongPressEnded(translation) + } else { + onTap() + } + } + + private func cancelPhysicalPress() { + let wasLongPress = didBeginLongPress + longPressTask?.cancel() + longPressTask = nil + isPressing = false + didBeginLongPress = false + pressStartedAt = nil + if wasLongPress { + // UIKit distinguishes a real touch-up from an interruption (for + // example an app transition or system gesture). Never translate a + // cancelled touch into the Telegram-style "release to send" path. + onLongPressCancelled() + } + } + + private func scheduleLongPress() { + longPressTask?.cancel() + longPressTask = Task { @MainActor in + do { + try await Task.sleep( + nanoseconds: ComposerRecordingGesturePolicy.longPressDelayNanoseconds + ) + } catch { + return + } + guard isPressing else { return } + didBeginLongPress = true + onLongPressBegan() + } + } +} + +#if os(iOS) +/// A raw UIKit touch surface keeps ownership of the physical finger while the +/// SwiftUI composer swaps its idle controls for the recording preview. A +/// SwiftUI DragGesture can be cancelled by that hierarchy update and used to +/// report the cancellation as an early end on some iOS releases. +@MainActor +private struct ComposerRecordTouchSurface: UIViewRepresentable { + let onBegan: (Date) -> Void + let onMoved: (CGSize) -> Void + let onEnded: (CGSize, Date) -> Void + let onCancelled: () -> Void + + func makeUIView(context: Context) -> TouchView { + let view = TouchView(frame: .zero) + update(view) + return view + } + + func updateUIView(_ uiView: TouchView, context: Context) { + update(uiView) + } + + private func update(_ view: TouchView) { + view.onBegan = onBegan + view.onMoved = onMoved + view.onEnded = onEnded + view.onCancelled = onCancelled + } + + final class TouchView: UIView { + var onBegan: ((Date) -> Void)? + var onMoved: ((CGSize) -> Void)? + var onEnded: ((CGSize, Date) -> Void)? + var onCancelled: (() -> Void)? + + private var activeTouch: UITouch? + private var origin = CGPoint.zero + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + isExclusiveTouch = true + isMultipleTouchEnabled = false + isAccessibilityElement = false + } + + required init?(coder: NSCoder) { + nil + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard activeTouch == nil, let touch = touches.first else { return } + activeTouch = touch + origin = touch.location(in: window) + onBegan?(eventDate(for: touch)) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let touch = trackedTouch(in: touches) else { return } + onMoved?(translation(for: touch)) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let touch = trackedTouch(in: touches) else { return } + let translation = translation(for: touch) + let date = eventDate(for: touch) + activeTouch = nil + onEnded?(translation, date) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + guard trackedTouch(in: touches) != nil else { return } + activeTouch = nil + onCancelled?() + } + + private func trackedTouch(in touches: Set) -> UITouch? { + guard let activeTouch else { return nil } + return touches.first(where: { $0 === activeTouch }) + } + + private func translation(for touch: UITouch) -> CGSize { + let location = touch.location(in: window) + return CGSize( + width: location.x - origin.x, + height: location.y - origin.y + ) + } + + private func eventDate(for touch: UITouch) -> Date { + Date().addingTimeInterval( + touch.timestamp - ProcessInfo.processInfo.systemUptime + ) + } + } +} +#endif + +private struct ReplyThreadSelection: Equatable { + let rootID: String + let selectedReplyID: String +} + +private struct MessageForwardSelection: Identifiable { + let id = UUID() + let messages: [ChatMessage] +} + +private enum FileImportMode { + case files + case audio + + var allowedContentTypes: [UTType] { + switch self { + case .files: return [.item] + case .audio: return [.audio] + } + } + + var preferredKind: ChatMessage.Kind? { + switch self { + case .files: return nil + case .audio: return .audio + } + } +} + +private enum MediaPickerError: LocalizedError { + case cannotReadSelection + + var errorDescription: String? { + "Не удалось прочитать выбранное фото или видео. Попробуйте сохранить его в «Файлы» и отправить оттуда." + } +} + +private struct MessageDestructiveAction: Identifiable { + enum Kind: Equatable { + case localDelete + case retract + } + + let kind: Kind + let messages: [ChatMessage] + + var id: String { + let messageIDs = messages.map(\.id).sorted().joined(separator: "-") + return "\(kind == .localDelete ? "local" : "retract")-\(messageIDs)" + } + + var title: String { + let plural = messages.count > 1 + switch kind { + case .localDelete: + return plural ? "Удалить выбранные сообщения у себя?" : "Удалить сообщение у себя?" + case .retract: + return plural ? "Удалить выбранные сообщения у всех?" : "Удалить сообщение у всех?" + } + } + + var explanation: String { + switch kind { + case .localDelete: + return messages.count > 1 + ? "\(messageCountText) исчезнут только из истории Luma на этом устройстве." + : "Сообщение исчезнет только из истории Luma на этом устройстве." + case .retract: + return "Luma отправит XMPP-запрос retract для \(messageCountText). Сервер и другие клиенты могут не удалить уже полученные копии." + } + } + + var confirmationTitle: String { + kind == .localDelete ? "Удалить у меня" : "Удалить у всех" + } + + private var messageCountText: String { + let count = messages.count + let remainder100 = count % 100 + let remainder10 = count % 10 + let word: String + if (11...14).contains(remainder100) { + word = "сообщений" + } else if remainder10 == 1 { + word = "сообщение" + } else if (2...4).contains(remainder10) { + word = "сообщения" + } else { + word = "сообщений" + } + return "\(count) \(word)" + } +} + +private struct PickedPhoto: Transferable { + let url: URL + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(contentType: .image) { photo in + SentTransferredFile(photo.url) + } importing: { received in + PickedPhoto(url: try copyPickedMediaFile(received.file, fallbackExtension: "jpg")) + } + } +} + +private struct PickedVideo: Transferable { + let url: URL + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(contentType: .movie) { video in + SentTransferredFile(video.url) + } importing: { received in + PickedVideo(url: try copyPickedMediaFile(received.file, fallbackExtension: "mov")) + } + } +} + +private func copyPickedMediaFile(_ source: URL, fallbackExtension: String) throws -> URL { + let fileManager = FileManager.default + let rootDirectory = fileManager.temporaryDirectory + .appendingPathComponent("LumaPhotoPicker", isDirectory: true) + let selectionDirectory = rootDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.createDirectory( + at: selectionDirectory, + withIntermediateDirectories: true + ) + + let values = try? source.resourceValues(forKeys: [.nameKey, .contentTypeKey]) + let fileExtension = source.pathExtension.isEmpty + ? (values?.contentType?.preferredFilenameExtension ?? fallbackExtension) + : source.pathExtension + var filename = values?.name ?? source.lastPathComponent + if filename.isEmpty { + filename = "media.\(fileExtension)" + } else if URL(fileURLWithPath: filename).pathExtension.isEmpty { + filename += ".\(fileExtension)" + } + let forbidden = CharacterSet(charactersIn: "/\\:") + .union(.controlCharacters) + filename = filename.components(separatedBy: forbidden).joined(separator: "_") + let destination = selectionDirectory.appendingPathComponent(filename) + let accessed = source.startAccessingSecurityScopedResource() + defer { + if accessed { source.stopAccessingSecurityScopedResource() } + } + + var coordinatedError: NSError? + var copyResult: Result? + NSFileCoordinator(filePresenter: nil).coordinate( + readingItemAt: source, + options: [], + error: &coordinatedError + ) { coordinatedURL in + copyResult = Result { + do { + try fileManager.copyItem(at: coordinatedURL, to: destination) + let stagedValues = try destination.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ) + guard stagedValues.isRegularFile == true, + fileManager.isReadableFile(atPath: destination.path) else { + throw MediaFileIOError.unreadableFile(filename) + } + guard (stagedValues.fileSize ?? 0) > 0 else { + throw MediaFileIOError.emptyFile(filename) + } + return destination + } catch { + try? fileManager.removeItem(at: selectionDirectory) + throw error + } + } + } + if let copyResult { + return try copyResult.get() + } + try? fileManager.removeItem(at: selectionDirectory) + throw MediaFileIOError.coordinatedReadFailed( + coordinatedError?.localizedDescription ?? "неизвестная ошибка" + ) +} + +private func writePickedImageData(_ data: Data) throws -> URL { + guard !data.isEmpty else { + throw MediaFileIOError.emptyFile("Фото") + } + let contentType: UTType? + if let imageSource = CGImageSourceCreateWithData(data as CFData, nil), + let typeIdentifier = CGImageSourceGetType(imageSource) { + contentType = UTType(typeIdentifier as String) + } else { + contentType = nil + } + let fileExtension = contentType?.preferredFilenameExtension ?? "jpg" + let rootDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaPhotoPicker", isDirectory: true) + let selectionDirectory = rootDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: selectionDirectory, + withIntermediateDirectories: true + ) + let destination = selectionDirectory.appendingPathComponent( + "photo.\(fileExtension)" + ) + do { + try data.write(to: destination, options: [.atomic]) + return destination + } catch { + try? FileManager.default.removeItem(at: selectionDirectory) + throw error + } +} diff --git a/Sources/Shared/UI/Components/AudioMessagePlayer.swift b/Sources/Shared/UI/Components/AudioMessagePlayer.swift new file mode 100644 index 0000000..efe6331 --- /dev/null +++ b/Sources/Shared/UI/Components/AudioMessagePlayer.swift @@ -0,0 +1,307 @@ +import Foundation +import SwiftUI + +@MainActor +struct AudioMessagePlayer: View { + @ObservedObject var model: AppModel + @ObservedObject private var playback: MediaPlaybackCoordinator + let message: ChatMessage + let onFallbackTap: () -> Void + + init( + model: AppModel, + message: ChatMessage, + onFallbackTap: @escaping () -> Void + ) { + self.model = model + _playback = ObservedObject(wrappedValue: model.audioPlayback) + self.message = message + self.onFallbackTap = onFallbackTap + } + + var body: some View { + Group { + if message.kind == .voice { + voicePlayer + } else { + musicPlayer + } + } + .frame(width: 252, alignment: .leading) + .task(id: message.remoteAttachmentURL) { + await model.prepareMediaPreview(message) + } + .accessibilityElement(children: .contain) + } + + private var voicePlayer: some View { + HStack(spacing: 10) { + playButton + + VStack(alignment: .leading, spacing: 3) { + WaveformScrubber( + samples: model.audioWaveform(for: message), + progress: progress, + activeColor: accent, + inactiveColor: secondaryAccent, + onSeek: seek + ) + .frame(height: 30) + + HStack(spacing: 7) { + Text(voiceTimeText) + .font(.caption2.monospacedDigit()) + .foregroundStyle(secondaryText) + Spacer() + Button(action: playback.cycleVoiceRate) { + Text(rateText) + .font(.caption2.monospacedDigit().weight(.bold)) + .foregroundStyle(accent) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(accent.opacity(0.14), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Скорость воспроизведения \(rateText)") + } + } + } + } + + private var musicPlayer: some View { + HStack(spacing: 11) { + playButton + + VStack(alignment: .leading, spacing: 4) { + Text(message.localFilename ?? "Аудиофайл") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(primaryText) + .lineLimit(1) + + MediaProgressScrubber( + progress: progress, + activeColor: accent, + inactiveColor: secondaryAccent, + onSeek: seek + ) + .frame(height: 12) + + HStack(spacing: 6) { + Text(audioTimeText) + if let byteCount = message.byteCount { + Text("·") + Text(ByteCountFormatter.string( + fromByteCount: Int64(byteCount), + countStyle: .file + )) + } + } + .font(.caption2.monospacedDigit()) + .foregroundStyle(secondaryText) + } + } + } + + private var playButton: some View { + Button(action: togglePlayback) { + ZStack { + Circle() + .fill(accent.opacity(message.direction == .outgoing ? 0.2 : 0.14)) + if model.isMediaPreviewLoading(message) && localURL == nil { + ProgressView() + .tint(accent) + .controlSize(.small) + } else { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(accent) + .offset(x: isPlaying ? 0 : 1) + } + } + .frame(width: 44, height: 44) + } + .buttonStyle(.plain) + .accessibilityLabel(isPlaying ? "Пауза" : "Воспроизвести") + } + + private var localURL: URL? { + model.mediaPreviewURL(for: message) + } + + private var isCurrent: Bool { + playback.currentMessageID == message.id + } + + private var isPlaying: Bool { + isCurrent && playback.isPlaying + } + + private var totalDuration: TimeInterval { + let value = isCurrent && playback.duration > 0 + ? playback.duration + : (message.duration ?? 0) + return max(0, value) + } + + private var displayedTime: TimeInterval { + isCurrent ? playback.currentTime : 0 + } + + private var progress: Double { + guard isCurrent, totalDuration > 0 else { return 0 } + return max(0, min(1, playback.currentTime / totalDuration)) + } + + private var voiceTimeText: String { + if isCurrent, playback.currentTime > 0 { + return "\(MediaTimeFormatter.string(displayedTime)) / \(MediaTimeFormatter.string(totalDuration))" + } + return MediaTimeFormatter.string(totalDuration) + } + + private var audioTimeText: String { + "\(MediaTimeFormatter.string(displayedTime)) / \(MediaTimeFormatter.string(totalDuration))" + } + + private var rateText: String { + if playback.voiceRate < 1.25 { return "1×" } + if playback.voiceRate < 1.75 { return "1.5×" } + return "2×" + } + + private var accent: Color { + message.direction == .outgoing ? .white : .accentColor + } + + private var secondaryAccent: Color { + message.direction == .outgoing ? .white.opacity(0.30) : Color.secondary.opacity(0.24) + } + + private var primaryText: Color { + message.direction == .outgoing ? .white : .primary + } + + private var secondaryText: Color { + message.direction == .outgoing ? .white.opacity(0.78) : .secondary + } + + private func togglePlayback() { + guard let localURL else { + Task { + await model.prepareMediaPreview(message) + guard let downloadedURL = model.mediaPreviewURL(for: message) else { + onFallbackTap() + return + } + playback.toggle( + messageID: message.id, + url: downloadedURL, + durationHint: message.duration, + isVoice: message.kind == .voice + ) + } + return + } + playback.toggle( + messageID: message.id, + url: localURL, + durationHint: message.duration, + isVoice: message.kind == .voice + ) + } + + private func seek(_ fraction: Double) { + guard let localURL else { return } + playback.seek( + messageID: message.id, + url: localURL, + durationHint: message.duration, + isVoice: message.kind == .voice, + fraction: fraction + ) + } +} + +private struct WaveformScrubber: View { + let samples: [Float] + let progress: Double + let activeColor: Color + let inactiveColor: Color + let onSeek: (Double) -> Void + + var body: some View { + GeometryReader { geometry in + HStack(alignment: .center, spacing: 2) { + ForEach(Array(samples.enumerated()), id: \.offset) { index, sample in + Capsule() + .fill(sampleProgress(index) <= progress ? activeColor : inactiveColor) + .frame(maxWidth: .infinity) + .frame(height: max(3, geometry.size.height * CGFloat(sample))) + } + } + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + onSeek(fraction(at: value.location.x, width: geometry.size.width)) + } + ) + } + .accessibilityLabel("Позиция голосового сообщения") + .accessibilityValue("\(Int(progress * 100)) процентов") + } + + private func sampleProgress(_ index: Int) -> Double { + guard samples.count > 1 else { return 0 } + return Double(index) / Double(samples.count - 1) + } +} + +private struct MediaProgressScrubber: View { + let progress: Double + let activeColor: Color + let inactiveColor: Color + let onSeek: (Double) -> Void + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(inactiveColor).frame(height: 3) + Capsule() + .fill(activeColor) + .frame(width: geometry.size.width * CGFloat(progress), height: 3) + Circle() + .fill(activeColor) + .frame(width: 9, height: 9) + .offset(x: max(0, geometry.size.width * CGFloat(progress) - 4.5)) + } + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + onSeek(fraction(at: value.location.x, width: geometry.size.width)) + } + ) + } + .accessibilityLabel("Позиция аудиофайла") + .accessibilityValue("\(Int(progress * 100)) процентов") + } +} + +private func fraction(at x: CGFloat, width: CGFloat) -> Double { + guard width > 0 else { return 0 } + return max(0, min(1, Double(x / width))) +} + +enum MediaTimeFormatter { + static func string(_ duration: TimeInterval) -> String { + guard duration.isFinite else { return "0:00" } + let seconds = max(0, Int(duration.rounded(.down))) + if seconds >= 3_600 { + return String(format: "%d:%02d:%02d", seconds / 3_600, (seconds / 60) % 60, seconds % 60) + } + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } +} diff --git a/Sources/Shared/UI/Components/AvatarView.swift b/Sources/Shared/UI/Components/AvatarView.swift new file mode 100644 index 0000000..7e716a8 --- /dev/null +++ b/Sources/Shared/UI/Components/AvatarView.swift @@ -0,0 +1,92 @@ +import Foundation +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +struct AvatarView: View { + let conversation: Conversation + var imageData: Data? + var size: CGFloat = 50 + + var body: some View { + ZStack(alignment: .bottomTrailing) { + avatarContent + .frame(width: size, height: size) + .clipShape(Circle()) + + if isActive { + Circle() + .fill(.green) + .frame(width: size * 0.25, height: size * 0.25) + .overlay(Circle().stroke(.background, lineWidth: 2.5)) + } + } + .accessibilityLabel(accessibilityText) + } + + @ViewBuilder + private var avatarContent: some View { +#if os(iOS) + if let imageData, let image = UIImage(data: imageData) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + fallback + } +#elseif os(macOS) + if let imageData, let image = NSImage(data: imageData) { + Image(nsImage: image) + .resizable() + .scaledToFill() + } else { + fallback + } +#endif + } + + private var fallback: some View { + Circle() + .fill( + LinearGradient( + colors: [baseColor, baseColor.opacity(0.68)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .overlay { + if conversation.isGroup { + Image(systemName: "person.3.fill") + .font(.system(size: size * 0.31, weight: .semibold)) + .foregroundStyle(.white) + } else { + Text(conversation.initials) + .font(.system(size: size * 0.35, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + } + } + } + + private var isActive: Bool { + conversation.isGroup ? conversation.isGroupJoined : conversation.isOnline + } + + private var accessibilityText: String { + if conversation.isGroup { + return "\(conversation.displayName), \(conversation.isGroupJoined ? "подключено" : "не подключено")" + } + return "\(conversation.displayName), \(conversation.isOnline ? "в сети" : "не в сети")" + } + + private var baseColor: Color { + Color( + hue: Double(conversation.colorSeed % 360) / 360, + saturation: 0.68, + brightness: 0.88 + ) + } +} diff --git a/Sources/Shared/UI/Components/BubbleShapes.swift b/Sources/Shared/UI/Components/BubbleShapes.swift new file mode 100644 index 0000000..c2a23c8 --- /dev/null +++ b/Sources/Shared/UI/Components/BubbleShapes.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// Short faded curve under the compact source bubble in a collapsed reply. +struct ReplyThreadCurve: Shape { + let sourceDirection: ChatMessage.Direction + + func path(in rect: CGRect) -> Path { + var path = Path() + if sourceDirection == .incoming { + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addCurve( + to: CGPoint(x: rect.maxX, y: rect.maxY), + control1: CGPoint(x: rect.minX + 5, y: rect.maxY * 0.78), + control2: CGPoint(x: rect.midX, y: rect.maxY) + ) + } else { + path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addCurve( + to: CGPoint(x: rect.minX, y: rect.maxY), + control1: CGPoint(x: rect.maxX - 5, y: rect.maxY * 0.78), + control2: CGPoint(x: rect.midX, y: rect.maxY) + ) + } + return path + } +} + +/// Bracket displayed beside a source message and several replies in focus mode. +struct ReplyThreadBracket: Shape { + let opensToTrailing: Bool + + func path(in rect: CGRect) -> Path { + let edgeX = opensToTrailing ? rect.maxX : rect.minX + let spineX = opensToTrailing ? rect.minX : rect.maxX + let radius = min(26, rect.width) + var path = Path() + + path.move(to: CGPoint(x: edgeX, y: rect.minY)) + path.addCurve( + to: CGPoint(x: spineX, y: rect.minY + radius), + control1: CGPoint(x: spineX, y: rect.minY), + control2: CGPoint(x: spineX, y: rect.minY + radius * 0.45) + ) + path.addLine(to: CGPoint(x: spineX, y: rect.maxY - radius)) + path.addCurve( + to: CGPoint(x: edgeX, y: rect.maxY), + control1: CGPoint(x: spineX, y: rect.maxY - radius * 0.45), + control2: CGPoint(x: spineX, y: rect.maxY) + ) + return path + } +} diff --git a/Sources/Shared/UI/Components/ConnectionBanner.swift b/Sources/Shared/UI/Components/ConnectionBanner.swift new file mode 100644 index 0000000..305ae1b --- /dev/null +++ b/Sources/Shared/UI/Components/ConnectionBanner.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct ConnectionBanner: View { + @ObservedObject var model: AppModel + + var body: some View { + switch model.connectionStatus { + case .connected: + if model.isArchiveSyncing || !model.isOMEMOReady { + banner( + icon: model.isArchiveSyncing ? "arrow.triangle.2.circlepath" : "lock.rotation", + text: model.isArchiveSyncing ? "Синхронизация истории…" : "Публикация ключей OMEMO…", + color: .blue, + progress: true + ) + } + case .connecting: + banner(icon: "antenna.radiowaves.left.and.right", text: "Подключение…", color: .orange, progress: true) + case .disconnected(let reason): + HStack(spacing: 8) { + Image(systemName: "wifi.exclamationmark") + Text(reason ?? "Нет соединения") + .lineLimit(1) + Spacer() + Button("Повторить") { Task { await model.reconnect() } } + .buttonStyle(.borderless) + .fontWeight(.semibold) + } + .font(.caption) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.red.opacity(0.88)) + } + } + + private func banner(icon: String, text: String, color: Color, progress: Bool) -> some View { + HStack(spacing: 8) { + if progress { + ProgressView().controlSize(.mini).tint(.white) + } else { + Image(systemName: icon) + } + Text(text) + Spacer() + } + .font(.caption) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(color.opacity(0.88)) + } +} + diff --git a/Sources/Shared/UI/Components/ConversationRow.swift b/Sources/Shared/UI/Components/ConversationRow.swift new file mode 100644 index 0000000..bc314e4 --- /dev/null +++ b/Sources/Shared/UI/Components/ConversationRow.swift @@ -0,0 +1,60 @@ +import Foundation +import SwiftUI + +struct ConversationRow: View { + let conversation: Conversation + let imageData: Data? + let isEncrypted: Bool + + var body: some View { + HStack(spacing: 12) { + AvatarView(conversation: conversation, imageData: imageData) + + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(conversation.displayName) + .font(.headline) + .lineLimit(1) + Spacer(minLength: 8) + if conversation.lastActivity != .distantPast { + Text(conversation.lastActivity, format: .dateTime.hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + HStack(spacing: 7) { + if conversation.isGroup { + Image(systemName: "person.3.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } else if !conversation.lastMessage.isEmpty { + Image(systemName: isEncrypted ? "lock.fill" : "lock.open") + .font(.caption2) + .foregroundStyle(isEncrypted ? Color.secondary : Color.orange) + } + Text(conversation.lastMessage.isEmpty ? conversation.jid : conversation.lastMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 8) + if conversation.isGroup, conversation.occupantCount > 0 { + Text("\(conversation.occupantCount)") + .font(.caption2) + .foregroundStyle(.secondary) + } + if conversation.unreadCount > 0 { + Text("\(min(conversation.unreadCount, 99))") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(.tint, in: Capsule()) + } + } + } + } + .contentShape(Rectangle()) + .padding(.vertical, 2) + } +} diff --git a/Sources/Shared/UI/Components/InlineVideoPlayer.swift b/Sources/Shared/UI/Components/InlineVideoPlayer.swift new file mode 100644 index 0000000..697b92d --- /dev/null +++ b/Sources/Shared/UI/Components/InlineVideoPlayer.swift @@ -0,0 +1,73 @@ +import AVFoundation +import SwiftUI + +#if os(iOS) +import UIKit + +struct InlineVideoPlayer: UIViewRepresentable { + let player: AVPlayer + + func makeUIView(context: Context) -> PlayerView { + let view = PlayerView() + view.playerLayer.player = player + return view + } + + func updateUIView(_ uiView: PlayerView, context: Context) { + uiView.playerLayer.player = player + } + + final class PlayerView: UIView { + override class var layerClass: AnyClass { AVPlayerLayer.self } + + var playerLayer: AVPlayerLayer { + layer as! AVPlayerLayer + } + + override init(frame: CGRect) { + super.init(frame: frame) + playerLayer.videoGravity = .resizeAspectFill + } + + required init?(coder: NSCoder) { + nil + } + } +} +#elseif os(macOS) +import AppKit + +struct InlineVideoPlayer: NSViewRepresentable { + let player: AVPlayer + + func makeNSView(context: Context) -> PlayerView { + let view = PlayerView() + view.playerLayer.player = player + return view + } + + func updateNSView(_ nsView: PlayerView, context: Context) { + nsView.playerLayer.player = player + } + + final class PlayerView: NSView { + let playerLayer = AVPlayerLayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer = playerLayer + playerLayer.videoGravity = .resizeAspectFill + } + + required init?(coder: NSCoder) { + nil + } + + override func layout() { + super.layout() + playerLayer.frame = bounds + } + } +} +#endif diff --git a/Sources/Shared/UI/Components/LocationMessagePreview.swift b/Sources/Shared/UI/Components/LocationMessagePreview.swift new file mode 100644 index 0000000..d3b1198 --- /dev/null +++ b/Sources/Shared/UI/Components/LocationMessagePreview.swift @@ -0,0 +1,63 @@ +import MapKit +import SwiftUI + +struct LocationMessagePreview: View { + let location: GeoLocation + + var body: some View { + Button(action: openInMaps) { + ZStack(alignment: .bottomLeading) { + Map(initialPosition: cameraPosition, interactionModes: []) { + Marker("Геопозиция", coordinate: coordinate) + .tint(.red) + } + .allowsHitTesting(false) + + HStack(spacing: 7) { + Image(systemName: "location.fill") + VStack(alignment: .leading, spacing: 1) { + Text("Геопозиция") + .font(.subheadline.weight(.semibold)) + Text("Открыть в Картах") + .font(.caption) + .opacity(0.82) + } + } + .foregroundStyle(.white) + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background(.black.opacity(0.58), in: RoundedRectangle(cornerRadius: 11)) + .padding(9) + } + .frame(width: 252, height: 170) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(.primary.opacity(0.1), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Геопозиция") + .accessibilityHint("Открывает точку в Картах") + } + + private var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude) + } + + private var cameraPosition: MapCameraPosition { + .region( + MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 1_500, + longitudinalMeters: 1_500 + ) + ) + } + + private func openInMaps() { + let item = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) + item.name = "Геопозиция из Luma" + item.openInMaps() + } +} diff --git a/Sources/Shared/UI/Components/MediaViewer.swift b/Sources/Shared/UI/Components/MediaViewer.swift new file mode 100644 index 0000000..88cf5b3 --- /dev/null +++ b/Sources/Shared/UI/Components/MediaViewer.swift @@ -0,0 +1,363 @@ +import AVFoundation +import AVKit +import SwiftUI + +#if os(iOS) +import UIKit +private typealias MediaViewerPlatformImage = UIImage +#elseif os(macOS) +import AppKit +private typealias MediaViewerPlatformImage = NSImage +#endif + +enum MediaViewerDismissGesturePolicy { + private static let verticalDominance: CGFloat = 1.15 + + static func isVertical(_ translation: CGSize) -> Bool { + let verticalDistance = abs(translation.height) + let horizontalDistance = abs(translation.width) + return verticalDistance >= 8 && verticalDistance > horizontalDistance * verticalDominance + } + + static func shouldDismiss( + translation: CGSize, + predictedEndTranslation: CGSize, + containerHeight: CGFloat + ) -> Bool { + guard isVertical(translation) else { return false } + + let distanceThreshold = min(140, max(88, containerHeight * 0.14)) + if abs(translation.height) >= distanceThreshold { + return true + } + + return isVertical(predictedEndTranslation) + && abs(predictedEndTranslation.height) >= distanceThreshold * 1.35 + } +} + +@MainActor +struct MediaViewer: View { + let item: MediaViewerItem + let onClose: () -> Void + + @State private var dragOffset: CGFloat = 0 + @State private var isTrackingDismissGesture = false + @State private var isCompletingDismissal = false + @State private var isPhotoZoomed = false + + var body: some View { + GeometryReader { geometry in + ZStack { + Color.black + .opacity(backgroundOpacity(in: geometry.size.height)) + .ignoresSafeArea() + + ZStack { + viewerContent + .ignoresSafeArea(edges: .bottom) + + VStack(spacing: 0) { + HStack { + Text(item.title) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(1) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 8) + .background( + LinearGradient( + colors: [.black.opacity(0.72), .clear], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + ) + + Spacer() + } + } + .offset(y: dragOffset) + .scaleEffect(contentScale(in: geometry.size.height)) + } + .contentShape(Rectangle()) + .simultaneousGesture( + verticalDismissGesture(in: geometry.size), + including: .all + ) + .onChange(of: isPhotoZoomed) { _, isZoomed in + guard isZoomed else { return } + resetDismissGesture(animated: true) + } + .accessibilityAction(.escape) { + closeImmediately() + } + } +#if os(macOS) + .onExitCommand { + closeImmediately() + } +#endif + } + + private var canTrackDismissGesture: Bool { + !isPhotoZoomed && !isCompletingDismissal + } + + private func verticalDismissGesture(in size: CGSize) -> some Gesture { + DragGesture(minimumDistance: 8, coordinateSpace: .global) + .onChanged { value in + guard canTrackDismissGesture else { return } + + if !isTrackingDismissGesture { + guard MediaViewerDismissGesturePolicy.isVertical(value.translation) else { return } + isTrackingDismissGesture = true + } + + dragOffset = value.translation.height + } + .onEnded { value in + guard isTrackingDismissGesture else { return } + isTrackingDismissGesture = false + + guard canTrackDismissGesture else { + resetDismissGesture(animated: true) + return + } + + if MediaViewerDismissGesturePolicy.shouldDismiss( + translation: value.translation, + predictedEndTranslation: value.predictedEndTranslation, + containerHeight: size.height + ) { + completeDismissal( + direction: dismissalDirection(for: value), + containerHeight: size.height + ) + } else { + resetDismissGesture(animated: true) + } + } + } + + private func dismissalDirection(for value: DragGesture.Value) -> CGFloat { + let projected = value.predictedEndTranslation.height + let distance = abs(projected) > abs(value.translation.height) + ? projected + : value.translation.height + return distance < 0 ? -1 : 1 + } + + private func completeDismissal(direction: CGFloat, containerHeight: CGFloat) { + guard !isCompletingDismissal else { return } + isCompletingDismissal = true + + withAnimation(.easeOut(duration: 0.18)) { + dragOffset = direction * max(700, containerHeight * 1.15) + } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 150_000_000) + onClose() + } + } + + private func resetDismissGesture(animated: Bool) { + isTrackingDismissGesture = false + if animated { + withAnimation(.spring(response: 0.3, dampingFraction: 0.84)) { + dragOffset = 0 + } + } else { + dragOffset = 0 + } + } + + private func closeImmediately() { + guard !isCompletingDismissal else { return } + isCompletingDismissal = true + onClose() + } + + private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat { + min(1, abs(dragOffset) / max(1, containerHeight * 0.62)) + } + + private func backgroundOpacity(in containerHeight: CGFloat) -> Double { + Double(1 - dismissalProgress(in: containerHeight) * 0.72) + } + + private func contentScale(in containerHeight: CGFloat) -> CGFloat { + 1 - dismissalProgress(in: containerHeight) * 0.045 + } + + @ViewBuilder + private var viewerContent: some View { + switch item.kind { + case .photo: + ZoomablePhotoView(url: item.url, isZoomed: $isPhotoZoomed) + case .video: + FullWindowVideoPlayer(url: item.url) + default: + ContentUnavailableView( + "Не удалось открыть медиа", + systemImage: "exclamationmark.triangle" + ) + .foregroundStyle(.white) + } + } +} + +@MainActor +private struct FullWindowVideoPlayer: View { + let url: URL + @State private var player: AVPlayer? + + init(url: URL) { + self.url = url + } + + var body: some View { + VideoPlayer(player: player) + .task(id: url) { + player?.pause() + let nextPlayer = AVPlayer(url: url) + player = nextPlayer + nextPlayer.play() + } + .onDisappear { + player?.pause() + player = nil + } + } +} + +@MainActor +private struct ZoomablePhotoView: View { + let url: URL + @Binding var isZoomed: Bool + + @State private var imageValue: MediaViewerPlatformImage? + @State private var hasFinishedLoading = false + @State private var scale: CGFloat = 1 + @State private var offset: CGSize = .zero + @GestureState private var gestureScale: CGFloat = 1 + @GestureState private var gestureOffset: CGSize = .zero + + var body: some View { + GeometryReader { geometry in + ZStack { + Color.black + + if let imageValue { + image(imageValue) + .scaledToFit() + .frame(width: geometry.size.width, height: geometry.size.height) + .scaleEffect(effectiveScale) + .offset(effectiveOffset) + .gesture(dragGesture) + .simultaneousGesture(magnifyGesture) + .onTapGesture(count: 2, perform: toggleZoom) + } else if hasFinishedLoading { + invalidImage + } else { + ProgressView() + .tint(.white) + .controlSize(.large) + } + } + .clipped() + } + .onChange(of: effectiveScale) { _, value in + isZoomed = value > 1.01 + } + .onDisappear { + isZoomed = false + } + .task(id: url) { + imageValue = nil + hasFinishedLoading = false + let data = await Task.detached(priority: .userInitiated) { + try? Data(contentsOf: url, options: [.mappedIfSafe]) + }.value + guard !Task.isCancelled else { return } + imageValue = data.flatMap { MediaViewerPlatformImage(data: $0) } + hasFinishedLoading = true + } + } + + private var effectiveScale: CGFloat { + min(5, max(1, scale * gestureScale)) + } + + private var effectiveOffset: CGSize { + guard effectiveScale > 1 else { return .zero } + return CGSize( + width: offset.width + gestureOffset.width, + height: offset.height + gestureOffset.height + ) + } + + @ViewBuilder + private func image(_ value: MediaViewerPlatformImage) -> some View { +#if os(iOS) + Image(uiImage: value) + .resizable() +#elseif os(macOS) + Image(nsImage: value) + .resizable() +#endif + } + + private var invalidImage: some View { + ContentUnavailableView( + "Фото повреждено", + systemImage: "photo.badge.exclamationmark" + ) + .foregroundStyle(.white) + } + + private var dragGesture: some Gesture { + DragGesture() + .updating($gestureOffset) { value, state, _ in + guard effectiveScale > 1 else { return } + state = value.translation + } + .onEnded { value in + guard effectiveScale > 1 else { + offset = .zero + return + } + offset.width += value.translation.width + offset.height += value.translation.height + } + } + + private var magnifyGesture: some Gesture { + MagnifyGesture() + .updating($gestureScale) { value, state, _ in + state = value.magnification + } + .onEnded { value in + scale = min(5, max(1, scale * value.magnification)) + if scale == 1 { + offset = .zero + } + } + } + + private func toggleZoom() { + withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) { + if scale > 1 { + scale = 1 + offset = .zero + } else { + scale = 2 + } + } + } +} diff --git a/Sources/Shared/UI/Components/MessageBubble.swift b/Sources/Shared/UI/Components/MessageBubble.swift new file mode 100644 index 0000000..7207025 --- /dev/null +++ b/Sources/Shared/UI/Components/MessageBubble.swift @@ -0,0 +1,737 @@ +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +@MainActor +struct MessageBubble: View { + let model: AppModel + let message: ChatMessage + let isSelectionMode: Bool + let isSelected: Bool + let onAttachmentTap: () -> Void + let onEdit: () -> Void + let onReply: () -> Void + let onForward: () -> Void + let onRetry: () -> Void + let onToggleSelection: () -> Void + let onBeginSelection: () -> Void + let onRetract: () -> Void + let onDelete: () -> Void + let onReplyTap: (String) -> Void + let onReact: (String) -> Void + + @State private var replySwipeOffset: CGFloat = 0 + @State private var replySwipeArmed = false + + var body: some View { + ZStack { + replySwipeIndicator + messageContainer + .offset(x: replySwipeOffset) + } + .frame(maxWidth: .infinity) + .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + guard isSelectionMode else { return } + onToggleSelection() + } + .padding( + .vertical, + message.kind == .photo || message.kind == .video ? 7 : 2 + ) + .simultaneousGesture( + replySwipeGesture, + including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none + ) + .onChange(of: isSelectionMode) { _, selecting in + guard selecting else { return } + replySwipeOffset = 0 + replySwipeArmed = false + } + .contextMenu { + if !isSelectionMode { + if message.canBeReactedTo { + Menu { + ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in + Button(emoji) { onReact(emoji) } + } + } label: { + Label("Реакция", systemImage: "face.smiling") + } + } + if message.canBeRepliedTo { + Button(action: onReply) { + Label("Ответить", systemImage: "arrowshape.turn.up.left") + } + } + if message.canBeForwarded { + Button(action: onForward) { + Label("Переслать", systemImage: "arrowshape.turn.up.right") + } + } + if model.canRetryMediaMessage(message) { + Button(action: onRetry) { + Label("Повторить отправку", systemImage: "arrow.clockwise") + } + } + if message.canBeEdited { + Button(action: onEdit) { + Label("Редактировать", systemImage: "pencil") + } + } + Button(action: onBeginSelection) { + Label("Выбрать", systemImage: "checkmark.circle") + } + Divider() + Button(role: .destructive, action: onDelete) { + Label("Удалить у меня", systemImage: "trash") + } + if message.canBeRetracted { + Button(role: .destructive, action: onRetract) { + Label("Удалить у всех", systemImage: "trash.slash") + } + } + } + } + .accessibilityLabel(Text(selectionAccessibilityLabel)) + .accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) { + if isSelectionMode { + onToggleSelection() + } else { + onBeginSelection() + } + } + } + + private var messageContainer: some View { + HStack(spacing: 2) { + if isSelectionMode { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 23, weight: .semibold)) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .frame(width: 34) + .padding(.leading, 7) + .accessibilityHidden(true) + } + + VStack(spacing: 0) { + if hasReplyReference { + compactReplyReference + } + messageRow + } + .allowsHitTesting(!isSelectionMode) + } + } + + private var replySwipeIndicator: some View { + HStack { + if replySwipeOffset >= 0 { + replySwipeIcon + Spacer() + } else { + Spacer() + replySwipeIcon + } + } + .padding(.horizontal, 18) + .allowsHitTesting(false) + .opacity(replySwipeOffset == 0 ? 0 : 1) + } + + private var replySwipeIcon: some View { + Image(systemName: replySwipeArmed + ? "arrowshape.turn.up.left.fill" + : "arrowshape.turn.up.left") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .scaleEffect( + CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset) + ) + .opacity(Double( + CGFloat(0.35) + CGFloat(0.65) * MessageReplySwipePolicy.progress(for: replySwipeOffset) + )) + } + + private var replySwipeGesture: some Gesture { + DragGesture(minimumDistance: 20) + .onChanged { value in + let offset = MessageReplySwipePolicy.offset(for: value.translation) + guard offset != 0 || replySwipeOffset != 0 else { + // A vertical timeline pan must not publish per-cell state + // changes or compete with UIScrollView's own recognizer. + return + } + replySwipeOffset = offset + let armed = MessageReplySwipePolicy.shouldReply( + translation: value.translation, + predictedEndTranslation: value.translation + ) + if armed, !replySwipeArmed { + replySwipeFeedback() + } + replySwipeArmed = armed + } + .onEnded { value in + let shouldReply = MessageReplySwipePolicy.shouldReply( + translation: value.translation, + predictedEndTranslation: value.predictedEndTranslation + ) + if replySwipeOffset != 0 || replySwipeArmed { + withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { + replySwipeOffset = 0 + replySwipeArmed = false + } + } + if shouldReply { + onReply() + } + } + } + + private func replySwipeFeedback() { +#if os(iOS) + UIImpactFeedbackGenerator(style: .light).impactOccurred() +#endif + } + + private var selectionAccessibilityLabel: String { + guard isSelectionMode else { return message.previewText } + return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")" + } + + private var messageRow: some View { + HStack(alignment: .bottom, spacing: 8) { + if message.direction == .outgoing { Spacer(minLength: sideSpacerMinimum) } + + VStack(alignment: message.direction == .outgoing ? .trailing : .leading, spacing: 4) { + if message.isGroupMessage, message.direction == .incoming { + Text(message.senderDisplayName ?? model.displayName(for: message.senderJID)) + .font(.caption.weight(.semibold)) + .foregroundStyle(senderColor) + .padding(.horizontal, 8) + } + messageContent + + if !reactionSummaries.isEmpty { + reactionStrip + } + + if hasReplyReference { + replyWarningIndicators + } else { + standardMetadata + } + } + + if message.direction == .incoming { Spacer(minLength: sideSpacerMinimum) } + } + .padding(.horizontal, 12) + .contentShape(Rectangle()) + .onTapGesture { + guard let targetID = message.replyToID, + message.kind == .text || message.kind == .system else { return } + onReplyTap(targetID) + } + } + + private var sideSpacerMinimum: CGFloat { + message.kind.isMedia ? 16 : 54 + } + + @ViewBuilder + private var replyWarningIndicators: some View { + if message.security != .omemo || message.delivery == .failed { + HStack(spacing: 4) { + if message.security != .omemo { + Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield") + .foregroundStyle(message.security == .plaintext ? .orange : .red) + } + if message.delivery == .failed { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(.red) + } + } + .font(.caption2) + .padding(.horizontal, 4) + } + } + + private var standardMetadata: some View { + HStack(spacing: 4) { + if message.callHistory == nil, message.security != .omemo { + Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield") + .foregroundStyle(message.security == .plaintext ? .orange : .red) + } + if message.editedAt != nil { + Text("изм.") + } + Text(message.timestamp, format: .dateTime.hour().minute()) + if message.direction == .outgoing, message.callHistory == nil { + Image(systemName: deliveryIcon) + .foregroundStyle(message.delivery == .failed ? .red : .secondary) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + } + + @ViewBuilder + private var messageContent: some View { + if message.isRetracted { + HStack(spacing: 7) { + Image(systemName: "trash.slash") + Text("Сообщение удалено") + .italic() + } + .font(.subheadline) + .padding(.horizontal, 13) + .padding(.vertical, 9) + .foregroundStyle(message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary) + .background(bubbleBackground) + .clipShape(messageBubbleShape) + } else { + regularMessageContent + } + } + + @ViewBuilder + private var regularMessageContent: some View { + switch message.kind { + case .videoNote: + VideoNotePreview( + model: model, + message: message, + onFallbackTap: onAttachmentTap + ) + case .video: + VideoAttachmentPreview( + model: model, + message: message, + onFallbackTap: onAttachmentTap + ) + case .audio, .voice: + AudioMessagePlayer( + model: model, + message: message, + onFallbackTap: onAttachmentTap + ) + .padding(.horizontal, 13) + .padding(.vertical, 10) + .background(bubbleBackground) + .clipShape(messageBubbleShape) + case .photo: + PhotoAttachmentPreview( + model: model, + message: message, + onFallbackTap: onAttachmentTap + ) + case .location: + if let location = GeoLocation(uri: message.body) { + LocationMessagePreview(location: location) + } else { + Text(message.body) + .lumaTextSelection() + .padding(.horizontal, 13) + .padding(.vertical, 9) + .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .background(bubbleBackground) + .clipShape(messageBubbleShape) + } + case .attachment: + Button(action: onAttachmentTap) { + mediaContent + } + .buttonStyle(.plain) + .padding(.horizontal, 13) + .padding(.vertical, 9) + .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .background(bubbleBackground) + .clipShape(messageBubbleShape) + case .text, .system: + if let callHistory = message.callHistory { + callHistoryContent(callHistory) + } else { + Text(message.body) + .lumaTextSelection() + .padding(.horizontal, 13) + .padding(.vertical, 9) + .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .background(bubbleBackground) + .clipShape(messageBubbleShape) + } + } + } + + private func callHistoryContent(_ history: CallHistoryMetadata) -> some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(callAccentColor(for: history).opacity( + message.direction == .outgoing ? 0.28 : 0.14 + )) + Image(systemName: history.isVideo ? "video.fill" : "phone.fill") + .font(.system(size: 17, weight: .semibold)) + Image(systemName: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left") + .font(.system(size: 8, weight: .heavy)) + .padding(3) + .background(Circle().fill(callDirectionBadgeBackground)) + .offset(x: 14, y: 14) + } + .frame(width: 42, height: 42) + .foregroundStyle(callIconForeground(for: history)) + + VStack(alignment: .leading, spacing: 3) { + Text(message.callTitle) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + if let subtitle = message.callSubtitle { + Text(subtitle) + .font(.caption) + .opacity(0.78) + .lineLimit(1) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: 300, alignment: .leading) + .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .background(bubbleBackground) + .clipShape(messageBubbleShape) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(message.callTitle)) + } + + private func callAccentColor(for history: CallHistoryMetadata) -> Color { + switch history.outcome { + case .completed: + return .green + case .declined, .missed, .unanswered, .failed: + return .red + case .cancelled, .answeredElsewhere: + return .secondary + } + } + + private func callIconForeground(for history: CallHistoryMetadata) -> Color { + message.direction == .outgoing ? .white : callAccentColor(for: history) + } + + private var callDirectionBadgeBackground: Color { + message.direction == .outgoing + ? Color(red: 0.13, green: 0.57, blue: 0.94) + : Color.secondary.opacity(0.16) + } + + private var reactionSummaries: [MessageReactionSummary] { + message.reactionSummaries(ownJID: model.account?.normalizedJID) + } + + private var reactionStrip: some View { + HStack(spacing: 5) { + ForEach(reactionSummaries) { summary in + Button { + onReact(summary.emoji) + } label: { + HStack(spacing: 3) { + Text(summary.emoji) + Text(String(summary.count)) + .font(.caption2.weight(.semibold)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + summary.includesOwnReaction + ? Color.accentColor.opacity(0.2) + : Color.secondary.opacity(0.12), + in: Capsule() + ) + .overlay { + Capsule().stroke( + summary.includesOwnReaction + ? Color.accentColor.opacity(0.55) + : Color.secondary.opacity(0.16), + lineWidth: 0.8 + ) + } + } + .buttonStyle(.plain) + .accessibilityLabel("\(summary.emoji), реакций: \(summary.count)") + } + } + .padding(.horizontal, 4) + } + + private var hasReplyReference: Bool { + message.replyToID != nil || message.replyPreview != nil + } + + private var compactReplyReference: some View { + Group { + if let targetID = message.replyToID { + Button { + onReplyTap(targetID) + } label: { + compactReplyLayout + } + .buttonStyle(.plain) + .accessibilityLabel("Открыть ветку ответа") + } else { + compactReplyLayout + } + } + .opacity(0.56) + .padding(.bottom, -2) + .task(id: replyTarget?.remoteAttachmentURL) { + if let replyTarget, + replyTarget.kind == .photo + || replyTarget.kind == .video + || replyTarget.kind == .videoNote { + await model.prepareMediaPreview(replyTarget) + } + } + } + + private var compactReplyLayout: some View { + HStack { + if compactTargetDirection == .outgoing { + Spacer(minLength: 58) + } + + VStack( + alignment: compactTargetDirection == .outgoing ? .trailing : .leading, + spacing: -1 + ) { + compactSourceBubble + ReplyThreadCurve(sourceDirection: compactTargetDirection) + .stroke( + compactReplyColor.opacity(0.72), + style: StrokeStyle(lineWidth: 2.4, lineCap: .round) + ) + .frame(width: 58, height: 34) + .offset(x: compactTargetDirection == .outgoing ? -18 : 18) + } + + if compactTargetDirection == .incoming { + Spacer(minLength: 58) + } + } + .padding(.horizontal, 18) + .frame(maxWidth: .infinity) + } + + private var compactSourceBubble: some View { + HStack(spacing: 7) { + compactReplyThumbnail + Text(replyText) + .font(.callout.weight(.medium)) + .foregroundStyle(compactReplyColor) + .lineLimit(1) + .multilineTextAlignment(.leading) + } + .padding(.leading, compactTargetDirection == .incoming ? 12 : 10) + .padding(.trailing, compactTargetDirection == .outgoing ? 12 : 10) + .padding(.vertical, 7) + .frame(maxWidth: 230, minHeight: 34, alignment: .leading) + .background(Color.primary.opacity(0.018)) + .clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .stroke(compactReplyColor.opacity(0.75), lineWidth: 1.25) + } + } + + @ViewBuilder + private var compactReplyThumbnail: some View { + if let target = replyTarget, target.kind != .text, target.kind != .system { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.08)) + replyThumbnailImage(for: target) + } + .frame(width: 27, height: 27) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + } + + @ViewBuilder + private func replyThumbnailImage(for target: ChatMessage) -> some View { + if let data = model.mediaThumbnail(for: target) { +#if os(iOS) + if let image = ChatMediaImageCache.image(for: target, data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: replyMediaIcon(for: target.kind)) + .foregroundStyle(compactReplyColor) + } +#elseif os(macOS) + if let image = ChatMediaImageCache.image(for: target, data: data) { + Image(nsImage: image) + .resizable() + .scaledToFill() + } else { + Image(systemName: replyMediaIcon(for: target.kind)) + .foregroundStyle(compactReplyColor) + } +#endif + } else { + Image(systemName: replyMediaIcon(for: target.kind)) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(compactReplyColor) + } + } + + private var replyTarget: ChatMessage? { + message.replyToID.flatMap { model.message(withID: $0, in: message.conversationID) } + } + + private var compactTargetDirection: ChatMessage.Direction { + if let direction = replyTarget?.direction { return direction } + return message.direction == .outgoing ? .incoming : .outgoing + } + + private var compactReplyColor: Color { + compactTargetDirection == .outgoing + ? Color(red: 0.18, green: 0.59, blue: 0.98) + : Color.secondary + } + + private var replyText: String { + replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно" + } + + private var mediaContent: some View { + HStack(spacing: 11) { + ZStack { + Circle() + .fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12)) + .frame(width: message.kind == .videoNote ? 58 : 38, height: message.kind == .videoNote ? 58 : 38) + Image(systemName: mediaIcon) + .font(.system(size: message.kind == .videoNote ? 24 : 17)) + } + VStack(alignment: .leading, spacing: 2) { + Text(mediaTitle) + .font(.subheadline.weight(.semibold)) + .lineLimit(2) + Text(mediaSubtitle) + .font(.caption) + .opacity(0.8) + } + } + } + + private var mediaTitle: String { + switch message.kind { + case .photo: return "Фото" + case .video: return "Видео" + case .audio: return message.localFilename ?? "Аудио" + case .voice: return "Голосовое сообщение" + case .videoNote: return "Видеосообщение" + case .attachment: return message.localFilename ?? message.body + case .location: return "Геопозиция" + case .text, .system: return message.body + } + } + + private var mediaSubtitle: String { + if message.remoteAttachmentURL == nil { + return "Подготовка…" + } + var parts: [String] = [] + if let duration = message.duration { + let seconds = max(0, Int(duration.rounded())) + parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60)) + } + if let byteCount = message.byteCount { + parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)) + } + return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ") + } + + private var mediaIcon: String { + switch message.kind { + case .attachment: return "doc.fill" + case .photo: return "photo.fill" + case .video: return "play.rectangle.fill" + case .audio: return "music.note" + case .voice: return "waveform" + case .videoNote: return "video.circle.fill" + case .location: return "location.fill" + case .text, .system: return "doc.fill" + } + } + + private func replyMediaIcon(for kind: ChatMessage.Kind) -> String { + switch kind { + case .photo: return "photo.fill" + case .video: return "play.fill" + case .audio: return "music.note" + case .voice: return "waveform" + case .videoNote: return "video.circle.fill" + case .attachment: return "doc.fill" + case .location: return "location.fill" + case .text, .system: return "quote.bubble.fill" + } + } + + @ViewBuilder + private var bubbleBackground: some View { + if message.direction == .outgoing { + LinearGradient( + colors: [Color(red: 0.13, green: 0.51, blue: 0.94), Color(red: 0.12, green: 0.63, blue: 0.94)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + Color.secondary.opacity(0.13) + } + } + + private var messageBubbleShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 18, style: .continuous) + } + + private var deliveryIcon: String { + switch message.delivery { + case .sending: return "clock" + case .sent: return "checkmark" + case .delivered: return "checkmark.circle.fill" + case .failed: return "exclamationmark.circle.fill" + } + } + + private var senderColor: Color { + color(for: message.senderJID) + } + + private func color(for jid: String) -> Color { + let seed = jid.utf8.reduce(UInt64(2_166_136_261)) { value, byte in + (value ^ UInt64(byte)) &* 16_777_619 + } + return Color( + hue: Double(seed % 360) / 360, + saturation: 0.72, + brightness: 0.78 + ) + } +} + +private extension View { + @ViewBuilder + func lumaTextSelection() -> some View { +#if os(macOS) + textSelection(.enabled) +#else + self +#endif + } +} diff --git a/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift b/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift new file mode 100644 index 0000000..965fa48 --- /dev/null +++ b/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift @@ -0,0 +1,133 @@ +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +@MainActor +struct PhotoAttachmentPreview: View { + @ObservedObject var model: AppModel + let message: ChatMessage + let onFallbackTap: () -> Void + + private let islandWidth: CGFloat = 268 + + var body: some View { + Button(action: openViewer) { + ZStack { + Color.secondary.opacity(0.12) + thumbnail + .padding(6) + + if model.isMediaPreviewLoading(message) { + ProgressView() + .tint(.white) + .controlSize(.large) + .padding(15) + .background(.black.opacity(0.34), in: Circle()) + } + + VStack { + HStack { + Spacer() + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .padding(7) + .background(.black.opacity(0.42), in: Circle()) + } + Spacer() + HStack(spacing: 6) { + if let filename = message.localFilename { + Text(filename) + .lineLimit(1) + } + Spacer(minLength: 6) + Text(sizeText) + .monospacedDigit() + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.black.opacity(0.48), in: Capsule()) + } + .padding(9) + } + .frame(width: islandWidth, height: islandHeight) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.secondary.opacity(0.18), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.vertical, 5) + .task(id: message.remoteAttachmentURL) { + await model.prepareMediaPreview(message) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Фото, \(sizeText)") + .accessibilityHint("Открывает фото на весь экран") + } + + @ViewBuilder + private var thumbnail: some View { + if let data = model.mediaThumbnail(for: message) { +#if os(iOS) + if let image = ChatMediaImageCache.image(for: message, data: data) { + Image(uiImage: image) + .resizable() + .scaledToFit() + } +#elseif os(macOS) + if let image = ChatMediaImageCache.image(for: message, data: data) { + Image(nsImage: image) + .resizable() + .scaledToFit() + } +#endif + } else { + Image(systemName: "photo.fill") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(.secondary) + } + } + + private var sizeText: String { + guard let byteCount = message.byteCount else { return "Фото" } + return ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file) + } + + private var islandHeight: CGFloat { + let contentWidth = islandWidth - 12 + let naturalHeight = contentWidth / thumbnailAspectRatio + 12 + return min(max(naturalHeight, 164), 328) + } + + private var thumbnailAspectRatio: CGFloat { + guard let data = model.mediaThumbnail(for: message) else { return 4 / 3 } +#if os(iOS) + guard let image = ChatMediaImageCache.image(for: message, data: data), + image.size.height > 0 else { return 4 / 3 } + return max(0.45, min(2.2, image.size.width / image.size.height)) +#elseif os(macOS) + guard let image = ChatMediaImageCache.image(for: message, data: data), + image.size.height > 0 else { return 4 / 3 } + return max(0.45, min(2.2, image.size.width / image.size.height)) +#endif + } + + private func openViewer() { + Task { + await model.presentMediaViewer(message) + if model.mediaViewerItem?.id != message.id, + message.remoteAttachmentURL == nil { + onFallbackTap() + } + } + } +} diff --git a/Sources/Shared/UI/Components/RTCVideoRendererView.swift b/Sources/Shared/UI/Components/RTCVideoRendererView.swift new file mode 100644 index 0000000..246094e --- /dev/null +++ b/Sources/Shared/UI/Components/RTCVideoRendererView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import WebRTC + +#if os(iOS) +import UIKit + +struct RTCVideoRendererView: UIViewRepresentable { + let track: RTCVideoTrack? + var mirrored = false + var contentMode: UIView.ContentMode = .scaleAspectFill + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> RTCMTLVideoView { + let view = RTCMTLVideoView(frame: .zero) + view.videoContentMode = contentMode + view.backgroundColor = .black + context.coordinator.update(track: track, renderer: view) + return view + } + + func updateUIView(_ view: RTCMTLVideoView, context: Context) { + view.videoContentMode = contentMode + view.transform = mirrored + ? CGAffineTransform(scaleX: -1, y: 1) + : .identity + context.coordinator.update(track: track, renderer: view) + } + + static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) { + coordinator.detach(renderer: view) + } + + final class Coordinator { + private weak var track: RTCVideoTrack? + + func update(track newTrack: RTCVideoTrack?, renderer: RTCMTLVideoView) { + guard track !== newTrack else { return } + track?.remove(renderer) + track = newTrack + newTrack?.add(renderer) + } + + func detach(renderer: RTCMTLVideoView) { + track?.remove(renderer) + track = nil + } + } +} + +#elseif os(macOS) +import AppKit + +struct RTCVideoRendererView: NSViewRepresentable { + let track: RTCVideoTrack? + var mirrored = false + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> RTCMTLNSVideoView { + let view = RTCMTLNSVideoView(frame: .zero) + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.black.cgColor + context.coordinator.update(track: track, renderer: view) + return view + } + + func updateNSView(_ view: RTCMTLNSVideoView, context: Context) { + view.layer?.setAffineTransform( + mirrored ? CGAffineTransform(scaleX: -1, y: 1) : .identity + ) + context.coordinator.update(track: track, renderer: view) + } + + static func dismantleNSView(_ view: RTCMTLNSVideoView, coordinator: Coordinator) { + coordinator.detach(renderer: view) + } + + final class Coordinator { + private weak var track: RTCVideoTrack? + + func update(track newTrack: RTCVideoTrack?, renderer: RTCMTLNSVideoView) { + guard track !== newTrack else { return } + track?.remove(renderer) + track = newTrack + newTrack?.add(renderer) + } + + func detach(renderer: RTCMTLNSVideoView) { + track?.remove(renderer) + track = nil + } + } +} +#endif diff --git a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift new file mode 100644 index 0000000..728e2ab --- /dev/null +++ b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift @@ -0,0 +1,347 @@ +import Foundation +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +/// iMessage-style focus mode for one source message and its reply thread. +@MainActor +struct ReplyThreadOverlay: View { + @ObservedObject var model: AppModel + let rootMessage: ChatMessage + let replies: [ChatMessage] + let selectedReplyID: String + let onDismiss: () -> Void + + @Environment(\.colorScheme) private var colorScheme + @State private var isVisible = false + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .topTrailing) { + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + + Color.black + .opacity(colorScheme == .dark ? 0.72 : 0.62) + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 14) { + Text( + rootMessage.timestamp, + format: .dateTime + .weekday(.abbreviated) + .month(.abbreviated) + .day() + .hour() + .minute() + ) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white.opacity(0.56)) + .padding(.bottom, 2) + + threadContent( + maximumBubbleWidth: min(520, geometry.size.width * 0.76) + ) + } + .frame(maxWidth: min(760, geometry.size.width)) + .padding(.horizontal, 18) + .padding(.top, max(58, geometry.safeAreaInsets.top + 30)) + .padding(.bottom, max(34, geometry.safeAreaInsets.bottom + 20)) + .frame(maxWidth: .infinity) + } + .scrollIndicators(.hidden) + .onAppear { + isVisible = true + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.28)) { + proxy.scrollTo(selectedReplyID, anchor: .center) + } + } + } + } + + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white.opacity(0.82)) + .frame(width: 32, height: 32) + .background(.white.opacity(0.12), in: Circle()) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .accessibilityLabel("Закрыть ветку ответов") + .padding(.top, max(12, geometry.safeAreaInsets.top + 6)) + .padding(.trailing, 14) + } + .opacity(isVisible ? 1 : 0) + .scaleEffect(isVisible ? 1 : 0.985) + .animation(.easeOut(duration: 0.22), value: isVisible) + } + .transition(.opacity.combined(with: .scale(scale: 0.985))) + } + + private func threadContent(maximumBubbleWidth: CGFloat) -> some View { + let showsBracket = replies.count > 1 + let bracketOnLeadingEdge = rootMessage.direction == .outgoing + + return VStack(spacing: 9) { + ReplyThreadMessageRow( + model: model, + message: rootMessage, + maximumBubbleWidth: maximumBubbleWidth, + isSelected: false, + showsDeliveryStatus: false + ) + .id(rootMessage.id) + + if showsBracket { + replyCountRow + } + + ForEach(replies) { reply in + ReplyThreadMessageRow( + model: model, + message: reply, + maximumBubbleWidth: maximumBubbleWidth, + isSelected: reply.id == selectedReplyID, + showsDeliveryStatus: reply.id == selectedReplyID + ) + .id(reply.id) + } + } + .padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0) + .padding(.trailing, showsBracket && !bracketOnLeadingEdge ? 43 : 0) + .overlay( + alignment: bracketOnLeadingEdge ? .leading : .trailing + ) { + if showsBracket { + ReplyThreadBracket(opensToTrailing: bracketOnLeadingEdge) + .stroke( + Color.white.opacity(0.25), + style: StrokeStyle(lineWidth: 3, lineCap: .round) + ) + .frame(width: 38) + .padding(.vertical, 28) + .allowsHitTesting(false) + } + } + } + + private var replyCountRow: some View { + HStack { + if rootMessage.direction == .outgoing { Spacer(minLength: 40) } + Text(replyCountText) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color(red: 0.16, green: 0.59, blue: 1)) + .padding(.horizontal, 13) + if rootMessage.direction == .incoming { Spacer(minLength: 40) } + } + } + + private var replyCountText: String { + let value = replies.count + let mod10 = value % 10 + let mod100 = value % 100 + let noun: String + if mod10 == 1, mod100 != 11 { + noun = "ответ" + } else if (2...4).contains(mod10), !(12...14).contains(mod100) { + noun = "ответа" + } else { + noun = "ответов" + } + return "\(value) \(noun)" + } +} + +@MainActor +private struct ReplyThreadMessageRow: View { + @ObservedObject var model: AppModel + let message: ChatMessage + let maximumBubbleWidth: CGFloat + let isSelected: Bool + let showsDeliveryStatus: Bool + + var body: some View { + VStack( + alignment: message.direction == .outgoing ? .trailing : .leading, + spacing: 4 + ) { + if message.isGroupMessage, message.direction == .incoming { + Text(message.senderDisplayName ?? model.displayName(for: message.senderJID)) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.62)) + .padding(.horizontal, 12) + } + + HStack(alignment: .bottom) { + if message.direction == .outgoing { Spacer(minLength: 44) } + focusedBubble + .frame(maxWidth: maximumBubbleWidth, alignment: message.direction == .outgoing ? .trailing : .leading) + .shadow( + color: isSelected ? Color.blue.opacity(0.5) : Color.clear, + radius: isSelected ? 22 : 0, + y: isSelected ? 8 : 0 + ) + .scaleEffect(isSelected ? 1.018 : 1) + if message.direction == .incoming { Spacer(minLength: 44) } + } + + if showsDeliveryStatus, message.direction == .outgoing { + Text(deliveryText) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white.opacity(0.54)) + .padding(.trailing, 13) + } + } + .animation(.spring(response: 0.34, dampingFraction: 0.78), value: isSelected) + .task(id: message.remoteAttachmentURL) { + if isVisualMedia { + await model.prepareMediaPreview(message) + } + } + } + + @ViewBuilder + private var focusedBubble: some View { + if isVisualMedia { + visualMediaBubble + } else { + HStack(spacing: 10) { + if let icon = nonVisualMediaIcon { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + } + Text(displayText) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.leading, message.direction == .incoming ? 17 : 14) + .padding(.trailing, message.direction == .outgoing ? 17 : 14) + .padding(.vertical, 11) + .foregroundStyle(.white) + .background(focusedBubbleBackground) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + } + + private var visualMediaBubble: some View { + VStack(alignment: .leading, spacing: 0) { + ZStack { + Color.white.opacity(0.08) + mediaThumbnail + if message.kind != .photo { + Image(systemName: "play.fill") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 52, height: 52) + .background(.black.opacity(0.42), in: Circle()) + } + } + .frame(width: min(maximumBubbleWidth, 430), height: 190) + .clipped() + + Text(message.previewText) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 15) + .padding(.vertical, 11) + } + .background(focusedBubbleBackground) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + } + + @ViewBuilder + private var mediaThumbnail: some View { + if let data = model.mediaThumbnail(for: message) { +#if os(iOS) + if let image = ChatMediaImageCache.image(for: message, data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } else { + mediaPlaceholder + } +#elseif os(macOS) + if let image = ChatMediaImageCache.image(for: message, data: data) { + Image(nsImage: image) + .resizable() + .scaledToFill() + } else { + mediaPlaceholder + } +#endif + } else { + mediaPlaceholder + } + } + + private var mediaPlaceholder: some View { + Image(systemName: message.kind == .photo ? "photo.fill" : "video.fill") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(.white.opacity(0.62)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private var focusedBubbleBackground: some View { + if message.direction == .outgoing { + LinearGradient( + colors: [ + Color(red: 0.12, green: 0.51, blue: 0.96), + Color(red: 0.10, green: 0.63, blue: 1) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } else { + LinearGradient( + colors: [Color.white.opacity(0.34), Color.white.opacity(0.23)], + startPoint: .top, + endPoint: .bottom + ) + } + } + + private var isVisualMedia: Bool { + !message.isRetracted + && (message.kind == .photo || message.kind == .video || message.kind == .videoNote) + } + + private var nonVisualMediaIcon: String? { + switch message.kind { + case .attachment: return "doc.fill" + case .audio: return "music.note" + case .voice: return "waveform" + case .location: return "location.fill" + case .text, .system, .photo, .video, .videoNote: return nil + } + } + + private var displayText: String { + message.isRetracted ? "Сообщение удалено" : message.body + } + + private var deliveryText: String { + let state: String + switch message.delivery { + case .sending: state = "Отправка" + case .sent: state = "Отправлено" + case .delivered: state = "Доставлено" + case .failed: state = "Не отправлено" + } + let value = Calendar.current.isDateInToday(message.timestamp) + ? message.timestamp.formatted(date: .omitted, time: .shortened) + : message.timestamp.formatted(date: .numeric, time: .omitted) + return "\(state) \(value)" + } +} diff --git a/Sources/Shared/UI/Components/VideoAttachmentPreview.swift b/Sources/Shared/UI/Components/VideoAttachmentPreview.swift new file mode 100644 index 0000000..7d49694 --- /dev/null +++ b/Sources/Shared/UI/Components/VideoAttachmentPreview.swift @@ -0,0 +1,151 @@ +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +@MainActor +struct VideoAttachmentPreview: View { + @ObservedObject var model: AppModel + let message: ChatMessage + let onFallbackTap: () -> Void + + private let islandWidth: CGFloat = 268 + + var body: some View { + Button(action: openViewer) { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.06, green: 0.17, blue: 0.29), + Color(red: 0.09, green: 0.43, blue: 0.64) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + thumbnail + .padding(6) + + if model.isMediaPreviewLoading(message) { + ProgressView() + .tint(.white) + .controlSize(.large) + } else { + Image(systemName: "play.fill") + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.white) + .padding(15) + .background(.black.opacity(0.38), in: Circle()) + } + + VStack { + HStack { + Spacer() + Image(systemName: "arrow.up.left.and.arrow.down.right") + .font(.caption.weight(.bold)) + .foregroundStyle(.white) + .padding(7) + .background(.black.opacity(0.42), in: Circle()) + } + Spacer() + HStack(spacing: 6) { + if let filename = message.localFilename { + Text(filename) + .lineLimit(1) + } + Spacer(minLength: 6) + Text(metadataText) + .monospacedDigit() + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background(.black.opacity(0.48), in: Capsule()) + } + .padding(9) + } + .frame(width: islandWidth, height: islandHeight) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke(Color.secondary.opacity(0.18), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + .buttonStyle(.plain) + .padding(.vertical, 5) + .task(id: message.remoteAttachmentURL) { + await model.prepareMediaPreview(message) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Видео, \(metadataText)") + .accessibilityHint("Открывает видео на весь экран") + } + + @ViewBuilder + private var thumbnail: some View { + if let data = model.mediaThumbnail(for: message) { +#if os(iOS) + if let image = ChatMediaImageCache.image(for: message, data: data) { + Image(uiImage: image) + .resizable() + .scaledToFit() + } +#elseif os(macOS) + if let image = ChatMediaImageCache.image(for: message, data: data) { + Image(nsImage: image) + .resizable() + .scaledToFit() + } +#endif + } else { + Image(systemName: "film.fill") + .font(.system(size: 38, weight: .medium)) + .foregroundStyle(.white.opacity(0.65)) + } + } + + private var metadataText: String { + var parts: [String] = [] + if let duration = message.duration { + parts.append(MediaTimeFormatter.string(duration)) + } + if let byteCount = message.byteCount { + parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)) + } + return parts.isEmpty ? "Видео" : parts.joined(separator: " · ") + } + + private var islandHeight: CGFloat { + let contentWidth = islandWidth - 12 + let naturalHeight = contentWidth / thumbnailAspectRatio + 12 + return min(max(naturalHeight, 164), 250) + } + + private var thumbnailAspectRatio: CGFloat { + guard let data = model.mediaThumbnail(for: message) else { return 16 / 9 } +#if os(iOS) + guard let image = ChatMediaImageCache.image(for: message, data: data), + image.size.height > 0 else { return 16 / 9 } + return max(0.7, min(2.2, image.size.width / image.size.height)) +#elseif os(macOS) + guard let image = ChatMediaImageCache.image(for: message, data: data), + image.size.height > 0 else { return 16 / 9 } + return max(0.7, min(2.2, image.size.width / image.size.height)) +#endif + } + + private func openViewer() { + Task { + await model.presentMediaViewer(message) + if model.mediaViewerItem?.id != message.id, + message.remoteAttachmentURL == nil { + onFallbackTap() + } + } + } +} diff --git a/Sources/Shared/UI/Components/VideoNotePreview.swift b/Sources/Shared/UI/Components/VideoNotePreview.swift new file mode 100644 index 0000000..977f20c --- /dev/null +++ b/Sources/Shared/UI/Components/VideoNotePreview.swift @@ -0,0 +1,155 @@ +import AVFoundation +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +@MainActor +struct VideoNotePreview: View { + @ObservedObject var model: AppModel + let message: ChatMessage + let onFallbackTap: () -> Void + + @State private var player: AVPlayer? + @State private var isPlaying = false + + private var diameter: CGFloat { isPlaying ? 176 : 144 } + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.08, green: 0.30, blue: 0.56), + Color(red: 0.07, green: 0.64, blue: 0.83) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + thumbnail + + if let player { + InlineVideoPlayer(player: player) + } + + if previewURL == nil, model.isMediaPreviewLoading(message) { + ProgressView() + .tint(.white) + .controlSize(.large) + } else if !isPlaying { + Image(systemName: "play.fill") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.white) + .padding(17) + .background(.black.opacity(0.34), in: Circle()) + } + + VStack { + Spacer() + HStack { + Spacer() + Text(durationText) + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(.black.opacity(0.48), in: Capsule()) + } + .padding(12) + } + } + .frame(width: diameter, height: diameter) + .animation(.spring(response: 0.32, dampingFraction: 0.82), value: isPlaying) + .clipShape(Circle()) + .overlay(Circle().stroke(.white.opacity(0.18), lineWidth: 1)) + .contentShape(Circle()) + .onTapGesture(perform: togglePlayback) + .task(id: message.remoteAttachmentURL) { + await model.prepareMediaPreview(message) + configurePlayerIfNeeded() + } + .onChange(of: previewURL) { _, _ in + configurePlayerIfNeeded() + } + .onReceive(NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime)) { notification in + guard let item = notification.object as? AVPlayerItem, + item === player?.currentItem else { return } + player?.seek(to: .zero) + isPlaying = false + } + .onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in + guard let activeID = notification.object as? String, + activeID != message.id else { return } + player?.pause() + isPlaying = false + } + .onDisappear { + player?.pause() + isPlaying = false + } + .accessibilityLabel("Видеосообщение, \(durationText)") + .accessibilityHint("Нажмите для воспроизведения или паузы") + } + + private var previewURL: URL? { + model.mediaPreviewURL(for: message) + } + + @ViewBuilder + private var thumbnail: some View { + if let data = model.mediaThumbnail(for: message) { +#if os(iOS) + if let image = ChatMediaImageCache.image(for: message, data: data) { + Image(uiImage: image) + .resizable() + .scaledToFill() + } +#elseif os(macOS) + if let image = ChatMediaImageCache.image(for: message, data: data) { + Image(nsImage: image) + .resizable() + .scaledToFill() + } +#endif + } else { + Image(systemName: "video.fill") + .font(.system(size: 34, weight: .medium)) + .foregroundStyle(.white.opacity(0.72)) + } + } + + private var durationText: String { + let seconds = max(0, Int((message.duration ?? 0).rounded())) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + private func configurePlayerIfNeeded() { + guard player == nil, let previewURL else { return } + let player = AVPlayer(url: previewURL) + player.actionAtItemEnd = .pause + self.player = player + } + + private func togglePlayback() { + guard previewURL != nil else { + onFallbackTap() + return + } + configurePlayerIfNeeded() + guard let player else { return } + if isPlaying { + player.pause() + } else { + model.audioPlayback.stop() + NotificationCenter.default.post( + name: .lumaExclusiveMediaPlayback, + object: message.id + ) + player.play() + } + isPlaying.toggle() + } +} diff --git a/Sources/Shared/UI/EncryptionDevicesView.swift b/Sources/Shared/UI/EncryptionDevicesView.swift new file mode 100644 index 0000000..1562a41 --- /dev/null +++ b/Sources/Shared/UI/EncryptionDevicesView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +struct EncryptionDevicesView: View { + @ObservedObject var model: AppModel + let conversation: Conversation + @Environment(\.dismiss) private var dismiss + @State private var devices: [OMEMODevice] = [] + + var body: some View { + NavigationStack { + List { + Section { + Label( + statusTitle, + systemImage: statusIcon + ) + .foregroundStyle(statusColor) + } footer: { + Text("Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное.") + } + + if devices.isEmpty { + Section("Устройства контакта") { + ContentUnavailableView( + "Ключи ещё не получены", + systemImage: "key.horizontal", + description: Text("Они появятся после первой попытки отправки или получения OMEMO-сообщения.") + ) + } + } else { + Section("Устройства контакта") { + ForEach(devices) { device in + DeviceRow(device: device) { + model.setDeviceVerified( + device.trust != .verified, + jid: device.jid, + deviceID: device.deviceID + ) + reload() + } + } + } + } + + if let fingerprint = model.ownFingerprint { + Section("Это устройство") { + Text(fingerprint.chunked(every: 8).joined(separator: " ")) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + } + .navigationTitle("Шифрование") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Готово") { dismiss() } + } + } + .onAppear(perform: reload) + } +#if os(macOS) + .frame(minWidth: 480, minHeight: 500) +#endif + } + + private func reload() { + devices = model.devices(for: conversation.jid).filter { !$0.isOwn } + } + + private var statusTitle: String { + guard model.encryptionEnabled(for: conversation.jid) else { return "OMEMO выключено для этого чата" } + return model.isOMEMOReady ? "OMEMO активно" : "OMEMO подготавливается" + } + + private var statusIcon: String { + guard model.encryptionEnabled(for: conversation.jid) else { return "lock.open" } + return model.isOMEMOReady ? "lock.shield.fill" : "lock.rotation" + } + + private var statusColor: Color { + model.encryptionEnabled(for: conversation.jid) && model.isOMEMOReady ? .green : .orange + } +} + +private struct DeviceRow: View { + let device: OMEMODevice + let toggleVerification: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("Устройство \(device.deviceID)", systemImage: device.isActive ? "iphone" : "iphone.slash") + .font(.headline) + Spacer() + Button(action: toggleVerification) { + Label(trustTitle, systemImage: trustIcon) + } + .buttonStyle(.bordered) + .tint(trustColor) + } + Text(device.formattedFingerprint) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .padding(.vertical, 5) + } + + private var trustTitle: String { + switch device.trust { + case .verified: return "Проверено" + case .trusted: return "Доверено" + case .undecided: return "Проверить" + case .compromised: return "Скомпрометировано" + } + } + + private var trustIcon: String { + switch device.trust { + case .verified: return "checkmark.seal.fill" + case .trusted: return "checkmark.shield" + case .undecided: return "questionmark.diamond" + case .compromised: return "exclamationmark.triangle.fill" + } + } + + private var trustColor: Color { + switch device.trust { + case .verified: return .green + case .trusted: return .blue + case .undecided: return .orange + case .compromised: return .red + } + } +} + +private extension String { + func chunked(every size: Int) -> [String] { + guard size > 0 else { return [self] } + var result: [String] = [] + var cursor = startIndex + while cursor < endIndex { + let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex + result.append(String(self[cursor.. Void + + @Environment(\.dismiss) private var dismiss + @State private var destinationJID = "" + @State private var isForwarding = false + + init( + model: AppModel, + messages: [ChatMessage], + onComplete: @escaping () -> Void = {} + ) { + _model = ObservedObject(wrappedValue: model) + self.messages = messages.filter(\.canBeForwarded) + self.onComplete = onComplete + } + + var body: some View { + NavigationStack { + List { + Section(messages.count == 1 ? "Пересылаемое сообщение" : "Пересылаемые сообщения") { + Label(forwardingSummary, systemImage: "arrowshape.turn.up.right.fill") + .fontWeight(.semibold) + ForEach(Array(messages.prefix(3))) { message in + Text(message.previewText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + if messages.count > 3 { + Text("И ещё \(messages.count - 3)…") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + if !model.conversations.isEmpty { + Section("Чаты") { + ForEach(model.conversations) { conversation in + Button { + forward(to: conversation.jid) + } label: { + HStack(spacing: 11) { + AvatarView( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid), + size: 34 + ) + VStack(alignment: .leading, spacing: 2) { + Text(conversation.displayName) + .foregroundStyle(.primary) + Text(conversation.jid) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "paperplane.fill") + .foregroundStyle(.tint) + } + } + .buttonStyle(.plain) + .disabled(isForwarding) + } + } + } + + Section("Другой адрес") { + TextField("user@example.org", text: $destinationJID) + .disableAutocorrection(true) + Button { + forward(to: destinationJID) + } label: { + Label("Переслать", systemImage: "paperplane.fill") + } + .disabled( + isForwarding + || destinationJID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + } + } + .navigationTitle(messages.count == 1 ? "Переслать" : "Переслать сообщения") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + } + .overlay { + if isForwarding { + ZStack { + Color.black.opacity(0.08).ignoresSafeArea() + ProgressView("Пересылка…") + .padding(18) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + } + } + } + } +#if os(macOS) + .frame(minWidth: 420, minHeight: 520) +#endif + } + + private func forward(to jid: String) { + guard !isForwarding, !messages.isEmpty else { return } + isForwarding = true + Task { + let succeeded = await model.forwardMessages(messages, to: jid) + isForwarding = false + if succeeded { + onComplete() + dismiss() + } + } + } + + private var forwardingSummary: String { + let count = messages.count + let remainder100 = count % 100 + let remainder10 = count % 10 + let word: String + if (11...14).contains(remainder100) { + word = "сообщений" + } else if remainder10 == 1 { + word = "сообщение" + } else if (2...4).contains(remainder10) { + word = "сообщения" + } else { + word = "сообщений" + } + return "\(count) \(word)" + } +} diff --git a/Sources/Shared/UI/GroupInfoView.swift b/Sources/Shared/UI/GroupInfoView.swift new file mode 100644 index 0000000..5961956 --- /dev/null +++ b/Sources/Shared/UI/GroupInfoView.swift @@ -0,0 +1,121 @@ +import Foundation +import SwiftUI + +@MainActor +struct GroupInfoView: View { + @ObservedObject var model: AppModel + let conversation: Conversation + + @Environment(\.dismiss) private var dismiss + @State private var nickname: String + @State private var invitees = "" + @State private var isJoining = false + @State private var isInviting = false + + init(model: AppModel, conversation: Conversation) { + self.model = model + self.conversation = conversation + _nickname = State(initialValue: conversation.groupNickname ?? model.suggestedGroupNickname) + } + + var body: some View { + NavigationStack { + Form { + Section("Групповой чат") { + LabeledContent("Комната", value: conversation.jid) + LabeledContent("Состояние", value: liveConversation.isGroupJoined ? "Подключено" : "Не подключено") + LabeledContent("Участников", value: "\(liveConversation.occupantCount)") + TextField("Ваш псевдоним", text: $nickname) + .disabled(liveConversation.isGroupJoined) + } + + if !liveConversation.isGroupJoined { + Section { + Button { + join() + } label: { + Label(liveConversation.invitedBy == nil ? "Войти в комнату" : "Принять приглашение", systemImage: "person.2.fill") + } + .disabled(nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isJoining) + } footer: { + if let inviter = liveConversation.invitedBy { + Text("Приглашение от \(inviter)") + } + } + } else { + Section("Пригласить участников") { + TextField("user@example.org, ещё@example.org", text: $invitees, axis: .vertical) + .lineLimit(2...4) + Button { + invite() + } label: { + if isInviting { + ProgressView() + } else { + Label("Отправить приглашения", systemImage: "person.badge.plus") + } + } + .disabled(parsedInvitees.isEmpty || isInviting) + } + + Section { + Button(role: .destructive) { + model.leaveGroup(jid: conversation.jid) + dismiss() + } label: { + Label("Покинуть комнату", systemImage: "rectangle.portrait.and.arrow.right") + } + } + } + + Section { + Label( + model.encryptionEnabled(for: conversation.jid) + ? "Для комнаты включено OMEMO. Нужны неанонимный MUC и OMEMO-устройства у всех участников." + : "OMEMO для этой комнаты выключено: сообщения передаются открытым текстом внутри TLS.", + systemImage: model.encryptionEnabled(for: conversation.jid) ? "lock.fill" : "lock.open" + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationTitle(conversation.displayName) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Готово") { dismiss() } + } + } + } +#if os(macOS) + .frame(minWidth: 480, minHeight: 500) +#endif + } + + private var liveConversation: Conversation { + model.conversations.first(where: { $0.id == conversation.id }) ?? conversation + } + + private var parsedInvitees: [String] { + invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t")) + .filter { !$0.isEmpty } + } + + private func join() { + isJoining = true + Task { + await model.joinGroup(jid: conversation.jid, nickname: nickname) + isJoining = false + } + } + + private func invite() { + let outgoing = parsedInvitees + guard !outgoing.isEmpty else { return } + isInviting = true + Task { + await model.inviteMembers(outgoing, to: conversation.jid) + invitees = "" + isInviting = false + } + } +} diff --git a/Sources/Shared/UI/LocationPickerView.swift b/Sources/Shared/UI/LocationPickerView.swift new file mode 100644 index 0000000..83cc303 --- /dev/null +++ b/Sources/Shared/UI/LocationPickerView.swift @@ -0,0 +1,148 @@ +import CoreLocation +import Combine +import Foundation +import MapKit +import SwiftUI + +@MainActor +struct LocationPickerView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var locationProvider = LocationProvider() + @State private var cameraPosition: MapCameraPosition = .automatic + @State private var selectedCoordinate: CLLocationCoordinate2D? + + let onSend: (GeoLocation) -> Void + + var body: some View { + NavigationStack { + ZStack { + Map(position: $cameraPosition) { + UserAnnotation() + } + .mapStyle(.standard(elevation: .realistic)) + .onMapCameraChange(frequency: .onEnd) { context in + selectedCoordinate = context.region.center + } + + Image(systemName: "mappin.circle.fill") + .font(.system(size: 42, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.accentColor) + .shadow(color: .black.opacity(0.28), radius: 4, y: 2) + .offset(y: -20) + .allowsHitTesting(false) + + VStack { + if let message = locationProvider.errorMessage { + Text(message) + .font(.caption) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.regularMaterial, in: Capsule()) + .padding() + } + + Spacer() + + HStack { + Spacer() + Button(action: centerOnCurrentLocation) { + Group { + if locationProvider.isLocating { + ProgressView() + } else { + Image(systemName: "location.fill") + } + } + .frame(width: 44, height: 44) + .background(.regularMaterial, in: Circle()) + .shadow(color: .black.opacity(0.16), radius: 5, y: 2) + } + .buttonStyle(.plain) + .accessibilityLabel("Показать мою геопозицию") + } + .padding(.horizontal, 16) + .padding(.bottom, 10) + } + } + .safeAreaInset(edge: .bottom) { + VStack(spacing: 10) { + Text(coordinateText) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Button(action: sendSelection) { + Label("Отправить эту геопозицию", systemImage: "location.fill") + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 14)) + } + .buttonStyle(.plain) + .disabled(selectedCoordinate == nil) + } + .padding(16) + .background(.regularMaterial) + } + .navigationTitle("Геопозиция") +#if os(iOS) + .navigationBarTitleDisplayMode(.inline) +#endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + } + } + .onAppear { + locationProvider.requestCurrentLocation() + } + .onReceive(locationProvider.$location.compactMap { $0 }) { location in + selectedCoordinate = location.coordinate + cameraPosition = .region( + MKCoordinateRegion( + center: location.coordinate, + latitudinalMeters: 1_200, + longitudinalMeters: 1_200 + ) + ) + } + } + + private var coordinateText: String { + guard let selectedCoordinate else { return "Переместите карту, чтобы выбрать точку" } + return String( + format: "%.6f, %.6f", + locale: Locale(identifier: "en_US_POSIX"), + selectedCoordinate.latitude, + selectedCoordinate.longitude + ) + } + + private func centerOnCurrentLocation() { + if let location = locationProvider.location { + selectedCoordinate = location.coordinate + cameraPosition = .region( + MKCoordinateRegion( + center: location.coordinate, + latitudinalMeters: 1_200, + longitudinalMeters: 1_200 + ) + ) + } else { + locationProvider.requestCurrentLocation() + } + } + + private func sendSelection() { + guard let selectedCoordinate, + let location = GeoLocation( + latitude: selectedCoordinate.latitude, + longitude: selectedCoordinate.longitude + ) else { return } + onSend(location) + dismiss() + } +} diff --git a/Sources/Shared/UI/LoginView.swift b/Sources/Shared/UI/LoginView.swift new file mode 100644 index 0000000..2d1adb5 --- /dev/null +++ b/Sources/Shared/UI/LoginView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +struct LoginView: View { + @ObservedObject var model: AppModel + @State private var jid = "" + @State private var password = "" + @State private var displayName = "" + @State private var resource = "Luma" + @State private var showAdvanced = false + @State private var manualHost = "" + @State private var manualPort = "" + @State private var directTLS = false + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.08, green: 0.38, blue: 0.78), + Color(red: 0.12, green: 0.62, blue: 0.96), + Color(red: 0.44, green: 0.84, blue: 0.96) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + Circle() + .fill(.white.opacity(0.12)) + .frame(width: 420, height: 420) + .offset(x: -210, y: -260) + + ScrollView { + VStack(spacing: 28) { + brand + loginCard + } + .frame(maxWidth: 460) + .padding(.horizontal, 24) + .padding(.vertical, 48) + .frame(maxWidth: .infinity) + } + } + } + + private var brand: some View { + VStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(.white) + .frame(width: 88, height: 88) + .shadow(color: .black.opacity(0.16), radius: 22, y: 10) + Image(systemName: "bubble.left.and.bubble.right.fill") + .font(.system(size: 39, weight: .semibold)) + .foregroundStyle( + LinearGradient( + colors: [.blue, .cyan], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + } + + Text("Luma") + .font(.system(size: 42, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text("Приватный XMPP без привязки к одному серверу") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.86)) + .multilineTextAlignment(.center) + } + } + + private var loginCard: some View { + VStack(spacing: 18) { + VStack(alignment: .leading, spacing: 7) { + Text("XMPP-аккаунт") + .font(.headline) + TextField("you@example.org", text: $jid) + .textFieldStyle(.roundedBorder) + .disableAutocorrection(true) + } + + VStack(alignment: .leading, spacing: 7) { + Text("Пароль") + .font(.headline) + SecureField("Пароль", text: $password) + .textFieldStyle(.roundedBorder) + } + + DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) { + VStack(spacing: 14) { + TextField("Отображаемое имя", text: $displayName) + .textFieldStyle(.roundedBorder) + TextField("Ресурс устройства", text: $resource) + .textFieldStyle(.roundedBorder) + TextField("Сервер вручную (необязательно)", text: $manualHost) + .textFieldStyle(.roundedBorder) + TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort) + .textFieldStyle(.roundedBorder) + Toggle("Direct TLS", isOn: $directTLS) + Text("Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS.") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.top, 14) + } + + Button { + Task { await signIn() } + } label: { + HStack { + if model.isAuthenticating { + ProgressView().controlSize(.small) + } + Text(model.isAuthenticating ? "Подключение…" : "Войти") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .frame(height: 34) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(model.isAuthenticating) + + Label("Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.", systemImage: "lock.shield.fill") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(26) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .shadow(color: .black.opacity(0.14), radius: 24, y: 12) + } + + private func signIn() async { + let port = Int(manualPort.trimmingCharacters(in: .whitespacesAndNewlines)) + let account = AccountConfiguration( + jid: jid, + displayName: displayName, + resource: resource, + manualHost: manualHost, + manualPort: port, + usesDirectTLS: directTLS + ) + await model.signIn(account: account, password: password) + } +} + diff --git a/Sources/Shared/UI/MainChatView.swift b/Sources/Shared/UI/MainChatView.swift new file mode 100644 index 0000000..2843079 --- /dev/null +++ b/Sources/Shared/UI/MainChatView.swift @@ -0,0 +1,268 @@ +import Foundation +import SwiftUI + +struct MainChatView: View { + private enum SidebarMode: String, CaseIterable { + case chats + case contacts + + var title: String { + switch self { + case .chats: return "Чаты" + case .contacts: return "Контакты" + } + } + } + + @ObservedObject var model: AppModel + @State private var searchText = "" + @State private var sidebarMode: SidebarMode = .chats + @State private var showingNewChat = false + @State private var showingNewGroup = false + @State private var showingSettings = false + + private var filteredConversations: [Conversation] { + guard !searchText.isEmpty else { return model.conversations } + return model.conversations.filter(matchesSearch) + } + + private var filteredRosterContacts: [Conversation] { + model.conversations + .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.id) } + .filter { searchText.isEmpty || matchesSearch($0) } + .sorted(by: contactSort) + } + + private var filteredGroupContacts: [Conversation] { + model.conversations + .filter { $0.isGroup } + .filter { searchText.isEmpty || matchesSearch($0) } + .sorted(by: contactSort) + } + + private var hasStoredContacts: Bool { + !model.rosterContactJIDs.isEmpty || model.conversations.contains { $0.isGroup } + } + + var body: some View { + NavigationSplitView { + VStack(spacing: 0) { + ConnectionBanner(model: model) + + Picker("Раздел", selection: $sidebarMode) { + Label("Чаты", systemImage: "bubble.left.and.bubble.right.fill") + .tag(SidebarMode.chats) + Label("Контакты", systemImage: "person.2.fill") + .tag(SidebarMode.contacts) + } + .pickerStyle(.segmented) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + switch sidebarMode { + case .chats: + chatList + case .contacts: + contactsList + } + } + .navigationTitle(sidebarMode.title) + .searchable( + text: $searchText, + prompt: sidebarMode == .chats ? "Поиск чатов" : "Поиск контактов" + ) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { showingSettings = true } label: { + Image(systemName: "gearshape") + } + Button { showingNewGroup = true } label: { + Image(systemName: "person.3.fill") + } + .accessibilityLabel("Новый групповой чат") + .help("Новый групповой чат") + Button { showingNewChat = true } label: { + Image(systemName: "square.and.pencil") + } + .accessibilityLabel("Новый личный чат") + .help("Новый личный чат") + } + } + } detail: { + if let conversation = model.selectedConversation { + ChatView(model: model, conversation: conversation) + .id(conversation.id) + } else { + WelcomeDetailView() + } + } + .navigationSplitViewStyle(.balanced) + .onChange(of: model.selectedConversationID) { _, id in + guard let id else { return } + model.selectConversation(id: id) + } + .sheet(isPresented: $showingNewChat) { + NewChatView(model: model) + } + .sheet(isPresented: $showingNewGroup) { + NewGroupView(model: model) + } + .sheet(isPresented: $showingSettings) { + SettingsView(model: model) + } + } + + private var chatList: some View { + List(selection: $model.selectedConversationID) { + ForEach(filteredConversations) { conversation in + ConversationRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid), + isEncrypted: model.encryptionEnabled(for: conversation.jid) + ) + .tag(conversation.id) + .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + } + } + .listStyle(.plain) + .overlay { + if filteredConversations.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет чатов" : "Ничего не найдено", + systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass", + description: Text( + searchText.isEmpty + ? "Создайте личный или групповой чат." + : "Попробуйте другой JID или имя." + ) + ) + } + } + } + + private var contactsList: some View { + List(selection: $model.selectedConversationID) { + if !filteredRosterContacts.isEmpty { + Section { + ForEach(filteredRosterContacts) { conversation in + ContactRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid) + ) + .tag(conversation.id) + .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)) + } + } header: { + Label("Люди из roster", systemImage: "person.2.fill") + } + } + + if !filteredGroupContacts.isEmpty { + Section { + ForEach(filteredGroupContacts) { conversation in + ContactRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid) + ) + .tag(conversation.id) + .listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12)) + } + } header: { + Label("Групповые чаты", systemImage: "person.3.fill") + } + } + } + .listStyle(.plain) + .overlay { + if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет контактов" : "Ничего не найдено", + systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass", + description: Text(contactListDescription) + ) + } + } + } + + private var contactListDescription: String { + if !searchText.isEmpty { + return "Попробуйте другой JID или имя." + } + if !hasStoredContacts, model.connectionStatus != .connected { + return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты." + } + return "Добавьте человека в roster или создайте групповой чат." + } + + private func matchesSearch(_ conversation: Conversation) -> Bool { + conversation.displayName.localizedCaseInsensitiveContains(searchText) + || conversation.jid.localizedCaseInsensitiveContains(searchText) + } + + private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool { + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } +} + +private struct ContactRow: View { + let conversation: Conversation + let imageData: Data? + + var body: some View { + HStack(spacing: 12) { + AvatarView(conversation: conversation, imageData: imageData, size: 42) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(conversation.displayName) + .font(.headline) + .lineLimit(1) + if !conversation.isGroup, conversation.isOnline { + Circle() + .fill(Color.green) + .frame(width: 7, height: 7) + .accessibilityLabel("В сети") + } + } + + if conversation.isGroup { + HStack(spacing: 4) { + Image(systemName: "person.3.fill") + Text(groupSubtitle) + } + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(conversation.jid) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.caption.bold()) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + } + + private var groupSubtitle: String { + guard conversation.occupantCount > 0 else { return conversation.jid } + return "\(conversation.occupantCount) участн. • \(conversation.jid)" + } +} + +private struct WelcomeDetailView: View { + var body: some View { + ZStack { + Color.secondary.opacity(0.04).ignoresSafeArea() + ContentUnavailableView { + Label("Luma", systemImage: "bubble.left.and.bubble.right.fill") + } description: { + Text("Выберите чат или начните новый защищённый диалог.") + } + } + } +} diff --git a/Sources/Shared/UI/NewChatView.swift b/Sources/Shared/UI/NewChatView.swift new file mode 100644 index 0000000..7905d5b --- /dev/null +++ b/Sources/Shared/UI/NewChatView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +struct NewChatView: View { + @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss + @State private var jid = "" + @State private var name = "" + @State private var addToRoster = true + + var body: some View { + NavigationStack { + Form { + Section("Контакт") { + TextField("user@example.org", text: $jid) + .disableAutocorrection(true) + TextField("Имя (необязательно)", text: $name) + } + Section { + Toggle("Добавить в roster", isOn: $addToRoster) + } footer: { + Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.") + } + } + .navigationTitle("Новый чат") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Начать") { + model.openConversation( + jid: jid, + name: name.isEmpty ? nil : name, + addToRoster: addToRoster + ) + if model.errorMessage == nil { dismiss() } + } + .fontWeight(.semibold) + .disabled(jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } +#if os(macOS) + .frame(minWidth: 380, minHeight: 300) +#endif + } +} diff --git a/Sources/Shared/UI/NewGroupView.swift b/Sources/Shared/UI/NewGroupView.swift new file mode 100644 index 0000000..571236d --- /dev/null +++ b/Sources/Shared/UI/NewGroupView.swift @@ -0,0 +1,94 @@ +import Foundation +import SwiftUI + +@MainActor +struct NewGroupView: View { + @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss + + @State private var roomJID = "" + @State private var name = "" + @State private var nickname = "" + @State private var invitees = "" + @State private var isJoining = false + + var body: some View { + NavigationStack { + Form { + GroupBox { + VStack(alignment: .leading, spacing: 12) { + TextField("room@conference.example.org", text: $roomJID) + .disableAutocorrection(true) + TextField("Название (необязательно)", text: $name) + TextField("Ваш псевдоним", text: $nickname) + } + } label: { + Text("Комната") + .font(.headline) + } + + GroupBox { + VStack(alignment: .leading, spacing: 8) { + TextField("alice@example.org, bob@example.org", text: $invitees, axis: .vertical) + .lineLimit(2...5) + Text("Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } label: { + Text("Пригласить") + .font(.headline) + } + + Label( + "Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.", + systemImage: "lock.shield.fill" + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + .navigationTitle("Новая группа") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + .disabled(isJoining) + } + ToolbarItem(placement: .confirmationAction) { + Button("Создать / войти") { join() } + .fontWeight(.semibold) + .disabled(!canJoin || isJoining) + } + } + .onAppear { + if nickname.isEmpty { nickname = model.suggestedGroupNickname } + } + } +#if os(macOS) + .frame(minWidth: 480, minHeight: 470) +#endif + } + + private var canJoin: Bool { + !roomJID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func join() { + isJoining = true + Task { + await model.createOrJoinGroup( + roomJID: roomJID, + name: name.isEmpty ? nil : name, + nickname: nickname, + invitees: parsedInvitees + ) + isJoining = false + if model.errorMessage == nil { dismiss() } + } + } + + private var parsedInvitees: [String] { + invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t")) + .filter { !$0.isEmpty } + } +} diff --git a/Sources/Shared/UI/RootView.swift b/Sources/Shared/UI/RootView.swift new file mode 100644 index 0000000..269b465 --- /dev/null +++ b/Sources/Shared/UI/RootView.swift @@ -0,0 +1,46 @@ +import QuickLook +import SwiftUI + +struct RootView: View { + @ObservedObject var model: AppModel + + var body: some View { + ZStack { + Group { + if model.account == nil { + LoginView(model: model) + } else { + MainChatView(model: model) + } + } + + if let item = model.mediaViewerItem { + MediaViewer(item: item, onClose: model.closeMediaViewer) + .transition(.opacity.combined(with: .scale(scale: 0.98))) + .zIndex(100) + } + + if let call = model.activeCall { + CallView(model: model, call: call) + .transition(.opacity.combined(with: .scale(scale: 1.015))) + .zIndex(200) + } + } + .animation(.easeInOut(duration: 0.22), value: model.account?.id) + .animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id) + .animation(.easeInOut(duration: 0.2), value: model.activeCall?.id) + .alert("Luma", isPresented: errorBinding) { + Button("OK") { model.clearError() } + } message: { + Text(model.errorMessage ?? "Неизвестная ошибка") + } + .quickLookPreview($model.previewURL) + } + + private var errorBinding: Binding { + Binding( + get: { model.errorMessage != nil }, + set: { visible in if !visible { model.clearError() } } + ) + } +} diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift new file mode 100644 index 0000000..8ef85a5 --- /dev/null +++ b/Sources/Shared/UI/SettingsView.swift @@ -0,0 +1,194 @@ +import PhotosUI +import SwiftUI + +struct SettingsView: View { + @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss + @State private var showingSignOutConfirmation = false + @State private var forgetHistory = false + @State private var avatarItem: PhotosPickerItem? + + var body: some View { + NavigationStack { + Form { + if let account = model.account { + Section("Профиль") { + HStack(spacing: 14) { + AvatarView( + conversation: Conversation( + jid: account.normalizedJID, + displayName: account.displayName.isEmpty ? nil : account.displayName + ), + imageData: model.avatarData(for: account.normalizedJID), + size: 64 + ) + VStack(alignment: .leading, spacing: 6) { + Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName) + .font(.headline) + PhotosPicker(selection: $avatarItem, matching: .images) { + Label("Обновить аватар", systemImage: "photo.badge.plus") + } + .disabled(model.isUpdatingAvatar) + } + Spacer() + if model.isUpdatingAvatar { + ProgressView() + } + } + } + + Section("Аккаунт") { + LabeledContent("JID", value: account.normalizedJID) + LabeledContent("Ресурс", value: account.effectiveResource) + if let host = account.manualHost { + LabeledContent("Сервер", value: "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))") + } else { + LabeledContent("Обнаружение", value: "DNS SRV") + } + } + } + + Section("Соединение") { + HStack { + Label(connectionTitle, systemImage: connectionIcon) + .foregroundStyle(connectionColor) + Spacer() + if model.connectionStatus != .connected { + Button("Подключить") { Task { await model.reconnect() } } + } + } + LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово") + } + + Section("Шифрование") { + Toggle("OMEMO по умолчанию", isOn: Binding( + get: { model.globalEncryptionEnabled }, + set: { model.setGlobalEncryptionEnabled($0) } + )) + Text("Для отдельного чата режим можно изменить через значок замка в его панели.") + .font(.caption) + .foregroundStyle(.secondary) + if !model.globalEncryptionEnabled { + Label("Новые сообщения без отдельного правила отправляются открытым текстом.", systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + LabeledContent("Состояние", value: model.isOMEMOReady ? "Активно" : "Подготовка") + if let fingerprint = model.ownFingerprint { + VStack(alignment: .leading, spacing: 7) { + Text("Отпечаток этого устройства") + .font(.subheadline) + Text(fingerprint.chunked(every: 8).joined(separator: " ")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + + Section("Уведомления") { + Label("Локальные уведомления включаются после входа", systemImage: "bell.badge") + Text("Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Приватность чата") { + Toggle("Показывать статус набора", isOn: Binding( + get: { model.typingIndicatorsEnabled }, + set: { model.setTypingIndicatorsEnabled($0) } + )) + Text("Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory) + + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label("Выйти из аккаунта", systemImage: "rectangle.portrait.and.arrow.right") + } + } + } + .navigationTitle("Настройки") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Готово") { dismiss() } + } + } + .confirmationDialog( + "Выйти из Luma?", + isPresented: $showingSignOutConfirmation, + titleVisibility: .visible + ) { + Button("Выйти", role: .destructive) { + Task { + await model.signOut(forgetHistory: forgetHistory) + dismiss() + } + } + Button("Отмена", role: .cancel) {} + } message: { + Text(forgetHistory + ? "Пароль и локальная история будут удалены. Серверная история MAM не изменится." + : "Пароль будет удалён из Keychain. Серверная история MAM не изменится.") + } + .onChange(of: avatarItem) { _, item in + guard let item else { return } + Task { + do { + guard let data = try await item.loadTransferable(type: Data.self) else { return } + await model.updateOwnAvatar(from: data) + avatarItem = nil + } catch { + model.errorMessage = error.localizedDescription + } + } + } + } +#if os(macOS) + .frame(minWidth: 500, minHeight: 560) +#endif + } + + private var connectionTitle: String { + switch model.connectionStatus { + case .connected: return "Подключено" + case .connecting: return "Подключение" + case .disconnected: return "Отключено" + } + } + + private var connectionIcon: String { + switch model.connectionStatus { + case .connected: return "checkmark.circle.fill" + case .connecting: return "arrow.triangle.2.circlepath" + case .disconnected: return "xmark.circle.fill" + } + } + + private var connectionColor: Color { + switch model.connectionStatus { + case .connected: return .green + case .connecting: return .orange + case .disconnected: return .red + } + } +} + +private extension String { + func chunked(every size: Int) -> [String] { + guard size > 0 else { return [self] } + var result: [String] = [] + var cursor = startIndex + while cursor < endIndex { + let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex + result.append(String(self[cursor..) -> Void + let onCancel: () -> Void + + static var isAvailable: Bool { + UIImagePickerController.isSourceTypeAvailable(.camera) + } + + func makeCoordinator() -> Coordinator { + Coordinator(onMedia: onMedia, onCancel: onCancel) + } + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .camera + picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier] + picker.cameraCaptureMode = .photo + picker.videoQuality = .typeHigh + picker.videoMaximumDuration = 180 + picker.allowsEditing = false + picker.modalPresentationStyle = .fullScreen + return picker + } + + func updateUIViewController( + _ uiViewController: UIImagePickerController, + context: Context + ) {} + + final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + private let onMedia: (Result) -> Void + private let onCancel: () -> Void + private var completed = false + + init( + onMedia: @escaping (Result) -> Void, + onCancel: @escaping () -> Void + ) { + self.onMedia = onMedia + self.onCancel = onCancel + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + guard !completed else { return } + completed = true + + let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) } + if mediaType?.conforms(to: .movie) == true, + let sourceURL = info[.mediaURL] as? URL { + prepareMovie(from: sourceURL) + return + } + + guard let image = info[.originalImage] as? UIImage else { + onMedia(.failure(SystemCameraError.missingMedia)) + return + } + preparePhoto(image) + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + guard !completed else { return } + completed = true + onCancel() + } + + private func preparePhoto(_ image: UIImage) { + let completion = onMedia + DispatchQueue.global(qos: .userInitiated).async { + let result: Result + do { + let url = try Self.destinationURL(extension: "jpg") + guard let data = image.jpegData(compressionQuality: 0.9), + !data.isEmpty else { + throw SystemCameraError.encodingFailed + } + try data.write(to: url, options: [.atomic]) + result = .success(CapturedCameraMedia(url: url, kind: .photo)) + } catch { + result = .failure(error) + } + DispatchQueue.main.async { completion(result) } + } + } + + private func prepareMovie(from sourceURL: URL) { + let completion = onMedia + DispatchQueue.global(qos: .userInitiated).async { + let result: Result + do { + let fileExtension = sourceURL.pathExtension.isEmpty + ? "mov" + : sourceURL.pathExtension + let destination = try Self.destinationURL(extension: fileExtension) + try FileManager.default.copyItem(at: sourceURL, to: destination) + let values = try destination.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ) + guard values.isRegularFile == true, + (values.fileSize ?? 0) > 0 else { + try? FileManager.default.removeItem(at: destination) + throw SystemCameraError.emptyMovie + } + result = .success(CapturedCameraMedia(url: destination, kind: .video)) + } catch { + result = .failure(error) + } + DispatchQueue.main.async { completion(result) } + } + } + + private static func destinationURL(extension fileExtension: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaCameraMedia", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return directory.appendingPathComponent( + "camera-\(UUID().uuidString).\(fileExtension)" + ) + } + } +} + +private enum SystemCameraError: LocalizedError { + case missingMedia + case encodingFailed + case emptyMovie + + var errorDescription: String? { + switch self { + case .missingMedia: + return "Камера не вернула фото или видео." + case .encodingFailed: + return "Не удалось подготовить фотографию к отправке." + case .emptyMovie: + return "Камера вернула пустой видеофайл. Повторите запись." + } + } +} +#endif diff --git a/Sources/Shared/UI/VideoNoteCaptureView.swift b/Sources/Shared/UI/VideoNoteCaptureView.swift new file mode 100644 index 0000000..7a2f047 --- /dev/null +++ b/Sources/Shared/UI/VideoNoteCaptureView.swift @@ -0,0 +1,192 @@ +import AVFoundation +import SwiftUI + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +@MainActor +struct VideoNoteCaptureView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var recorder = VideoNoteRecorder() + @State private var errorMessage: String? + @State private var isFinalizing = false + + let onComplete: (VideoNoteRecorder.Recording) -> Void + + var body: some View { + NavigationStack { + VStack(spacing: 22) { + ZStack { + Circle().fill(Color.black) + CameraPreview(session: recorder.session) + .clipShape(Circle()) + if !recorder.isPrepared { + ProgressView("Подготовка камеры…") + .tint(.white) + .foregroundStyle(.white) + } + } + .frame(maxWidth: 420) + .aspectRatio(1, contentMode: .fit) + .overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1)) + + Text(recordingStatus) + .font(.system(.title3, design: .monospaced).weight(.semibold)) + .foregroundStyle(recorder.isRecording ? .red : .secondary) + + Button { + guard recorder.isRecording, !isFinalizing else { return } + isFinalizing = true + Task { @MainActor in + await recorder.stop( + afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration + ) + } + } label: { + ZStack { + Circle() + .stroke(Color.red, lineWidth: 4) + .frame(width: 76, height: 76) + if isFinalizing { + ProgressView() + .controlSize(.regular) + .tint(.red) + } else { + RoundedRectangle(cornerRadius: 7) + .fill(Color.red) + .frame(width: 32, height: 32) + } + } + } + .buttonStyle(.plain) + .disabled(!recorder.isRecording || isFinalizing) + .accessibilityLabel(isFinalizing ? "Видео завершается" : "Завершить запись") + + Text("Запись началась. Нажмите красную кнопку, чтобы завершить и отправить.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(24) + .navigationTitle("Видеосообщение") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { + recorder.cancel() + dismiss() + } + } + } + } + .task { + recorder.onFinished = { result in + switch result { + case .success(let recording): + onComplete(recording) + dismiss() + case .failure(let error): + isFinalizing = false + errorMessage = error.localizedDescription + } + } + do { + try await recorder.prepare() + guard !Task.isCancelled else { return } + try recorder.start() + } catch { + errorMessage = error.localizedDescription + } + } + .onDisappear { + if recorder.isRecording { + recorder.cancel() + } else { + recorder.shutdown() + } + } + .alert("Luma", isPresented: Binding( + get: { errorMessage != nil }, + set: { if !$0 { errorMessage = nil } } + )) { + Button("OK") { errorMessage = nil } + } message: { + Text(errorMessage ?? "Неизвестная ошибка") + } +#if os(macOS) + .frame(minWidth: 540, minHeight: 650) +#endif + } + + private func formatted(_ duration: TimeInterval) -> String { + let seconds = max(0, Int(duration.rounded(.down))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + private var recordingStatus: String { + if isFinalizing { return "Завершение…" } + if recorder.isRecording { return formatted(recorder.elapsed) } + return "Подготовка камеры…" + } +} + +#if os(iOS) +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.previewLayer.session = session + view.previewLayer.videoGravity = .resizeAspectFill + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) { + uiView.previewLayer.session = session + } + + final class PreviewView: UIView { + override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } + + var previewLayer: AVCaptureVideoPreviewLayer { + layer as! AVCaptureVideoPreviewLayer + } + } +} +#elseif os(macOS) +struct CameraPreview: NSViewRepresentable { + let session: AVCaptureSession + + func makeNSView(context: Context) -> PreviewView { + let view = PreviewView() + view.previewLayer.session = session + view.previewLayer.videoGravity = .resizeAspectFill + return view + } + + func updateNSView(_ nsView: PreviewView, context: Context) { + nsView.previewLayer.session = session + } + + final class PreviewView: NSView { + let previewLayer = AVCaptureVideoPreviewLayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer = previewLayer + } + + required init?(coder: NSCoder) { + return nil + } + + override func layout() { + super.layout() + previewLayer.frame = bounds + } + } +} +#endif diff --git a/Sources/Shared/XMPP/CryptoKitAESGCMEngine.swift b/Sources/Shared/XMPP/CryptoKitAESGCMEngine.swift new file mode 100644 index 0000000..eea1143 --- /dev/null +++ b/Sources/Shared/XMPP/CryptoKitAESGCMEngine.swift @@ -0,0 +1,60 @@ +import CryptoKit +import Foundation +import MartinOMEMO + +final class CryptoKitAESGCMEngine: AES_GCM_Engine { + func encrypt( + iv: Data, + key: Data, + message: Data, + output: UnsafeMutablePointer?, + tag: UnsafeMutablePointer? + ) -> Bool { + do { + let nonce = try AES.GCM.Nonce(data: iv) + let sealed = try AES.GCM.seal( + message, + using: SymmetricKey(data: key), + nonce: nonce + ) + output?.pointee = sealed.ciphertext + tag?.pointee = sealed.tag + return true + } catch { + return false + } + } + + func decrypt( + iv: Data, + key: Data, + encoded: Data, + auth tag: Data?, + output: UnsafeMutablePointer? + ) -> Bool { + do { + let nonce = try AES.GCM.Nonce(data: iv) + let authenticationTag: Data + let ciphertext: Data + + if let tag { + authenticationTag = tag + ciphertext = encoded + } else { + guard encoded.count >= 16 else { return false } + authenticationTag = Data(encoded.suffix(16)) + ciphertext = Data(encoded.dropLast(16)) + } + + let box = try AES.GCM.SealedBox( + nonce: nonce, + ciphertext: ciphertext, + tag: authenticationTag + ) + output?.pointee = try AES.GCM.open(box, using: SymmetricKey(data: key)) + return true + } catch { + return false + } + } +} diff --git a/Sources/Shared/XMPP/LumaCallEngine.swift b/Sources/Shared/XMPP/LumaCallEngine.swift new file mode 100644 index 0000000..d1ffff0 --- /dev/null +++ b/Sources/Shared/XMPP/LumaCallEngine.swift @@ -0,0 +1,1772 @@ +import AVFoundation +import Foundation +import Martin +import WebRTC + +#if os(iOS) +import UIKit +#elseif os(macOS) +import AppKit +#endif + +/// Owns one active 1:1 Jingle RTP session and bridges Martin to WebRTC. +/// Incoming calls are available while the XMPP connection is alive; production +/// wake-up for a terminated iOS app still requires VoIP push infrastructure. +final class LumaCallEngine: NSObject { + var snapshotHandler: (@MainActor (CallSnapshot?) -> Void)? + var historyHandler: (@MainActor (CallHistoryEntry) -> Void)? + var errorHandler: (@MainActor (String) -> Void)? + + private let queue = DispatchQueue(label: "app.luma.call-engine") + private let queueKey = DispatchSpecificKey() + fileprivate let peerConnectionFactory: RTCPeerConnectionFactory + private weak var client: XMPPClient? + private var sessions: [String: LumaJingleSession] = [:] + private var activeCall: LumaWebRTCCall? + private var timeoutWorkItem: DispatchWorkItem? + + override init() { + RTCInitializeSSL() + peerConnectionFactory = RTCPeerConnectionFactory( + encoderFactory: RTCDefaultVideoEncoderFactory(), + decoderFactory: RTCDefaultVideoDecoderFactory() + ) + super.init() + queue.setSpecific(key: queueKey, value: 1) + } + + func attach(client: XMPPClient) { + performSync { + self.client = client + } + } + + func detach() { + queue.async { + self.finishActiveCall( + cause: .connectionDetached, + sendTermination: false, + trigger: "XMPP engine detached" + ) + self.sessions.removeAll() + self.client = nil + } + } + + func startCall( + to peerJID: String, + withVideo: Bool, + completion: @escaping (Result) -> Void + ) { + queue.async { + guard self.activeCall == nil else { + completion(.failure(LumaCallError.callAlreadyActive)) + return + } + guard let client = self.client, client.state == .connected() else { + completion(.failure(LumaCallError.notConnected)) + return + } + + let peer = BareJID(peerJID.lowercased()) + let media: Set = withVideo ? [.audio, .video] : [.audio] + do { + let destination = try self.destination( + for: peer, + media: media, + client: client + ) +#if DEBUG + print( + "Luma Call: selected \(destination.jid.stringValue) via " + + (destination.usesMessageInitiation ? "JMI" : "direct Jingle") + + " for \(media.map(\.rawValue).sorted().joined(separator: "+"))." + ) +#endif + let sid = UUID().uuidString.lowercased() + let initiationType: JingleSessionInitiationType = destination.usesMessageInitiation + ? .message + : .iq + let session = self.makeSession( + context: client, + jid: destination.jid, + sid: sid, + role: .initiator, + initiationType: initiationType + ) + let call = LumaWebRTCCall( + engine: self, + session: session, + peerJID: peer.stringValue, + direction: .outgoing, + media: media + ) + self.activeCall = call + self.publish(call) + self.scheduleTimeout(for: call.id, after: 60, stage: "ringing") + + if destination.usesMessageInitiation { + Task { + do { + try await session.initiate(descriptions: media + .sorted { $0.rawValue < $1.rawValue } + .map { + Jingle.MessageInitiationAction.Description( + xmlns: "urn:xmpp:jingle:apps:rtp:1", + media: $0.rawValue + ) + }) + completion(.success(())) + } catch { + self.queue.async { + self.fail(call, error: error) + } + completion(.failure(error)) + } + } + } else { + call.preparePeerConnection { result in + switch result { + case .success: + call.bindSessionActions() + call.sendOffer { result in + if case .failure(let error) = result { + self.fail(call, error: error) + } + completion(result) + } + case .failure(let error): + self.fail(call, error: error) + completion(.failure(error)) + } + } + } + } catch { + completion(.failure(error)) + } + } + } + + func answerCall(completion: @escaping (Result) -> Void) { + queue.async { + guard let call = self.activeCall, + call.direction == .incoming, + call.phase == .ringing else { + completion(.failure(LumaCallError.noIncomingCall)) + return + } + call.preparePeerConnection { result in + switch result { + case .success: + call.phase = .connecting + call.session.accept() + call.bindSessionActions() + self.scheduleTimeout( + for: call.id, + after: 90, + stage: "incoming negotiation" + ) + self.publish(call) + completion(.success(())) + case .failure(let error): + self.fail(call, error: error) + completion(.failure(error)) + } + } + } + } + + func rejectCall() { + queue.async { + guard self.activeCall != nil else { return } + self.finishActiveCall( + reason: .decline, + cause: .localRejected, + sendTermination: true, + trigger: "local reject button" + ) + } + } + + func endCall() { + queue.async { + self.finishActiveCall( + reason: .success, + cause: .localEnded, + sendTermination: true, + trigger: "local end button" + ) + } + } + + func setMuted(_ muted: Bool) { + queue.async { + guard let call = self.activeCall else { return } + call.isMuted = muted + call.localAudioTrack?.isEnabled = !muted + self.publish(call) + } + } + + func setCameraEnabled(_ enabled: Bool) { + queue.async { + guard let call = self.activeCall, call.media.contains(.video) else { return } + call.isCameraEnabled = enabled + call.localVideoTrack?.isEnabled = enabled + self.publish(call) + } + } + + func setSpeakerEnabled(_ enabled: Bool) { + queue.async { + guard let call = self.activeCall else { return } + do { + try call.configureSpeaker(enabled) + call.isSpeakerEnabled = enabled + self.publish(call) + } catch { + self.report(error) + } + } + } + + func switchCamera() { + queue.async { + self.activeCall?.switchCamera() + } + } + + func localVideoTrack(for callID: UUID) -> RTCVideoTrack? { + performSync { + guard activeCall?.id == callID else { return nil } + return activeCall?.localVideoTrack + } + } + + func remoteVideoTrack(for callID: UUID) -> RTCVideoTrack? { + performSync { + guard activeCall?.id == callID else { return nil } + return activeCall?.remoteVideoTrack + } + } + + fileprivate func iceServers( + for client: XMPPClient, + completion: @escaping ([RTCIceServer]) -> Void + ) { + let fallback = Self.publicSTUNServers() + guard let module = client.moduleOrNil(.externalServiceDiscovery), module.isAvailable else { + completion(fallback) + return + } + module.discover(from: nil, type: nil) { result in + let servers: [RTCIceServer] + switch result { + case .success(let services): + servers = services.compactMap { $0.rtcIceServer } + case .failure: + servers = [] + } + completion(servers.isEmpty ? fallback : servers) + } + } + + fileprivate static func publicSTUNServers() -> [RTCIceServer] { + [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])] + } + + fileprivate func publish(_ call: LumaWebRTCCall?) { + let snapshot = call?.snapshot + Task { @MainActor [weak self] in + self?.snapshotHandler?(snapshot) + } + } + + fileprivate func callDidConnect(_ call: LumaWebRTCCall) { + queue.async { + guard self.activeCall === call else { return } + self.timeoutWorkItem?.cancel() + call.phase = .connected + if call.connectedAt == nil { call.connectedAt = Date() } + self.publish(call) + } + } + + fileprivate func callDidLoseConnection(_ call: LumaWebRTCCall) { + queue.async { + guard self.activeCall === call else { return } + self.fail(call, error: LumaCallError.connectionLost) + } + } + + fileprivate func callDidFail(_ call: LumaWebRTCCall, error: Error) { + queue.async { + guard self.activeCall === call else { return } + self.fail(call, error: error) + } + } + + fileprivate func callVideoTracksDidChange(_ call: LumaWebRTCCall) { + queue.async { + guard self.activeCall === call else { return } + self.publish(call) + } + } + + private func destination( + for peer: BareJID, + media: Set, + client: XMPPClient + ) throws -> (jid: JID, usesMessageInitiation: Bool) { + let presences = client.module(.presence).store.presences(for: peer, context: client) + guard !presences.isEmpty else { throw LumaCallError.contactOffline } + + let capabilities = client.module(.caps) + let candidates = presences.compactMap(\.from).filter { jid in + guard capabilities.isFeatureSupported(JingleModule.XMLNS, by: jid), + capabilities.isFeatureSupported(Jingle.Transport.ICEUDPTransport.XMLNS, by: jid), + capabilities.isFeatureSupported("urn:xmpp:jingle:apps:rtp:audio", by: jid) else { + return false + } + return !media.contains(.video) + || capabilities.isFeatureSupported("urn:xmpp:jingle:apps:rtp:video", by: jid) + } + guard let directJID = candidates.first else { + throw LumaCallError.unsupportedByContact + } + let jmiJID = candidates.first { + capabilities.isFeatureSupported(JingleModule.MESSAGE_INITIATION_XMLNS, by: $0) + } + // A proposal addressed to the bare JID can return a bare `proceed` on + // some server/client combinations. We would then have no safe endpoint + // for transport-info: sending it to the bare JID produces + // item-not-found, while waiting for a resource leaves ICE without any + // candidates. Address JMI to one advertised full JID instead. It still + // uses XEP-0353, but every stanza now belongs to the same resource. + if let jmiJID { + return (jid: jmiJID, usesMessageInitiation: true) + } + return (jid: directJID, usesMessageInitiation: false) + } + + private func makeSession( + context: Context, + jid: JID, + sid: String, + role: Jingle.Content.Creator, + initiationType: JingleSessionInitiationType + ) -> LumaJingleSession { + let session = LumaJingleSession( + context: context, + jid: jid, + sid: sid, + role: role, + initiationType: initiationType + ) + sessions[sid] = session + return session + } + + private func scheduleTimeout( + for callID: UUID, + after delay: TimeInterval, + stage: String + ) { + timeoutWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self, self.activeCall?.id == callID else { return } +#if DEBUG + print("Luma Call: timeout during \(stage) after \(Int(delay)) seconds.") +#endif + self.fail(self.activeCall, error: LumaCallError.timedOut) + } + timeoutWorkItem = item + queue.asyncAfter(deadline: .now() + delay, execute: item) + } + + private func fail(_ call: LumaWebRTCCall?, error: Error) { + guard call == nil || activeCall === call else { return } + let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription +#if DEBUG + print("Luma Call: failure: \(text)") +#endif + report(error) + let cause: CallTerminationCause + if let callError = error as? LumaCallError, case .timedOut = callError { + cause = .timedOut + } else { + cause = .failed + } + finishActiveCall( + reason: .failedApplication, + cause: cause, + sendTermination: true, + trigger: "failure: \(text)" + ) + } + + private func report(_ error: Error) { + let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + Task { @MainActor [weak self] in + self?.errorHandler?(text) + } + } + + private func finishActiveCall( + reason: JingleSessionTerminateReason = .success, + cause: CallTerminationCause, + sendTermination: Bool, + trigger: String + ) { + timeoutWorkItem?.cancel() + timeoutWorkItem = nil + guard let call = activeCall else { + publish(nil) + return + } +#if DEBUG + print( + "Luma Call: closing sid=\(call.session.sid) " + + "direction=\(String(describing: call.direction)) " + + "phase=\(String(describing: call.phase)) " + + "peer=\(call.session.jid.stringValue) trigger=\(trigger)." + ) +#endif + let endedAt = Date() + let outcome = CallHistoryPolicy.outcome( + direction: call.direction, + phase: call.phase, + connectedAt: call.connectedAt, + cause: cause + ) + let historyEntry = CallHistoryEntry( + id: "call-\(call.peerJID)-\(call.session.sid)", + peerJID: call.peerJID, + direction: call.direction, + isVideo: call.media.contains(.video), + startedAt: call.startedAt, + endedAt: endedAt, + duration: call.connectedAt.map { max(0, endedAt.timeIntervalSince($0)) }, + outcome: outcome + ) + activeCall = nil + sessions.removeValue(forKey: call.session.sid) + if sendTermination { + call.session.finish(reason: reason) + } else { + call.session.terminated() + } + call.closePeerConnection() + publish(nil) + publish(historyEntry) + } + + private func publish(_ historyEntry: CallHistoryEntry) { + Task { @MainActor [weak self] in + self?.historyHandler?(historyEntry) + } + } + + private func performSync(_ operation: () throws -> T) rethrows -> T { + if DispatchQueue.getSpecific(key: queueKey) != nil { + return try operation() + } + return try queue.sync(execute: operation) + } +} + +// MARK: - Martin Jingle session manager + +extension LumaCallEngine: JingleSessionManager { + func messageInitiation( + for context: Context, + from jid: JID, + action: Jingle.MessageInitiationAction + ) throws { + try performSync { + switch action { + case .propose(let id, let descriptions): + guard activeCall == nil else { + context.module(.jingle).sendMessageInitiation(action: .reject(id: id), to: jid) + return + } + let media = Set(descriptions.compactMap { CallMedia(rawValue: $0.media) }) + guard media.contains(.audio) else { + context.module(.jingle).sendMessageInitiation(action: .reject(id: id), to: jid) + throw XMPPError.feature_not_implemented + } + let session = makeSession( + context: context, + jid: jid, + sid: id, + role: .responder, + initiationType: .message + ) + let call = LumaWebRTCCall( + engine: self, + session: session, + peerJID: jid.bareJid.stringValue, + direction: .incoming, + media: media + ) + activeCall = call + publish(call) + scheduleTimeout(for: call.id, after: 60, stage: "incoming ringing") + + case .proceed(let id): + guard let session = sessions[id], + let call = activeCall, + call.session === session, + call.direction == .outgoing, + call.phase == .ringing, + session.pinPeerResource(jid) else { +#if DEBUG + print("Luma Jingle: ignored duplicate or foreign proceed for session \(id).") +#endif + return + } + call.phase = .connecting +#if DEBUG + print("Luma Jingle: proceed from \(jid.stringValue) for session \(id).") +#endif + scheduleTimeout( + for: call.id, + after: 90, + stage: "outgoing negotiation" + ) + publish(call) + call.preparePeerConnection { result in + switch result { + case .success: + call.bindSessionActions() + call.sendOffer { result in + if case .failure(let error) = result { + self.fail(call, error: error) + } + } + case .failure(let error): + self.fail(call, error: error) + } + } + + case .retract(let id): + guard let session = sessions[id], + session.matchesPeerEndpoint(jid) else { return } + finishActiveCall( + cause: .remoteCancelled, + sendTermination: false, + trigger: "remote JMI retract from \(jid.stringValue)" + ) + + case .reject(let id): + guard let session = sessions[id], + let call = activeCall, + call.session === session, + session.matchesPeerEndpoint(jid) else { return } + // A JMI proposal is delivered to every online resource. Once a + // resource has proceeded, a delayed reject from a sibling must + // not tear down the session that already owns the call. + guard call.phase == .ringing else { +#if DEBUG + print("Luma Jingle: ignored late reject after proceed for session \(id).") +#endif + return + } + finishActiveCall( + cause: .remoteRejected, + sendTermination: false, + trigger: "remote JMI reject from \(jid.stringValue)" + ) + + case .accept(let id): + // A carbon from another local resource means that device + // answered the incoming proposal. + guard jid.bareJid == context.userBareJid, sessions[id] != nil else { return } + finishActiveCall( + cause: .answeredElsewhere, + sendTermination: false, + trigger: "answered on another local resource \(jid.stringValue)" + ) + } + } + } + + func sessionInitiated( + for context: Context, + with jid: JID, + sid: String, + contents: [Jingle.Content], + bundle: [String]? + ) throws { + try performSync { + let media = Set(contents.compactMap { CallMedia(rawValue: $0.description?.media ?? "") }) + guard media.contains(.audio) else { throw XMPPError.feature_not_implemented } + + if let existing = sessions[sid] { + guard existing.pinPeerResource(jid) else { + throw XMPPError.item_not_found + } + existing.initiated(contents: contents, bundle: bundle) + if let call = activeCall, call.session === existing { + scheduleTimeout( + for: call.id, + after: 90, + stage: "incoming offer negotiation" + ) + } + return + } + guard activeCall == nil else { throw XMPPError.resource_constraint("Другой звонок уже активен") } + + let session = makeSession( + context: context, + jid: jid, + sid: sid, + role: .responder, + initiationType: .iq + ) + session.initiated(contents: contents, bundle: bundle) + let call = LumaWebRTCCall( + engine: self, + session: session, + peerJID: jid.bareJid.stringValue, + direction: .incoming, + media: media + ) + activeCall = call + publish(call) + scheduleTimeout(for: call.id, after: 60, stage: "incoming ringing") + } + } + + func sessionAccepted( + for context: Context, + with jid: JID, + sid: String, + contents: [Jingle.Content], + bundle: [String]? + ) throws { + try performSync { + guard let session = sessions[sid], + session.pinPeerResource(jid) else { + throw XMPPError.item_not_found + } + // JMI may initially address a bare JID. session-accept is the first + // IQ guaranteed to identify the resource that owns this Jingle + // session, so keep every later transport-info stanza on it. +#if DEBUG + let acceptedMedia = contents.map { + "\($0.name):\($0.description?.media ?? "unknown")" + }.joined(separator: ",") + print( + "Luma Jingle: session-accept from \(jid.stringValue) " + + "sid=\(sid) contents=[\(acceptedMedia)]." + ) +#endif + session.accepted(contents: contents, bundle: bundle) + if let call = activeCall, call.session === session { + call.phase = .connecting + scheduleTimeout( + for: call.id, + after: 90, + stage: "ICE negotiation" + ) + publish(call) + } + } + } + + func sessionTerminated(for context: Context, with jid: JID, sid: String) throws { + try performSync { + guard let session = sessions[sid], + session.matchesPeerEndpoint(jid) else { + throw XMPPError.item_not_found + } + if activeCall?.session.sid == sid { + finishActiveCall( + cause: .remoteEnded, + sendTermination: false, + trigger: "remote session-terminate from \(jid.stringValue)" + ) + } else { + session.terminated() + sessions.removeValue(forKey: sid) + } + } + } + + func transportInfo( + for context: Context, + with jid: JID, + sid: String, + contents: [Jingle.Content] + ) throws { + try performSync { + guard let session = sessions[sid], + session.pinPeerResource(jid) else { + throw XMPPError.item_not_found + } + // Also learn the endpoint from an early candidate sent by the + // selected resource. This covers clients that trickle before their + // session-accept reaches us. + for content in contents { + for transport in content.transports { + guard let ice = transport as? Jingle.Transport.ICEUDPTransport else { continue } + ice.candidates.forEach { session.addCandidate($0, contentName: content.name) } + } + } + } + } + + func contentModified( + for context: Context, + with jid: JID, + sid: String, + action: Jingle.ContentAction, + contents: [Jingle.Content], + bundle: [String]? + ) throws { + try performSync { + guard let session = sessions[sid], + session.matchesPeerEndpoint(jid) else { + throw XMPPError.item_not_found + } + session.contentModified(action: action, contents: contents, bundle: bundle) + } + } + + func sessionInfo( + for context: Context, + with jid: JID, + sid: String, + info: [Jingle.SessionInfo] + ) throws { + try performSync { + guard let session = sessions[sid], + session.matchesPeerEndpoint(jid) else { + throw XMPPError.item_not_found + } + session.sessionInfoReceived(info: info) + } + } +} + +// MARK: - Session action queue + +private final class LumaJingleSession: JingleSession { + enum Action { + case contentSet(SDP) + case contentApply(Jingle.ContentAction, SDP) + case transportAdd(Jingle.Transport.ICEUDPTransport.Candidate, String) + case sessionInfo([Jingle.SessionInfo]) + } + + private let lock = NSLock() + private weak var owningContext: Context? + private var pendingActions: [Action] = [] + private var actionHandler: ((Action) -> Void)? + + override init( + context: Context, + jid: JID, + sid: String, + role: Jingle.Content.Creator, + initiationType: JingleSessionInitiationType + ) { + owningContext = context + super.init( + context: context, + jid: jid, + sid: sid, + role: role, + initiationType: initiationType + ) + } + + func finish(reason: JingleSessionTerminateReason) { + guard state != .terminated else { return } + guard initiationType == .message, state != .accepted else { + terminate(reason: reason) + return + } + let action: Jingle.MessageInitiationAction = role == .initiator + ? .retract(id: sid) + : .reject(id: sid) + owningContext?.module(.jingle).sendMessageInitiation(action: action, to: jid) + terminated() + } + + /// Pins a JMI session to the concrete endpoint that answered the call. + /// Martin intentionally exposes `jid` as read-only; `accepted(by:)` is its + /// supported resource-rebinding operation. + @discardableResult + func pinPeerResource(_ peer: JID) -> Bool { + guard peer.bareJid == jid.bareJid else { return false } + guard peer.resource != nil else { return true } + if jid.resource != nil { + return peer == jid + } +#if DEBUG + print("Luma Jingle: pinned session \(sid) to resource \(peer.stringValue).") +#endif + accepted(by: peer) + return true + } + + func matchesPeerEndpoint(_ peer: JID) -> Bool { + guard peer.bareJid == jid.bareJid else { return false } + guard jid.resource != nil, peer.resource != nil else { return true } + return peer == jid + } + + func setActionHandler(_ handler: @escaping (Action) -> Void) { + lock.lock() + actionHandler = handler + let actions = pendingActions + pendingActions.removeAll() + lock.unlock() + actions.forEach(handler) + } + + private func emit(_ action: Action) { + lock.lock() + guard let actionHandler else { + pendingActions.append(action) + lock.unlock() + return + } + lock.unlock() + actionHandler(action) + } + + override func initiated(contents: [Jingle.Content], bundle: [String]?) { + super.initiated(contents: contents, bundle: bundle) + emit(.contentSet(SDP(contents: contents, bundle: bundle))) + } + + override func accepted(contents: [Jingle.Content], bundle: [String]?) { + super.accepted(contents: contents, bundle: bundle) + emit(.contentSet(SDP(contents: contents, bundle: bundle))) + } + + override func contentModified( + action: Jingle.ContentAction, + contents: [Jingle.Content], + bundle: [String]? + ) { + emit(.contentApply(action, SDP(contents: contents, bundle: bundle))) + } + + override func sessionInfoReceived(info: [Jingle.SessionInfo]) { + emit(.sessionInfo(info)) + } + + func addCandidate( + _ candidate: Jingle.Transport.ICEUDPTransport.Candidate, + contentName: String + ) { + emit(.transportAdd(candidate, contentName)) + } +} + +// MARK: - Live WebRTC call + +private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { + let id = UUID() + let startedAt = Date() + unowned let engine: LumaCallEngine + let session: LumaJingleSession + let peerJID: String + let direction: CallDirection + let media: Set + + var phase: CallPhase = .ringing + var connectedAt: Date? + var isMuted = false + var isCameraEnabled: Bool + var isSpeakerEnabled = false + var localAudioTrack: RTCAudioTrack? + var localVideoTrack: RTCVideoTrack? + var remoteVideoTrack: RTCVideoTrack? + + private var peerConnection: RTCPeerConnection? + private var videoCapturer: RTCCameraVideoCapturer? + private var localVideoSource: RTCVideoSource? + private var cameraDeviceID: String? + private var localSDP: SDP? + private var remoteSDP: SDP? + private var localCandidates: [RTCIceCandidate] = [] + private var remoteCandidates: [(Jingle.Transport.ICEUDPTransport.Candidate, String)] = [] + private var initialDescriptionWasSignaled = false + private var connectionFailureWorkItem: DispatchWorkItem? + private var isPreparingPeerConnection = false + private var prepareCallbacks: [(Result) -> Void] = [] + private let webRTCSID = String(UInt64.random(in: UInt64.min ... UInt64.max)) + + init( + engine: LumaCallEngine, + session: LumaJingleSession, + peerJID: String, + direction: CallDirection, + media: Set + ) { + self.engine = engine + self.session = session + self.peerJID = peerJID + self.direction = direction + self.media = media + self.isCameraEnabled = media.contains(.video) + self.isSpeakerEnabled = media.contains(.video) + super.init() + } + + var snapshot: CallSnapshot { + CallSnapshot( + id: id, + peerJID: peerJID, + direction: direction, + media: media, + phase: phase, + connectedAt: connectedAt, + isMuted: isMuted, + isCameraEnabled: isCameraEnabled, + isSpeakerEnabled: isSpeakerEnabled, + hasLocalVideo: localVideoTrack != nil, + hasRemoteVideo: remoteVideoTrack != nil + ) + } + + func preparePeerConnection(completion: @escaping (Result) -> Void) { + if peerConnection != nil { + completion(.success(())) + return + } + prepareCallbacks.append(completion) + guard !isPreparingPeerConnection else { return } + isPreparingPeerConnection = true + guard let client = engine.performAttachedClient() else { + completePreparation(.failure(LumaCallError.notConnected)) + return + } + engine.iceServers(for: client) { [weak self] servers in + guard let self else { return } + self.engine.performOnQueue { + do { + try self.createPeerConnection(iceServers: servers) + self.completePreparation(.success(())) + } catch { + self.completePreparation(.failure(error)) + } + } + } + } + + func bindSessionActions() { + session.setActionHandler { [weak self] action in + guard let self else { return } + self.engine.performOnQueue { + self.receive(action) + } + } + } + + func sendOffer(completion: @escaping (Result) -> Void) { + guard let peerConnection else { + completion(.failure(LumaCallError.mediaEngineUnavailable)) + return + } + generateDescription(type: .offer, peerConnection: peerConnection) { result in + switch result { + case .success(let sdp): + Task { + do { + try await self.session.initiate(contents: sdp.contents, bundle: sdp.bundle) + self.engine.performOnQueue { + self.initialDescriptionWasSignaled = true + self.sendLocalCandidates() + completion(.success(())) + } + } catch { + self.engine.performOnQueue { + completion(.failure(error)) + } + } + } + case .failure(let error): + self.engine.performOnQueue { + completion(.failure(error)) + } + } + } + } + + func configureSpeaker(_ enabled: Bool) throws { +#if os(iOS) + let audioSession = AVAudioSession.sharedInstance() + try audioSession.overrideOutputAudioPort(enabled ? .speaker : .none) + try audioSession.setActive(true) +#else + _ = enabled +#endif + } + + func switchCamera() { + guard let capturer = videoCapturer else { return } + let currentPosition = RTCCameraVideoCapturer.captureDevices() + .first(where: { $0.uniqueID == cameraDeviceID })?.position ?? .front + guard let device = RTCCameraVideoCapturer.captureDevices().first(where: { + $0.position != currentPosition && $0.position != .unspecified + }), let format = Self.preferredFormat(for: device) else { return } + cameraDeviceID = device.uniqueID + capturer.startCapture(with: device, format: format, fps: Self.preferredFPS(for: format)) + } + + func closePeerConnection() { + connectionFailureWorkItem?.cancel() + connectionFailureWorkItem = nil + videoCapturer?.stopCapture() + videoCapturer = nil + localVideoSource = nil + peerConnection?.close() + peerConnection = nil + localAudioTrack = nil + localVideoTrack = nil + remoteVideoTrack = nil +#if os(iOS) + try? AVAudioSession.sharedInstance().setActive( + false, + options: .notifyOthersOnDeactivation + ) +#endif + } + + private func createPeerConnection(iceServers: [RTCIceServer]) throws { + // libwebrtc rejects the complete RTCConfiguration when even one ICE URL + // is malformed or a TURN entry has incomplete credentials. Prosody + // modules and older XEP-0215 implementations occasionally publish such + // entries, so retry with a known STUN server and finally host candidates. + var attempts: [(name: String, servers: [RTCIceServer])] = [] + if !iceServers.isEmpty { + attempts.append(("XEP-0215", iceServers)) + } + attempts.append(("public STUN", LumaCallEngine.publicSTUNServers())) + attempts.append(("host candidates", [])) + + let constraints = RTCMediaConstraints( + mandatoryConstraints: nil, + optionalConstraints: nil + ) + var createdPeerConnection: RTCPeerConnection? + for (index, attempt) in attempts.enumerated() { + let configuration = RTCConfiguration() + configuration.iceServers = attempt.servers + configuration.sdpSemantics = .unifiedPlan + configuration.bundlePolicy = .maxCompat + configuration.rtcpMuxPolicy = .require + configuration.iceCandidatePoolSize = 0 + + if let candidate = engine.peerConnectionFactory.peerConnection( + with: configuration, + constraints: constraints, + delegate: self + ) { + createdPeerConnection = candidate +#if DEBUG + if index > 0 { + print("Luma WebRTC: RTCConfiguration recovered with \(attempt.name).") + } +#endif + break + } +#if DEBUG + print( + "Luma WebRTC: RTCConfiguration rejected " + + "\(attempt.name) (\(attempt.servers.count) ICE server(s))." + ) +#endif + } + + guard let peerConnection = createdPeerConnection else { + throw LumaCallError.mediaEngineUnavailable + } + self.peerConnection = peerConnection + +#if os(iOS) + let audioSession = AVAudioSession.sharedInstance() + let audioOptions: AVAudioSession.CategoryOptions = [ + .allowBluetoothHFP, + .allowBluetoothA2DP + ] + try audioSession.setCategory( + .playAndRecord, + mode: media.contains(.video) ? .videoChat : .voiceChat, + options: audioOptions + ) + try audioSession.setActive(true) + if media.contains(.video) { + try audioSession.overrideOutputAudioPort(.speaker) + } +#endif + + let audioConstraints = RTCMediaConstraints( + mandatoryConstraints: nil, + optionalConstraints: nil + ) + let audioSource = engine.peerConnectionFactory.audioSource(with: audioConstraints) + let audioTrack = engine.peerConnectionFactory.audioTrack( + with: audioSource, + trackId: "luma-audio-\(id.uuidString)" + ) + localAudioTrack = audioTrack + peerConnection.add(audioTrack, streamIds: ["luma-stream"]) + + if media.contains(.video) { + let source = engine.peerConnectionFactory.videoSource() + localVideoSource = source + let track = engine.peerConnectionFactory.videoTrack( + with: source, + trackId: "luma-video-\(id.uuidString)" + ) + localVideoTrack = track + peerConnection.add(track, streamIds: ["luma-stream"]) + let capturer = RTCCameraVideoCapturer(delegate: source) + videoCapturer = capturer + if let device = Self.preferredCamera(), let format = Self.preferredFormat(for: device) { + cameraDeviceID = device.uniqueID + capturer.startCapture( + with: device, + format: format, + fps: Self.preferredFPS(for: format) + ) + } + } + engine.callVideoTracksDidChange(self) + } + + private func completePreparation(_ result: Result) { + isPreparingPeerConnection = false + let callbacks = prepareCallbacks + prepareCallbacks.removeAll() + callbacks.forEach { $0(result) } + } + + private func receive(_ action: LumaJingleSession.Action) { + guard let peerConnection else { return } + switch action { + case .transportAdd(let candidate, let contentName): + guard remoteSDP != nil else { + remoteCandidates.append((candidate, contentName)) + return + } + addRemoteCandidate(candidate, contentName: contentName, to: peerConnection) + + case .contentSet(let sdp): + applyRemoteSDP(sdp, to: peerConnection) + + case .contentApply(let action, let diff): + guard let remoteSDP else { return } + applyRemoteSDP(remoteSDP.applyDiff(action: action, diff: diff), to: peerConnection) + + case .sessionInfo: + break + } + } + + private func applyRemoteSDP(_ sdp: SDP, to peerConnection: RTCPeerConnection) { + let normalizedSDP: SDP + do { + normalizedSDP = try normalizeInitialRemoteAnswer(sdp) + } catch { + engine.callDidFail(self, error: error) + return + } + let type: RTCSdpType = direction == .incoming ? .offer : .answer + let text = normalizedSDP.toString( + withSid: webRTCSID, + localRole: session.role, + direction: .incoming + ) + peerConnection.setRemoteDescription( + RTCSessionDescription(type: type, sdp: text) + ) { [weak self] error in + guard let self else { return } + self.engine.performOnQueue { + if let error { +#if DEBUG + print("Luma WebRTC: setRemoteDescription failed: \(error.localizedDescription)") +#endif + self.engine.callDidFail(self, error: error) + return + } + self.remoteSDP = normalizedSDP + let candidates = self.remoteCandidates + self.remoteCandidates.removeAll() + candidates.forEach { + self.addRemoteCandidate($0.0, contentName: $0.1, to: peerConnection) + } + // For an outgoing call session-initiate may already be + // acknowledged. Flush only now, after session-accept supplied + // both the answer and the exact full JID of its owner. + self.sendLocalCandidates() + if peerConnection.signalingState == .haveRemoteOffer { + self.generateDescription(type: .answer, peerConnection: peerConnection) { result in + switch result { + case .success(let local): + Task { + do { + try await self.session.accept( + contents: local.contents, + bundle: local.bundle + ) + self.engine.performOnQueue { + self.initialDescriptionWasSignaled = true + self.sendLocalCandidates() + } + } catch { + self.engine.callDidFail(self, error: error) + } + } + case .failure(let error): + self.engine.callDidFail(self, error: error) + } + } + } + } + } + } + + private func generateDescription( + type: RTCSdpType, + peerConnection: RTCPeerConnection, + completion: @escaping (Result) -> Void + ) { + let mediaConstraints = RTCMediaConstraints( + mandatoryConstraints: nil, + optionalConstraints: nil + ) + let handler: (RTCSessionDescription?, Error?) -> Void = { [weak self] description, error in + guard let self else { return } + guard let description else { + self.engine.performOnQueue { + completion(.failure(error ?? LumaCallError.sdpGenerationFailed)) + } + return + } + peerConnection.setLocalDescription(description) { error in + self.engine.performOnQueue { + if let error { + completion(.failure(error)) + return + } + // Martin 3.2.4 assumes that libwebrtc terminates SDP with + // CRLF. Normalize its input first, then repair the FID + // source metadata independently below. The latter is + // intentionally kept even with normalized line endings: + // WebRTC M150 can still expose a shortened final RTX msid. + let martinSDP = CallSDPNormalization.martinParseInput( + description.sdp + ) + guard let (sdp, _) = SDP.parse( + sdpString: martinSDP, + creatorProvider: self.session.contentCreator(of:), + localRole: self.session.role + ) else { + completion(.failure(LumaCallError.sdpGenerationFailed)) + return + } + let repaired = CallSDPCompatibility.repairLocalFIDMSIDs(in: sdp) + let fidStatus = CallSDPCompatibility.fidGroupStatus(in: repaired.sdp) + guard fidStatus.inconsistent == 0 else { + completion(.failure(LumaCallError.incompatibleLocalDescription)) + return + } +#if DEBUG + if fidStatus.total > 0 { + print( + "Luma SDP: verified \(fidStatus.total) FID group(s); " + + "repaired \(repaired.repairedCount) source msid(s)." + ) + } +#endif + self.localSDP = repaired.sdp + completion(.success(repaired.sdp)) + } + } + } + if type == .offer { + peerConnection.offer(for: mediaConstraints, completionHandler: handler) + } else { + peerConnection.answer(for: mediaConstraints, completionHandler: handler) + } + } + + private func sendLocalCandidates() { + // setLocalDescription starts ICE before the corresponding Jingle stanza + // is acknowledged. With JMI, session-initiate can be addressed to a + // bare JID while only one resource accepts it. Keep candidates queued + // until the remote SDP arrives from that concrete full JID; otherwise + // Prosody can route transport-info to another resource, which correctly + // responds with item-not-found for the SID. + guard CallCandidateDispatchGate.canSend( + initialDescriptionWasSignaled: initialDescriptionWasSignaled, + hasLocalDescription: localSDP != nil, + hasRemoteDescription: remoteSDP != nil, + hasFullPeerJID: session.jid.resource != nil + ), let localSDP else { return } + let candidates = localCandidates + localCandidates.removeAll() +#if DEBUG + if !candidates.isEmpty { + print( + "Luma Jingle: sending \(candidates.count) ICE candidate(s) " + + "to \(session.jid.stringValue) sid=\(session.sid)." + ) + } +#endif + for candidate in candidates { + let line = candidate.sdp.hasPrefix("a=") ? candidate.sdp : "a=\(candidate.sdp)" + guard let jingleCandidate = Jingle.Transport.ICEUDPTransport.Candidate(fromSDP: line), + let contentName = candidate.sdpMid, + let content = localSDP.contents.first(where: { $0.name == contentName }), + let transport = content.transports.first(where: { + $0 is Jingle.Transport.ICEUDPTransport + }) as? Jingle.Transport.ICEUDPTransport else { continue } + _ = session.transportInfo( + contentName: contentName, + transport: Jingle.Transport.ICEUDPTransport( + pwd: transport.pwd, + ufrag: transport.ufrag, + candidates: [jingleCandidate] + ) + ) + } + } + + private func addRemoteCandidate( + _ candidate: Jingle.Transport.ICEUDPTransport.Candidate, + contentName: String, + to peerConnection: RTCPeerConnection + ) { + let index = remoteSDP?.contents.firstIndex(where: { $0.name == contentName }) ?? 0 + let candidateSDP = CallSDPOrdering.webRTCCandidateLine(candidate.toSDP()) + peerConnection.add( + RTCIceCandidate( + sdp: candidateSDP, + sdpMLineIndex: Int32(index), + sdpMid: contentName + ), + completionHandler: { error in +#if DEBUG + if let error { + print("Luma WebRTC: remote ICE candidate rejected: \(error.localizedDescription)") + } +#endif + } + ) + } + + private func normalizeInitialRemoteAnswer(_ sdp: SDP) throws -> SDP { + guard direction == .outgoing, remoteSDP == nil, let localSDP else { + return sdp + } + + let expectedNames = localSDP.contents.map(\.name) + let receivedNames = sdp.contents.map(\.name) + guard let indices = CallSDPOrdering.answerIndices( + localOrder: expectedNames, + remoteOrder: receivedNames + ) else { + throw LumaCallError.incompatibleRemoteDescription + } + let normalizedBundle: [String]? + if let bundle = sdp.bundle { + let bundleNames = Set(bundle) + guard bundleNames.count == bundle.count, + bundleNames.isSubset(of: Set(receivedNames)) else { + throw LumaCallError.incompatibleRemoteDescription + } + normalizedBundle = expectedNames.filter { bundleNames.contains($0) } + } else { + normalizedBundle = nil + } + guard indices != Array(sdp.contents.indices) || normalizedBundle != sdp.bundle else { + return sdp + } + + let orderedContents = indices.map { sdp.contents[$0] } +#if DEBUG + print("Luma WebRTC: reordered session-accept contents to match the local offer.") +#endif + return SDP(contents: orderedContents, bundle: normalizedBundle) + } + + private func cancelScheduledConnectionFailure() { + connectionFailureWorkItem?.cancel() + connectionFailureWorkItem = nil + } + + private func scheduleConnectionFailure(after delay: TimeInterval) { + connectionFailureWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self else { return } + self.connectionFailureWorkItem = nil + self.engine.callDidLoseConnection(self) + } + connectionFailureWorkItem = item + engine.performOnQueue(after: delay, execute: item) + } + + private static func preferredCamera() -> AVCaptureDevice? { + RTCCameraVideoCapturer.captureDevices().first(where: { $0.position == .front }) + ?? RTCCameraVideoCapturer.captureDevices().first + } + + private static func preferredFormat(for device: AVCaptureDevice) -> AVCaptureDevice.Format? { + RTCCameraVideoCapturer.supportedFormats(for: device).min { lhs, rhs in + let left = CMVideoFormatDescriptionGetDimensions(lhs.formatDescription) + let right = CMVideoFormatDescriptionGetDimensions(rhs.formatDescription) + let leftDistance = abs(Int(left.width) - 1_280) + abs(Int(left.height) - 720) + let rightDistance = abs(Int(right.width) - 1_280) + abs(Int(right.height) - 720) + return leftDistance < rightDistance + } + } + + private static func preferredFPS(for format: AVCaptureDevice.Format) -> Int { + let maximum = format.videoSupportedFrameRateRanges.map(\.maxFrameRate).max() ?? 30 + return max(15, min(30, Int(maximum.rounded()))) + } + + // MARK: RTCPeerConnectionDelegate + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { + guard let track = stream.videoTracks.first else { return } + engine.performOnQueue { + self.remoteVideoTrack = track + self.engine.callVideoTracksDidChange(self) + } + } + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {} + func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + engine.performOnQueue { +#if DEBUG + print("Luma WebRTC: ICE state changed to \(String(describing: newState)).") +#endif + switch newState { + case .connected, .completed: + self.cancelScheduledConnectionFailure() + self.engine.callDidConnect(self) + case .checking: + self.cancelScheduledConnectionFailure() + case .disconnected: + self.scheduleConnectionFailure(after: 12) + case .failed: + // A late TURN or video-component candidate can still move ICE + // back to checking. Keep the call alive during trickle ICE. + self.scheduleConnectionFailure(after: 8) + case .closed: + self.cancelScheduledConnectionFailure() + self.engine.callDidLoseConnection(self) + default: + break + } + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {} + + func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { + engine.performOnQueue { + self.localCandidates.append(candidate) + self.sendLocalCandidates() + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} + func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} + + func peerConnection( + _ peerConnection: RTCPeerConnection, + didAdd rtpReceiver: RTCRtpReceiver, + streams mediaStreams: [RTCMediaStream] + ) { + guard let track = rtpReceiver.track as? RTCVideoTrack else { return } + engine.performOnQueue { + self.remoteVideoTrack = track + self.engine.callVideoTracksDidChange(self) + } + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove rtpReceiver: RTCRtpReceiver) { + guard let track = rtpReceiver.track as? RTCVideoTrack else { return } + engine.performOnQueue { + guard self.remoteVideoTrack === track else { return } + self.remoteVideoTrack = nil + self.engine.callVideoTracksDidChange(self) + } + } +} + +// MARK: - Queue helpers kept fileprivate for the live call object + +private extension LumaCallEngine { + func performOnQueue(_ action: @escaping () -> Void) { + queue.async(execute: action) + } + + func performOnQueue(after delay: TimeInterval, execute item: DispatchWorkItem) { + queue.asyncAfter(deadline: .now() + delay, execute: item) + } + + func performAttachedClient() -> XMPPClient? { + performSync { client } + } +} + +private extension ExternalServiceDiscoveryModule.Service { + var rtcIceServer: RTCIceServer? { + let normalizedType = type.lowercased() + let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard ["stun", "stuns", "turn", "turns"].contains(normalizedType) else { return nil } + guard !normalizedHost.isEmpty, + normalizedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil, + !normalizedHost.contains("/"), + !normalizedHost.contains("?"), + !normalizedHost.contains("#") else { return nil } + guard !normalizedType.hasSuffix("s") || transport == nil || transport == .tcp else { + return nil + } + if normalizedType.hasPrefix("turn") { + guard let username, !username.isEmpty, + let password, !password.isEmpty else { return nil } + } + if let expires, expires <= Date() { return nil } + + let escapedHost = normalizedHost.contains(":") && !normalizedHost.hasPrefix("[") + ? "[\(normalizedHost)]" + : normalizedHost + var url = "\(normalizedType):\(escapedHost)" + if let port { url += ":\(port)" } + // RFC 7064 STUN URLs do not have a transport query. The query belongs + // to TURN URLs; appending it to STUN makes newer libwebrtc reject the + // entire RTCConfiguration. + if normalizedType.hasPrefix("turn"), let transport { + url += "?transport=\(transport.rawValue)" + } + return RTCIceServer( + urlStrings: [url], + username: normalizedType.hasPrefix("turn") ? username : nil, + credential: normalizedType.hasPrefix("turn") ? password : nil, + tlsCertPolicy: .secure + ) + } +} + +enum CallSDPOrdering { + static func answerIndices(localOrder: [String], remoteOrder: [String]) -> [Int]? { + guard localOrder.count == remoteOrder.count, + Set(localOrder).count == localOrder.count, + Set(remoteOrder).count == remoteOrder.count, + Set(localOrder) == Set(remoteOrder) else { return nil } + + let remoteIndices = Dictionary( + uniqueKeysWithValues: remoteOrder.enumerated().map { ($0.element, $0.offset) } + ) + return localOrder.compactMap { remoteIndices[$0] } + } + + static func webRTCCandidateLine(_ jingleSDP: String) -> String { + jingleSDP.hasPrefix("a=") ? String(jingleSDP.dropFirst(2)) : jingleSDP + } +} + +enum CallSDPNormalization { + /// Martin 3.2.4 expects CRLF between lines and unconditionally drops the + /// final two characters of its input. Give it one, and only one, trailing + /// CRLF so the last character of an SSRC/MSID value is never discarded. + static func martinParseInput(_ webRTCSDP: String) -> String { + var normalized = webRTCSDP + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + while normalized.hasSuffix("\n") { + normalized.removeLast() + } + return normalized.replacingOccurrences(of: "\n", with: "\r\n") + "\r\n" + } +} + +enum CallSDPCompatibility { + struct SourceGroup: Equatable { + let semantics: String + let sources: [String] + } + + /// Every SSRC in an FID group represents the same media track (the later + /// sources carry retransmissions), so their msid values must be identical. + /// Prefer the longest value when a converter shortened one at the end; for + /// equal lengths this naturally keeps the primary source's value. + static func canonicalFIDMSIDs( + groups: [SourceGroup], + msids: [String: String] + ) -> [String: String] { + var result = msids + for group in groups where group.semantics.uppercased() == "FID" { + guard group.sources.count >= 2 else { continue } + let candidates = group.sources.compactMap { result[$0] } + guard let canonical = candidates.max(by: { $0.count < $1.count }) else { + continue + } + group.sources.forEach { result[$0] = canonical } + } + return result + } + + static func repairLocalFIDMSIDs(in sdp: SDP) -> (sdp: SDP, repairedCount: Int) { + var repairedCount = 0 + let contents = sdp.contents.map { content -> Jingle.Content in + guard let description = content.description as? Jingle.RTP.Description else { + return content + } + + var currentMSIDs: [String: String] = [:] + for source in description.ssrcs { + guard let value = source.parameters.first(where: { + $0.key.lowercased() == "msid" + })?.value, !value.isEmpty else { continue } + currentMSIDs[source.ssrc] = value + } + let desiredMSIDs = canonicalFIDMSIDs( + groups: description.ssrcGroups.map { + SourceGroup(semantics: $0.semantics, sources: $0.sources) + }, + msids: currentMSIDs + ) + + var changed = false + let sources = description.ssrcs.map { source -> Jingle.RTP.Description.SSRC in + guard let desired = desiredMSIDs[source.ssrc], + currentMSIDs[source.ssrc] != desired else { + return source + } + changed = true + repairedCount += 1 + let parameters = source.parameters.filter { + $0.key.lowercased() != "msid" + } + [ + Jingle.RTP.Description.SSRC.Parameter( + key: "msid", + value: desired + ) + ] + return Jingle.RTP.Description.SSRC( + ssrc: source.ssrc, + parameters: parameters + ) + } + guard changed else { return content } + + let repairedDescription = Jingle.RTP.Description( + media: description.media, + ssrc: description.ssrc, + payloads: description.payloads, + bandwidth: description.bandwidth, + encryption: description.encryption, + rtcpMux: description.rtcpMux, + ssrcs: sources, + ssrcGroups: description.ssrcGroups, + hdrExts: description.hdrExts + ) + return Jingle.Content( + name: content.name, + creator: content.creator, + senders: content.senders, + description: repairedDescription, + transports: content.transports + ) + } + guard repairedCount > 0 else { return (sdp, 0) } + return (SDP(contents: contents, bundle: sdp.bundle), repairedCount) + } + + static func fidGroupStatus(in sdp: SDP) -> (total: Int, inconsistent: Int) { + var total = 0 + var inconsistent = 0 + for content in sdp.contents { + guard let description = content.description as? Jingle.RTP.Description else { + continue + } + let msids = Dictionary( + uniqueKeysWithValues: description.ssrcs.compactMap { source in + source.parameters.first(where: { + $0.key.lowercased() == "msid" + })?.value.map { (source.ssrc, $0) } + } + ) + for group in description.ssrcGroups where group.semantics.uppercased() == "FID" { + total += 1 + let values = group.sources.compactMap { msids[$0] } + if values.count != group.sources.count || Set(values).count != 1 { + inconsistent += 1 + } + } + } + return (total, inconsistent) + } +} + +enum CallCandidateDispatchGate { + static func canSend( + initialDescriptionWasSignaled: Bool, + hasLocalDescription: Bool, + hasRemoteDescription: Bool, + hasFullPeerJID: Bool + ) -> Bool { + initialDescriptionWasSignaled + && hasLocalDescription + && hasRemoteDescription + && hasFullPeerJID + } +} + +private enum LumaCallError: LocalizedError { + case callAlreadyActive + case noIncomingCall + case notConnected + case contactOffline + case unsupportedByContact + case mediaEngineUnavailable + case incompatibleLocalDescription + case incompatibleRemoteDescription + case sdpGenerationFailed + case connectionLost + case timedOut + + var errorDescription: String? { + switch self { + case .callAlreadyActive: + return "Сначала завершите текущий звонок." + case .noIncomingCall: + return "Входящий звонок уже завершён." + case .notConnected: + return "Для звонка нужно активное XMPP-соединение." + case .contactOffline: + return "Контакт сейчас не в сети." + case .unsupportedByContact: + return "Устройства контакта не объявили поддержку совместимых Jingle-звонков." + case .mediaEngineUnavailable: + return "WebRTC не смог создать соединение даже без STUN/TURN. Очистите сборку и переустановите приложение." + case .incompatibleLocalDescription: + return "WebRTC сформировал несовместимое описание видеопотока. Повторите звонок после перезапуска приложения." + case .incompatibleRemoteDescription: + return "Ответ на видеозвонок содержит другой набор аудио/видео потоков. Обновите клиент собеседника или повторите аудиозвонок." + case .sdpGenerationFailed: + return "Не удалось согласовать параметры аудио/видео." + case .connectionLost: + return "Соединение звонка потеряно. Проверьте TURN/STUN на сервере." + case .timedOut: + return "Контакт не ответил на звонок." + } + } +} diff --git a/Sources/Shared/XMPP/LumaOMEMOStore.swift b/Sources/Shared/XMPP/LumaOMEMOStore.swift new file mode 100644 index 0000000..155309d --- /dev/null +++ b/Sources/Shared/XMPP/LumaOMEMOStore.swift @@ -0,0 +1,586 @@ +import Foundation +import MartinOMEMO + +final class LumaOMEMOStore: SignalStorage { + private let repository: OMEMOStateRepository + private let sessionAdapter: LumaSessionStore + private let preKeyAdapter: LumaPreKeyStore + private let signedPreKeyAdapter: LumaSignedPreKeyStore + private let identityAdapter: LumaIdentityKeyStore + private let senderKeyAdapter: LumaSenderKeyStore + private var signalContext: SignalContext? + + init(accountJID: String) { + let repository = OMEMOStateRepository(accountJID: accountJID) + let sessionAdapter = LumaSessionStore(repository: repository) + let preKeyAdapter = LumaPreKeyStore(repository: repository) + let signedPreKeyAdapter = LumaSignedPreKeyStore(repository: repository) + let identityAdapter = LumaIdentityKeyStore(repository: repository) + let senderKeyAdapter = LumaSenderKeyStore(repository: repository) + + self.repository = repository + self.sessionAdapter = sessionAdapter + self.preKeyAdapter = preKeyAdapter + self.signedPreKeyAdapter = signedPreKeyAdapter + self.identityAdapter = identityAdapter + self.senderKeyAdapter = senderKeyAdapter + + super.init( + sessionStore: sessionAdapter, + preKeyStore: preKeyAdapter, + signedPreKeyStore: signedPreKeyAdapter, + identityKeyStore: identityAdapter, + senderKeyStore: senderKeyAdapter + ) + } + + override func setup(withContext context: SignalContext) { + signalContext = context + _ = regenerateKeys(wipe: false) + super.setup(withContext: context) + } + + override func regenerateKeys(wipe: Bool = false) -> Bool { + guard let signalContext else { return false } + if wipe { + repository.reset() + } + + let needsIdentity = identityAdapter.localRegistrationId() == 0 || identityAdapter.keyPair() == nil + guard needsIdentity else { return true } + + let registrationID = signalContext.generateRegistrationId() + guard let pair = SignalIdentityKeyPair.generateKeyPair(context: signalContext), + let publicKey = pair.publicKey else { + return false + } + + repository.mutate { state in + state.registrationID = registrationID + state.identityKeyPair = pair.serialized() + let address = SignalAddress( + name: repository.accountJID, + deviceId: Int32(bitPattern: registrationID) + ) + state.identities[address.storageKey] = StoredOMEMOIdentity( + name: address.name, + deviceID: address.deviceId, + fingerprint: Self.fingerprint(publicKey), + key: publicKey, + status: IdentityStatus.verifiedActive.rawValue, + own: true + ) + } + return true + } + + var ownFingerprint: String? { + let registrationID = identityAdapter.localRegistrationId() + guard registrationID != 0 else { return nil } + return identityAdapter.identityFingerprint( + forAddress: SignalAddress( + name: repository.accountJID, + deviceId: Int32(bitPattern: registrationID) + ) + ) + } + + func devices(for jid: String) -> [OMEMODevice] { + identityAdapter.identities(forName: jid.lowercased()).map { identity in + OMEMODevice( + jid: identity.address.name, + deviceID: identity.address.deviceId, + fingerprint: identity.fingerprint, + trust: Self.mapTrust(identity.status), + isActive: identity.status.isActive, + isOwn: identity.own + ) + } + .sorted { lhs, rhs in lhs.deviceID < rhs.deviceID } + } + + func hasSession(for address: SignalAddress) -> Bool { + sessionAdapter.containsSessionRecord(forAddress: address) + } + + func flushPendingPersistence() { + repository.flush() + } + + @discardableResult + func setVerified(_ verified: Bool, jid: String, deviceID: Int32) -> Bool { + identityAdapter.setStatus( + verified ? .verifiedActive : .trustedActive, + forIdentity: SignalAddress(name: jid.lowercased(), deviceId: deviceID) + ) + } + + private static func mapTrust(_ status: IdentityStatus) -> OMEMODevice.Trust { + switch status.trust { + case .undecided: return .undecided + case .trusted: return .trusted + case .verified: return .verified + case .compromised: return .compromised + } + } + + fileprivate static func fingerprint(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private final class LumaSessionStore: SignalSessionStoreProtocol { + private let repository: OMEMOStateRepository + + init(repository: OMEMOStateRepository) { + self.repository = repository + } + + func sessionRecord(forAddress address: SignalAddress) -> Data? { + repository.read { $0.sessions[address.storageKey] } + } + + func allDevices(for name: String, activeAndTrusted: Bool) -> [Int32] { + repository.read { state in + state.sessions.keys.compactMap { key in + guard let address = StoredAddress(key), address.name == name.lowercased() else { return nil } + guard activeAndTrusted else { return address.deviceID } + guard let identity = state.identities[key] else { return address.deviceID } + let status = IdentityStatus(rawValue: identity.status) ?? .undecidedActive + let trusted = status.trust == .trusted || status.trust == .verified + return status.isActive && trusted ? address.deviceID : nil + } + } + } + + func storeSessionRecord(_ data: Data, forAddress address: SignalAddress) -> Bool { + repository.mutate { $0.sessions[address.storageKey] = data } + return true + } + + func containsSessionRecord(forAddress address: SignalAddress) -> Bool { + sessionRecord(forAddress: address) != nil + } + + func deleteSessionRecord(forAddress address: SignalAddress) -> Bool { + repository.mutate { $0.sessions.removeValue(forKey: address.storageKey) } + return true + } + + func deleteAllSessions(for name: String) -> Bool { + repository.mutate { state in + state.sessions = state.sessions.filter { key, _ in + StoredAddress(key)?.name != name.lowercased() + } + } + return true + } +} + +private final class LumaPreKeyStore: SignalPreKeyStoreProtocol { + private let repository: OMEMOStateRepository + private let deletionLock = NSLock() + private var pendingDeletion: Set = [] + + init(repository: OMEMOStateRepository) { + self.repository = repository + } + + func currentPreKeyId() -> UInt32 { + repository.read { state in + state.preKeys.keys.compactMap(UInt32.init).max() ?? 0 + } + } + + func loadPreKey(withId id: UInt32) -> Data? { + repository.read { $0.preKeys[String(id)] } + } + + func storePreKey(_ data: Data, withId id: UInt32) -> Bool { + repository.mutate { $0.preKeys[String(id)] = data } + return true + } + + func containsPreKey(withId id: UInt32) -> Bool { + loadPreKey(withId: id) != nil + } + + func deletePreKey(withId id: UInt32) -> Bool { + deletionLock.lock() + pendingDeletion.insert(id) + deletionLock.unlock() + return true + } + + func flushDeletedPreKeys() -> Bool { + deletionLock.lock() + let ids = pendingDeletion + pendingDeletion.removeAll() + deletionLock.unlock() + + guard !ids.isEmpty else { return false } + repository.mutate { state in + ids.forEach { state.preKeys.removeValue(forKey: String($0)) } + } + return true + } +} + +private final class LumaSignedPreKeyStore: SignalSignedPreKeyStoreProtocol { + private let repository: OMEMOStateRepository + + init(repository: OMEMOStateRepository) { + self.repository = repository + } + + func countSignedPreKeys() -> Int { + repository.read { $0.signedPreKeys.count } + } + + func loadSignedPreKey(withId id: UInt32) -> Data? { + repository.read { $0.signedPreKeys[String(id)] } + } + + func storeSignedPreKey(_ data: Data, withId id: UInt32) -> Bool { + repository.mutate { $0.signedPreKeys[String(id)] = data } + return true + } + + func containsSignedPreKey(withId id: UInt32) -> Bool { + loadSignedPreKey(withId: id) != nil + } + + func deleteSignedPreKey(withId id: UInt32) -> Bool { + repository.mutate { $0.signedPreKeys.removeValue(forKey: String(id)) } + return true + } +} + +private final class LumaIdentityKeyStore: SignalIdentityKeyStoreProtocol { + private let repository: OMEMOStateRepository + + init(repository: OMEMOStateRepository) { + self.repository = repository + } + + func keyPair() -> SignalIdentityKeyPairProtocol? { + guard let data = repository.read({ $0.identityKeyPair }) else { return nil } + return SignalIdentityKeyPair(fromKeyPairData: data) + } + + func localRegistrationId() -> UInt32 { + repository.read { $0.registrationID } + } + + func save(identity: SignalAddress, key: SignalIdentityKeyProtocol?) -> Bool { + save(identity: identity, publicKeyData: key?.publicKey) + } + + func isTrusted(identity: SignalAddress, key: SignalIdentityKeyProtocol?) -> Bool { + isTrusted(identity: identity, publicKeyData: key?.publicKey) + } + + func save(identity: SignalAddress, publicKeyData: Data?) -> Bool { + guard let publicKeyData else { return false } + let fingerprint = LumaOMEMOStore.fingerprint(publicKeyData) + repository.mutate { state in + let existing = state.identities[identity.storageKey] + let changed = existing.map { $0.key != publicKeyData } ?? false + state.identities[identity.storageKey] = StoredOMEMOIdentity( + name: identity.name.lowercased(), + deviceID: identity.deviceId, + fingerprint: fingerprint, + key: publicKeyData, + status: changed ? IdentityStatus.undecidedActive.rawValue : (existing?.status ?? IdentityStatus.trustedActive.rawValue), + own: existing?.own ?? false + ) + } + return true + } + + func isTrusted(identity: SignalAddress, publicKeyData: Data?) -> Bool { + guard let publicKeyData else { return false } + return repository.read { state in + guard let existing = state.identities[identity.storageKey] else { + return true + } + let status = IdentityStatus(rawValue: existing.status) ?? .undecidedActive + return existing.key == publicKeyData + && (status.trust == .trusted || status.trust == .verified) + } + } + + func setStatus(_ status: IdentityStatus, forIdentity identity: SignalAddress) -> Bool { + var updated = false + repository.mutate { state in + guard var value = state.identities[identity.storageKey] else { return } + value.status = status.rawValue + state.identities[identity.storageKey] = value + updated = true + } + return updated + } + + func setStatus(active: Bool, forIdentity identity: SignalAddress) -> Bool { + var updated = false + repository.mutate { state in + guard var value = state.identities[identity.storageKey], + let status = IdentityStatus(rawValue: value.status) else { return } + value.status = (active ? status.toActive() : status.toInactive()).rawValue + state.identities[identity.storageKey] = value + updated = true + } + return updated + } + + func identities(forName name: String) -> [Identity] { + repository.read { state in + state.identities.values.compactMap { value in + guard value.name == name.lowercased(), + let status = IdentityStatus(rawValue: value.status) else { return nil } + return Identity( + address: SignalAddress(name: value.name, deviceId: value.deviceID), + status: status, + fingerprint: value.fingerprint, + key: value.key, + own: value.own + ) + } + } + } + + func identityFingerprint(forAddress address: SignalAddress) -> String? { + repository.read { $0.identities[address.storageKey]?.fingerprint } + } +} + +private final class LumaSenderKeyStore: SignalSenderKeyStoreProtocol { + private let repository: OMEMOStateRepository + + init(repository: OMEMOStateRepository) { + self.repository = repository + } + + func storeSenderKey(_ key: Data, address: SignalAddress?, groupId: String?) -> Bool { + repository.mutate { state in + state.senderKeys[Self.key(address: address, groupID: groupId)] = key + } + return true + } + + func loadSenderKey(forAddress address: SignalAddress?, groupId: String?) -> Data? { + repository.read { $0.senderKeys[Self.key(address: address, groupID: groupId)] } + } + + private static func key(address: SignalAddress?, groupID: String?) -> String { + "\(address?.storageKey ?? "-")|\(groupID ?? "-")" + } +} + +private final class OMEMOStateRepository: @unchecked Sendable { + fileprivate let accountJID: String + private let lock = NSLock() + private let persistenceQueue = DispatchQueue( + label: "app.luma.omemo.persistence", + qos: .utility + ) + private let fileURL: URL + private var state: StoredOMEMOState + private var persistenceRevision: UInt64 = 0 + private var persistenceScheduled = false + private var persistenceNotBeforeNanoseconds: UInt64 = 0 + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private static let persistenceDebounceNanoseconds: UInt64 = 350_000_000 + + init(accountJID: String) { + self.accountJID = accountJID.lowercased() + let fileManager = FileManager.default + let root = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let directory = root.appendingPathComponent("Luma/OMEMO", isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json") + + encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + decoder = JSONDecoder() + state = .empty + if let data = try? Data(contentsOf: fileURL), + let decoded = try? decoder.decode(StoredOMEMOState.self, from: data) { + state = decoded + } + } + + func read(_ operation: (StoredOMEMOState) -> T) -> T { + lock.lock() + defer { lock.unlock() } + return operation(state) + } + + func mutate(_ operation: (inout StoredOMEMOState) -> Void) { + let shouldSchedule: Bool + let scheduledDeadlineNanoseconds: UInt64 + lock.lock() + operation(&state) + persistenceRevision &+= 1 + persistenceNotBeforeNanoseconds = DispatchTime.now().uptimeNanoseconds + &+ Self.persistenceDebounceNanoseconds + scheduledDeadlineNanoseconds = persistenceNotBeforeNanoseconds + shouldSchedule = !persistenceScheduled + if shouldSchedule { + persistenceScheduled = true + } + lock.unlock() + + if shouldSchedule { + persistenceQueue.asyncAfter( + deadline: DispatchTime( + uptimeNanoseconds: scheduledDeadlineNanoseconds + ) + ) { [weak self] in + self?.persistWhenQuiet() + } + } + } + + func reset() { + mutate { $0 = .empty } + } + + func flush() { + persistenceQueue.sync { [self] in + persistUntilCurrent() + } + } + + private func persistWhenQuiet() { + let snapshot: StoredOMEMOState + let revision: UInt64 + let notBefore: UInt64 + + lock.lock() + guard persistenceScheduled else { + lock.unlock() + return + } + notBefore = persistenceNotBeforeNanoseconds + let now = DispatchTime.now().uptimeNanoseconds + guard now >= notBefore else { + lock.unlock() + persistenceQueue.asyncAfter( + deadline: DispatchTime(uptimeNanoseconds: notBefore) + ) { [weak self] in + self?.persistWhenQuiet() + } + return + } + snapshot = state + revision = persistenceRevision + lock.unlock() + + persist(snapshot) + + lock.lock() + if revision == persistenceRevision { + persistenceScheduled = false + lock.unlock() + return + } + let retryAt = persistenceNotBeforeNanoseconds + lock.unlock() + persistenceQueue.asyncAfter( + deadline: DispatchTime(uptimeNanoseconds: retryAt) + ) { [weak self] in + self?.persistWhenQuiet() + } + } + + private func persistUntilCurrent() { + while true { + let snapshot: StoredOMEMOState + let revision: UInt64 + lock.lock() + snapshot = state + revision = persistenceRevision + lock.unlock() + + persist(snapshot) + + lock.lock() + if revision == persistenceRevision { + persistenceScheduled = false + lock.unlock() + return + } + lock.unlock() + } + } + + private func persist(_ snapshot: StoredOMEMOState) { + guard let data = try? encoder.encode(snapshot) else { return } + try? data.write(to: fileURL, options: [.atomic]) +#if os(iOS) + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) +#endif + } + + private static func stableHash(_ value: String) -> String { + let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in + (partial ^ UInt64(byte)) &* 1_099_511_628_211 + } + return String(hash, radix: 16) + } +} + +private struct StoredOMEMOState: Codable { + var registrationID: UInt32 + var identityKeyPair: Data? + var identities: [String: StoredOMEMOIdentity] + var preKeys: [String: Data] + var signedPreKeys: [String: Data] + var sessions: [String: Data] + var senderKeys: [String: Data] + + static let empty = StoredOMEMOState( + registrationID: 0, + identityKeyPair: nil, + identities: [:], + preKeys: [:], + signedPreKeys: [:], + sessions: [:], + senderKeys: [:] + ) +} + +private struct StoredOMEMOIdentity: Codable { + var name: String + var deviceID: Int32 + var fingerprint: String + var key: Data + var status: Int + var own: Bool +} + +private struct StoredAddress { + let name: String + let deviceID: Int32 + + init?(_ storageKey: String) { + guard let separator = storageKey.lastIndex(of: "|") else { return nil } + let rawName = String(storageKey[.. [RoomBase] { + dispatcher.sync { + Array(roomsByJID.values) + } + } + + func room(for context: Context, with jid: BareJID) -> RoomBase? { + dispatcher.sync { + roomsByJID[jid] + } + } + + func createRoom( + for context: Context, + with jid: BareJID, + nickname: String, + password: String? + ) -> ConversationCreateResult { + dispatcher.sync { + if let room = roomsByJID[jid] { + return .found(room) + } + let room = RoomBase( + context: context, + jid: jid, + nickname: nickname, + password: password, + dispatcher: dispatcher + ) + roomsByJID[jid] = room + return .created(room) + } + } + + func close(room: RoomBase) -> Bool { + dispatcher.sync { + guard roomsByJID[room.jid] === room else { return false } + roomsByJID.removeValue(forKey: room.jid) + return true + } + } + + func initialize(context: Context) {} + + func deinitialize(context: Context) {} +} diff --git a/Sources/Shared/XMPP/LumaRosterStore.swift b/Sources/Shared/XMPP/LumaRosterStore.swift new file mode 100644 index 0000000..7a57818 --- /dev/null +++ b/Sources/Shared/XMPP/LumaRosterStore.swift @@ -0,0 +1,61 @@ +import Foundation +import Martin + +final class LumaRosterStore: RosterStore { + typealias RosterItem = RosterItemBase + + private let queue = DispatchQueue(label: "app.luma.roster-store") + private var itemsByJID: [JID: RosterItemBase] = [:] + private var rosterVersion: String? + + func clear(for context: Context) { + queue.sync { + itemsByJID.removeAll() + rosterVersion = nil + } + } + + func items(for context: Context) -> [RosterItemBase] { + queue.sync { Array(itemsByJID.values) } + } + + func item(for context: Context, jid: JID) -> RosterItemBase? { + queue.sync { itemsByJID[jid] } + } + + func updateItem( + for context: Context, + jid: JID, + name: String?, + subscription: RosterItemSubscription, + groups: [String], + ask: Bool, + annotations: [RosterItemAnnotation] + ) { + let item = RosterItemBase( + jid: jid, + name: name, + subscription: subscription, + groups: groups, + ask: ask, + annotations: annotations + ) + queue.sync { itemsByJID[jid] = item } + } + + func deleteItem(for context: Context, jid: JID) { + _ = queue.sync { itemsByJID.removeValue(forKey: jid) } + } + + func version(for context: Context) -> String? { + queue.sync { rosterVersion } + } + + func set(version: String?, for context: Context) { + queue.sync { rosterVersion = version } + } + + func initialize(context: Context) {} + func deinitialize(context: Context) {} +} + diff --git a/Sources/Shared/XMPP/StaticDNSSrvResolver.swift b/Sources/Shared/XMPP/StaticDNSSrvResolver.swift new file mode 100644 index 0000000..2d391b5 --- /dev/null +++ b/Sources/Shared/XMPP/StaticDNSSrvResolver.swift @@ -0,0 +1,34 @@ +import Foundation +import Martin + +final class StaticDNSSrvResolver: DNSSrvResolver { + private let host: String + private let port: Int + private let directTLS: Bool + + init(host: String, port: Int, directTLS: Bool) { + self.host = host + self.port = port + self.directTLS = directTLS + } + + func resolve( + domain: String, + for jid: BareJID, + completionHandler: @escaping (Result) -> Void + ) { + let record = XMPPSrvRecord( + port: port, + weight: 0, + priority: 0, + target: host, + directTls: directTLS + ) + completionHandler(.success(XMPPSrvResult(domain: domain, records: [record]))) + } + + func markAsInvalid(for domain: String, record: XMPPSrvRecord, for period: TimeInterval) {} + + func markAsInvalid(for domain: String, host: String, port: Int, for period: TimeInterval) {} +} + diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift new file mode 100644 index 0000000..bd034c2 --- /dev/null +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -0,0 +1,2819 @@ +import Combine +import Foundation +import Martin +import MartinOMEMO +import WebRTC + +private struct BufferedArchiveStanza { + let queryID: String + let message: Message + let timestamp: Date + let archiveID: String +} + +private struct BufferedArchivePage { + let stanzas: [BufferedArchiveStanza] + let overflowed: Bool + let rejectedSource: Bool +} + +/// Martin publishes every MAM result synchronously on its parser queue. Keep +/// that burst off the main queue and hand the completed page to XMPPService +/// only after the final IQ callback. Message instances are immutable after the +/// publisher returns; the lock provides the ownership hand-off. +private final class ArchiveStanzaInbox: @unchecked Sendable { + private let lock = NSLock() + private let maximumCount: Int + private var activeQueryID: String? + private var allowedSources: Set = [] + private var stanzas: [BufferedArchiveStanza] = [] + private var overflowed = false + private var rejectedSource = false + + init(maximumCount: Int) { + self.maximumCount = maximumCount + } + + func begin(queryID: String, allowedSources: Set) { + lock.lock() + defer { lock.unlock() } + activeQueryID = queryID + self.allowedSources = Set(allowedSources.map { $0.lowercased() }) + stanzas.removeAll(keepingCapacity: true) + overflowed = false + rejectedSource = false + } + + func append( + queryID: String, + source: BareJID, + message: Message, + timestamp: Date, + archiveID: String + ) { + lock.lock() + defer { lock.unlock() } + guard activeQueryID == queryID else { return } + guard allowedSources.contains(source.stringValue.lowercased()) else { + rejectedSource = true + return + } + guard stanzas.count < maximumCount else { + overflowed = true + return + } + stanzas.append(BufferedArchiveStanza( + queryID: queryID, + message: message, + timestamp: timestamp, + archiveID: archiveID + )) + } + + func take(queryID: String) -> BufferedArchivePage { + lock.lock() + defer { lock.unlock() } + guard activeQueryID == queryID else { + return BufferedArchivePage(stanzas: [], overflowed: false, rejectedSource: false) + } + let page = BufferedArchivePage( + stanzas: stanzas, + overflowed: overflowed, + rejectedSource: rejectedSource + ) + activeQueryID = nil + allowedSources.removeAll(keepingCapacity: true) + stanzas.removeAll(keepingCapacity: true) + overflowed = false + rejectedSource = false + return page + } + + func cancel(queryID: String? = nil) { + lock.lock() + defer { lock.unlock() } + if let queryID, activeQueryID != queryID { return } + activeQueryID = nil + allowedSources.removeAll(keepingCapacity: true) + stanzas.removeAll(keepingCapacity: true) + overflowed = false + rejectedSource = false + } +} + +@MainActor +final class XMPPService { + enum ConnectionStatus: Equatable, Sendable { + case disconnected(reason: String?) + case connecting + case connected + } + + struct MessageEnvelope: Sendable { + let id: String + let peerJID: String + let senderJID: String + let body: String + let timestamp: Date + let isOutgoing: Bool + let security: ChatMessage.Security + let kind: ChatMessage.Kind + let remoteAttachmentURL: String? + let localFilename: String? + let mimeType: String? + let duration: TimeInterval? + let byteCount: Int? + let fingerprint: String? + let correctionTargetID: String? + let replyToID: String? + let replyToJID: String? + let replyPreview: String? + let stanzaID: String? + let senderDisplayName: String? + let isGroupMessage: Bool + let isArchived: Bool + } + + struct ReplyReference: Sendable { + let id: String + let authorJID: String? + let fallbackAuthor: String + let preview: String + } + + struct RoomStateEnvelope: Sendable { + let roomJID: String + let nickname: String + let joined: Bool + let occupantCount: Int + } + + struct RoomInvitationEnvelope: Sendable { + let roomJID: String + let inviterJID: String? + let reason: String? + let password: String? + } + + struct RetractionEnvelope: Sendable { + let peerJID: String + let senderJID: String + let targetID: String + let retractionID: String + let timestamp: Date + let isOutgoing: Bool + } + + struct ReactionEnvelope: Sendable { + let peerJID: String + let senderJID: String + let targetID: String + let emojis: [String] + let timestamp: Date + let isOutgoing: Bool + let isGroupMessage: Bool + } + + struct ChatStateEnvelope: Sendable { + let peerJID: String + let senderJID: String + let senderDisplayName: String? + let state: ChatTypingState + let isGroupMessage: Bool + } + + struct MediaSendResult: Sendable { + let remoteURL: String + let fingerprint: String? + } + + enum ArchiveMutation: Sendable { + case message(MessageEnvelope) + case retraction(RetractionEnvelope) + case reaction(ReactionEnvelope) + } + + enum Event: Sendable { + case connection(ConnectionStatus) + case rosterItem(jid: String, name: String?) + case rosterRemoved(jid: String) + case presence(jid: String, online: Bool) + case message(MessageEnvelope) + case retraction(RetractionEnvelope) + case reaction(ReactionEnvelope) + case chatState(ChatStateEnvelope) + case groupMessageEcho(roomJID: String, messageID: String, stanzaID: String, senderJID: String) + case roomState(RoomStateEnvelope) + case roomInvitation(RoomInvitationEnvelope) + case avatar(jid: String, data: Data) + case delivered(messageID: String) + case omemo(ready: Bool, ownFingerprint: String?) + case archiveBatch([ArchiveMutation]) + case archiveSyncing(Bool) + case archiveSyncCompleted(ArchiveSyncCheckpoint) + case call(CallSnapshot?) + case callHistory(CallHistoryEntry) + case callError(String) + case recoverableError(String) + } + + var eventHandler: ((Event) -> Void)? + + private let callEngine: LumaCallEngine + private var client: XMPPClient? + private var omemoStorage: LumaOMEMOStore? + private var cancellables: Set = [] + private var account: AccountConfiguration? + private var archiveSyncStarted = false + private var archiveSyncCompletedForConnection = false + private var archiveSyncCheckpoint: ArchiveSyncCheckpoint? + private var archiveSyncQueryStartedAt: Date? + private var archiveSyncIndicatorVisible = false + private var archivePagination = ArchiveSyncPagination() + private var archiveRetryTask: Task? + private var archiveQueryTimeoutTask: Task? + private var archiveQueryCompletionTask: Task? + private var archiveActiveQueryID: String? + private var archiveResumeAfter: String? + private var archiveWorkBudget = ArchiveSyncWorkBudget() + private var archiveHighWatermark: Date? + private var archiveLastCompletedCursor: String? + private var archiveHasCompletedPage = false + private var archiveCursorFallbackUsed = false + private var archivePassMutations: [ArchiveMutation] = [] + private var archiveIsBootstrapQuery = false + private var archiveRetrySuppressedUntilActivation = false + private var archiveSyncSuspended = false + private var archiveResumeAfterMediaWorkTask: Task? + private var archiveStanzaBuffer: [BufferedArchiveStanza] = [] + private var archiveBufferOverflowed = false + private var archiveRejectedSource = false + private let archiveStanzaInbox = ArchiveStanzaInbox( + maximumCount: ArchiveMessageBatchPolicy.maximumBufferedStanzas + ) + private var avatarRequests: Set = [] + private var observedRoomJIDs: Set = [] + private var roomOccupantsByJID: [String: [MucOccupant]] = [:] + private var roomRealJIDByNickname: [String: [String: BareJID]] = [:] + private var omemoConfiguredRoomJIDs: Set = [] + private var chatStatesEnabled = true + private var knownChatStatePeers: Set = [] + private let archivePageSize = ArchiveMessageBatchPolicy.pageSize + private let archiveBootstrapMessageLimit = ArchiveMessageBatchPolicy.bootstrapMessageLimit + private let archiveApplyBatchSize = ArchiveMessageBatchPolicy.decodeSliceSize + private let archiveForegroundRefreshInterval: TimeInterval = 60 + private static let replyNamespace = "urn:xmpp:reply:0" + private static let fallbackNamespace = "urn:xmpp:fallback:0" + private static let retractionNamespace = "urn:xmpp:message-retract:1" + private static let reactionsNamespace = "urn:xmpp:reactions:0" + private static let stanzaIDNamespace = "urn:xmpp:sid:0" + private static let extendedAddressingNamespace = "http://jabber.org/protocol/address" + + init(callEngine: LumaCallEngine? = nil) { + let callEngine = callEngine ?? LumaCallEngine() + self.callEngine = callEngine + callEngine.snapshotHandler = { [weak self] snapshot in + self?.eventHandler?(.call(snapshot)) + } + callEngine.historyHandler = { [weak self] entry in + self?.eventHandler?(.callHistory(entry)) + } + callEngine.errorHandler = { [weak self] message in + self?.eventHandler?(.callError(message)) + } + } + + var connectionStatus: ConnectionStatus { + guard let client else { return .disconnected(reason: nil) } + switch client.state { + case .connecting, .disconnecting: + return .connecting + case .connected: + return .connected + case .disconnected(let reason): + return .disconnected(reason: Self.reasonText(reason)) + } + } + + func connect( + account: AccountConfiguration, + password: String, + archiveCheckpoint: ArchiveSyncCheckpoint? = nil + ) async throws { + await disconnect() + cancellables.removeAll() + archiveSyncStarted = false + archiveSyncCompletedForConnection = false + archiveSyncCheckpoint = archiveCheckpoint + archiveSyncQueryStartedAt = nil + archivePagination = ArchiveSyncPagination() + archiveRetryTask?.cancel() + archiveRetryTask = nil + archiveQueryTimeoutTask?.cancel() + archiveQueryTimeoutTask = nil + archiveQueryCompletionTask?.cancel() + archiveQueryCompletionTask = nil + archiveActiveQueryID = nil + archiveResumeAfter = nil + archiveWorkBudget = ArchiveSyncWorkBudget() + archiveHighWatermark = nil + archiveLastCompletedCursor = archiveCheckpoint?.cursor + archiveHasCompletedPage = false + archiveCursorFallbackUsed = false + archivePassMutations.removeAll(keepingCapacity: false) + archiveIsBootstrapQuery = false + archiveRetrySuppressedUntilActivation = false + archiveStanzaBuffer.removeAll(keepingCapacity: false) + archiveBufferOverflowed = false + archiveRejectedSource = false + archiveStanzaInbox.cancel() + setArchiveSyncIndicator(false) + avatarRequests.removeAll() + observedRoomJIDs.removeAll() + roomOccupantsByJID.removeAll() + roomRealJIDByNickname.removeAll() + omemoConfiguredRoomJIDs.removeAll() + knownChatStatePeers.removeAll() + + let account = try account.validated() + self.account = account + + let client = XMPPClient() + let omemoStorage = LumaOMEMOStore(accountJID: account.normalizedJID) + guard let signalContext = SignalContext(withStorage: omemoStorage) else { + throw LumaXMPPError.omemoInitializationFailed + } + + configureModules(client: client, signalContext: signalContext, omemoStorage: omemoStorage) + configureConnection(client: client, account: account, password: password) + subscribe(to: client, omemoStorage: omemoStorage) + + self.client = client + self.omemoStorage = omemoStorage + callEngine.attach(client: client) + eventHandler?(.connection(.connecting)) + + do { + try await client.loginAndWait() + eventHandler?(.connection(.connected)) + fetchAvatar(for: account.normalizedJID) + startArchiveSyncIfAvailable() + } catch { + callEngine.detach() + eventHandler?(.connection(.disconnected(reason: error.localizedDescription))) + throw LumaXMPPError.connection(error.localizedDescription) + } + } + + func disconnect() async { + callEngine.detach() + archiveRetryTask?.cancel() + archiveRetryTask = nil + archiveQueryTimeoutTask?.cancel() + archiveQueryTimeoutTask = nil + archiveQueryCompletionTask?.cancel() + archiveQueryCompletionTask = nil + archiveActiveQueryID = nil + archiveResumeAfter = nil + archiveWorkBudget = ArchiveSyncWorkBudget() + archiveHighWatermark = nil + archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor + archiveHasCompletedPage = false + archiveCursorFallbackUsed = false + archivePassMutations.removeAll(keepingCapacity: false) + archiveIsBootstrapQuery = false + archiveRetrySuppressedUntilActivation = false + archiveStanzaBuffer.removeAll(keepingCapacity: false) + archiveBufferOverflowed = false + archiveRejectedSource = false + archiveStanzaInbox.cancel() + if archiveSyncStarted, let client { + client.module(.omemo).mamSyncFinished(for: nil) + } + setArchiveSyncIndicator(false) + archiveSyncStarted = false + archiveSyncCompletedForConnection = false + archiveSyncQueryStartedAt = nil + archivePagination = ArchiveSyncPagination() + guard let client else { return } + if client.state == .connected() || client.state == .connecting { + try? await client.disconnect(force: false) + } + self.client = nil + omemoStorage?.flushPendingPersistence() + omemoStorage = nil + cancellables.removeAll() + avatarRequests.removeAll() + observedRoomJIDs.removeAll() + roomOccupantsByJID.removeAll() + roomRealJIDByNickname.removeAll() + omemoConfiguredRoomJIDs.removeAll() + knownChatStatePeers.removeAll() + eventHandler?(.connection(.disconnected(reason: nil))) + } + + func setChatStatesEnabled(_ enabled: Bool) { + chatStatesEnabled = enabled + } + + func prepareDirectChat(with recipient: String) { + guard let client, client.state == .connected() else { return } + let peer = BareJID(recipient.lowercased()) + let manager = client.module(.message).chatManager + if manager.chat(for: client, with: peer) == nil { + _ = manager.createChat(for: client, with: peer) + } + } + + func setApplicationActive(_ active: Bool) { + guard let client, client.state == .connected() else { return } + _ = client.module(.csi).setState(active) + if active { + archiveRetrySuppressedUntilActivation = false + refreshArchiveIfNeeded(client: client) + } else { + // iOS may freeze network callbacks and timeout tasks while the + // scene is inactive. Close the visible MAM pass now so it cannot + // return with a stale spinner after the app becomes active again. + pauseArchiveSync(client: client) + } + } + + /// Media staging, encryption and MAM decryption compete for main-thread and + /// I/O time. Keep MAM finite and paused until camera/gallery work and the + /// corresponding upload have released their resources. + func setArchiveSyncSuspendedForMediaWork(_ suspended: Bool) { + archiveResumeAfterMediaWorkTask?.cancel() + archiveResumeAfterMediaWorkTask = nil + + if suspended { + guard !archiveSyncSuspended else { return } + archiveSyncSuspended = true + if let client, client.state == .connected() { + pauseArchiveSync(client: client) + } + return + } + + guard archiveSyncSuspended else { return } + archiveResumeAfterMediaWorkTask = Task { @MainActor [weak self] in + try? await Task.sleep( + nanoseconds: ArchiveSyncRecoveryPolicy.resumeAfterCaptureDelayNanoseconds + ) + guard !Task.isCancelled, let self else { return } + self.archiveResumeAfterMediaWorkTask = nil + self.archiveSyncSuspended = false + guard let client = self.client, + client.state == .connected() else { return } + self.startArchiveSyncIfAvailable() + } + } + + func reconnectIfNeeded(password: String) async throws { + guard connectionStatus != .connected, let account else { return } + try await connect( + account: account, + password: password, + archiveCheckpoint: archiveSyncCheckpoint + ) + } + + func startCall(to peerJID: String, withVideo: Bool) async throws { + try await withCheckedThrowingContinuation { continuation in + callEngine.startCall(to: peerJID, withVideo: withVideo) { result in + continuation.resume(with: result) + } + } + } + + func answerCall() async throws { + try await withCheckedThrowingContinuation { continuation in + callEngine.answerCall { result in + continuation.resume(with: result) + } + } + } + + func rejectCall() { + callEngine.rejectCall() + } + + func endCall() { + callEngine.endCall() + } + + func setCallMuted(_ muted: Bool) { + callEngine.setMuted(muted) + } + + func setCallCameraEnabled(_ enabled: Bool) { + callEngine.setCameraEnabled(enabled) + } + + func setCallSpeakerEnabled(_ enabled: Bool) { + callEngine.setSpeakerEnabled(enabled) + } + + func switchCallCamera() { + callEngine.switchCamera() + } + + func localCallVideoTrack(for callID: UUID) -> RTCVideoTrack? { + callEngine.localVideoTrack(for: callID) + } + + func remoteCallVideoTrack(for callID: UUID) -> RTCVideoTrack? { + callEngine.remoteVideoTrack(for: callID) + } + + func joinRoom( + roomJID rawRoomJID: String, + nickname rawNickname: String, + password: String? = nil + ) async throws { + guard let client, client.state == .connected() else { + throw LumaXMPPError.notConnected + } + let roomJID = rawRoomJID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let parts = roomJID.split(separator: "@", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + throw LumaXMPPError.invalidRoomJID + } + let nickname = rawNickname.trimmingCharacters(in: .whitespacesAndNewlines) + guard !nickname.isEmpty else { throw LumaXMPPError.emptyRoomNickname } + + let muc = client.module(.muc) + let result: RoomJoinResult = try await withCheckedThrowingContinuation { continuation in + muc.join( + roomName: String(parts[0]), + mucServer: String(parts[1]), + nickname: nickname, + password: password + ) { result in + switch result { + case .success(let value): continuation.resume(returning: value) + case .failure(let error): continuation.resume(throwing: error) + } + } + } + + let room: RoomProtocol + switch result { + case .created(let createdRoom): + room = createdRoom + do { + try await configureInstantRoom(createdRoom, using: muc) + } catch { + try await muc.leave(room: createdRoom) + throw error + } + case .joined(let joinedRoom): + room = joinedRoom + } + observe(room: room) + eventHandler?(.roomState(RoomStateEnvelope( + roomJID: room.jid.stringValue, + nickname: room.nickname, + joined: room.state == .joined, + occupantCount: 0 + ))) + } + + func leaveRoom(roomJID: String) { + guard let client else { return } + let normalized = roomJID.lowercased() + let muc = client.module(.muc) + guard let room = muc.roomManager.room(for: client, with: BareJID(normalized)) else { return } + muc.leave(room: room) + observedRoomJIDs.remove(normalized) + roomOccupantsByJID.removeValue(forKey: normalized) + roomRealJIDByNickname.removeValue(forKey: normalized) + omemoConfiguredRoomJIDs.remove(normalized) + eventHandler?(.roomState(RoomStateEnvelope( + roomJID: normalized, + nickname: room.nickname, + joined: false, + occupantCount: 0 + ))) + } + + func inviteMembers(_ jids: [String], to roomJID: String) async throws { + guard let client, client.state == .connected() else { + throw LumaXMPPError.notConnected + } + let normalized = roomJID.lowercased() + let muc = client.module(.muc) + guard let room = muc.roomManager.room(for: client, with: BareJID(normalized)), + room.state == .joined else { + throw LumaXMPPError.roomNotJoined + } + let invitees = jids.map { + JID($0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + let ownAffiliation = room.occupant(nickname: room.nickname)?.affiliation ?? room.affiliation + if ownAffiliation == .owner || ownAffiliation == .admin { + let owners = try? await roomAffiliations( + in: room, + affiliation: .owner, + using: muc + ) + let admins = try? await roomAffiliations( + in: room, + affiliation: .admin, + using: muc + ) + if let owners, let admins { + let privilegedJIDs = Set((owners + admins).map { $0.jid.bareJid }) + let memberships = invitees + .filter { !privilegedJIDs.contains($0.bareJid) } + .map { + MucModule.RoomAffiliation(jid: $0, affiliation: .member) + } + if !memberships.isEmpty { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + muc.setRoomAffiliations(to: room, changedAffiliations: memberships) { result in + continuation.resume(with: result) + } + } + } + } + } + for invitee in invitees { + try await muc.invite( + to: room, + invitee: invitee, + reason: "Приглашение в групповой чат Luma" + ) + } + } + + func sendText( + _ text: String, + to recipient: String, + messageID: String, + encrypted: Bool, + outOfBandURL: String? = nil, + replacingMessageID: String? = nil, + replyTo: ReplyReference? = nil, + isGroup: Bool = false + ) async throws -> String? { + guard let client, client.state == .connected() else { + throw LumaXMPPError.notConnected + } + + let wireBody: String + let replyFallback: (prefix: String, scalarCount: Int)? + if let replyTo { + let fallback = MessageReplyFallback.make( + author: replyTo.fallbackAuthor, + preview: replyTo.preview + ) + wireBody = fallback.prefix + text + replyFallback = fallback + } else { + wireBody = text + replyFallback = nil + } + + if isGroup { + let roomJID = BareJID(recipient.lowercased()) + let muc = client.module(.muc) + guard let room = muc.roomManager.room(for: client, with: roomJID), + room.state == .joined else { + throw LumaXMPPError.roomNotJoined + } + let message = room.createMessage(text: wireBody, id: messageID) + message.lastMessageCorrectionId = replacingMessageID + if chatStatesEnabled { message.chatState = .active } + addReply(replyTo, fallback: replyFallback, to: message) + if !encrypted, let outOfBandURL { message.oob = outOfBandURL } + if encrypted { + guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else { + throw LumaXMPPError.omemoNotReady + } + let recipients = try await groupOMEMORecipients( + in: room, + using: muc, + client: client + ) + let encryptedMessage = try await encrypt( + message, + forGroupRecipients: recipients, + using: omemo + ) + try await room.send(message: encryptedMessage.message) + return encryptedMessage.fingerprint + } else { + try await room.send(message: message) + return nil + } + } + + let peer = BareJID(recipient.lowercased()) + let manager = client.module(.message).chatManager + guard let chat = manager.chat(for: client, with: peer) ?? manager.createChat(for: client, with: peer) else { + throw LumaXMPPError.cannotCreateChat + } + + let message = chat.createMessage(text: wireBody, id: messageID) + message.lastMessageCorrectionId = replacingMessageID + if chatStatesEnabled { message.chatState = .active } + addReply(replyTo, fallback: replyFallback, to: message) + if !encrypted, let outOfBandURL { + message.oob = outOfBandURL + } + if encrypted { + guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else { + throw LumaXMPPError.omemoNotReady + } + let encryptedMessage = try await encrypt(message, using: omemo) + try await chat.send(message: encryptedMessage.message) + return encryptedMessage.fingerprint + } else { + try await chat.send(message: message) + return nil + } + } + + func sendRetraction( + to recipient: String, + targetID: String, + retractionID: String, + encrypted: Bool + ) async throws { + guard let client, client.state == .connected() else { + throw LumaXMPPError.notConnected + } + + let peer = BareJID(recipient.lowercased()) + let manager = client.module(.message).chatManager + guard let chat = manager.chat(for: client, with: peer) ?? manager.createChat(for: client, with: peer) else { + throw LumaXMPPError.cannotCreateChat + } + + let fallbackText = "/me удалил(а) сообщение. Обновите клиент, если оно всё ещё отображается." + let message = chat.createMessage(text: fallbackText, id: retractionID) + let retract = Element(name: "retract", xmlns: Self.retractionNamespace) + retract.setAttribute("id", value: targetID) + message.addChild(retract) + + let fallback = Element(name: "fallback", xmlns: Self.fallbackNamespace) + fallback.setAttribute("for", value: Self.retractionNamespace) + message.addChild(fallback) + message.hints = [.store] + + if encrypted { + guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else { + throw LumaXMPPError.omemoNotReady + } + let encryptedMessage = try await encrypt(message, using: omemo) + try await chat.send(message: encryptedMessage.message) + } else { + try await chat.send(message: message) + } + } + + func sendReactions( + to recipient: String, + targetID: String, + emojis: [String], + reactionMessageID: String, + isGroup: Bool + ) async throws { + guard let client, client.state == .connected() else { + throw LumaXMPPError.notConnected + } + let normalizedTarget = targetID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedTarget.isEmpty else { return } + + let reactions = Element(name: "reactions", xmlns: Self.reactionsNamespace) + reactions.setAttribute("id", value: normalizedTarget) + for emoji in MessageReactionPolicy.sanitized(emojis) { + reactions.addChild(Element(name: "reaction", cdata: emoji)) + } + + if isGroup { + let roomJID = BareJID(recipient.lowercased()) + let muc = client.module(.muc) + guard let room = muc.roomManager.room(for: client, with: roomJID), + room.state == .joined else { + throw LumaXMPPError.roomNotJoined + } + let message = room.createMessage(id: reactionMessageID, type: .groupchat) + message.addChild(reactions) + message.hints = [.store] + try await room.send(message: message) + return + } + + let peer = BareJID(recipient.lowercased()) + let manager = client.module(.message).chatManager + guard let chat = manager.chat(for: client, with: peer) + ?? manager.createChat(for: client, with: peer) else { + throw LumaXMPPError.cannotCreateChat + } + let message = chat.createMessage(id: reactionMessageID, type: .chat) + message.addChild(reactions) + message.hints = [.store] + try await chat.send(message: message) + } + + func sendChatState( + _ state: ChatTypingState, + to recipient: String, + isGroup: Bool + ) async throws { + guard chatStatesEnabled, + let client, + client.state == .connected() else { return } + + let martinState = Self.martinChatState(state) + if isGroup { + let roomJID = BareJID(recipient.lowercased()) + let muc = client.module(.muc) + guard let room = muc.roomManager.room(for: client, with: roomJID), + room.state == .joined else { return } + let message = room.createMessage(id: UUID().uuidString, type: .groupchat) + message.chatState = martinState + try await room.send(message: message) + return + } + + let peer = BareJID(recipient.lowercased()) + let chatStateModule = client.module(.chatStateNotifications) + let hasSupport = knownChatStatePeers.contains(peer.stringValue.lowercased()) + || chatStateModule.hasSupport(jid: peer) + guard hasSupport else { return } + + let manager = client.module(.message).chatManager + guard let chat = manager.chat(for: client, with: peer) + ?? manager.createChat(for: client, with: peer) else { return } + let message = chat.createMessage(id: UUID().uuidString, type: .chat) + message.chatState = martinState + try await chat.send(message: message) + } + + func uploadAndSendMedia( + data: Data, + filename: String, + mimeType: String, + kind: ChatMessage.Kind, + duration: TimeInterval?, + to recipient: String, + messageID: String, + encrypted: Bool, + isGroup: Bool = false + ) async throws -> MediaSendResult { + guard let client, client.state == .connected() else { + throw LumaXMPPError.notConnected + } + + let uploadData: Data + let uploadFilename: String + let uploadMimeType: String + var decryptionFragment: String? + let transportFilename = MediaMetadata.transportFilename( + originalFilename: filename, + kind: kind, + duration: duration + ) + if encrypted { + guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else { + throw LumaXMPPError.omemoNotReady + } + switch omemo.encryptFile(data: data) { + case .success(let value): + uploadData = value.0 + decryptionFragment = value.1 + case .failure: + throw LumaXMPPError.fileEncryptionFailed + } + // XEP-0454 uses aesgcm as the URI scheme, not as a filename + // extension. Keeping the original extension lets Conversations, + // Monal and other clients recover the media type after decrypting. + uploadFilename = transportFilename + uploadMimeType = "application/octet-stream" + } else { + uploadData = data + uploadFilename = transportFilename + uploadMimeType = mimeType + } + + let uploadModule = client.module(.httpFileUpload) + let components: [HttpFileUploadModule.UploadComponent] + do { + components = try await uploadModule.findHttpUploadComponents() + } catch { + throw LumaXMPPError.uploadDiscoveryFailed(error.localizedDescription) + } + guard let component = components + .filter({ $0.maxSize >= uploadData.count }) + .max(by: { $0.maxSize < $1.maxSize }) else { + throw LumaXMPPError.uploadUnavailable(size: uploadData.count) + } + + let slot: HttpFileUploadModule.Slot + do { + slot = try await uploadModule.requestUploadSlot( + componentJid: component.jid, + filename: uploadFilename, + size: uploadData.count, + contentType: uploadMimeType + ) + } catch { + throw LumaXMPPError.uploadSlotFailed(error.localizedDescription) + } + guard slot.putUri.scheme?.lowercased() == "https", + slot.getUri.scheme?.lowercased() == "https" else { + throw LumaXMPPError.insecureUploadURL + } + + var request = URLRequest(url: slot.putUri) + request.httpMethod = "PUT" + request.timeoutInterval = 120 + request.setValue(uploadMimeType, forHTTPHeaderField: "Content-Type") + for (name, value) in slot.putHeaders { + request.setValue(value, forHTTPHeaderField: name) + } + let response: URLResponse + let uploadConfiguration = URLSessionConfiguration.ephemeral + uploadConfiguration.timeoutIntervalForRequest = 120 + uploadConfiguration.timeoutIntervalForResource = 300 + uploadConfiguration.waitsForConnectivity = false + let uploadSession = URLSession(configuration: uploadConfiguration) + defer { uploadSession.finishTasksAndInvalidate() } + do { + let (_, receivedResponse) = try await uploadSession.upload(for: request, from: uploadData) + response = receivedResponse + } catch { + throw LumaXMPPError.uploadTransportFailed(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { + throw LumaXMPPError.uploadFailed(statusCode: nil) + } + guard (200..<300).contains(http.statusCode) else { + throw LumaXMPPError.uploadFailed(statusCode: http.statusCode) + } + + guard var urlComponents = URLComponents(url: slot.getUri, resolvingAgainstBaseURL: false) else { + throw LumaXMPPError.invalidUploadURL + } + if let decryptionFragment { + urlComponents.scheme = "aesgcm" + urlComponents.fragment = decryptionFragment + } + + guard let remoteURL = urlComponents.string else { + throw LumaXMPPError.invalidUploadURL + } + let fingerprint = try await sendText( + remoteURL, + to: recipient, + messageID: messageID, + encrypted: encrypted, + outOfBandURL: encrypted ? nil : remoteURL, + isGroup: isGroup + ) + return MediaSendResult(remoteURL: remoteURL, fingerprint: fingerprint) + } + + func downloadAttachment(_ remoteURL: String) async throws -> Data { + guard var components = URLComponents(string: remoteURL) else { + throw LumaXMPPError.invalidUploadURL + } + let encrypted = components.scheme?.lowercased() == "aesgcm" + let fragment = components.fragment + if encrypted { + components.scheme = "https" + components.fragment = nil + } else if components.scheme?.lowercased() != "https" { + throw LumaXMPPError.invalidUploadURL + } + guard let url = components.url else { + throw LumaXMPPError.invalidUploadURL + } + + let (downloadedData, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw LumaXMPPError.downloadFailed + } + guard encrypted else { return downloadedData } + guard let omemo = client?.moduleOrNil(.omemo), let fragment else { + throw LumaXMPPError.fileDecryptionFailed + } + switch omemo.decryptFile(data: downloadedData, fragment: fragment) { + case .success(let data): + return data + case .failure: + throw LumaXMPPError.fileDecryptionFailed + } + } + + func devices(for jid: String) -> [OMEMODevice] { + omemoStorage?.devices(for: jid) ?? [] + } + + func setDeviceVerified(_ verified: Bool, jid: String, deviceID: Int32) { + guard omemoStorage?.setVerified(verified, jid: jid, deviceID: deviceID) == true else { return } + eventHandler?(.omemo(ready: client?.moduleOrNil(.omemo)?.isReady ?? false, ownFingerprint: omemoStorage?.ownFingerprint)) + } + + func addToRoster(jid: String, name: String?) { + guard let client, client.state == .connected() else { return } + client.module(.roster).addItem(jid: JID(jid), name: name, groups: [], completionHandler: nil) + client.module(.presence).subscribe(to: JID(jid)) + fetchAvatar(for: jid) + } + + func fetchAvatar(for jid: String, force: Bool = false) { + guard let client, client.state == .connected() else { return } + let normalized = jid.lowercased() + if force { + avatarRequests.remove(normalized) + } + guard avatarRequests.insert(normalized).inserted else { return } + + Task { [weak self] in + guard let self else { return } + let data: Data? + if let pepData = await self.retrievePEPAvatar(for: normalized, client: client) { + data = pepData + } else { + data = await self.retrieveLegacyAvatar(for: normalized, client: client) + } + if let data { + self.eventHandler?(.avatar(jid: normalized, data: data)) + } + } + } + + func updateOwnAvatar(pngData: Data) async throws { + guard let client, client.state == .connected(), let account else { + throw LumaXMPPError.notConnected + } + do { + let avatar = PEPUserAvatarModule.Avatar(data: pngData, mimeType: "image/png") + _ = try await client.module(.pepUserAvatar).publishAvatar(avatar: [avatar]) + } catch { + throw LumaXMPPError.avatarUpdateFailed(error.localizedDescription) + } + + eventHandler?(.avatar(jid: account.normalizedJID, data: pngData)) + + // XEP-0084 is authoritative. vCard-temp is updated as a best-effort + // compatibility bridge for older clients. + Task { + let vcardModule = client.module(.vcardTemp) + let vcard = (try? await vcardModule.retrieveVCard(from: nil as JID?)) ?? VCard() + vcard.photos = [VCard.Photo(type: "image/png", binval: pngData.base64EncodedString())] + _ = try? await vcardModule.publish(vcard: vcard, to: nil) + } + } + + private func configureModules( + client: XMPPClient, + signalContext: SignalContext, + omemoStorage: LumaOMEMOStore + ) { + _ = client.modulesManager.register(AuthModule()) + _ = client.modulesManager.register(StreamFeaturesModule()) + _ = client.modulesManager.register(StreamManagementModule()) + _ = client.modulesManager.register(SaslModule()) + _ = client.modulesManager.register(ResourceBinderModule()) + _ = client.modulesManager.register(SessionEstablishmentModule()) + _ = client.modulesManager.register( + DiscoveryModule(identity: .init(category: "client", type: "phone", name: "Luma")) + ) + _ = client.modulesManager.register( + SoftwareVersionModule(version: .init(name: "Luma", version: "0.10.6", os: Self.platformName)) + ) + _ = client.modulesManager.register(PingModule()) + _ = client.modulesManager.register(ClientStateIndicationModule()) +// _ = client.modulesManager.register(ClientStateIndicationModule()) + _ = client.modulesManager.register( + RosterModule(rosterManager: RosterManagerBase(store: LumaRosterStore())) + ) + _ = client.modulesManager.register(PresenceModule(store: DefaultPresenceStore())) + _ = client.modulesManager.register( + CapabilitiesModule(additionalFeatures: [ + .lastMessageCorrection, + .init(rawValue: Self.replyNamespace), + .init(rawValue: Self.fallbackNamespace), + .init(rawValue: Self.retractionNamespace), + .init(rawValue: Self.reactionsNamespace), + .init(rawValue: ChatStateNotificationsModule.XMLNS), + .init(rawValue: Self.stanzaIDNamespace) + ]) + ) + _ = client.modulesManager.register(PubSubModule()) + _ = client.modulesManager.register(PEPUserAvatarModule()) + _ = client.modulesManager.register(VCardTempModule()) + _ = client.modulesManager.register( + MessageModule(chatManager: ChatManagerBase(store: DefaultChatStore())) + ) + _ = client.modulesManager.register(ChatStateNotificationsModule()) + _ = client.modulesManager.register( + MucModule(roomManager: RoomManagerBase(store: LumaRoomStore())) + ) + _ = client.modulesManager.register(MessageCarbonsModule()) + _ = client.modulesManager.register(MessageArchiveManagementModule()) + let receipts = client.modulesManager.register(MessageDeliveryReceiptsModule()) + receipts.sendReceived = true + _ = client.modulesManager.register(ChatMarkersModule()) + _ = client.modulesManager.register(HttpFileUploadModule()) + let jingle = client.modulesManager.register(JingleModule(sessionManager: callEngine)) + jingle.register( + transport: Jingle.Transport.ICEUDPTransport.self, + features: [ + Jingle.Transport.ICEUDPTransport.XMLNS, + "urn:xmpp:jingle:apps:dtls:0" + ] + ) + jingle.register( + description: Jingle.RTP.Description.self, + features: [ + "urn:xmpp:jingle:apps:rtp:1", + "urn:xmpp:jingle:apps:rtp:audio", + "urn:xmpp:jingle:apps:rtp:video" + ] + ) + jingle.supportsMessageInitiation = true + _ = client.modulesManager.register(ExternalServiceDiscoveryModule()) + _ = client.modulesManager.register( + OMEMOModule( + aesGCMEngine: CryptoKitAESGCMEngine(), + signalContext: signalContext, + signalStorage: omemoStorage + ) + ) + } + + private func configureConnection( + client: XMPPClient, + account: AccountConfiguration, + password: String + ) { + client.connectionConfiguration.userJid = BareJID(account.normalizedJID) + client.connectionConfiguration.resource = account.effectiveResource + client.connectionConfiguration.nickname = account.displayName.isEmpty ? nil : account.displayName + client.connectionConfiguration.credentials = .password( + password: password, + authenticationName: nil, + cache: nil + ) + let expectedDomain = account.domain ?? account.normalizedJID + client.connectionConfiguration.modifyConnectorOptions(type: SocketConnector.Options.self) { options in + options.conntectionTimeout = 20 + options.sslCertificateValidation = .customValidator { trust in + CertificateTrustEvaluator.evaluateServerTrust( + trust, + expectedDomain: expectedDomain + ) + } + if let host = account.manualHost { + let defaultPort = account.usesDirectTLS ? 5223 : 5222 + options.dnsResolver = StaticDNSSrvResolver( + host: host, + port: account.manualPort ?? defaultPort, + directTLS: account.usesDirectTLS + ) + } + } + } + + private func subscribe(to client: XMPPClient, omemoStorage: LumaOMEMOStore) { + client.$state + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + self?.handle(state: state) + } + .store(in: &cancellables) + + client.module(.roster).events + .receive(on: DispatchQueue.main) + .sink { [weak self] action in + switch action { + case .addedOrUpdated(let item): + let jid = item.jid.bareJid.stringValue + self?.eventHandler?(.rosterItem(jid: jid, name: item.name)) + self?.fetchAvatar(for: jid) + case .removed(let jid): + self?.eventHandler?(.rosterRemoved(jid: jid.bareJid.stringValue)) + } + } + .store(in: &cancellables) + + client.module(.pepUserAvatar).avatarChangePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + self?.fetchAvatar(for: change.jid.bareJid.stringValue, force: true) + } + .store(in: &cancellables) + + client.module(.presence).presencePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] change in + self?.eventHandler?(.presence( + jid: change.jid.bareJid.stringValue, + online: change.presence.type != .unavailable + )) + } + .store(in: &cancellables) + + client.module(.message).messagesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] incoming in + self?.handle(message: incoming.message, timestamp: Date(), archiveID: nil) + } + .store(in: &cancellables) + + client.module(.muc).messagesPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] incoming in + self?.handle(groupMessage: incoming.message, room: incoming.room) + } + .store(in: &cancellables) + + client.module(.muc).inivitationsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] invitation in + self?.eventHandler?(.roomInvitation(RoomInvitationEnvelope( + roomJID: invitation.roomJid.stringValue, + inviterJID: invitation.inviter?.stringValue, + reason: invitation.reason, + password: invitation.password + ))) + } + .store(in: &cancellables) + + client.module(.messageCarbons).carbonsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] carbon in + self?.handle(message: carbon.message, timestamp: Date(), archiveID: nil) + } + .store(in: &cancellables) + + client.module(.messageCarbons).$isAvailable + .filter { $0 } + .prefix(1) + .receive(on: DispatchQueue.main) + .sink { [weak client] _ in + client?.module(.messageCarbons).enable() + } + .store(in: &cancellables) + + client.module(.messageDeliveryReceipts).receiptsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] receipt in + self?.eventHandler?(.delivered(messageID: receipt.messageId)) + } + .store(in: &cancellables) + + // Do not enqueue every archived stanza onto DispatchQueue.main. Martin + // publishes all results before the final IQ callback, so a locked inbox + // can receive the whole page on the parser queue and hand it to the + // main actor once. This mirrors Monal's delayed-stanza queue and avoids + // flooding SwiftUI's run loop on large histories. + let archiveStanzaInbox = self.archiveStanzaInbox + client.module(.mam).archivedMessagesPublisher + .sink { archived in + archiveStanzaInbox.append( + queryID: archived.query.id, + source: archived.source, + message: archived.message, + timestamp: archived.timestamp, + archiveID: archived.messageId + ) + } + .store(in: &cancellables) + + client.module(.mam).$availableVersions + .filter { !$0.isEmpty } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.startArchiveSyncIfAvailable() + } + .store(in: &cancellables) + + client.module(.omemo).$isReady + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] ready in + self?.eventHandler?(.omemo(ready: ready, ownFingerprint: omemoStorage.ownFingerprint)) + } + .store(in: &cancellables) + } + + private func handle(state: XMPPClient.State) { + switch state { + case .connecting, .disconnecting: + eventHandler?(.connection(.connecting)) + case .connected: + eventHandler?(.connection(.connected)) + startArchiveSyncIfAvailable() + case .disconnected(let reason): + suspendArchiveSyncForDisconnect() + eventHandler?(.connection(.disconnected(reason: Self.reasonText(reason)))) + } + } + + private func handle( + message: Message, + timestamp: Date, + archiveID: String?, + isArchived: Bool = false + ) { + guard message.type != .error, + message.type != .groupchat, + let client, + let sender = message.from?.bareJid else { return } + + let ownJID = client.userBareJid + let outgoing = sender == ownJID + guard let peer = outgoing ? message.to?.bareJid : message.from?.bareJid else { return } + + let security: ChatMessage.Security + let fingerprint: String? + let contentMessage: Message? + switch client.module(.omemo).decode(message: message, from: sender, serverMsgId: archiveID) { + case .successMessage(let decodedMessage, let value): + security = .omemo + fingerprint = value + contentMessage = decodedMessage + case .successTransportKey(_, _): + return + case .failure(let error): + switch error { + case .notEncrypted: + security = .plaintext + fingerprint = nil + contentMessage = message + case .duplicateMessage: + return + default: + security = .decryptionFailed + fingerprint = nil + contentMessage = nil + } + } + + let payload = contentMessage ?? message + if !isArchived, + let state = Self.chatTypingState(payload.chatState ?? message.chatState) { + knownChatStatePeers.insert(peer.stringValue.lowercased()) + if !outgoing { + eventHandler?(.chatState(ChatStateEnvelope( + peerJID: peer.stringValue, + senderJID: sender.stringValue, + senderDisplayName: nil, + state: state, + isGroupMessage: false + ))) + } + } + + if let reactions = payload.firstChild(name: "reactions", xmlns: Self.reactionsNamespace) + ?? message.firstChild(name: "reactions", xmlns: Self.reactionsNamespace), + let targetID = reactions.getAttribute("id"), + !targetID.isEmpty { + emitReaction( + ReactionEnvelope( + peerJID: peer.stringValue, + senderJID: sender.stringValue, + targetID: targetID, + emojis: reactions.children.compactMap { child in + guard child.name == "reaction" else { return nil } + return child.value + }, + timestamp: timestamp, + isOutgoing: outgoing, + isGroupMessage: false + ), + isArchived: isArchived + ) + return + } + + if let retract = payload.firstChild(name: "retract", xmlns: Self.retractionNamespace) + ?? message.firstChild(name: "retract", xmlns: Self.retractionNamespace), + let targetID = retract.getAttribute("id"), + !targetID.isEmpty { + emitRetraction( + peer: peer, + sender: sender, + targetID: targetID, + retractionID: payload.id ?? message.id ?? archiveID ?? UUID().uuidString, + timestamp: timestamp, + outgoing: outgoing, + isArchived: isArchived + ) + return + } + + if payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace) != nil + || message.firstChild(name: "retracted", xmlns: Self.retractionNamespace) != nil { + guard let targetID = message.originId ?? message.id ?? archiveID else { return } + let tombstone = payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace) + ?? message.firstChild(name: "retracted", xmlns: Self.retractionNamespace) + emitRetraction( + peer: peer, + sender: sender, + targetID: targetID, + retractionID: tombstone?.getAttribute("id") ?? UUID().uuidString, + timestamp: timestamp, + outgoing: outgoing, + isArchived: isArchived + ) + return + } + + let rawBody = contentMessage?.body + ?? (security == .decryptionFailed ? "Не удалось расшифровать сообщение" : nil) + guard let body = rawBody, !body.isEmpty else { return } + + let reply = payload.firstChild(name: "reply", xmlns: Self.replyNamespace) + ?? message.firstChild(name: "reply", xmlns: Self.replyNamespace) + let replyFallback = payload.children.first { child in + child.name == "fallback" + && child.xmlns == Self.fallbackNamespace + && child.getAttribute("for") == Self.replyNamespace + } ?? message.children.first { child in + child.name == "fallback" + && child.xmlns == Self.fallbackNamespace + && child.getAttribute("for") == Self.replyNamespace + } + let fallbackBody = replyFallback?.findChild(name: "body") + let parsedReply = MessageReplyFallback.parse( + body: body, + fallbackStart: fallbackBody?.getAttribute("start").flatMap(Int.init), + fallbackEnd: fallbackBody?.getAttribute("end").flatMap(Int.init) + ) + let visibleBody = parsedReply.body + + let isEncryptedAttachment = visibleBody.hasPrefix("aesgcm://") + && visibleBody.rangeOfCharacter(from: .whitespacesAndNewlines) == nil + && URLComponents(string: visibleBody)?.scheme?.lowercased() == "aesgcm" + let oob = payload.oob ?? message.oob + let standardOOB = oob?.hasPrefix("https://") == true ? oob : nil + let attachmentURL = isEncryptedAttachment ? visibleBody : standardOOB + let transportFilename = attachmentURL.flatMap { value -> String? in + guard let url = URL(string: value) else { return nil } + guard var filename = url.lastPathComponent.removingPercentEncoding else { return nil } + if filename.hasSuffix(".aesgcm") { + filename = String(filename.dropLast(".aesgcm".count)) + } + return filename + } + let inferred = transportFilename.map(MediaMetadata.infer(from:)) + let kind = inferred?.kind ?? (GeoLocation(uri: visibleBody) == nil ? .text : .location) + let displayBody = attachmentURL == nil + ? visibleBody + : Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение") + + let id = message.originId ?? message.id ?? archiveID ?? UUID().uuidString + emitMessage(MessageEnvelope( + id: id, + peerJID: peer.stringValue, + senderJID: sender.stringValue, + body: displayBody, + timestamp: timestamp, + isOutgoing: outgoing, + security: security, + kind: kind, + remoteAttachmentURL: attachmentURL, + localFilename: transportFilename, + mimeType: inferred?.mimeType, + duration: inferred?.duration, + byteCount: nil, + fingerprint: fingerprint, + correctionTargetID: payload.lastMessageCorrectionId ?? message.lastMessageCorrectionId, + replyToID: reply?.getAttribute("id"), + replyToJID: reply?.getAttribute("to"), + replyPreview: parsedReply.preview, + stanzaID: nil, + senderDisplayName: nil, + isGroupMessage: false, + isArchived: isArchived + ), isArchived: isArchived) + } + + private func handle(groupMessage message: Message, room: RoomProtocol) { + guard message.type != .error, + let client, + let from = message.from else { return } + + let nickname = from.resource ?? "Участник" + let outgoing = nickname == room.nickname + let roomJID = room.jid.stringValue.lowercased() + // In a MUC the author address used by XEP-0461 is the full occupant + // JID (room@service/nickname), not the participant's disclosed real JID. + let senderJID = from.stringValue + let stanzaID = Self.roomStanzaID(in: message, roomJID: room.jid) + let id = outgoing + ? (message.originId ?? message.id ?? stanzaID ?? UUID().uuidString) + : (stanzaID ?? message.originId ?? message.id ?? UUID().uuidString) + + let encrypted = message.firstChild(name: "encrypted", xmlns: OMEMOModule.XMLNS) != nil + let realSender: BareJID? + if outgoing { + realSender = client.userBareJid + } else if let disclosed = Self.originalSenderJID(in: message) { + realSender = disclosed + roomRealJIDByNickname[roomJID, default: [:]][nickname] = disclosed + } else if let disclosed = XMucUserElement.extract(from: message)?.jid?.bareJid { + realSender = disclosed + roomRealJIDByNickname[roomJID, default: [:]][nickname] = disclosed + } else if let disclosed = room.occupant(nickname: nickname)?.jid?.bareJid { + realSender = disclosed + roomRealJIDByNickname[roomJID, default: [:]][nickname] = disclosed + } else { + realSender = roomRealJIDByNickname[roomJID]?[nickname] + } + + let security: ChatMessage.Security + let fingerprint: String? + let contentMessage: Message? + if let realSender { + switch client.module(.omemo).decode( + message: message, + from: realSender, + serverMsgId: stanzaID + ) { + case .successMessage(let decodedMessage, let value): + security = .omemo + fingerprint = value + contentMessage = decodedMessage + case .successTransportKey(_, _): + return + case .failure(let error): + switch error { + case .notEncrypted: + security = .plaintext + fingerprint = nil + contentMessage = message + case .duplicateMessage: + emitGroupEchoIfPossible( + roomJID: roomJID, + messageID: id, + stanzaID: stanzaID, + senderJID: senderJID + ) + return + default: + if outgoing { + emitGroupEchoIfPossible( + roomJID: roomJID, + messageID: id, + stanzaID: stanzaID, + senderJID: senderJID + ) + return + } + security = .decryptionFailed + fingerprint = nil + contentMessage = nil + } + } + } else if encrypted { + security = .decryptionFailed + fingerprint = nil + contentMessage = nil + } else { + security = .plaintext + fingerprint = nil + contentMessage = message + } + + let payload = contentMessage ?? message + if let state = Self.chatTypingState(payload.chatState ?? message.chatState) { + knownChatStatePeers.insert(roomJID) + if !outgoing { + eventHandler?(.chatState(ChatStateEnvelope( + peerJID: roomJID, + senderJID: senderJID, + senderDisplayName: nickname, + state: state, + isGroupMessage: true + ))) + } + } + + if let reactions = payload.firstChild(name: "reactions", xmlns: Self.reactionsNamespace) + ?? message.firstChild(name: "reactions", xmlns: Self.reactionsNamespace), + let targetID = reactions.getAttribute("id"), + !targetID.isEmpty { + let reactionSender = outgoing + ? client.userBareJid.stringValue + : (realSender?.stringValue ?? senderJID) + emitReaction( + ReactionEnvelope( + peerJID: roomJID, + senderJID: reactionSender, + targetID: targetID, + emojis: reactions.children.compactMap { child in + guard child.name == "reaction" else { return nil } + return child.value + }, + timestamp: message.delay?.stamp ?? Date(), + isOutgoing: outgoing, + isGroupMessage: true + ), + isArchived: false + ) + return + } + + let rawBody = contentMessage?.body + ?? (security == .decryptionFailed ? "Не удалось расшифровать групповое сообщение" : nil) + guard let rawBody, !rawBody.isEmpty else { return } + let reply = payload.firstChild(name: "reply", xmlns: Self.replyNamespace) + ?? message.firstChild(name: "reply", xmlns: Self.replyNamespace) + let replyFallback = payload.children.first { child in + child.name == "fallback" + && child.xmlns == Self.fallbackNamespace + && child.getAttribute("for") == Self.replyNamespace + } ?? message.children.first { child in + child.name == "fallback" + && child.xmlns == Self.fallbackNamespace + && child.getAttribute("for") == Self.replyNamespace + } + let fallbackBody = replyFallback?.findChild(name: "body") + let parsedReply = MessageReplyFallback.parse( + body: rawBody, + fallbackStart: fallbackBody?.getAttribute("start").flatMap(Int.init), + fallbackEnd: fallbackBody?.getAttribute("end").flatMap(Int.init) + ) + let visibleBody = parsedReply.body + + let isEncryptedAttachment = visibleBody.hasPrefix("aesgcm://") + && visibleBody.rangeOfCharacter(from: .whitespacesAndNewlines) == nil + && URLComponents(string: visibleBody)?.scheme?.lowercased() == "aesgcm" + let oob = payload.oob ?? message.oob + let standardOOB = oob?.hasPrefix("https://") == true ? oob : nil + let attachmentURL = isEncryptedAttachment ? visibleBody : standardOOB + let transportFilename = attachmentURL.flatMap { value -> String? in + guard let url = URL(string: value) else { return nil } + guard var filename = url.lastPathComponent.removingPercentEncoding else { return nil } + if filename.hasSuffix(".aesgcm") { + filename = String(filename.dropLast(".aesgcm".count)) + } + return filename + } + let inferred = transportFilename.map(MediaMetadata.infer(from:)) + let kind = inferred?.kind ?? (GeoLocation(uri: visibleBody) == nil ? .text : .location) + let displayBody = attachmentURL == nil + ? visibleBody + : Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение") + eventHandler?(.message(MessageEnvelope( + id: id, + peerJID: room.jid.stringValue, + senderJID: senderJID, + body: displayBody, + timestamp: message.delay?.stamp ?? Date(), + isOutgoing: outgoing, + security: security, + kind: kind, + remoteAttachmentURL: attachmentURL, + localFilename: transportFilename, + mimeType: inferred?.mimeType, + duration: inferred?.duration, + byteCount: nil, + fingerprint: fingerprint, + correctionTargetID: payload.lastMessageCorrectionId ?? message.lastMessageCorrectionId, + replyToID: reply?.getAttribute("id"), + replyToJID: reply?.getAttribute("to"), + replyPreview: parsedReply.preview, + stanzaID: stanzaID, + senderDisplayName: nickname, + isGroupMessage: true, + isArchived: false + ))) + } + + private func observe(room: RoomProtocol) { + let roomJID = room.jid.stringValue.lowercased() + guard observedRoomJIDs.insert(roomJID).inserted else { return } + + room.statePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self, weak room] state in + guard let room else { return } + self?.eventHandler?(.roomState(RoomStateEnvelope( + roomJID: roomJID, + nickname: room.nickname, + joined: state == .joined, + occupantCount: 0 + ))) + } + .store(in: &cancellables) + + room.occupantsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self, weak room] occupants in + guard let self, let room else { return } + self.roomOccupantsByJID[roomJID] = occupants + var knownAuthors = self.roomRealJIDByNickname[roomJID] ?? [:] + for occupant in occupants { + if let realJID = occupant.jid?.bareJid { + knownAuthors[occupant.nickname] = realJID + } + } + self.roomRealJIDByNickname[roomJID] = knownAuthors + self.eventHandler?(.roomState(RoomStateEnvelope( + roomJID: roomJID, + nickname: room.nickname, + joined: room.state == .joined, + occupantCount: occupants.count + ))) + } + .store(in: &cancellables) + } + + private func configureInstantRoom(_ room: RoomProtocol, using muc: MucModule) async throws { + _ = try await configureRoomForOMEMO( + room, + using: muc, + makeMembersOnly: true + ) + } + + @discardableResult + private func configureRoomForOMEMO( + _ room: RoomProtocol, + using muc: MucModule, + makeMembersOnly: Bool + ) async throws -> Bool { + let roomJID = JID(room.jid) + let configuration: JabberDataElement = try await withCheckedThrowingContinuation { continuation in + muc.getRoomConfiguration(roomJid: roomJID) { result in + switch result { + case .success(let value): continuation.resume(returning: value) + case .failure(let error): continuation.resume(throwing: error) + } + } + } + + var supportsNonAnonymousRooms = false + if let field: ListSingleField = configuration.getField(named: "muc#roomconfig_whois") { + field.value = "anyone" + supportsNonAnonymousRooms = true + } else if let field: TextSingleField = configuration.getField(named: "muc#roomconfig_whois") { + field.value = "anyone" + supportsNonAnonymousRooms = true + } + if let field: ListMultiField = configuration.getField(named: "muc#roomconfig_getmemberlist") { + field.value = Array(Set(field.value + ["moderator", "participant"])).sorted() + } + if makeMembersOnly, + let field: BooleanField = configuration.getField(named: "muc#roomconfig_membersonly") { + field.value = true + } + if makeMembersOnly, + let field: BooleanField = configuration.getField(named: "muc#roomconfig_persistentroom") { + field.value = true + } + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + muc.setRoomConfiguration(roomJid: roomJID, configuration: configuration) { result in + switch result { + case .success(let value): continuation.resume(returning: value) + case .failure(let error): continuation.resume(throwing: error) + } + } + } + if supportsNonAnonymousRooms { + omemoConfiguredRoomJIDs.insert(room.jid.stringValue.lowercased()) + } + return supportsNonAnonymousRooms + } + + private func groupOMEMORecipients( + in room: RoomProtocol, + using muc: MucModule, + client: XMPPClient + ) async throws -> [BareJID] { + let roomJID = room.jid.stringValue.lowercased() + try await ensureRoomIsNonAnonymous(room, using: muc, client: client) + + var occupantsByNickname = Dictionary( + uniqueKeysWithValues: (roomOccupantsByJID[roomJID] ?? []).map { + ($0.nickname, $0) + } + ) + // `receive(on:)` delivers the cached publisher value on the next main + // queue turn. Read RoomBase synchronously as well so a send immediately + // after joining cannot miss already-present open-room occupants. + if let currentOccupants = (room as? RoomBase)?.occupants { + for occupant in currentOccupants { + occupantsByNickname[occupant.nickname] = occupant + } + } + let occupants = Array(occupantsByNickname.values) + let hiddenOccupants = occupants.filter { + $0.nickname != room.nickname && $0.role != .none && $0.jid == nil + } + guard hiddenOccupants.isEmpty else { + throw LumaXMPPError.groupEncryptionParticipantsHidden( + hiddenOccupants.map(\.nickname).sorted() + ) + } + + var recipients = Set(occupants.compactMap { $0.jid?.bareJid }) + var knownAuthors = roomRealJIDByNickname[roomJID] ?? [:] + for affiliation in [MucAffiliation.member, .admin, .owner] { + let values: [MucModule.RoomAffiliation] + do { + values = try await roomAffiliations( + in: room, + affiliation: affiliation, + using: muc + ) + } catch { + throw LumaXMPPError.groupEncryptionMemberListUnavailable( + error.localizedDescription + ) + } + for value in values { + recipients.insert(value.jid.bareJid) + if let nickname = value.nickname, !nickname.isEmpty { + knownAuthors[nickname] = value.jid.bareJid + } + } + } + roomRealJIDByNickname[roomJID] = knownAuthors + + // MartinOMEMO automatically adds the sender's other devices in + // _encode(..., forSelf: true). Remove the sender from the occupant + // set: its published device list also contains the active device and + // would incorrectly require a Signal session with itself. + recipients.remove(client.userBareJid) + recipients.remove(room.jid) + return recipients.sorted { $0.stringValue < $1.stringValue } + } + + private func ensureRoomIsNonAnonymous( + _ room: RoomProtocol, + using muc: MucModule, + client: XMPPClient + ) async throws { + let roomJID = room.jid.stringValue.lowercased() + if omemoConfiguredRoomJIDs.contains(roomJID) { return } + + let info = try? await client.module(.disco).info(for: JID(room.jid)) + if info?.features.contains("muc_nonanonymous") == true { + omemoConfiguredRoomJIDs.insert(roomJID) + return + } + + let ownAffiliation = room.occupant(nickname: room.nickname)?.affiliation ?? room.affiliation + if ownAffiliation == .owner { + let configured = try await configureRoomForOMEMO( + room, + using: muc, + makeMembersOnly: false + ) + guard configured else { + throw LumaXMPPError.groupEncryptionConfigurationUnsupported + } + eventHandler?(.recoverableError( + "Комната переведена в неанонимный режим, необходимый для группового OMEMO." + )) + return + } + + throw LumaXMPPError.groupEncryptionRequiresNonAnonymousRoom + } + + private func roomAffiliations( + in room: RoomProtocol, + affiliation: MucAffiliation, + using muc: MucModule + ) async throws -> [MucModule.RoomAffiliation] { + try await withCheckedThrowingContinuation { continuation in + muc.getRoomAffiliations(from: room, with: affiliation) { result in + continuation.resume(with: result) + } + } + } + + private func addReply( + _ replyTo: ReplyReference?, + fallback replyFallback: (prefix: String, scalarCount: Int)?, + to message: Message + ) { + guard let replyTo, let replyFallback else { return } + let reply = Element(name: "reply", xmlns: Self.replyNamespace) + reply.setAttribute("id", value: replyTo.id) + if let authorJID = replyTo.authorJID { + reply.setAttribute("to", value: authorJID) + } + message.addChild(reply) + + let fallback = Element(name: "fallback", xmlns: Self.fallbackNamespace) + fallback.setAttribute("for", value: Self.replyNamespace) + let bodyRange = Element(name: "body") + bodyRange.setAttribute("start", value: "0") + bodyRange.setAttribute("end", value: String(replyFallback.scalarCount)) + fallback.addChild(bodyRange) + message.addChild(fallback) + } + + private static func roomStanzaID(in message: Message, roomJID: BareJID) -> String? { + message.children.first { child in + guard child.name == "stanza-id", child.xmlns == Self.stanzaIDNamespace else { return false } + guard let by = child.getAttribute("by")?.lowercased() else { return false } + return by == roomJID.stringValue.lowercased() + }?.getAttribute("id") + } + + private static func originalSenderJID(in message: Message) -> BareJID? { + guard let address = message.firstChild( + name: "addresses", + xmlns: Self.extendedAddressingNamespace + )?.children.first(where: { child in + child.name == "address" + && child.getAttribute("type") == "ofrom" + && child.getAttribute("jid") != nil + }), + let value = address.getAttribute("jid") else { return nil } + return JID(value).bareJid + } + + private static func martinChatState(_ state: ChatTypingState) -> ChatState { + switch state { + case .active: return .active + case .composing: return .composing + case .paused: return .paused + case .inactive: return .inactive + case .gone: return .gone + } + } + + private static func chatTypingState(_ state: ChatState?) -> ChatTypingState? { + guard let state else { return nil } + switch state { + case .active: return .active + case .composing: return .composing + case .paused: return .paused + case .inactive: return .inactive + case .gone: return .gone + } + } + + private func emitGroupEchoIfPossible( + roomJID: String, + messageID: String, + stanzaID: String?, + senderJID: String + ) { + guard let stanzaID, !stanzaID.isEmpty else { return } + eventHandler?(.groupMessageEcho( + roomJID: roomJID, + messageID: messageID, + stanzaID: stanzaID, + senderJID: senderJID + )) + } + + private func emitRetraction( + peer: BareJID, + sender: BareJID, + targetID: String, + retractionID: String, + timestamp: Date, + outgoing: Bool, + isArchived: Bool + ) { + let envelope = RetractionEnvelope( + peerJID: peer.stringValue, + senderJID: sender.stringValue, + targetID: targetID, + retractionID: retractionID, + timestamp: timestamp, + isOutgoing: outgoing + ) + if isArchived { + archivePassMutations.append(.retraction(envelope)) + } else { + eventHandler?(.retraction(envelope)) + } + } + + private func emitReaction( + _ envelope: ReactionEnvelope, + isArchived: Bool + ) { + if isArchived { + archivePassMutations.append(.reaction(envelope)) + } else { + eventHandler?(.reaction(envelope)) + } + } + + private func emitMessage(_ envelope: MessageEnvelope, isArchived: Bool) { + if isArchived { + archivePassMutations.append(.message(envelope)) + } else { + eventHandler?(.message(envelope)) + } + } + + private func retrievePEPAvatar(for jid: String, client: XMPPClient) async -> Data? { + do { + let avatarModule = client.module(.pepUserAvatar) + let info = try await avatarModule.retrieveAvatarMetadata( + from: BareJID(jid), + fireEvents: false + ) + guard info.size > 0, info.size <= 2 * 1_024 * 1_024 else { return nil } + let (_, data) = try await avatarModule.retrieveAvatar(from: BareJID(jid), itemId: info.id) + return data.count <= 2 * 1_024 * 1_024 ? data : nil + } catch { + return nil + } + } + + private func retrieveLegacyAvatar(for jid: String, client: XMPPClient) async -> Data? { + do { + let card = try await client.module(.vcardTemp).retrieveVCard(from: JID(jid)) + guard let encoded = card.photos.first(where: { $0.binval != nil })?.binval, + let data = Data(base64Encoded: encoded), + data.count <= 2 * 1_024 * 1_024 else { return nil } + return data + } catch { + return nil + } + } + + private static func displayBody(kind: ChatMessage.Kind, filename: String) -> String { + switch kind { + case .attachment: + return filename + case .photo: + return "Фото" + case .video: + return "Видео" + case .audio: + return filename + case .voice: + return "Голосовое сообщение" + case .videoNote: + return "Видеосообщение" + case .location, .text, .system: + return filename + } + } + + private func encrypt(_ message: Message, using omemo: OMEMOModule) async throws -> (message: Message, fingerprint: String?) { + try await withCheckedThrowingContinuation { continuation in + omemo.encode(message: message, withStoreHint: true) { result in + switch result { + case .successMessage(let message, let fingerprint): + continuation.resume(returning: (message, fingerprint)) + case .failure(let error): + continuation.resume(throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) + } + } + } + } + + private func encrypt( + _ message: Message, + forGroupRecipients recipients: [BareJID], + using omemo: OMEMOModule + ) async throws -> (message: Message, fingerprint: String?) { + let fetchedAddresses: [SignalAddress] = try await withCheckedThrowingContinuation { continuation in + omemo.addresses(for: recipients) { result in + switch result { + case .success(let addresses): + continuation.resume(returning: addresses) + case .failure(let error): + continuation.resume(throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) + } + } + } + let addresses = Array(Set(fetchedAddresses)) + + let availableJIDs = Set(addresses.map { $0.name.lowercased() }) + let missingJIDs = recipients + .map(\.stringValue) + .filter { !availableJIDs.contains($0.lowercased()) } + guard missingJIDs.isEmpty else { + throw LumaXMPPError.groupOMEMODevicesUnavailable(missingJIDs.sorted()) + } + + let addressesWithoutSessions = addresses.filter { + omemoStorage?.hasSession(for: $0) != true + } + guard addressesWithoutSessions.isEmpty else { + throw LumaXMPPError.groupOMEMOSessionsUnavailable( + addressesWithoutSessions + .map { "\($0.name)/\($0.deviceId)" } + .sorted() + ) + } + + let encryptedMessage: (message: Message, fingerprint: String?) = try await withCheckedThrowingContinuation { continuation in + omemo.encode(message: message, forAddresses: addresses, withStoreHint: true) { result in + switch result { + case .successMessage(let message, let fingerprint): + continuation.resume(returning: (message, fingerprint)) + case .failure(let error): + continuation.resume(throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) + } + } + } + + var encryptedKeyCounts: [Int32: Int] = [:] + encryptedMessage.message + .firstChild(name: "encrypted", xmlns: OMEMOModule.XMLNS)? + .findChild(name: "header")? + .children + .filter { $0.name == "key" } + .compactMap { $0.getAttribute("rid").flatMap(Int32.init) } + .forEach { encryptedKeyCounts[$0, default: 0] += 1 } + + var unavailableAddresses: [String] = [] + for address in addresses { + let availableCount = encryptedKeyCounts[address.deviceId, default: 0] + if availableCount > 0 { + encryptedKeyCounts[address.deviceId] = availableCount - 1 + } else { + unavailableAddresses.append("\(address.name)/\(address.deviceId)") + } + } + guard unavailableAddresses.isEmpty else { + throw LumaXMPPError.groupOMEMOSessionsUnavailable(unavailableAddresses.sorted()) + } + return encryptedMessage + } + + private func startArchiveSyncIfAvailable() { + guard let client, + client.state == .connected(), + !archiveSyncSuspended, + !archiveRetrySuppressedUntilActivation, + !client.module(.mam).availableVersions.isEmpty else { return } + startArchiveSync(client: client) + } + + private func startArchiveSync(client: XMPPClient) { + guard !archiveSyncStarted, + !archiveSyncCompletedForConnection, + !archiveSyncSuspended, + !archiveRetrySuppressedUntilActivation, + self.client === client else { return } + archiveSyncStarted = true + if archiveSyncQueryStartedAt == nil { + archiveSyncQueryStartedAt = Date() + } + archiveIsBootstrapQuery = archiveSyncCheckpoint == nil + archiveWorkBudget = ArchiveSyncWorkBudget() + archiveHighWatermark = nil + archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor + archiveHasCompletedPage = false + archiveCursorFallbackUsed = false + archivePassMutations.removeAll(keepingCapacity: true) + archivePagination = ArchiveSyncPagination() + archiveRetryTask?.cancel() + archiveRetryTask = nil + archiveQueryTimeoutTask?.cancel() + archiveQueryTimeoutTask = nil + archiveQueryCompletionTask?.cancel() + archiveQueryCompletionTask = nil + archiveActiveQueryID = nil + archiveStanzaBuffer.removeAll(keepingCapacity: true) + archiveBufferOverflowed = false + archiveRejectedSource = false + archiveStanzaInbox.cancel() + setArchiveSyncIndicator(true) + client.module(.omemo).mamSyncStarted(for: nil) + let initialPosition = ArchiveSyncCursorPolicy.requestPosition( + checkpoint: archiveSyncCheckpoint, + resumeAfter: archiveResumeAfter, + overlap: ArchiveSyncRecoveryPolicy.incrementalOverlap + ) + queryArchive( + client: client, + after: archiveIsBootstrapQuery ? nil : initialPosition.after, + retry: 0 + ) + } + + private func queryArchive(client: XMPPClient, after: String?, retry: Int) { + guard archiveSyncStarted, + self.client === client, + client.state == .connected() else { + if archiveSyncStarted, self.client === client { + finishArchiveSync(client: client, succeeded: false) + } else { + setArchiveSyncIndicator(false) + } + return + } + let queryID = UUID().uuidString + archiveActiveQueryID = queryID + archiveStanzaBuffer.removeAll(keepingCapacity: true) + archiveBufferOverflowed = false + archiveRejectedSource = false + archiveStanzaInbox.begin( + queryID: queryID, + allowedSources: [ + client.userBareJid.stringValue, + client.userBareJid.domain + ] + ) + scheduleArchiveQueryTimeout(client: client, queryID: queryID) + let start = archiveIsBootstrapQuery + ? nil + : (after == nil ? archiveSyncCheckpoint?.timestamp.addingTimeInterval( + -ArchiveSyncRecoveryPolicy.incrementalOverlap + ) : nil) + let resultSet = archiveIsBootstrapQuery + ? RSM.Query(lastItems: archiveBootstrapMessageLimit) + : RSM.Query(after: after, max: archivePageSize) + client.module(.mam).queryItems( + start: start, + queryId: queryID, + rsm: resultSet + ) { [weak self, weak client] result in + // XEP-0313 guarantees that the final IQ follows every result. The + // parser-queue inbox is therefore complete before this single main + // actor hand-off runs. + DispatchQueue.main.async { + guard let self, + let client, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client else { return } + self.archiveQueryTimeoutTask?.cancel() + self.archiveQueryTimeoutTask = nil + let page = self.archiveStanzaInbox.take(queryID: queryID) + self.archiveStanzaBuffer = page.stanzas + self.archiveBufferOverflowed = page.overflowed + self.archiveRejectedSource = page.rejectedSource + + guard case .success = result, + !page.overflowed, + !page.rejectedSource else { + self.archiveActiveQueryID = nil + self.completeArchivePage( + result, + client: client, + requestedAfter: after, + retry: retry + ) + return + } + + self.scheduleArchiveApplyTimeout(client: client, queryID: queryID) + self.archiveQueryCompletionTask?.cancel() + self.archiveQueryCompletionTask = Task { @MainActor [weak self, weak client] in + guard let self, let client else { return } + let applied = await self.drainArchiveStanzas(queryID: queryID) + guard !Task.isCancelled, + applied, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client else { return } + self.archiveQueryTimeoutTask?.cancel() + self.archiveQueryTimeoutTask = nil + self.archiveQueryCompletionTask = nil + self.archiveActiveQueryID = nil + self.completeArchivePage( + result, + client: client, + requestedAfter: after, + retry: retry + ) + } + } + } + } + + private func drainArchiveStanzas(queryID: String) async -> Bool { + while archiveSyncStarted, + archiveActiveQueryID == queryID, + !Task.isCancelled { + guard !archiveStanzaBuffer.isEmpty else { return true } + let count = min(archiveApplyBatchSize, archiveStanzaBuffer.count) + let batch = Array(archiveStanzaBuffer.prefix(count)) + archiveStanzaBuffer.removeFirst(count) + + for stanza in batch where stanza.queryID == queryID { + if let current = archiveHighWatermark { + archiveHighWatermark = max(current, stanza.timestamp) + } else { + archiveHighWatermark = stanza.timestamp + } + handle( + message: stanza.message, + timestamp: stanza.timestamp, + archiveID: stanza.archiveID, + isArchived: true + ) + } + + // Give SwiftUI, UIScrollView and AVFoundation delegate tasks a + // chance to run after every decryption. + await Task.yield() + try? await Task.sleep( + nanoseconds: ArchiveMessageBatchPolicy.interSliceDelayNanoseconds + ) + } + return false + } + + private func completeArchivePage( + _ result: Result, + client: XMPPClient, + requestedAfter after: String?, + retry: Int + ) { + if archiveBufferOverflowed { + archiveBufferOverflowed = false + archiveStanzaBuffer.removeAll(keepingCapacity: true) + archivePassMutations.removeAll(keepingCapacity: true) + eventHandler?(.recoverableError( + "Сервер вернул слишком большую страницу MAM; синхронизация остановлена без потери контрольной точки." + )) + finishArchiveSync(client: client, succeeded: false) + return + } + if archiveRejectedSource { + archiveRejectedSource = false + archiveStanzaBuffer.removeAll(keepingCapacity: true) + archivePassMutations.removeAll(keepingCapacity: true) + eventHandler?(.recoverableError( + "MAM вернул результат от неожиданного отправителя; страница отклонена." + )) + finishArchiveSync(client: client, succeeded: false) + return + } + + switch result { + case .success(let response): + archiveHasCompletedPage = true + let workBudgetReached = archiveWorkBudget.recordCompletedPage() + if archiveIsBootstrapQuery { + // Like Monal, start from a small recent window backed by local + // storage instead of replaying the whole account archive. + let checkpoint = resolvedArchiveCheckpoint( + cursor: response.rsm?.last, + caughtUp: true + ) + archiveLastCompletedCursor = checkpoint.cursor + finishArchiveSync( + client: client, + succeeded: true, + checkpoint: checkpoint + ) + return + } + switch archivePagination.decision( + complete: response.complete, + lastCursor: response.rsm?.last, + requestedAfter: after + ) { + case .finished: + let checkpoint = resolvedArchiveCheckpoint( + cursor: response.rsm?.last ?? after, + caughtUp: true + ) + archiveLastCompletedCursor = checkpoint.cursor + finishArchiveSync( + client: client, + succeeded: true, + checkpoint: checkpoint + ) + case .next(let cursor): + archiveResumeAfter = cursor + let checkpoint = resolvedArchiveCheckpoint( + cursor: cursor, + caughtUp: false + ) + archiveLastCompletedCursor = checkpoint.cursor + if workBudgetReached { + if checkpoint.advances(over: archiveSyncCheckpoint) { + finishArchiveSync( + client: client, + succeeded: true, + checkpoint: checkpoint + ) + } else { + archivePassMutations.removeAll(keepingCapacity: true) + eventHandler?(.recoverableError( + "MAM не продвинул контрольную точку; синхронизация приостановлена." + )) + finishArchiveSync(client: client, succeeded: false) + } + } else { + scheduleArchiveNextPage(client: client, after: cursor) + } + case .invalidCursor: + // An incomplete page without a new UID cannot be committed + // atomically. Drop its decoded mutations so the next attempt + // can safely request the same page again. + archivePassMutations.removeAll(keepingCapacity: true) + eventHandler?(.recoverableError( + "MAM вернул некорректный курсор; страница отклонена без изменения истории." + )) + finishArchiveSync(client: client, succeeded: false) + } + case .failure(let error): + if retry < ArchiveSyncRecoveryPolicy.pageRetryLimit { + scheduleArchivePageRetry( + client: client, + after: after, + retry: retry + 1 + ) + } else if ArchiveSyncCursorPolicy.shouldFallbackFromStoredCursor( + requestedAfter: after, + checkpoint: archiveSyncCheckpoint, + hasCompletedPage: archiveHasCompletedPage, + fallbackAlreadyUsed: archiveCursorFallbackUsed + ) { + // The server may have expired the UID because of archive + // retention. Fall back once to the legacy timestamp overlap; + // never loop between the two strategies automatically. + archiveCursorFallbackUsed = true + archivePagination = ArchiveSyncPagination() + archiveResumeAfter = nil + archiveHighWatermark = nil + archivePassMutations.removeAll(keepingCapacity: true) + queryArchive(client: client, after: nil, retry: 0) + } else { + eventHandler?(.recoverableError( + "MAM: \(error.localizedDescription). Продолжим после следующего открытия приложения." + )) + finishArchiveSync(client: client, succeeded: false) + } + } + } + + private func resolvedArchiveCheckpoint( + cursor: String?, + caughtUp: Bool + ) -> ArchiveSyncCheckpoint { + ArchiveSyncCursorPolicy.resolvedCheckpoint( + previous: archiveSyncCheckpoint, + cursor: cursor ?? archiveLastCompletedCursor, + highWatermark: archiveHighWatermark, + queryStartedAt: archiveSyncQueryStartedAt ?? Date(), + caughtUp: caughtUp + ) + } + + private func scheduleArchivePageRetry( + client: XMPPClient, + after: String?, + retry: Int + ) { + archiveRetryTask?.cancel() + archiveRetryTask = Task { @MainActor [weak self, weak client] in + let delaySeconds = min(8, 1 << max(0, retry - 1)) + try? await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000) + guard !Task.isCancelled, + let self, + let client, + self.archiveSyncStarted, + self.client === client else { return } + self.queryArchive(client: client, after: after, retry: retry) + } + } + + private func scheduleArchiveNextPage(client: XMPPClient, after: String) { + archiveRetryTask?.cancel() + archiveRetryTask = Task { @MainActor [weak self, weak client] in + // A short hand-off between MAM pages lets SwiftUI render and lets + // the archive consumer apply the previous batch. Without it a + // large server archive can monopolize the main run loop. + try? await Task.sleep( + nanoseconds: ArchiveSyncRecoveryPolicy.interPageDelayNanoseconds + ) + guard !Task.isCancelled, + let self, + let client, + self.archiveSyncStarted, + self.client === client else { return } + self.queryArchive(client: client, after: after, retry: 0) + } + } + + private func scheduleArchiveQueryTimeout(client: XMPPClient, queryID: String) { + archiveQueryTimeoutTask?.cancel() + archiveQueryTimeoutTask = Task { @MainActor [weak self, weak client] in + try? await Task.sleep( + nanoseconds: ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds + ) + guard !Task.isCancelled, + let self, + let client, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client else { return } + self.archiveQueryCompletionTask?.cancel() + self.archiveQueryCompletionTask = nil + self.archiveStanzaInbox.cancel(queryID: queryID) + self.archiveActiveQueryID = nil + self.archiveResumeAfter = self.archiveSyncCheckpoint?.cursor + self.archiveStanzaBuffer.removeAll(keepingCapacity: true) + self.archiveBufferOverflowed = false + self.archiveRejectedSource = false + self.archivePassMutations.removeAll(keepingCapacity: true) + self.eventHandler?(.recoverableError( + "MAM не ответил вовремя; синхронизация остановлена до следующего открытия приложения." + )) + self.finishArchiveSync(client: client, succeeded: false) + } + } + + private func scheduleArchiveApplyTimeout(client: XMPPClient, queryID: String) { + archiveQueryTimeoutTask?.cancel() + archiveQueryTimeoutTask = Task { @MainActor [weak self, weak client] in + try? await Task.sleep( + nanoseconds: ArchiveSyncRecoveryPolicy.pageApplyTimeoutNanoseconds + ) + guard !Task.isCancelled, + let self, + let client, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client else { return } + self.archiveQueryCompletionTask?.cancel() + self.archiveQueryCompletionTask = nil + self.archiveStanzaInbox.cancel(queryID: queryID) + self.archiveActiveQueryID = nil + self.archiveResumeAfter = self.archiveSyncCheckpoint?.cursor + self.archiveStanzaBuffer.removeAll(keepingCapacity: true) + self.archiveBufferOverflowed = false + self.archiveRejectedSource = false + self.archivePassMutations.removeAll(keepingCapacity: true) + self.eventHandler?(.recoverableError( + "Обработка MAM заняла слишком много времени; синхронизация приостановлена." + )) + self.finishArchiveSync(client: client, succeeded: false) + } + } + + private func finishArchiveSync( + client: XMPPClient, + succeeded: Bool, + checkpoint: ArchiveSyncCheckpoint? = nil + ) { + guard archiveSyncStarted, self.client === client else { + if !archiveSyncStarted { + setArchiveSyncIndicator(false) + } + return + } + archiveRetryTask?.cancel() + archiveRetryTask = nil + archiveQueryTimeoutTask?.cancel() + archiveQueryTimeoutTask = nil + archiveQueryCompletionTask?.cancel() + archiveQueryCompletionTask = nil + archiveStanzaInbox.cancel() + archiveActiveQueryID = nil + archiveStanzaBuffer.removeAll(keepingCapacity: true) + archiveBufferOverflowed = false + archiveRejectedSource = false + client.module(.omemo).mamSyncFinished(for: nil) + + if succeeded, !archivePassMutations.isEmpty { + let mutations = archivePassMutations + archivePassMutations.removeAll(keepingCapacity: true) + eventHandler?(.archiveBatch(mutations)) + } else { + archivePassMutations.removeAll(keepingCapacity: true) + } + setArchiveSyncIndicator(false) + archiveSyncStarted = false + archiveIsBootstrapQuery = false + archiveHighWatermark = nil + archiveHasCompletedPage = false + archiveCursorFallbackUsed = false + + if succeeded { + let resolvedCheckpoint = checkpoint ?? ArchiveSyncCheckpoint( + timestamp: archiveSyncQueryStartedAt ?? Date(), + cursor: archiveLastCompletedCursor ?? archiveSyncCheckpoint?.cursor + ) + archiveSyncCheckpoint = resolvedCheckpoint + archiveLastCompletedCursor = resolvedCheckpoint.cursor + archiveSyncQueryStartedAt = nil + archiveSyncCompletedForConnection = true + archiveResumeAfter = nil + archiveRetrySuppressedUntilActivation = false + eventHandler?(.archiveSyncCompleted(resolvedCheckpoint)) + return + } + + archiveSyncQueryStartedAt = nil + archiveSyncCompletedForConnection = false + archiveResumeAfter = archiveSyncCheckpoint?.cursor + archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor + archiveRetrySuppressedUntilActivation = true + eventHandler?(.recoverableError( + "Синхронизация истории приостановлена. Luma продолжит после возврата в приложение." + )) + } + + private func refreshArchiveIfNeeded(client: XMPPClient) { + guard !archiveSyncStarted, + !archiveSyncSuspended, + self.client === client else { return } + if !archiveSyncCompletedForConnection { + archiveRetrySuppressedUntilActivation = false + startArchiveSync(client: client) + return + } + let lastCheckpoint = archiveSyncCheckpoint?.timestamp ?? .distantPast + guard Date().timeIntervalSince(lastCheckpoint) >= archiveForegroundRefreshInterval else { return } + archiveSyncCompletedForConnection = false + archiveResumeAfter = nil + archiveSyncQueryStartedAt = nil + startArchiveSync(client: client) + } + + private func suspendArchiveSyncForDisconnect() { + setArchiveSyncIndicator(false) + guard let client else { + archiveSyncStarted = false + archiveStanzaInbox.cancel() + archiveActiveQueryID = nil + archiveStanzaBuffer.removeAll(keepingCapacity: false) + archiveBufferOverflowed = false + archiveRejectedSource = false + archivePassMutations.removeAll(keepingCapacity: false) + return + } + archiveRetryTask?.cancel() + archiveRetryTask = nil + archiveQueryTimeoutTask?.cancel() + archiveQueryTimeoutTask = nil + archiveQueryCompletionTask?.cancel() + archiveQueryCompletionTask = nil + if archiveSyncStarted { + archiveResumeAfter = archiveSyncCheckpoint?.cursor + client.module(.omemo).mamSyncFinished(for: nil) + } + archiveStanzaInbox.cancel() + archiveActiveQueryID = nil + archiveSyncStarted = false + archiveSyncCompletedForConnection = false + archiveRetrySuppressedUntilActivation = false + archiveSyncQueryStartedAt = nil + archiveWorkBudget = ArchiveSyncWorkBudget() + archiveHighWatermark = nil + archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor + archiveHasCompletedPage = false + archiveCursorFallbackUsed = false + archivePassMutations.removeAll(keepingCapacity: false) + archiveIsBootstrapQuery = false + archiveStanzaBuffer.removeAll(keepingCapacity: false) + archiveBufferOverflowed = false + archiveRejectedSource = false + } + + private func pauseArchiveSync(client: XMPPClient) { + setArchiveSyncIndicator(false) + guard archiveSyncStarted, self.client === client else { return } + archiveRetryTask?.cancel() + archiveRetryTask = nil + archiveQueryTimeoutTask?.cancel() + archiveQueryTimeoutTask = nil + archiveQueryCompletionTask?.cancel() + archiveQueryCompletionTask = nil + archiveStanzaInbox.cancel() + archiveResumeAfter = archiveSyncCheckpoint?.cursor + archiveActiveQueryID = nil + archiveSyncStarted = false + archiveSyncCompletedForConnection = false + archiveSyncQueryStartedAt = nil + archivePagination = ArchiveSyncPagination() + archiveWorkBudget = ArchiveSyncWorkBudget() + archiveHighWatermark = nil + archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor + archiveHasCompletedPage = false + archiveCursorFallbackUsed = false + archivePassMutations.removeAll(keepingCapacity: true) + archiveIsBootstrapQuery = false + archiveStanzaBuffer.removeAll(keepingCapacity: true) + archiveBufferOverflowed = false + archiveRejectedSource = false + client.module(.omemo).mamSyncFinished(for: nil) + } + + private func setArchiveSyncIndicator(_ visible: Bool) { + guard archiveSyncIndicatorVisible != visible else { return } + archiveSyncIndicatorVisible = visible + eventHandler?(.archiveSyncing(visible)) + } + + private static var platformName: String { +#if os(macOS) + return "macOS" +#else + return "iOS/iPadOS" +#endif + } + + private static func reasonText(_ reason: XMPPClient.State.DisconnectionReason) -> String? { + if case .none = reason { + return nil + } + return reason.localizedDescription + } +} + +enum LumaXMPPError: LocalizedError { + case notConnected + case connection(String) + case omemoInitializationFailed + case omemoNotReady + case omemoEncryptionFailed(Int) + case cannotCreateChat + case invalidRoomJID + case emptyRoomNickname + case roomNotJoined + case groupEncryptionRequiresNonAnonymousRoom + case groupEncryptionConfigurationUnsupported + case groupEncryptionParticipantsHidden([String]) + case groupEncryptionMemberListUnavailable(String) + case groupOMEMODevicesUnavailable([String]) + case groupOMEMOSessionsUnavailable([String]) + case fileEncryptionFailed + case fileDecryptionFailed + case uploadDiscoveryFailed(String) + case uploadUnavailable(size: Int) + case uploadSlotFailed(String) + case uploadTransportFailed(String) + case uploadFailed(statusCode: Int?) + case downloadFailed + case invalidUploadURL + case insecureUploadURL + case avatarUpdateFailed(String) + + var errorDescription: String? { + switch self { + case .notConnected: + return "Нет соединения с XMPP-сервером." + case .connection(let message): + return "Не удалось подключиться: \(message)" + case .omemoInitializationFailed: + return "Не удалось инициализировать хранилище OMEMO." + case .omemoNotReady: + return "OMEMO ещё не готов. Подождите публикации ключей на сервере." + case .omemoEncryptionFailed(let code): + return code == SignalError.noSession.rawValue + ? "У контакта нет доступных OMEMO-устройств." + : "Не удалось зашифровать сообщение OMEMO (код \(code))." + case .cannotCreateChat: + return "Не удалось открыть чат с этим JID." + case .invalidRoomJID: + return "Введите адрес комнаты в формате room@conference.example.org." + case .emptyRoomNickname: + return "Введите псевдоним для группового чата." + case .roomNotJoined: + return "Сначала подключитесь к групповой комнате." + case .groupEncryptionRequiresNonAnonymousRoom: + return "Групповое OMEMO требует неанонимную MUC-комнату. Попросите владельца включить показ реальных JID (muc#roomconfig_whois = anyone)." + case .groupEncryptionConfigurationUnsupported: + return "Сервер не предлагает настройку неанонимной MUC-комнаты, необходимую для группового OMEMO." + case .groupEncryptionParticipantsHidden(let nicknames): + return "Нельзя безопасно зашифровать сообщение: комната скрывает JID участников \(nicknames.joined(separator: ", ")). Переподключитесь после включения неанонимного режима." + case .groupEncryptionMemberListUnavailable(let message): + return "Сообщение не отправлено: сервер не отдал полный список участников комнаты для OMEMO (\(message))." + case .groupOMEMODevicesUnavailable(let jids): + return "Сообщение не отправлено: OMEMO-устройства отсутствуют у \(jids.joined(separator: ", "))." + case .groupOMEMOSessionsUnavailable(let devices): + return "Сообщение не отправлено: не удалось создать OMEMO-сессию для \(devices.joined(separator: ", "))." + case .fileEncryptionFailed: + return "Не удалось зашифровать вложение." + case .fileDecryptionFailed: + return "Не удалось расшифровать вложение." + case .uploadDiscoveryFailed(let message): + return "Не удалось найти XEP-0363 HTTP Upload на сервере: \(message)" + case .uploadUnavailable(let size): + return "Сервер не предлагает HTTP Upload для файла размером \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))." + case .uploadSlotFailed(let message): + return "Prosody не выдал слот для загрузки: \(message)" + case .uploadTransportFailed(let message): + return "Не удалось отправить файл в HTTP Upload: \(message)" + case .uploadFailed(let statusCode): + if let statusCode { + return "HTTP Upload отклонил файл (HTTP \(statusCode))." + } + return "HTTP Upload вернул некорректный ответ." + case .downloadFailed: + return "Не удалось скачать вложение." + case .invalidUploadURL: + return "Получена некорректная ссылка на вложение." + case .insecureUploadURL: + return "Сервер предложил небезопасный HTTP Upload URL. Требуется HTTPS." + case .avatarUpdateFailed(let message): + return "Не удалось обновить аватар: \(message)" + } + } +} diff --git a/Sources/Watch/LumaWatchApp.swift b/Sources/Watch/LumaWatchApp.swift new file mode 100644 index 0000000..602ed33 --- /dev/null +++ b/Sources/Watch/LumaWatchApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +@MainActor +struct LumaWatchApp: App { + @StateObject private var model = WatchSessionModel() + + var body: some Scene { + WindowGroup { + WatchChatListView(model: model) + } + } +} diff --git a/Sources/Watch/WatchSessionModel.swift b/Sources/Watch/WatchSessionModel.swift new file mode 100644 index 0000000..5ee9ab7 --- /dev/null +++ b/Sources/Watch/WatchSessionModel.swift @@ -0,0 +1,240 @@ +import Foundation +import WatchConnectivity + +struct WatchSnapshot: Codable { + struct Chat: Codable, Identifiable, Hashable { + var id: String { jid } + let jid: String + let name: String + let unread: Int + let messages: [Message] + } + + struct Message: Codable, Identifiable, Hashable { + let id: String + let body: String + let timestamp: Date + let outgoing: Bool + let encrypted: Bool + } + + let chats: [Chat] +} + +@MainActor +final class WatchSessionModel: NSObject, ObservableObject { + @Published private(set) var chats: [WatchSnapshot.Chat] = [] + @Published private(set) var phoneReachable = false + @Published private(set) var voiceStatusByJID: [String: String] = [:] + @Published var errorMessage: String? + + private let session: WCSession? + private let cacheKey = "luma.watch.snapshot.v1" + private let voiceTransferCacheKey = "luma.watch.voice-transfers.v1" + private var outgoingVoiceTransfers: [String: OutgoingVoiceTransfer] = [:] + + override init() { + session = WCSession.isSupported() ? .default : nil + super.init() + loadCachedSnapshot() + loadOutgoingVoiceTransfers() + session?.delegate = self + session?.activate() + } + + func send(text rawText: String, to jid: String) { + let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, let session else { return } + let payload: [String: Any] = [ + "action": "reply", + "jid": jid, + "text": text + ] + + if session.isReachable { + session.sendMessage(payload, replyHandler: nil) { [weak self] error in + Task { @MainActor in self?.errorMessage = error.localizedDescription } + } + } else { + session.transferUserInfo(payload) + } + } + + @discardableResult + func sendVoice(fileURL: URL, duration: TimeInterval, to jid: String) -> Bool { + guard let session else { + errorMessage = "WatchConnectivity недоступен." + return false + } + guard session.activationState == .activated else { + errorMessage = "Соединение с iPhone ещё не готово. Повторите через несколько секунд." + return false + } + guard session.isCompanionAppInstalled else { + errorMessage = "Установите Luma на связанный iPhone." + return false + } + guard duration.isFinite, duration >= 0.4, + FileManager.default.fileExists(atPath: fileURL.path) else { + errorMessage = "Запись голосового сообщения недоступна." + return false + } + + let transferID = UUID().uuidString + let normalizedJID = jid.lowercased() + let metadata: [String: Any] = [ + "action": "voice", + "transferID": transferID, + "jid": normalizedJID, + "filename": fileURL.lastPathComponent, + "duration": duration + ] + outgoingVoiceTransfers[transferID] = OutgoingVoiceTransfer( + jid: normalizedJID, + fileURL: fileURL, + createdAt: Date() + ) + persistOutgoingVoiceTransfers() + voiceStatusByJID[normalizedJID] = "Голосовое в очереди на iPhone…" + session.transferFile(fileURL, metadata: metadata) + return true + } + + func voiceStatus(for jid: String) -> String? { + voiceStatusByJID[jid.lowercased()] + } + + private func apply(data: Data) { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + guard let snapshot = try? decoder.decode(WatchSnapshot.self, from: data) else { return } + chats = snapshot.chats + UserDefaults.standard.set(data, forKey: cacheKey) + } + + private func loadCachedSnapshot() { + guard let data = UserDefaults.standard.data(forKey: cacheKey) else { return } + apply(data: data) + } + + private func consume(_ payload: [String: Any]) { + guard payload["action"] as? String == "voiceResult", + let transferID = payload["transferID"] as? String, + let success = payload["success"] as? Bool else { return } + + let transfer = outgoingVoiceTransfers.removeValue(forKey: transferID) + let jid = transfer?.jid ?? (payload["jid"] as? String)?.lowercased() + guard let jid else { return } + + if let transfer { + try? FileManager.default.removeItem(at: transfer.fileURL) + } + persistOutgoingVoiceTransfers() + if success { + voiceStatusByJID[jid] = "Голосовое отправлено" + } else { + voiceStatusByJID[jid] = "Голосовое не отправлено" + errorMessage = (payload["error"] as? String) ?? "iPhone не смог отправить голосовое сообщение." + } + } + + private func finishVoiceTransfer( + transferID: String?, + fallbackURL: URL, + errorDescription: String? + ) { + guard let transferID, + let transfer = outgoingVoiceTransfers[transferID] else { + try? FileManager.default.removeItem(at: fallbackURL) + return + } + + // Once WatchConnectivity has copied the file to the iPhone the source + // recording is no longer needed. Keep only the transfer metadata while + // waiting for the actual XMPP upload result from the companion app. + try? FileManager.default.removeItem(at: transfer.fileURL) + if let errorDescription { + outgoingVoiceTransfers.removeValue(forKey: transferID) + persistOutgoingVoiceTransfers() + voiceStatusByJID[transfer.jid] = "Не удалось передать запись" + errorMessage = errorDescription + } else { + voiceStatusByJID[transfer.jid] = "iPhone получил запись. Отправляем…" + } + } + + private func loadOutgoingVoiceTransfers() { + guard let data = UserDefaults.standard.data(forKey: voiceTransferCacheKey), + let stored = try? JSONDecoder().decode( + [String: OutgoingVoiceTransfer].self, + from: data + ) else { return } + let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60) + outgoingVoiceTransfers = stored.filter { $0.value.createdAt >= expiration } + for transfer in outgoingVoiceTransfers.values { + voiceStatusByJID[transfer.jid] = "Ожидает подтверждения iPhone…" + } + persistOutgoingVoiceTransfers() + } + + private func persistOutgoingVoiceTransfers() { + guard let data = try? JSONEncoder().encode(outgoingVoiceTransfers) else { return } + UserDefaults.standard.set(data, forKey: voiceTransferCacheKey) + } +} + +private struct OutgoingVoiceTransfer: Codable { + let jid: String + let fileURL: URL + let createdAt: Date +} + +extension WatchSessionModel: WCSessionDelegate { + nonisolated func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + Task { @MainActor in + phoneReachable = session.isReachable + if let error { errorMessage = error.localizedDescription } + if let data = session.receivedApplicationContext["snapshot"] as? Data { + apply(data: data) + } + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + Task { @MainActor in phoneReachable = session.isReachable } + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let data = applicationContext["snapshot"] as? Data else { return } + Task { @MainActor in apply(data: data) } + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + Task { @MainActor in consume(message) } + } + + nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + Task { @MainActor in consume(userInfo) } + } + + nonisolated func session( + _ session: WCSession, + didFinish fileTransfer: WCSessionFileTransfer, + error: Error? + ) { + let transferID = fileTransfer.file.metadata?["transferID"] as? String + let fileURL = fileTransfer.file.fileURL + let errorDescription = error?.localizedDescription + Task { @MainActor in + finishVoiceTransfer( + transferID: transferID, + fallbackURL: fileURL, + errorDescription: errorDescription + ) + } + } +} diff --git a/Sources/Watch/WatchViews.swift b/Sources/Watch/WatchViews.swift new file mode 100644 index 0000000..fc91993 --- /dev/null +++ b/Sources/Watch/WatchViews.swift @@ -0,0 +1,228 @@ +import Foundation +import SwiftUI + +struct WatchChatListView: View { + @ObservedObject var model: WatchSessionModel + + var body: some View { + NavigationStack { + Group { + if model.chats.isEmpty { + ContentUnavailableView( + "Нет чатов", + systemImage: "iphone.and.arrow.forward", + description: Text("Откройте Luma на iPhone для синхронизации.") + ) + } else { + List(model.chats) { chat in + NavigationLink(value: chat) { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(chat.name).font(.headline).lineLimit(1) + Spacer() + if chat.unread > 0 { + Text("\(chat.unread)") + .font(.caption2.bold()) + .padding(5) + .background(.blue, in: Circle()) + } + } + Text(chat.messages.last?.body ?? chat.jid) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + } + .navigationTitle("Luma") + .navigationDestination(for: WatchSnapshot.Chat.self) { chat in + WatchChatView(model: model, chat: chat) + } + } + .alert("Luma", isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + )) { + Button("OK") { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? "Ошибка") + } + } +} + +private struct WatchChatView: View { + @ObservedObject var model: WatchSessionModel + let chat: WatchSnapshot.Chat + @StateObject private var voiceRecorder = WatchVoiceRecorder() + @State private var reply = "" + + var body: some View { + ScrollViewReader { proxy in + List { + ForEach(chat.messages) { message in + HStack { + if message.outgoing { Spacer(minLength: 16) } + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 4) { + if message.encrypted { + Image(systemName: "lock.fill").font(.caption2) + } + Text(message.body) + } + Text(message.timestamp, format: .dateTime.hour().minute()) + .font(.caption2) + .opacity(0.7) + } + .padding(7) + .background(message.outgoing ? Color.blue : Color.secondary.opacity(0.25), in: RoundedRectangle(cornerRadius: 11)) + if !message.outgoing { Spacer(minLength: 16) } + } + .id(message.id) + } + + Section { + TextField("Ответ", text: $reply) + Button("Отправить") { + model.send(text: reply, to: chat.jid) + reply = "" + } + .disabled(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + Section("Голосовое") { + voiceComposer + + if let status = model.voiceStatus(for: chat.jid) { + Label(status, systemImage: "iphone.and.arrow.forward") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + Section("Быстрый ответ") { + ForEach(["Хорошо", "Спасибо!", "Отвечу позже"], id: \.self) { text in + Button(text) { model.send(text: text, to: chat.jid) } + } + } + } + .navigationTitle(chat.name) + .onAppear { + if let id = chat.messages.last?.id { proxy.scrollTo(id, anchor: .bottom) } + } + .onDisappear { + voiceRecorder.reset() + } + } + } + + @ViewBuilder + private var voiceComposer: some View { + if voiceRecorder.isRecording { + VStack(spacing: 8) { + Label( + formatDuration(voiceRecorder.elapsed), + systemImage: "waveform" + ) + .font(.headline.monospacedDigit()) + .foregroundStyle(.red) + + ProgressView( + value: voiceRecorder.elapsed, + total: WatchVoiceRecorder.maximumDuration + ) + .tint(.red) + + HStack(spacing: 8) { + Button(role: .destructive) { + voiceRecorder.cancelRecording() + } label: { + Label("Отмена", systemImage: "xmark") + } + .buttonStyle(.borderless) + + Button { + finishVoiceRecording() + } label: { + Label("Готово", systemImage: "stop.fill") + } + .buttonStyle(.borderedProminent) + .tint(.red) + } + .font(.caption) + } + } else if let recording = voiceRecorder.recording { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + do { + try voiceRecorder.togglePlayback() + } catch { + model.errorMessage = error.localizedDescription + } + } label: { + Image(systemName: voiceRecorder.isPlaying ? "pause.fill" : "play.fill") + } + .buttonStyle(.bordered) + .accessibilityLabel(voiceRecorder.isPlaying ? "Пауза" : "Прослушать") + + VStack(alignment: .leading, spacing: 2) { + Text("Предпрослушивание") + .font(.caption) + Text(formatDuration(recording.duration)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + Button { + if model.sendVoice( + fileURL: recording.url, + duration: recording.duration, + to: chat.jid + ) { + _ = voiceRecorder.detachRecording() + } + } label: { + Label("Отправить голосовое", systemImage: "paperplane.fill") + } + .buttonStyle(.borderedProminent) + + Button(role: .destructive) { + voiceRecorder.discardRecording() + } label: { + Label("Удалить запись", systemImage: "trash") + } + .font(.caption) + } + } else { + Button { + Task { + do { + try await voiceRecorder.start() + } catch { + model.errorMessage = error.localizedDescription + } + } + } label: { + Label("Записать голосовое", systemImage: "mic.fill") + } + .buttonStyle(.borderedProminent) + .tint(.red) + } + } + + private func finishVoiceRecording() { + do { + try voiceRecorder.finish() + } catch { + model.errorMessage = error.localizedDescription + } + } + + private func formatDuration(_ value: TimeInterval) -> String { + let seconds = max(0, Int(value.rounded(.down))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } +} diff --git a/Sources/Watch/WatchVoiceRecorder.swift b/Sources/Watch/WatchVoiceRecorder.swift new file mode 100644 index 0000000..e1f8caa --- /dev/null +++ b/Sources/Watch/WatchVoiceRecorder.swift @@ -0,0 +1,205 @@ +import AVFoundation +import Combine +import Foundation + +@MainActor +final class WatchVoiceRecorder: NSObject, ObservableObject { + struct Recording: Equatable { + let url: URL + let duration: TimeInterval + } + + static let maximumDuration: TimeInterval = 60 + + @Published private(set) var isRecording = false + @Published private(set) var isPlaying = false + @Published private(set) var elapsed: TimeInterval = 0 + @Published private(set) var recording: Recording? + + private var recorder: AVAudioRecorder? + private var player: AVAudioPlayer? + private var timer: Timer? + private var startedAt: Date? + + func start() async throws { + guard !isRecording else { return } + discardRecording() + guard await requestPermission() else { throw WatchVoiceRecorderError.permissionDenied } + + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playAndRecord, mode: .default) + try audioSession.setActive(true) + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaWatchRecordings", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent("voice-\(UUID().uuidString).m4a") + let settings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44_100.0, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 32_000, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + let recorder = try AVAudioRecorder(url: url, settings: settings) + recorder.prepareToRecord() + guard recorder.record(forDuration: Self.maximumDuration) else { + throw WatchVoiceRecorderError.cannotStart + } + + self.recorder = recorder + startedAt = Date() + elapsed = 0 + isRecording = true + timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, let startedAt = self.startedAt else { return } + self.elapsed = min(Date().timeIntervalSince(startedAt), Self.maximumDuration) + if !self.recorderIsActivelyRecording { + _ = try? self.finish() + } + } + } + } catch { + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + throw error + } + } + + @discardableResult + func finish() throws -> Recording { + guard let recorder, isRecording else { throw WatchVoiceRecorderError.notRecording } + let duration = min(max(recorder.currentTime, elapsed), Self.maximumDuration) + let url = recorder.url + recorder.stop() + stopRecordingState() + guard duration >= 0.4 else { + try? FileManager.default.removeItem(at: url) + throw WatchVoiceRecorderError.tooShort + } + let recording = Recording(url: url, duration: duration) + self.recording = recording + return recording + } + + func cancelRecording() { + let url = recorder?.url + recorder?.stop() + stopRecordingState() + if let url { + try? FileManager.default.removeItem(at: url) + } + } + + func togglePlayback() throws { + if isPlaying { + stopPlayback() + return + } + guard let recording else { return } + + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .spokenAudio) + try audioSession.setActive(true) + do { + let player = try AVAudioPlayer(contentsOf: recording.url) + player.delegate = self + player.prepareToPlay() + guard player.play() else { throw WatchVoiceRecorderError.cannotPlay } + self.player = player + isPlaying = true + } catch { + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + throw error + } + } + + func discardRecording() { + stopPlayback() + if let url = recording?.url { + try? FileManager.default.removeItem(at: url) + } + recording = nil + } + + func detachRecording() -> Recording? { + stopPlayback() + defer { recording = nil } + return recording + } + + func reset() { + cancelRecording() + discardRecording() + } + + private var recorderIsActivelyRecording: Bool { + recorder?.isRecording == true + } + + private func stopRecordingState() { + timer?.invalidate() + timer = nil + recorder = nil + startedAt = nil + elapsed = 0 + isRecording = false + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private func stopPlayback() { + player?.stop() + player = nil + isPlaying = false + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private func requestPermission() async -> Bool { + switch AVAudioApplication.shared.recordPermission { + case .granted: + return true + case .denied: + return false + case .undetermined: + return await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + @unknown default: + return false + } + } +} + +extension WatchVoiceRecorder: AVAudioPlayerDelegate { + nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + Task { @MainActor [weak self] in + self?.stopPlayback() + } + } +} + +private enum WatchVoiceRecorderError: LocalizedError { + case permissionDenied + case cannotStart + case notRecording + case tooShort + case cannotPlay + + var errorDescription: String? { + switch self { + case .permissionDenied: + return "Разрешите Luma доступ к микрофону в настройках Apple Watch." + case .cannotStart: + return "Не удалось начать запись на Apple Watch." + case .notRecording: + return "Запись голосового сообщения не запущена." + case .tooShort: + return "Голосовое сообщение получилось слишком коротким." + case .cannotPlay: + return "Не удалось воспроизвести запись." + } + } +} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..2811ef0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party software + +Luma links the following Swift Package Manager dependencies at build time: + +- Martin 3.2.4 by Tigase, Inc. — AGPL-3.0. +- MartinOMEMO 2.2.3 by Tigase, Inc. — GPL-3.0. +- WebRTC M150 binary package by the WebRTC project/stasel — BSD-style license. +- tigase-logging.swift, pulled transitively by Martin — AGPL-3.0. +- Tigase's libsignal Swift package, pulled by MartinOMEMO — GPL-3.0. + +No third-party source code is vendored in this archive. Xcode resolves the +packages from their upstream repositories. The three direct package versions +are pinned exactly in `project.yml`; each resolved package includes its +upstream license text. diff --git a/Tests/AccountConfigurationTests.swift b/Tests/AccountConfigurationTests.swift new file mode 100644 index 0000000..42a7487 --- /dev/null +++ b/Tests/AccountConfigurationTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import Luma + +final class AccountConfigurationTests: XCTestCase { + func testValidAccountIsNormalized() throws { + let account = AccountConfiguration( + jid: " Alice@Example.ORG ", + resource: "", + manualHost: " xmpp.example.org ", + manualPort: 5222 + ) + + let validated = try account.validated() + + XCTAssertEqual(validated.normalizedJID, "alice@example.org") + XCTAssertEqual(validated.domain, "example.org") + XCTAssertEqual(validated.effectiveResource, "Luma") + XCTAssertEqual(validated.manualHost, "xmpp.example.org") + } + + func testInvalidJIDIsRejected() { + XCTAssertThrowsError(try AccountConfiguration(jid: "not-a-jid").validated()) + XCTAssertThrowsError(try AccountConfiguration(jid: "@example.org").validated()) + XCTAssertThrowsError(try AccountConfiguration(jid: "alice@").validated()) + } + + func testInvalidPortIsRejected() { + XCTAssertThrowsError( + try AccountConfiguration(jid: "alice@example.org", manualPort: 70_000).validated() + ) + } +} + diff --git a/Tests/ArchiveMessageBatchPolicyTests.swift b/Tests/ArchiveMessageBatchPolicyTests.swift new file mode 100644 index 0000000..8f28a9b --- /dev/null +++ b/Tests/ArchiveMessageBatchPolicyTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import Luma + +final class ArchiveMessageBatchPolicyTests: XCTestCase { + func testMamPagesStaySmallEnoughForInteractiveUI() { + XCTAssertLessThanOrEqual(ArchiveMessageBatchPolicy.pageSize, 32) + XCTAssertGreaterThan(ArchiveMessageBatchPolicy.pageSize, 0) + } + + func testOnlyOneStanzaIsDecodedPerMainActorSlice() { + XCTAssertEqual(ArchiveMessageBatchPolicy.decodeSliceSize, 1) + XCTAssertGreaterThan(ArchiveMessageBatchPolicy.interSliceDelayNanoseconds, 0) + } + + func testInboxCanHoldAProtocolViolatingDoublePageWithoutGrowingForever() { + XCTAssertGreaterThanOrEqual( + ArchiveMessageBatchPolicy.maximumBufferedStanzas, + ArchiveMessageBatchPolicy.pageSize * 2 + ) + XCTAssertLessThanOrEqual(ArchiveMessageBatchPolicy.maximumBufferedStanzas, 128) + } +} diff --git a/Tests/ArchiveSyncCheckpointTests.swift b/Tests/ArchiveSyncCheckpointTests.swift new file mode 100644 index 0000000..857fe1b --- /dev/null +++ b/Tests/ArchiveSyncCheckpointTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import Luma + +final class ArchiveSyncCheckpointTests: XCTestCase { + func testStoredCursorIsPreferredOverTimestampOverlap() { + let checkpoint = ArchiveSyncCheckpoint( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + cursor: " mam-42 " + ) + + let position = ArchiveSyncCursorPolicy.requestPosition( + checkpoint: checkpoint, + resumeAfter: nil, + overlap: 60 + ) + + XCTAssertEqual(position.after, "mam-42") + XCTAssertNil(position.start) + } + + func testLegacyTimestampUsesBoundedOverlap() { + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let position = ArchiveSyncCursorPolicy.requestPosition( + checkpoint: ArchiveSyncCheckpoint(timestamp: timestamp), + resumeAfter: nil, + overlap: 60 + ) + + XCTAssertNil(position.after) + XCTAssertEqual(position.start, timestamp.addingTimeInterval(-60)) + } + + func testResumeCursorWinsOverDurableCursor() { + let position = ArchiveSyncCursorPolicy.requestPosition( + checkpoint: ArchiveSyncCheckpoint( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + cursor: "saved" + ), + resumeAfter: "page-in-flight", + overlap: 60 + ) + + XCTAssertEqual(position.after, "page-in-flight") + XCTAssertNil(position.start) + } + + func testCaughtUpCheckpointKeepsCursorAndAdvancesTimestamp() { + let previous = ArchiveSyncCheckpoint( + timestamp: Date(timeIntervalSince1970: 100), + cursor: "known" + ) + let queryStart = Date(timeIntervalSince1970: 200) + + let resolved = ArchiveSyncCursorPolicy.resolvedCheckpoint( + previous: previous, + cursor: nil, + highWatermark: nil, + queryStartedAt: queryStart, + caughtUp: true + ) + + XCTAssertEqual(resolved.cursor, "known") + XCTAssertEqual(resolved.timestamp, queryStart) + } + + func testSameTimestampStillAdvancesWhenCursorChanges() { + let previous = ArchiveSyncCheckpoint( + timestamp: Date(timeIntervalSince1970: 100), + cursor: "old" + ) + let resolved = ArchiveSyncCursorPolicy.resolvedCheckpoint( + previous: previous, + cursor: "new", + highWatermark: previous.timestamp, + queryStartedAt: Date(timeIntervalSince1970: 200), + caughtUp: false + ) + + XCTAssertTrue(resolved.advances(over: previous)) + XCTAssertEqual(resolved.cursor, "new") + } + + func testStoredCursorFallbackCanRunOnlyOnFirstPageAndOnlyOnce() { + let checkpoint = ArchiveSyncCheckpoint( + timestamp: Date(timeIntervalSince1970: 100), + cursor: "stored" + ) + + XCTAssertTrue(ArchiveSyncCursorPolicy.shouldFallbackFromStoredCursor( + requestedAfter: "stored", + checkpoint: checkpoint, + hasCompletedPage: false, + fallbackAlreadyUsed: false + )) + XCTAssertFalse(ArchiveSyncCursorPolicy.shouldFallbackFromStoredCursor( + requestedAfter: "stored", + checkpoint: checkpoint, + hasCompletedPage: true, + fallbackAlreadyUsed: false + )) + XCTAssertFalse(ArchiveSyncCursorPolicy.shouldFallbackFromStoredCursor( + requestedAfter: "stored", + checkpoint: checkpoint, + hasCompletedPage: false, + fallbackAlreadyUsed: true + )) + } +} diff --git a/Tests/ArchiveSyncPaginationTests.swift b/Tests/ArchiveSyncPaginationTests.swift new file mode 100644 index 0000000..dc5e0e3 --- /dev/null +++ b/Tests/ArchiveSyncPaginationTests.swift @@ -0,0 +1,90 @@ +import XCTest +@testable import Luma + +final class ArchiveSyncPaginationTests: XCTestCase { + func testCompleteResponseFinishesEvenWhenCursorExists() { + var pagination = ArchiveSyncPagination() + + XCTAssertEqual( + pagination.decision( + complete: true, + lastCursor: "server-last", + requestedAfter: nil + ), + .finished + ) + } + + func testIncompleteResponseContinuesWithNewCursor() { + var pagination = ArchiveSyncPagination() + + XCTAssertEqual( + pagination.decision( + complete: false, + lastCursor: "page-1-last", + requestedAfter: nil + ), + .next("page-1-last") + ) + } + + func testRepeatedCursorStopsPaginationLoop() { + var pagination = ArchiveSyncPagination() + _ = pagination.decision( + complete: false, + lastCursor: "repeated", + requestedAfter: nil + ) + + XCTAssertEqual( + pagination.decision( + complete: false, + lastCursor: "repeated", + requestedAfter: "repeated" + ), + .invalidCursor + ) + } + + func testCursorCycleStopsPaginationLoop() { + var pagination = ArchiveSyncPagination() + XCTAssertEqual( + pagination.decision( + complete: false, + lastCursor: "page-a", + requestedAfter: nil + ), + .next("page-a") + ) + XCTAssertEqual( + pagination.decision( + complete: false, + lastCursor: "page-b", + requestedAfter: "page-a" + ), + .next("page-b") + ) + + XCTAssertEqual( + pagination.decision( + complete: false, + lastCursor: "page-a", + requestedAfter: "page-b" + ), + .invalidCursor + ) + } + + func testIncompleteResponseWithoutCursorDoesNotAdvanceCheckpoint() { + var pagination = ArchiveSyncPagination() + + XCTAssertEqual( + pagination.decision( + complete: false, + lastCursor: nil, + requestedAfter: nil + ), + .invalidCursor + ) + } +} diff --git a/Tests/ArchiveSyncRecoveryPolicyTests.swift b/Tests/ArchiveSyncRecoveryPolicyTests.swift new file mode 100644 index 0000000..40f0119 --- /dev/null +++ b/Tests/ArchiveSyncRecoveryPolicyTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import Luma + +final class ArchiveSyncRecoveryPolicyTests: XCTestCase { + func testIncrementalOverlapDoesNotReplaySeveralMinutesPerPass() { + XCTAssertEqual(ArchiveSyncRecoveryPolicy.incrementalOverlap, 60) + } + + func testQueryTimeoutIsFinite() { + XCTAssertGreaterThan(ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds, 0) + XCTAssertLessThanOrEqual( + ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds, + 15_000_000_000 + ) + } + + func testPageApplicationAlsoHasAFiniteWatchdog() { + XCTAssertGreaterThan(ArchiveSyncRecoveryPolicy.pageApplyTimeoutNanoseconds, 0) + XCTAssertLessThanOrEqual( + ArchiveSyncRecoveryPolicy.pageApplyTimeoutNanoseconds, + ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds + ) + } + + func testFailedPagesHaveAtMostOneAutomaticRetry() { + XCTAssertEqual(ArchiveSyncRecoveryPolicy.pageRetryLimit, 1) + } + + func testCaptureResumeDelayLetsCameraReleaseResources() { + XCTAssertGreaterThan( + ArchiveSyncRecoveryPolicy.resumeAfterCaptureDelayNanoseconds, + ArchiveSyncRecoveryPolicy.interPageDelayNanoseconds + ) + } +} diff --git a/Tests/ArchiveSyncWorkBudgetTests.swift b/Tests/ArchiveSyncWorkBudgetTests.swift new file mode 100644 index 0000000..17e9fb6 --- /dev/null +++ b/Tests/ArchiveSyncWorkBudgetTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import Luma + +final class ArchiveSyncWorkBudgetTests: XCTestCase { + func testStopsAfterFinitePageBudget() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + var budget = ArchiveSyncWorkBudget(startedAt: start) + + for page in 1.. Старое сообщение\n> в двух строках\n\nНовый ответ", + fallbackStart: nil, + fallbackEnd: nil + ) + + XCTAssertEqual(parsed.body, "Новый ответ") + XCTAssertEqual(parsed.preview, "Старое сообщение\nв двух строках") + } + + func testPlainBodyWithoutQuoteIsUnchanged() { + let body = "Обычное сообщение" + let parsed = MessageReplyFallback.parse( + body: body, + fallbackStart: nil, + fallbackEnd: nil + ) + + XCTAssertEqual(parsed.body, body) + XCTAssertNil(parsed.preview) + } +} diff --git a/Tests/MessageReplySwipeTests.swift b/Tests/MessageReplySwipeTests.swift new file mode 100644 index 0000000..516aa0a --- /dev/null +++ b/Tests/MessageReplySwipeTests.swift @@ -0,0 +1,55 @@ +import CoreGraphics +import XCTest +@testable import Luma + +final class MessageReplySwipeTests: XCTestCase { + func testHorizontalSwipeInEitherDirectionReplies() { + XCTAssertTrue(MessageReplySwipePolicy.shouldReply( + translation: CGSize(width: 70, height: 8), + predictedEndTranslation: CGSize(width: 72, height: 9) + )) + XCTAssertTrue(MessageReplySwipePolicy.shouldReply( + translation: CGSize(width: -70, height: 8), + predictedEndTranslation: CGSize(width: -72, height: 9) + )) + } + + func testVerticalTimelineScrollDoesNotReply() { + XCTAssertFalse(MessageReplySwipePolicy.shouldReply( + translation: CGSize(width: 42, height: 80), + predictedEndTranslation: CGSize(width: 50, height: 105) + )) + } + + func testDiagonalTimelineScrollDoesNotReply() { + XCTAssertFalse(MessageReplySwipePolicy.shouldReply( + translation: CGSize(width: 54, height: 44), + predictedEndTranslation: CGSize(width: 76, height: 58) + )) + XCTAssertEqual( + MessageReplySwipePolicy.offset(for: CGSize(width: 54, height: 44)), + 0 + ) + } + + func testShortSwipeDoesNotReply() { + XCTAssertFalse(MessageReplySwipePolicy.shouldReply( + translation: CGSize(width: 36, height: 4), + predictedEndTranslation: CGSize(width: 48, height: 5) + )) + } + + func testFastHorizontalFlickUsesPredictedEnd() { + XCTAssertTrue(MessageReplySwipePolicy.shouldReply( + translation: CGSize(width: -35, height: 3), + predictedEndTranslation: CGSize(width: -90, height: 7) + )) + } + + func testVisualOffsetIsCapped() { + XCTAssertEqual( + MessageReplySwipePolicy.offset(for: CGSize(width: 500, height: 0)), + MessageReplySwipePolicy.maximumOffset + ) + } +} diff --git a/Tests/NotificationPolicyTests.swift b/Tests/NotificationPolicyTests.swift new file mode 100644 index 0000000..e18c285 --- /dev/null +++ b/Tests/NotificationPolicyTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import Luma + +final class NotificationPolicyTests: XCTestCase { + func testForegroundMessageFromAnotherConversationIsPresented() { + XCTAssertTrue(NotificationPolicy.shouldPresentMessage( + inserted: true, + isOutgoing: false, + appIsActive: true, + selectedConversationID: "bob@example.org", + conversationID: "alice@example.org" + )) + } + + func testVisibleConversationDoesNotPresentDuplicateBanner() { + XCTAssertFalse(NotificationPolicy.shouldPresentMessage( + inserted: true, + isOutgoing: false, + appIsActive: true, + selectedConversationID: "alice@example.org", + conversationID: "ALICE@example.org" + )) + } + + func testBackgroundIncomingMessageIsPresented() { + XCTAssertTrue(NotificationPolicy.shouldPresentMessage( + inserted: true, + isOutgoing: false, + appIsActive: false, + selectedConversationID: "alice@example.org", + conversationID: "alice@example.org" + )) + } + + func testOutgoingOrDuplicateMessageIsNotPresented() { + XCTAssertFalse(NotificationPolicy.shouldPresentMessage( + inserted: true, + isOutgoing: true, + appIsActive: true, + selectedConversationID: nil, + conversationID: "alice@example.org" + )) + XCTAssertFalse(NotificationPolicy.shouldPresentMessage( + inserted: false, + isOutgoing: false, + appIsActive: true, + selectedConversationID: nil, + conversationID: "alice@example.org" + )) + } +} diff --git a/Tests/VideoNoteRecordingCompletionPolicyTests.swift b/Tests/VideoNoteRecordingCompletionPolicyTests.swift new file mode 100644 index 0000000..fd219e6 --- /dev/null +++ b/Tests/VideoNoteRecordingCompletionPolicyTests.swift @@ -0,0 +1,80 @@ +import AVFoundation +import XCTest +@testable import Luma + +final class VideoNoteRecordingCompletionPolicyTests: XCTestCase { + func testNilErrorKeepsRecording() { + XCTAssertTrue(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: nil)) + } + + func testSuccessfulFinishedFlagKeepsRecordingDespiteError() { + let error = NSError( + domain: AVFoundationErrorDomain, + code: -11_800, + userInfo: [AVErrorRecordingSuccessfullyFinishedKey: true] + ) + + XCTAssertTrue(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error)) + } + + func testFailedFinishedFlagRejectsRecording() { + let error = NSError( + domain: AVFoundationErrorDomain, + code: -11_800, + userInfo: [AVErrorRecordingSuccessfullyFinishedKey: false] + ) + + XCTAssertFalse(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error)) + } + + func testNSNumberSuccessfulFinishedFlagKeepsRecording() { + let error = NSError( + domain: AVFoundationErrorDomain, + code: -11_800, + userInfo: [AVErrorRecordingSuccessfullyFinishedKey: NSNumber(value: true)] + ) + + XCTAssertTrue(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error)) + } + + func testMaximumDurationStillKeepsRecording() { + let error = NSError( + domain: AVFoundationErrorDomain, + code: AVError.Code.maximumDurationReached.rawValue + ) + + XCTAssertTrue(VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error)) + } + + func testExplicitStopAllowsCompletedRecordingToSend() { + XCTAssertTrue(VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( + stopRequested: true, + error: nil, + recordedDuration: 3, + maximumDuration: 60 + )) + } + + func testUnexpectedCameraCompletionDoesNotSendPartialRecording() { + XCTAssertFalse(VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( + stopRequested: false, + error: nil, + recordedDuration: 3, + maximumDuration: 60 + )) + } + + func testAutomaticMaximumDurationCompletionMaySend() { + let error = NSError( + domain: AVFoundationErrorDomain, + code: AVError.Code.maximumDurationReached.rawValue + ) + + XCTAssertTrue(VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( + stopRequested: false, + error: error, + recordedDuration: 59.8, + maximumDuration: 60 + )) + } +} diff --git a/Tests/VideoNoteRecordingLifecycleTests.swift b/Tests/VideoNoteRecordingLifecycleTests.swift new file mode 100644 index 0000000..5e0be30 --- /dev/null +++ b/Tests/VideoNoteRecordingLifecycleTests.swift @@ -0,0 +1,47 @@ +import Foundation +import XCTest +@testable import Luma + +final class VideoNoteRecordingLifecycleTests: XCTestCase { + func testOnlyBusyPhasesBlockASecondRecording() { + XCTAssertFalse(VideoNoteRecordingLifecycle.idle.isBusy) + XCTAssertFalse(VideoNoteRecordingLifecycle.prepared.isBusy) + XCTAssertTrue(VideoNoteRecordingLifecycle.starting.isBusy) + XCTAssertTrue(VideoNoteRecordingLifecycle.recording.isBusy) + XCTAssertTrue(VideoNoteRecordingLifecycle.stopping.isBusy) + } + + func testStaleCompletionCannotFinishANewerRecording() { + let oldURL = URL(fileURLWithPath: "/tmp/old.mov") + let currentURL = URL(fileURLWithPath: "/tmp/current.mov") + + XCTAssertFalse(VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: currentURL, + outputURL: oldURL + )) + XCTAssertTrue(VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: currentURL, + outputURL: currentURL + )) + } + + func testMacOSTemporaryPathAliasesIdentifyTheSameRecording() { + let suppliedURL = URL(fileURLWithPath: "/var/folders/luma/video-note-id.mov") + let callbackURL = URL(fileURLWithPath: "/private/var/folders/luma/video-note-id.mov") + + XCTAssertTrue(VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: suppliedURL, + outputURL: callbackURL + )) + } + + func testTemporaryPathAliasDoesNotAcceptAnotherRecording() { + let currentURL = URL(fileURLWithPath: "/var/folders/luma/current.mov") + let oldURL = URL(fileURLWithPath: "/private/var/folders/luma/old.mov") + + XCTAssertFalse(VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: currentURL, + outputURL: oldURL + )) + } +} diff --git a/Tests/VideoNoteStopPolicyTests.swift b/Tests/VideoNoteStopPolicyTests.swift new file mode 100644 index 0000000..e701c08 --- /dev/null +++ b/Tests/VideoNoteStopPolicyTests.swift @@ -0,0 +1,52 @@ +import Foundation +import XCTest +@testable import Luma + +final class VideoNoteStopPolicyTests: XCTestCase { + func testEarlyReleaseWaitsForRecordedMedia() { + let remaining = VideoNoteStopPolicy.remainingRecordedDuration( + recordedDuration: 0.25, + minimumDuration: VideoNoteStopPolicy.minimumCaptureDuration + ) + + XCTAssertEqual( + remaining, + VideoNoteStopPolicy.minimumCaptureDuration - 0.25, + accuracy: 0.001 + ) + } + + func testStopDoesNotWaitAfterMinimumMediaDurationElapsed() { + XCTAssertEqual( + VideoNoteStopPolicy.remainingRecordedDuration( + recordedDuration: 2, + minimumDuration: VideoNoteStopPolicy.minimumCaptureDuration + ), + 0, + accuracy: 0.001 + ) + } + + func testRecordingFloorIsLongerThanLegacyShortClipThreshold() { + XCTAssertGreaterThanOrEqual(VideoNoteStopPolicy.minimumCaptureDuration, 1.1) + } + + func testRecordedMediaTimeControlsTheFinalStop() { + XCTAssertEqual( + VideoNoteStopPolicy.remainingRecordedDuration( + recordedDuration: 0.35, + minimumDuration: 1.2 + ), + 0.85, + accuracy: 0.001 + ) + XCTAssertEqual( + VideoNoteStopPolicy.remainingRecordedDuration( + recordedDuration: 1.3, + minimumDuration: 1.2 + ), + 0, + accuracy: 0.001 + ) + } +} diff --git a/Tests/WatchVoiceMessageTests.swift b/Tests/WatchVoiceMessageTests.swift new file mode 100644 index 0000000..f3f08dc --- /dev/null +++ b/Tests/WatchVoiceMessageTests.swift @@ -0,0 +1,20 @@ +import Foundation +import XCTest +@testable import Luma + +final class WatchVoiceMessageTests: XCTestCase { + func testTransferUsesStableXMPPMessageIDAcrossRetries() { + let message = WatchVoiceMessage( + transferID: "9B4D98BA-7EB0-487A-96A2-608052AFCA2E", + jid: "friend@example.org", + filename: "voice.m4a", + duration: 2.5, + data: Data([0x01]) + ) + + XCTAssertEqual( + message.stableMessageID, + "watch-9B4D98BA-7EB0-487A-96A2-608052AFCA2E" + ) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..0548e7c --- /dev/null +++ b/project.yml @@ -0,0 +1,211 @@ +name: Luma + +options: + bundleIdPrefix: app.luma + createIntermediateGroups: true + developmentLanguage: ru + indentWidth: 4 + tabWidth: 4 + xcodeVersion: "16.0" + +configs: + Debug: debug + Release: release + +settings: + base: + SWIFT_VERSION: 5.9 + CURRENT_PROJECT_VERSION: 31 + MARKETING_VERSION: 0.10.6 + ENABLE_USER_SCRIPT_SANDBOXING: true + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: true + configs: + Debug: + SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG + Release: + SWIFT_COMPILATION_MODE: wholemodule + +packages: + Martin: + url: https://github.com/tigase/Martin.git + exactVersion: 3.2.4 + MartinOMEMO: + url: https://github.com/tigase/MartinOMEMO.git + exactVersion: 2.2.3 + WebRTC: + url: https://github.com/stasel/WebRTC.git + exactVersion: 150.0.0 + +targets: + Luma: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: Sources/App + - path: Sources/Shared + resources: + - path: Resources/Shared.xcassets + - path: Resources/AppAssets.xcassets + dependencies: + - package: Martin + - package: MartinOMEMO + - package: WebRTC + - target: LumaWatch + info: + path: Config/Luma-Info.plist + properties: + CFBundleDisplayName: Luma + CFBundleIconName: AppIcon + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + UILaunchScreen: {} + UIBackgroundModes: + - audio + - fetch + - remote-notification + UISupportsDocumentBrowser: false + UIApplicationSupportsIndirectInputEvents: true + NSCameraUsageDescription: Luma использует камеру для фотографий, видеосообщений и видеозвонков. + NSMicrophoneUsageDescription: Luma использует микрофон для голосовых сообщений и звонков. + NSLocalNetworkUsageDescription: Luma использует локальную сеть для прямого соединения аудио- и видеозвонков. + NSLocationWhenInUseUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате. + entitlements: + path: Config/Luma.entitlements + properties: {} + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat + PRODUCT_NAME: Luma + TARGETED_DEVICE_FAMILY: "1,2" + SUPPORTS_MACCATALYST: false + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: true + INFOPLIST_KEY_CFBundleIconName: AppIcon + + LumaMac: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/App + - path: Sources/Shared + resources: + - path: Resources/Shared.xcassets + - path: Resources/AppAssets.xcassets + dependencies: + - package: Martin + - package: MartinOMEMO + - package: WebRTC + info: + path: Config/LumaMac-Info.plist + properties: + CFBundleDisplayName: Luma + CFBundleIconName: AppIcon + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType: public.app-category.social-networking + NSHighResolutionCapable: true + NSCameraUsageDescription: Luma использует камеру для фотографий, видеосообщений и видеозвонков. + NSMicrophoneUsageDescription: Luma использует микрофон для голосовых сообщений и звонков. + NSLocationUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате. + entitlements: + path: Config/LumaMac.entitlements + properties: + com.apple.security.app-sandbox: true + com.apple.security.network.client: true + com.apple.security.network.server: true + com.apple.security.files.user-selected.read-only: true + com.apple.security.device.camera: true + com.apple.security.device.audio-input: true + com.apple.security.personal-information.location: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.macos + PRODUCT_NAME: Luma + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: true + INFOPLIST_KEY_CFBundleIconName: AppIcon + + LumaWatch: + # Xcode 14+ single-target watchOS app. `application.watchapp2` is the + # legacy container that expects a separate WatchKit extension; compiling + # our SwiftUI @main sources into it makes the linker and WatchKit stub both + # try to produce Luma.app/Luma. + type: application + platform: watchOS + deploymentTarget: "10.0" + sources: + - path: Sources/Watch + resources: + - path: Resources/Shared.xcassets + - path: Resources/AppAssets.xcassets + info: + path: Config/LumaWatch-Info.plist + properties: + CFBundleName: Luma + CFBundleDisplayName: Luma + CFBundleIconName: AppIcon + CFBundleShortVersionString: $(MARKETING_VERSION) + CFBundleVersion: $(CURRENT_PROJECT_VERSION) + NSMicrophoneUsageDescription: Luma использует микрофон Apple Watch для голосовых сообщений. + WKApplication: true + WKCompanionAppBundleIdentifier: app.luma.chat + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.watchkitapp + # Keep the bundle filename distinct from the companion Luma.app while + # preserving the user-facing name through CFBundleDisplayName. + PRODUCT_NAME: LumaWatch + GENERATE_INFOPLIST_FILE: NO + TARGETED_DEVICE_FAMILY: "4" + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: true + INFOPLIST_KEY_CFBundleIconName: AppIcon + + LumaTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + sources: + - path: Tests + dependencies: + - target: Luma + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.tests + GENERATE_INFOPLIST_FILE: true + +schemes: + Luma: + build: + targets: + Luma: all + LumaTests: [test] + run: + config: Debug + test: + config: Debug + targets: + - LumaTests + archive: + config: Release + LumaMac: + build: + targets: + LumaMac: all + run: + config: Debug + archive: + config: Release + LumaWatch: + build: + targets: + LumaWatch: all + run: + config: Debug + archive: + config: Release