diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 335cc89..0e314d9 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -1,34 +1,42 @@ -name: iOS CI - +name: Build Unsigned iOS ipa on: push: - branches: [ main ] + branches: + - main + - develop + - issue-* pull_request: - branches: [ main ] - + branches: + - main + - develop jobs: - build-and-test: - name: Build and Test SwiftUI App - runs-on: macos-26 # Uses Apple Silicon M1/M2 runners - + build-ios: + name: Build Unsigned iOS IPA + runs-on: macos-26 steps: - # Step 1: Check out the repository code - - name: Checkout Repository + - name: Checkout repository uses: actions/checkout@v4 - - # Step 2: Select the correct Xcode version - - name: Set Xcode Version + - name: Set up Xcode version run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer - - # Step 3: Load dependencies if using Swift Package Manager - - name: Resolve Swift Packages - run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma - - # Step 4: Build and run tests using xcodebuild - - name: Build and Test + - name: Build Xcode Workspace run: | - xcodebuild test \ + xcodebuild build \ -project Luma.xcodeproj \ -scheme Luma \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \ - -quiet + -configuration Release \ + -destination 'generic/platform=iOS' \ + -derivedDataPath build/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" \ + PROVISIONING_PROFILE_SPECIFIER="" + - name: Package into IPA + run: | + mkdir -p build/Payload + mv build/DerivedData/Build/Products/Release-iphoneos/Luma.app build/Payload/ + cd build && zip -r Luma.ipa Payload/ + - name: Upload iOS Artifact + uses: actions/upload-artifact@v4 + with: + name: Luma${{ github.run_id }} + path: build/Luma.ipa diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..54131d8 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,37 @@ +name: Build Unsigned macOS App +on: + push: + branches: + - main + - develop + - issue-* + pull_request: + branches: + - main + - develop +jobs: + build-macos: + name: Build macOS ZIP + runs-on: macos-26 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Xcode version + run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer + - name: Build macOS App + run: | + xcodebuild build \ + -project Luma.xcodeproj \ + -scheme LumaMac \ + -configuration Release \ + -derivedDataPath build/DerivedData \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY="" + - name: Prepare macOS Artifact + run: mkdir -p upload && cp -rv build/DerivedData/Build/Products/Release/Luma.app upload/ + - name: Upload macOS Artifact + uses: actions/upload-artifact@v4 + with: + name: Luma + path: upload diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2ea372d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +name: iOS CI +on: + push: + branches: + - main + - develop + - issue-* + pull_request: + branches: + - main + - develop +jobs: + build-and-test: + name: Build and Test SwiftUI App + runs-on: macos-26 + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + - name: Set Xcode Version + run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer + - name: Resolve Swift Packages + run: xcodebuild -resolvePackageDependencies -project Luma.xcodeproj -scheme Luma + - name: Build and Test + run: | + xcodebuild test \ + -project Luma.xcodeproj \ + -scheme Luma \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \ + -only-testing:LumaTests \ + -resultBundlePath TestResults.xcresult + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: Luma-TestResults + path: TestResults.xcresult diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..295c951 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,74 @@ +# Repository Guidelines + +Luma is an XMPP client for iOS, iPadOS, macOS, and watchOS, built with SwiftUI +and the Martin / MartinOMEMO libraries. The Xcode project is generated from +`project.yml` by XcodeGen — never edit `Luma.xcodeproj` directly. + +## Project Structure + +- `Sources/App/` — iOS/macOS app entry point (`LumaApp.swift`). +- `Sources/Shared/` — shared code: + - `Models/` — value types and pure policy enums (`ArchiveSyncPagination`, + `ChatTypingPolicy`, …) plus the SwiftData `@Model` classes + (`ChatMessage`, `Conversation`). + - `UI/` — SwiftUI views; chat/contact lists read SwiftData through + `@Query` (`MainTabView`, `ChatView`, `ForwardMessageView`); the main + screen is Telegram-style: an iOS native `TabView` tab bar + (Контакты/Звонки/Чаты/Настройки) with inline search fields above + the lists, and a + macOS Telegram Desktop-style split view (`MainSplitView`: sidebar + with menu/search/list plus a chat detail pane). + - `XMPP/` — `XMPPService` (MAM/OMEMO/MUC), `LumaCallEngine`, OMEMO store, + `LumaOMEMO2Module` (urn:xmpp:omemo:2 wire format: AES-256-CBC + HMAC + payload, SCE envelope, device/bundle PEP nodes — shares the Double + Ratchet sessions with the legacy module), `SASLprep` (RFC 4013), + SCRAM-SHA-512 mechanism, SASL failure observer/messages. + - `Persistence/` — `ArchiveStore` (per-account SwiftData container), + `ArchiveMetadataRecord` (durable MAM checkpoint metadata), + `LegacyArchiveImporter` (one-time legacy JSON snapshot migration), + preferences. + - `Services/`, `Security/` — media, notifications, credentials. +- `Sources/Watch/` — single-target watchOS app. +- `Tests/` — XCTest unit tests. +- `Resources/`, `Config/`, `Brand/`, `Docs/` — assets, plists/entitlements, + icons, architecture/security docs. + +## Build, Test & Development + +- `make project` — regenerate `Luma.xcodeproj` from `project.yml` + (requires `brew install xcodegen`). +- `make open` — regenerate and open Xcode. +- `make verify` — run `Scripts/verify.sh`: grep-based structural invariants + plus an optional simulator build. +- `make clean` — remove the generated project and DerivedData. +- `xcodebuild test -project Luma.xcodeproj -scheme Luma \ +-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max'` — build and + run the test suite. + +## Coding Style + +- Swift 5.9; 4-space indentation (set in `project.yml`). +- UI-facing state is `@MainActor` (`AppModel`, `XMPPService`); keep heavy work + (OMEMO decryption, media, persistence) off the main actor. +- Prefer small, pure value types and unit-testable policy enums over inline + branching. +- UI strings are Russian; identifiers and code comments are English. +- No formatter/linter; `Scripts/verify.sh` is the guard — keep its greps in + sync when you change constants or symbols. + +## Testing + +- XCTest with `@testable import Luma`; one file per model/policy + (e.g., `ArchiveSyncPaginationTests.swift`), methods named `test...`. +- Unit-test pure logic (policies, pagination, checkpoints); network/UI flows + are verified manually. +- When a policy constant changes, update the matching `Tests/*` assertions and + the `Scripts/verify.sh` guard in the same commit. + +## Commit & Pull Request + +- Short, imperative, English subject lines (see `git log`: "mam fixes", + "fix errors and warnings"). +- CI (`.github/workflows/ios.yml`) runs `xcodebuild test` on push to + `main`/`develop` and on PRs to `main`; PRs must keep it green. +- Update `README.md` / `Docs/*` whenever behaviour or architecture changes. diff --git a/Config/Luma-Info.plist b/Config/Luma-Info.plist index 0d31df7..8914c70 100644 --- a/Config/Luma-Info.plist +++ b/Config/Luma-Info.plist @@ -24,12 +24,16 @@ $(CURRENT_PROJECT_VERSION) NSCameraUsageDescription Luma использует камеру для фотографий, видеосообщений и видеозвонков. + NSFaceIDUsageDescription + Luma использует Face ID для разблокировки приложения. NSLocalNetworkUsageDescription Luma использует локальную сеть для прямого соединения аудио- и видеозвонков. NSLocationWhenInUseUsageDescription Luma использует геопозицию только когда вы выбираете отправку точки в чате. NSMicrophoneUsageDescription Luma использует микрофон для голосовых сообщений и звонков. + NSPhotoLibraryAddUsageDescription + Luma сохраняет фотографии и видео в вашу медиатеку. UIApplicationSupportsIndirectInputEvents UIBackgroundModes @@ -40,6 +44,19 @@ UILaunchScreen + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UISupportsDocumentBrowser diff --git a/Config/LumaMac-Info.plist b/Config/LumaMac-Info.plist index 8bc023e..3530fb8 100644 --- a/Config/LumaMac-Info.plist +++ b/Config/LumaMac-Info.plist @@ -26,6 +26,8 @@ public.app-category.social-networking NSCameraUsageDescription Luma использует камеру для фотографий, видеосообщений и видеозвонков. + NSFaceIDUsageDescription + Luma использует Touch ID для разблокировки приложения. NSHighResolutionCapable NSLocationUsageDescription diff --git a/Config/LumaMac.entitlements b/Config/LumaMac.entitlements index 5c62a47..1dbabeb 100644 --- a/Config/LumaMac.entitlements +++ b/Config/LumaMac.entitlements @@ -10,6 +10,8 @@ com.apple.security.files.user-selected.read-only + com.apple.security.files.user-selected.read-write + com.apple.security.device.camera com.apple.security.device.audio-input diff --git a/Config/LumaWatch-Info.plist b/Config/LumaWatch-Info.plist index 613282f..09d9cc4 100644 --- a/Config/LumaWatch-Info.plist +++ b/Config/LumaWatch-Info.plist @@ -27,6 +27,6 @@ WKApplication WKCompanionAppBundleIdentifier - app.luma.chat + $(COMPANION_BUNDLE_IDENTIFIER) diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index b127d78..212328a 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -9,7 +9,9 @@ 3. `LumaOMEMOStore` адаптирует постоянное состояние к Signal storage callbacks; AES-GCM выполняется CryptoKit. 4. `AppModel` сводит сетевые события в UI-состояние, дедуплицирует MAM/live - сообщения и сохраняет snapshot локально. + сообщения и сохраняет их в per-account SwiftData-хранилище (`ArchiveStore`, + отдельный `ModelContainer` на аккаунт); список чатов, лента и список + получателей при пересылке читают данные напрямую через `@Query`. 5. SwiftUI views используют один и тот же слой моделей на iOS/iPadOS/macOS. 6. watchOS не держит отдельный XMPP-сокет: iPhone передаёт компактный snapshot, текстовые ответы возвращаются немедленно или через гарантированную очередь, @@ -35,11 +37,27 @@ XEP-0045-комнатами в отдельном разделе «Группо собственных дополнительных устройств; при выключенном отправляется обычный `` внутри TLS-соединения. Ошибка OMEMO никогда не вызывает автоматический fallback на plaintext. UI сразу показывает optimistic message и обновляет -состояние по результату записи/receipt. +состояние по результату записи/receipt; XEP-0333 ``-маркер +собеседника переводит исходящее сообщение в состояние «прочитано». + +## OMEMO 2 + +Помимо legacy `eu.siacs.conversations.axolotl` (MartinOMEMO) поддерживается +`urn:xmpp:omemo:2` (XEP-0384 0.8.3) через собственный модуль +`LumaOMEMO2Module`: payload — AES-256-CBC + HMAC-SHA-256 (HKDF, info +«OMEMO Payload»), plaintext — SCE-envelope (XEP-0420); device list и bundle +публикуются в PEP-узлах `urn:xmpp:omemo:2:devices` / +`urn:xmpp:omemo:2:bundles`. Double Ratchet-сессии общие с legacy-модулем +(тот же `LumaOMEMOStore`). Исходящие предпочитают OMEMO 2, когда у собеседника +есть OMEMO 2-устройства (в группах — только если устройства есть у всех +участников), иначе используется legacy. MartinOMEMO завендорен в +`ThirdParty/` с точечными патчами видимости (см. `THIRD_PARTY_NOTICES.md`). Входящая stanza может прийти напрямую, через carbons или MAM. Сервис определяет peer, расшифровывает payload, использует `origin-id`/stanza id для дедупликации и -передаёт value-type envelope в `AppModel`. +передаёт value-type envelope в `AppModel`. Дедупликация выполняется по +хеш-индексам (`origin-id`, `stanza-id`, `id`), поэтому стоимость применения пачки +не растёт квадратично с размером архива. Свежая установка начинает MAM с ограниченной последней страницы RSM, а не с самого старого сообщения. Инкрементальные проходы используют сохранённый @@ -48,14 +66,18 @@ peer, расшифровывает payload, использует `origin-id`/sta Каждая 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 отбрасывается целиком. +Расшифровка OMEMO выполняется на отдельной серийной фоновой очереди, поэтому +Signal-криптография не блокирует главный поток; построение envelope и публикация +остаются на main actor. Видимый индикатор «Синхронизация истории…» показывается +только на первичной странице, а инкрементальная догрузка backlog продолжается в +фоне молча, публикуя декодированные изменения атомарными пачками по мере +готовности страниц. MUC-архив проходит тем же путём и флашит декодированные +групповые сообщения по завершении catch-up комнаты. Ошибка отдельной страницы +автоматически повторяется ограниченное число раз. При уходе приложения с экрана +и на время записи/отправки видеосообщения активный MAM-запрос закрывается, чтобы +дешифрование истории не конкурировало с камерой и upload. Только после commit +`AppModel` обновляет SwiftUI, SwiftData-хранилище и Apple Watch; незавершённый +проход при timeout/disconnect отбрасывается целиком. Редактирование реализовано стандартным XEP-0308: исправление получает новый id и `` с id исходного сообщения. `AppModel` проверяет совпадение bare JID @@ -89,13 +111,19 @@ stanza отдельно сохраняет назначенный комнато декодер считает payload дубликатом, поэтому последующие XEP-0461 ответы остаются межклиентно совместимыми. +Выход из комнаты оставляет чат в списке как «Не подключено». Удаление чата +убирает его и локальную историю с устройства (и предварительно выходит из +комнаты на сервере, если участник в ней); подавление на время сессии не даёт +событиям комнаты создать удалённый чат заново. + XEP-0424 retraction проходит через тот же plaintext/OMEMO-путь, что и исходное сообщение. Перед применением `AppModel` проверяет conversation и JID автора, после чего заменяет payload tombstone, очищает медиакэш и блокирует дальнейшее редактирование/пересылку. Retract, пришедший раньше исходной stanza при MAM, временно удерживается в ограниченной очереди. Локальное удаление физически -убирает запись из snapshot и сохраняет отдельный набор подавленных id, чтобы -последующая MAM-синхронизация не добавила её обратно. +удаляет строку из SwiftData-хранилища (при открытии хранилище также вычищает +строки, помеченные удалёнными старыми сборками) и сохраняет отдельный набор +подавленных id, чтобы последующая MAM-синхронизация не добавила её обратно. Пересылка текста создаёт новое сообщение с явной подписью источника. Медиа не переиспользует старый `aesgcm://` URL: клиент получает расшифрованные байты и @@ -182,6 +210,33 @@ trickle ICE candidates накапливаются до успешного нач успешным и получает длительность. Удаление `activeCall` до закрытия Jingle-сессии гарантирует, что ответный callback не создаст дубликат карточки. +Карточка звонка синхронизируется между устройствами: по завершении вызова +устройство отправляет самому себе (собственный bare JID) служебное сообщение с +`` (направление, статус, +длительность, время начала, собеседник, видео). Carbons доставляют его на +остальные устройства, MAM сохраняет в архиве; получатели парсят payload и +создают ту же карточку, а дедупликация идёт по origin-id, совпадающему с +clientID карточки на исходном устройстве. Пропущенный входящий звонок +увеличивает счётчик непрочитанных на каждом устройстве. + +Состояние «прочитано» (`.read`) синхронизируется между устройствами тем же +каналом. Устройство, получившее от собеседника ``-маркер +(XEP-0333) или, для MUC, маркер от участника комнаты, переводит исходящее +сообщение в `.read` и отправляет собственному bare JID служебное сообщение с +`` (чат, origin-id, stanza-id +сообщения). Carbons доставляют его на остальные устройства, MAM архивирует — +поэтому свежая установка и повторные прогоны архива сходятся к одному +состоянию, даже когда маркер приходит раньше самого сообщения (MAM отдаёт +станзы от новых к старым): маркер ждёт в `pendingReadMarkers` до upsert +сообщения. Для комнат маркер адресован собственному bare JID и ссылается на +stanza-id из MUC-MAM, так что состояние прочтения не попадает в архив +комнаты. При открытии 1:1-чата Luma отправляет собеседнику `` для +последнего входящего сообщения (в MUC маркеры не отправляются — XEP-0333). +Слияние копий одного исходящего сообщения (live/MAM/MUC-MAM/Carbons) +монотонно: `Delivery.merged(with:)` не позволяет архивному повтору с `.sent` +понизить уже установленные `.delivered`/`.read`, а `.failed` остаётся +неизменным до явной повторной отправки. + Текущая реализация принимает вызов при живом XMPP-соединении. Wake-up завершённого iOS-приложения намеренно не имитируется: production-путь требует VoIP push, PushKit/CallKit и серверной APNs-инфраструктуры. @@ -199,6 +254,7 @@ PushKit/CallKit и серверной APNs-инфраструктуры. - APNs provider + XEP-0357 registration; - современный `urn:xmpp:omemo:2` adapter и миграция устройств; - MIX и реакции; -- SQLite/SQLCipher вместо JSON snapshot; +- шифрование базы SQLCipher поверх SwiftData SQLite (история уже хранится + в SwiftData, а не в JSON snapshot); - PushKit/CallKit для фоновых входящих звонков и групповые звонки; - share extension и notification service extension. diff --git a/Docs/PROSODY.md b/Docs/PROSODY.md index 19f24ff..5db038f 100644 --- a/Docs/PROSODY.md +++ b/Docs/PROSODY.md @@ -105,18 +105,18 @@ participant читать member list. Для уже существующей к Для `upload.example.org` нужен корректный HTTPS URL. Если компонент не является прямым поддоменом VirtualHost, добавьте его в `disco_items`. При reverse proxy задайте `http_host`/`http_external_url` согласно вашей топологии. Например, для -`wthome.ru` за HTTPS reverse proxy: +`example.com` за HTTPS reverse proxy: ```lua -Component "upload.wthome.ru" "http_file_share" +Component "upload.example.com" "http_file_share" http_file_share_size_limit = 1024 * 1024 * 1024 - http_host = "upload.wthome.ru" - http_external_url = "https://upload.wthome.ru/" + http_host = "upload.example.com" + http_external_url = "https://upload.example.com/" trusted_proxies = { "127.0.0.1", "::1" } modules_disabled = { "s2s" } ``` -Reverse proxy должен передавать `Host: upload.wthome.ru` и +Reverse proxy должен передавать `Host: upload.example.com` и `X-Forwarded-Proto: https`. Иначе Prosody может выдать клиенту `http://` URL или маршрут, возвращающий 404; Luma намеренно не понижает upload до HTTP. @@ -138,7 +138,7 @@ Reverse proxy должен передавать `Host: upload.wthome.ru` и prosodyctl check config prosodyctl check certs prosodyctl shell module info http_file_share -prosodyctl shell http list upload.wthome.ru +prosodyctl shell http list upload.example.com ``` Начиная с 0.3.0 Luma различает ошибки discovery, получения upload slot, diff --git a/Docs/SECURITY.md b/Docs/SECURITY.md index 22bfb87..1996fcb 100644 --- a/Docs/SECURITY.md +++ b/Docs/SECURITY.md @@ -1,12 +1,39 @@ # Безопасность и модель угроз MVP - Пароль XMPP сохраняется в Keychain и не попадает в `UserDefaults` или - snapshot истории. + локальное хранилище истории. - Проверка сертификата не отключается. Ручной host меняет маршрут подключения, но XMPP domain/JID остаётся исходной идентичностью. - TLS trust оценивается системным Apple Security framework как сертификат сервера (`SecPolicyCreateSSL(true, ...)`) для домена из JID; hostname и цепочка доверия обязательны даже при ручном адресе подключения. +- Приложение можно заблокировать паролем (хранится в Keychain, выключено по + умолчанию): блокировка срабатывает при запуске и при уходе в фон, а + разблокировка возможна по Face ID/Touch ID. +- Пароль для SCRAM нормализуется по RFC 4013 (SASLprep), чтобы совпадать с + серверной нормализацией; для PLAIN пароль отправляется как введён. +- SCRAM доступен в вариантах SHA-512, SHA-256 и SHA-1 (SHA-512 — + реализация Luma), а также в PLUS-вариантах с канальной привязкой + (SCRAM-SHA-512-PLUS > SHA-256-PLUS > SHA-1-PLUS): gs2-заголовок + «p=tls-exporter,» (RFC 9266) или «p=tls-server-end-point,» доказывает, + что SASL-обмен идёт по тому же TLS-каналу, который видит клиент, и + ломает MITM-ретрансляцию. PLUS выбирается только когда сервер + рекламирует соответствующий channel-binding тип, иначе — обычный SCRAM; + механизм выбирается по рекламе сервера. +- OMEMO работает в legacy-неймспейсе `eu.siacs.conversations.axolotl` (так + умеет закреплённая версия MartinOMEMO) и в OMEMO 2 (`urn:xmpp:omemo:2`, + payload AES-256-CBC + HMAC-SHA-256 в конверте SCE `urn:xmpp:sce:0`). Luma + публикует omemo:2 bundle и список устройств, делит Double Ratchet-сессии с + legacy-модулем и помечает протокол каждого ключа на экране шифрования. +- Канал шифруется STARTTLS/direct TLS собственным TLS-процессором Luma на + OpenSSL 3 (SecureTransport ограничивает потоковый TLS версией 1.2 и не + имеет keying exporter): минимум TLS 1.2, максимум TLS 1.3, ALPN + «xmpp-client», SNI по домену XMPP. Сертификат проверяется через + SecTrust с серверной политикой домена из JID (та же логика + CertificateTrustEvaluator). Фактическая версия TLS, шифр и выбранный + SASL-механизм (самый стойкий из рекламируемых сервером, с канальной + привязкой при её доступности) показываются на экране информации о + сервере. - Когда OMEMO включено глобально или для конкретного чата, исходящие сообщения не откатываются на plaintext при ошибке: ошибка показывается пользователю. - Пользователь может осознанно отключить OMEMO глобально или для отдельного @@ -49,22 +76,34 @@ STUN/TURN-сервис видит сетевые адреса и объём трафика. TURN ретранслирует уже зашифрованный медиапоток. Если Prosody не публикует собственный сервис через XEP-0215, резервный публичный STUN получает запрос на определение адреса. -- На iOS файлы OMEMO-состояния и локальной истории получают data-protection - `completeUntilFirstUserAuthentication`. +- На iOS файл OMEMO-состояния и база локальной истории (SwiftData + `.store`) получают data-protection `completeUntilFirstUserAuthentication`. + +- Архивные эхо собственных служебных станз (receipts, chat markers, + повторно доставленные копии) и собственные сообщения, которые невозможно + расшифровать обратно (нет encrypt-to-self ключа или self-сессия устарела), + не превращаются в пузыри «Не удалось расшифровать сообщение»: они + отбрасываются, как это делают другие клиенты, чтобы не засорять историю + фантомами, которых нет в виде сообщений на сервере. +- Сохранение медиа выполняется только по явному действию пользователя: + фото/видео/видеосообщения — в медиатеку, файлы и голосовые — в «Файлы» + (Загрузки). Расшифрованное содержимое при этом покидает sandbox приложения. ## Известные ограничения -- На macOS история и OMEMO state лежат в sandbox Application Support без - дополнительного SQLCipher-слоя; защиту обеспечивает учётная запись и FileVault - пользователя. Для повышенной модели угроз нужна зашифрованная база. +- На macOS история (SwiftData SQLite в Application Support) и OMEMO state + лежат в sandbox без дополнительного SQLCipher-слоя; защиту обеспечивает + учётная запись и FileVault пользователя. Для повышенной модели угроз нужна + зашифрованная база. - Локальная история содержит уже расшифрованные тексты, как и у большинства клиентов. Полное удаление требует выхода с опцией удаления истории. -- «Удалить у меня» удаляет запись только из локального snapshot этого устройства - и запоминает id против повторной MAM-загрузки; оно не удаляет серверный архив, - другие собственные устройства или копию собеседника. +- «Удалить у меня» удаляет запись только из локального SwiftData-хранилища + этого устройства и запоминает id против повторной MAM-загрузки; оно не + удаляет серверный архив, другие собственные устройства или копию собеседника. - Глобальная политика шифрования хранится в `UserDefaults` отдельно для каждого - JID, а переопределение чата — в локальном snapshot. Эти значения не являются - секретами, но прямо влияют на конфиденциальность следующей отправки. + JID, а переопределение чата — в локальном SwiftData-хранилище. Эти значения + не являются секретами, но прямо влияют на конфиденциальность следующей + отправки. - Аватары XEP-0084/vCard не шифруются OMEMO. Их видимость определяется настройками PEP и roster на сервере; не используйте аватар как секретное фото. - Используемая реализация MartinOMEMO поддерживает распространённый legacy diff --git a/Luma.xcodeproj/project.pbxproj b/Luma.xcodeproj/project.pbxproj index 54a358e..7eb0ca2 100644 --- a/Luma.xcodeproj/project.pbxproj +++ b/Luma.xcodeproj/project.pbxproj @@ -9,141 +9,206 @@ /* Begin PBXBuildFile section */ 00FCAB7210D005D2C30747CB /* AccountConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */; }; 01039E013AB84FA338B2A5CF /* MessageCorrectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 096748EF4DC92889BCAB3260 /* MessageCorrectionTests.swift */; }; + 01E4131ED4DBED0BEAD465A2 /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */; }; 01EBE88C86DB1C4745D1AB08 /* VideoNoteRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */; }; 03629CA43EB6D367498E90E8 /* VideoNotePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */; }; 03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; }; 03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */; }; 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */; }; + 054AED20075FF49401C3E59C /* VideoNoteRotationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */; }; + 059A5169D66AD8F04B341403 /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; }; 067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; }; - 08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; }; 087E600676B3F6134BB26112 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; }; 088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; }; 097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */; }; 09D1784380F717ED92BEF000 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A97545E6E481D45E0BB20F /* ChatView.swift */; }; + 0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */; }; 0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = C86EBBD15843C6FB110C798E /* MediaFileIO.swift */; }; 0ECAB3E52DD12AD78AABE5F5 /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */; }; 10273AF92A952B707C58AEE6 /* ArchiveSyncWorkBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */; }; 10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */; }; + 120BD1C8A163015AF27FFA42 /* SASLMechanismPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */; }; + 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; }; 12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */; }; 137816B417A865C97F935A00 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = EFCAF9B0DE67466AF22E76C3 /* WebRTC */; }; + 14059C4EF59FBBDCC5338420 /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; }; 1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; }; 18606BEB0A2ABB7FDD995645 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; }; 18627D523DEC6B1A3064906D /* AttachmentPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */; }; 1AC03E6E9EA1D0C1F59F7619 /* ArchiveSyncPagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */; }; 1B20F2D3E4F18E6094B103BF /* LumaOMEMOStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */; }; + 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */; }; + 1BD08940BB8E5DE4EDB2AF30 /* ReadStateSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */; }; 1BD84AA796838F48F847D192 /* AudioMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */; }; + 1C78234B3FA4E528C7F701B7 /* LumaOMEMO2Module.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */; }; + 1C948E97A14DBA90C24A68FF /* SwiftChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */; }; 1CB1DAF27B8632B2E071338E /* VideoNoteRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */; }; 1D5B3487BDC63B051A33D874 /* VideoNoteRecordingLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */; }; 1E239B9338CEEA7EA704626C /* ChatTimelineEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */; }; + 1F467DB76CE93C90F09E2991 /* Shared.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7C9866558B72CB1909A01EEC /* Shared.xcassets */; }; 1FE98743A1FB61B4E9C86696 /* AttachmentPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */; }; 203D6124DF78461D8E86FDA8 /* AvatarImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */; }; 21AAF3FB5846B1B54BBEE7ED /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F36BB344D0FDF23127DF069 /* AppModel.swift */; }; 230E0DAA5396EA507D6F6D47 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */; }; 235ED577A9FB1D2A97A4749F /* LumaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0A53D1B25A1923ADF96C35 /* LumaWatchApp.swift */; }; 23AE47B738853110F72C0580 /* Martin in Frameworks */ = {isa = PBXBuildFile; productRef = 4BD4324EE01A6DD46EBE7FDA /* Martin */; }; + 24E3C4EEC4EB5116982D2F4E /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = 01363A8EB6E9A3C97C1B316D /* OpenSSL */; }; 25A9ADB0FF1496A4D3EF431D /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; }; 275501728E0597A8E53F1D4D /* VideoNoteRecordingCompletionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */; }; 2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; }; 28D758624032D813C607DE5B /* MediaSendActivityTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */; }; + 2987B54F6E358AAE2EE19993 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; }; + 2A728A647EDD4F098802C64C /* TLSVersionProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */; }; + 2A7611650AB52EDABB978362 /* ChatBubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */; }; + 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47680B7C615374A8C245818F /* AppLockPolicy.swift */; }; 2C5BAAEF4EC7AAE5FE0FEEDB /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; }; 2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; }; + 2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; }; 2D83457A8857592E23289EF7 /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; }; + 2F175DFA6D0FBF31B7EED21F /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; }; 2F44B4FDA9AF67EB24776A16 /* WatchViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */; }; 30BE19F459FDDAD640F66C68 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; }; 3121293D13481C39489CBC8F /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; }; + 312B8D266C5BEC4F7B2F3276 /* MediaPreviewProcessorVideoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */; }; 3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; }; 32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */; }; 32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */; }; + 32E78EE5035DD08181B50B86 /* VideoNoteRotationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */; }; 3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; }; + 33EF454D77366A0E9137D824 /* MessageBubbleEntrance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1180AA3C5AC03BB48393D97 /* MessageBubbleEntrance.swift */; }; + 39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = A99B4C1B1459E1C34F214164 /* OpenSSL */; }; + 3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; }; 3BEF3895B4C84880B9103058 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A4CE3E678096BA76F2988C /* NewChatView.swift */; }; + 3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */; }; 3C2727D165D2EF1CC742EEF7 /* VideoNoteCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */; }; 3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; }; + 3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */; }; 3D67F14421F89B6DA4400642 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05EEEE8A125347F7C5014A09 /* LoginView.swift */; }; 3D74BDDE2D978A2CC795E33A /* CertificateTrustEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */; }; + 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */; }; 3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; }; 3F1571511219DBA18F67209F /* ChatTypingPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */; }; + 411136B21632A796E26B207C /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */; }; 433A7B65E635FB59021F7D87 /* ChatTypingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */; }; 44AF85896EE023775B91DE8B /* VideoNotePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */; }; 46924A3275BFEFD2218AD748 /* SystemPhotoCameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */; }; 46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */; }; 481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */; }; + 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */; }; 48BE02EB8AA84E9CD1354E1A /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; }; + 48C34A0CBF309A652AE350B3 /* VideoNoteRotationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */; }; 496D99C625D771D6B91D99E7 /* ArchiveSyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */; }; 49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; }; 4A3844CC51129576741753E7 /* MediaPlaybackCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */; }; 4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */; }; + 4B65093C19D148E04A675440 /* ChatMessageEntrancePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B311D6C0234E1C910457FCE0 /* ChatMessageEntrancePolicy.swift */; }; 4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */; }; + 4DE3B27CFA3D524590FB751A /* AppLockPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */; }; + 4E1B116771922F92FEEB079B /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; }; 4EFADFE085B42E0B0593E501 /* ArchiveSyncCheckpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */; }; 51AE33BE56ED4543014119D0 /* ConversationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 758B448979C845F56168A51F /* ConversationRow.swift */; }; + 523D54621DD4DDD54EA8D510 /* MediaDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */; }; + 52CA5DEE0CF7697D1B2D1FEA /* MessageBubbleEntrance.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1180AA3C5AC03BB48393D97 /* MessageBubbleEntrance.swift */; }; 52F75F60459DA50E1815479A /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; }; 5409F5C90FBE0817F4AC8176 /* ChatTimelineEntryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */; }; 5415AF453AB8FCDA55FB59AD /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; }; + 5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; }; 5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */; }; 58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; }; - 5FB4DEEA5321D80CCD63B864 /* ChatArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */; }; + 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */; }; + 5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686F6A6EB3740DE94310653B /* PreviewSupport.swift */; }; + 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; }; + 6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; }; 60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */; }; 6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; }; 61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */; }; 6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */; }; 640C30ABB02E6486E6B7665E /* VideoNoteCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */; }; 64C8B9876278B711CC6479E0 /* LocationMessagePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */; }; + 64C9A8E42046AC0FCB701613 /* TLSVersionProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */; }; 64F4AD83D86B8900DE69F117 /* CredentialVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE381301A55B1C4F333B523 /* CredentialVault.swift */; }; + 65D2B572D508EF10931420FD /* ChatMessageEntrancePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B311D6C0234E1C910457FCE0 /* ChatMessageEntrancePolicy.swift */; }; + 662B8E72F00F5A77DE28819A /* SCRAMPlusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */; }; 66489D4D15366683D3095B3D /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */; }; 66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */; }; + 670970182A27A26B8E44E687 /* LumaTLSNetworkProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */; }; 67143F44FF20A87E8875CFFB /* MediaPreviewProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */; }; 67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */; }; + 6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 686F6A6EB3740DE94310653B /* PreviewSupport.swift */; }; + 6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */; }; 6AA0AC6E8E25E8A85D63E7CC /* WatchSessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A5787450609B0C14093679 /* WatchSessionModel.swift */; }; 6AB86E72201EB1A61898AD5F /* InlineVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */; }; 6B0B8F3CAC1E5854C6F81E70 /* AccountConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */; }; 6B311EF7CF11B52E75D929F0 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = 2A745A82DD6F34F0CB46B52B /* WebRTC */; }; - 6BB061F609EF40334C1DE979 /* LumaWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 19D4E9404712E8643E31CB93 /* LumaWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6DC36AA33268FC6457FADAE2 /* BubbleShapes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */; }; 6FB79BEEEF151D297F4EBB33 /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B11FED2D58621C071C3AD14 /* CallView.swift */; }; 6FCF6A4EFF8AE1FBFC2D81E1 /* ChatMediaImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */; }; + 7227469C6E25E0F4FF7FC805 /* Shared.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7C9866558B72CB1909A01EEC /* Shared.xcassets */; }; 7306BA14F94818248594AF91 /* WatchVoiceMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */; }; 73A21AFC42D6C00E6C2C5082 /* CertificateTrustEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */; }; 7425493304D731FA218E9151 /* ArchiveSyncPaginationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */; }; 7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */; }; 79169D5FE931EBE0D0801516 /* ArchiveSyncPagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */; }; + 791AB9983E2146DBF1D7B606 /* EmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */; }; 7A469F326228FB660739D1C2 /* ConnectionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */; }; 7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */; }; + 7B3DD63332C8149FAE907B0E /* ChatMessageDeliveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */; }; 7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; }; + 7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; }; + 7D095DA2AAD5E7D813586E7D /* LumaSaslChallengeModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */; }; 7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; }; - 7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AA66547A2AA2D0979DA76E /* MainChatView.swift */; }; 7F012252FA73A8282B92323C /* LumaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C051C2A7014E8B5E825477 /* LumaApp.swift */; }; 7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */; }; 7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; }; 7F7BA6805C1D1EBCEAFDD3AD /* ChatTimelineEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */; }; + 7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; }; + 81091B6BA7D793CA233812ED /* CallHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */; }; 814FE6C4CB53207F236E9236 /* LocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2786A15415F65305F78D892C /* LocationPickerView.swift */; }; 822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */; }; 8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81213335D275609C38056220 /* ForwardMessageView.swift */; }; 86D39FA124355A8B53903164 /* Martin in Frameworks */ = {isa = PBXBuildFile; productRef = 6EDEC6EEE315B77491214398 /* Martin */; }; 8714A5A67956D6A78D4D3E4B /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */; }; + 8831E0A88F3759766F9BCC34 /* CallHistorySync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */; }; 89712B216A98861B5D994587 /* LocationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */; }; 89E2B0373E2DA2B25863A866 /* VideoNoteStopPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */; }; 8B98B818AC4379F03907B0E0 /* ChatScrollPositionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */; }; 8DE9617B5B0CBCA875C299F9 /* WatchVoiceRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */; }; 902C6AD49443720ED4E4E43C /* ComposerRecordingGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */; }; 9089C00D2A0766A75B1EB93D /* CryptoKitAESGCMEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */; }; + 920CB33C235AF4BDB4107BB7 /* AppLockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */; }; 9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */; }; + 939ABD4DED652588957491CF /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; }; + 945014504AA02172175B48D2 /* SwiftChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */; }; 94FB3597E01FC508F872861E /* MessageReplySwipePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */; }; 958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */; }; + 97A3A0635ADA8A8227C94216 /* ChatBubbleShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */; }; 992195D6AE64DF2C24A52956 /* MartinOMEMO in Frameworks */ = {isa = PBXBuildFile; productRef = 371134B6F3E284082E28FE08 /* MartinOMEMO */; }; + 9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */; }; + 9B53D50C5EDE4AD507C3F5B6 /* AppAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */; }; 9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; }; + 9D2352A11D04AB0F2DDBD368 /* ReadStateSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */; }; + 9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */; }; 9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; }; 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */; }; + 9F288ACC92CD62A2E0CCE157 /* SASLprep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */; }; A07ABE478F7F48C3FEDC22D5 /* EncryptionPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */; }; A1D32581BA70F25420F0A322 /* ArchiveSyncWorkBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */; }; A29C828AC4B571941BF0B8E9 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */; }; A2AF2B19A1E9D9B8D6E1CD63 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */; }; + A4A742F3BBEFCAFD29975968 /* SaslFailureMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */; }; A69C1CD6CC1FF13631044BA3 /* GeoLocationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */; }; + A6B05CBBA8A2B4C33B41F47D /* OlderHistoryScrollPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */; }; + A73E17CEC3F4E8624102FC17 /* OlderHistoryScrollPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */; }; + A87AB7D40BD589F5EBCCBD6F /* LumaOMEMO2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4170740AD7BA5D0EC8C870F0 /* LumaOMEMO2Tests.swift */; }; A90142509385DE6E83818953 /* MessageBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */; }; ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515DD6F1F80A848C39EBAE84 /* RootView.swift */; }; AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */; }; ADB2CCC69CA91FA820B3A4CA /* MessageReactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD92CDC8D3E0DA178D0E3F52 /* MessageReactionTests.swift */; }; - AEC21F39F40AFBD3B4225B3C /* ChatArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */; }; + AF1AE686F7B4915C4636099D /* OlderHistoryScrollPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */; }; + AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */; }; AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */; }; B0C6096A8D8F1EAD0B4154B7 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */; }; + B13513FE5DAD462086B8B0B0 /* LumaConnectionStatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */; }; B33870CD89289E44882750C3 /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */; }; B429AC460AF49367463F59A8 /* VideoNoteRecordingCompletionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */; }; B492852FD89322434B0914AE /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F36BB344D0FDF23127DF069 /* AppModel.swift */; }; @@ -155,17 +220,23 @@ BAA3A9846822ED9F9E5C7111 /* ConnectionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */; }; BAF755D5DBE713646B4CE5AF /* AudioMessageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */; }; BE4D5853DAC0D44A6132D800 /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; }; + BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; }; + C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */; }; + C23C74AEDA88885A209C9BB1 /* AppLockVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */; }; C2CC9BABAE68FA9258694766 /* MessageReplyFallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */; }; - C557E56AE73731A5BC2FD327 /* ChatArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */; }; C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */; }; C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */; }; C824389F95739F79F3E075C8 /* MessageInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C97DDD3DA8B45C3F2085661 /* MessageInteractionTests.swift */; }; C92B463DCD052A3215CB7099 /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */; }; C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */; }; + CA39FA78E8BFE26BEBF976E5 /* MediaDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */; }; CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */; }; CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */; }; CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; }; + CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; }; CC5297F90A3DBD45C5E41A3A /* MediaViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */; }; + D0EB41F8E01D4B654254E101 /* ReadStateSyncTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */; }; + D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */; }; D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5B4C1116F6ACB6F9B64BF4 /* NotificationCoordinator.swift */; }; D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; }; D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */; }; @@ -173,22 +244,30 @@ D551F2AA5DCCCCDD8D8FB9BB /* AccountConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */; }; D5E6B1859101ABBD33D90C3C /* ChatScrollPositionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */; }; D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */; }; + D6718F8B1359CD06F44A91A8 /* SASLMechanismPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */; }; D6D0A798487AE98AE03A579C /* NewGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99BE34DC082D19007241AA62 /* NewGroupView.swift */; }; D6ED224CC13439A99A388676 /* GeoLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */; }; + D7179E7F8127A702FC58D3D4 /* DeviceResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 876CDF98063A4C948B00C7CE /* DeviceResource.swift */; }; D7CB4481F4F3B81D2C9EF5BD /* NewGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99BE34DC082D19007241AA62 /* NewGroupView.swift */; }; D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */; }; DA77E8A49D2D091EC0975DD3 /* InlineVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */; }; DBB0216BFAB04C3847FEE95C /* GroupInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */; }; DC4FAE63AB8AF89C30512763 /* MediaFileIOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 772A4F24CAF527E328657749 /* MediaFileIOTests.swift */; }; + DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */; }; E2245416CB055ECEF0C5F81D /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A338A8EB30206647BDD922F /* SettingsView.swift */; }; E42A674072CE5D3AF1F5848D /* Conversation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */; }; + E449F2B17E6D5053EBA7F6A8 /* Shared.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7C9866558B72CB1909A01EEC /* Shared.xcassets */; }; + E4B1FDFFD3BFFC47B5F154DE /* LumaOMEMO2Module.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */; }; E582A0424A63CA16DDA4E984 /* CryptoKitAESGCMEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */; }; E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */; }; E67E0D2C3FBA2C3739B692E0 /* VideoAttachmentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */; }; + E6890C743C5BBD0B287DDB53 /* ArchiveStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */; }; + E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */; }; E9FA22C965454FA6890DC3D5 /* AvatarImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */; }; EBAF4861DD93FBEAA4B37D1C /* MediaViewerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */; }; ECAC4761F04316A71EC3C716 /* AttachmentDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */; }; ECCD95DD4E16E0B393C707A9 /* MessageReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D414B62940A9234C51C3D14E /* MessageReaction.swift */; }; + ED1C54A2FB844226AA284CCC /* LumaScramPlusMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */; }; ED3869D5658BAA59F55B1E3D /* ArchiveSyncCheckpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */; }; EE2BF07D86C00AB6358EA5AE /* CredentialVault.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE381301A55B1C4F333B523 /* CredentialVault.swift */; }; EE4DFDBA97562C53C53FBF51 /* CryptoKitAESGCMEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */; }; @@ -196,30 +275,36 @@ F057C0C84B541A5564D0E7E7 /* ArchiveMessageBatchPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */; }; F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */; }; F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */; }; + F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */; }; + F5121F29EF3BEFD4FD54DD12 /* LumaTLSNetworkProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */; }; F558F58835D6E887DBF09EA0 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A97545E6E481D45E0BB20F /* ChatView.swift */; }; F80493F220991FF8A7CFF681 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05EEEE8A125347F7C5014A09 /* LoginView.swift */; }; + F80767EC81E8CAFAA280DD6A /* SaslFailureMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */; }; + F881ABC5077A3330B29C0DE6 /* LegacyArchiveImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */; }; F8B9CBE6849C45F828BE3BD4 /* XMPPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE298382FC70ECF1513BD4A /* XMPPService.swift */; }; F9BEB6552D0407A160501369 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A4CE3E678096BA76F2988C /* NewChatView.swift */; }; FB1052104B90994CF03FE6A2 /* LumaOMEMOStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */; }; FC5986CE50C84FA77983767C /* BubbleShapes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */; }; FC60F19740E3D0C75205DB61 /* ArchiveSyncWorkBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */; }; + FCC5133038CDBEC39B5B42D3 /* LumaScramPlusMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */; }; FCD330D04ED5B87E28883E58 /* EncryptionDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */; }; FD15BE18C54AE2693518CB4A /* AudioMessagePlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */; }; FD9F8640841236BB78563573 /* MessageReaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D414B62940A9234C51C3D14E /* MessageReaction.swift */; }; + FDB78F0A58D66E36B9E25A47 /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */; }; FE958AD8FF4A43DB9A887882 /* XMPPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE298382FC70ECF1513BD4A /* XMPPService.swift */; }; FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */; }; FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 0722750D38A3B60127F87761 /* PBXContainerItemProxy */ = { + 277A193FE4C68A777257EF0D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AACE5AD3943208FFB6B19277 /* Project object */; proxyType = 1; - remoteGlobalIDString = BAB78463D7DE07A8F7B3D27D; - remoteInfo = LumaWatch; + remoteGlobalIDString = 0565F6C7DA26F0482928704E; + remoteInfo = Luma; }; - 277A193FE4C68A777257EF0D /* PBXContainerItemProxy */ = { + 5C6218BA484572FDB4727ECA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = AACE5AD3943208FFB6B19277 /* Project object */; proxyType = 1; @@ -228,34 +313,24 @@ }; /* End PBXContainerItemProxy section */ -/* Begin PBXCopyFilesBuildPhase section */ - 8C792A6B9B9150263A7D975B /* Embed Watch Content */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; - dstSubfolderSpec = 16; - files = ( - 6BB061F609EF40334C1DE979 /* LumaWatch.app in Embed Watch Content */, - ); - name = "Embed Watch Content"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - /* Begin PBXFileReference section */ 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteCaptureView.swift; sourceTree = ""; }; 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleShapes.swift; sourceTree = ""; }; 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubble.swift; sourceTree = ""; }; + 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaScramPlusMechanism.swift; sourceTree = ""; }; 05EEEE8A125347F7C5014A09 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 096748EF4DC92889BCAB3260 /* MessageCorrectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCorrectionTests.swift; sourceTree = ""; }; + 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineUITests.swift; sourceTree = ""; }; 0D15DA1B85B68AD2419670C8 /* ArchiveSyncWorkBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncWorkBudget.swift; sourceTree = ""; }; 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicyTests.swift; sourceTree = ""; }; 0DE298382FC70ECF1513BD4A /* XMPPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XMPPService.swift; sourceTree = ""; }; - 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchive.swift; sourceTree = ""; }; 101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationTests.swift; sourceTree = ""; }; 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; }; 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntry.swift; sourceTree = ""; }; + 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLprepTests.swift; sourceTree = ""; }; + 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = AppAssets.xcassets; sourceTree = ""; }; 185E3C916B72D36733DE15AF /* EncryptionDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionDevicesView.swift; sourceTree = ""; }; + 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSVersionProbe.swift; sourceTree = ""; }; 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyThreadOverlay.swift; sourceTree = ""; }; 19D4E9404712E8643E31CB93 /* LumaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngine.swift; sourceTree = ""; }; @@ -263,7 +338,9 @@ 1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypingPolicy.swift; sourceTree = ""; }; 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationProvider.swift; sourceTree = ""; }; 1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceMessageTests.swift; sourceTree = ""; }; + 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessageTests.swift; sourceTree = ""; }; 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfiguration.swift; sourceTree = ""; }; + 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMPlusTests.swift; sourceTree = ""; }; 22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncWorkBudgetTests.swift; sourceTree = ""; }; 22A1DBD88AF90D3892A02E1A /* ArchiveSyncPagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPagination.swift; sourceTree = ""; }; 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPreferences.swift; sourceTree = ""; }; @@ -274,30 +351,49 @@ 2786A15415F65305F78D892C /* LocationPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPickerView.swift; sourceTree = ""; }; 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadata.swift; sourceTree = ""; }; 28187D29B1BB6E3ECB02F637 /* ArchiveSyncRecoveryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicy.swift; sourceTree = ""; }; + 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerView.swift; sourceTree = ""; }; 2F36BB344D0FDF23127DF069 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = ""; }; 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteStopPolicyTests.swift; sourceTree = ""; }; 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshotTests.swift; sourceTree = ""; }; 37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoLocationTests.swift; sourceTree = ""; }; + 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLMechanismPreferenceTests.swift; sourceTree = ""; }; + 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockView.swift; sourceTree = ""; }; + 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStore.swift; sourceTree = ""; }; 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessor.swift; sourceTree = ""; }; 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMessageBatchPolicy.swift; sourceTree = ""; }; + 4170740AD7BA5D0EC8C870F0 /* LumaOMEMO2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaOMEMO2Tests.swift; sourceTree = ""; }; 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewer.swift; sourceTree = ""; }; 43A4CE3E678096BA76F2988C /* NewChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; + 47680B7C615374A8C245818F /* AppLockPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicy.swift; sourceTree = ""; }; + 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRotationPolicyTests.swift; sourceTree = ""; }; + 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMDowngradeProtectionTests.swift; sourceTree = ""; }; 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTCVideoRendererView.swift; sourceTree = ""; }; + 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SCRAMSHA512Tests.swift; sourceTree = ""; }; 515DD6F1F80A848C39EBAE84 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPhotoCameraView.swift; sourceTree = ""; }; + 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaTLSNetworkProcessor.swift; sourceTree = ""; }; 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CertificateTrustEvaluator.swift; sourceTree = ""; }; + 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OlderHistoryScrollPolicy.swift; sourceTree = ""; }; 5BB168F9AD341C35EADACF10 /* VideoNoteRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecorder.swift; sourceTree = ""; }; + 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveMetadataRecord.swift; sourceTree = ""; }; 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreference.swift; sourceTree = ""; }; 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSnapshot.swift; sourceTree = ""; }; 60A97545E6E481D45E0BB20F /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncRecoveryPolicyTests.swift; sourceTree = ""; }; - 6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 6317FAA29F22A3EA8450208F /* LumaTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySync.swift; sourceTree = ""; }; + 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCheckUITests.swift; sourceTree = ""; }; 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupConversationTests.swift; sourceTree = ""; }; + 686F6A6EB3740DE94310653B /* PreviewSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewSupport.swift; sourceTree = ""; }; + 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaslFailureMessage.swift; sourceTree = ""; }; + 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaOMEMO2Module.swift; sourceTree = ""; }; 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerDismissGestureTests.swift; sourceTree = ""; }; 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplyFallback.swift; sourceTree = ""; }; 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicy.swift; sourceTree = ""; }; 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; + 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockVault.swift; sourceTree = ""; }; + 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLMechanismPreference.swift; sourceTree = ""; }; 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaCallEngine.swift; sourceTree = ""; }; 758B448979C845F56168A51F /* ConversationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationRow.swift; sourceTree = ""; }; 76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTimeFormatterTests.swift; sourceTree = ""; }; @@ -306,26 +402,40 @@ 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conversation.swift; sourceTree = ""; }; 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewView.swift; sourceTree = ""; }; 7BD0A69BACDF8F0C43218AEB /* AudioMessageRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessageRecorder.swift; sourceTree = ""; }; + 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaScramSha512Mechanism.swift; sourceTree = ""; }; 7C97DDD3DA8B45C3F2085661 /* MessageInteractionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageInteractionTests.swift; sourceTree = ""; }; + 7C9866558B72CB1909A01EEC /* Shared.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Shared.xcassets; sourceTree = ""; }; 7F5960729C94DC995374CFD6 /* ComposerRecordingGestureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerRecordingGestureTests.swift; sourceTree = ""; }; 81213335D275609C38056220 /* ForwardMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForwardMessageView.swift; sourceTree = ""; }; 83A5787450609B0C14093679 /* WatchSessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionModel.swift; sourceTree = ""; }; + 876CDF98063A4C948B00C7CE /* DeviceResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResource.swift; sourceTree = ""; }; 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoAttachmentPreview.swift; sourceTree = ""; }; + 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewProcessorVideoTests.swift; sourceTree = ""; }; + 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaConnectionStatsModule.swift; sourceTree = ""; }; + 96720381802141EBA1DA9772 /* LumaUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LumaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycle.swift; sourceTree = ""; }; 99BE34DC082D19007241AA62 /* NewGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewGroupView.swift; sourceTree = ""; }; + 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; 9A338A8EB30206647BDD922F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 9B11FED2D58621C071C3AD14 /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicyTests.swift; sourceTree = ""; }; 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReplySwipePolicy.swift; sourceTree = ""; }; + 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleShape.swift; sourceTree = ""; }; A0BE69D306AF1C207A30517D /* PhoneWatchBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneWatchBridge.swift; sourceTree = ""; }; A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaMetadataTests.swift; sourceTree = ""; }; A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OMEMODevice.swift; sourceTree = ""; }; A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMediaImageCache.swift; sourceTree = ""; }; A69C6820279A445E9CA59FB3 /* MediaSendActivityTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSendActivityTracker.swift; sourceTree = ""; }; + A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OlderHistoryScrollPolicyTests.swift; sourceTree = ""; }; A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncPaginationTests.swift; sourceTree = ""; }; A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoAttachmentPreview.swift; sourceTree = ""; }; A8C051C2A7014E8B5E825477 /* LumaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaApp.swift; sourceTree = ""; }; + AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDownloadService.swift; sourceTree = ""; }; + ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveStoreTests.swift; sourceTree = ""; }; B1E6875B522254FFB5FA880E /* MediaPickerSelectionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicy.swift; sourceTree = ""; }; + B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SASLprep.swift; sourceTree = ""; }; + B2DC8B629E8D4F078B192FAB /* MartinOMEMO */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MartinOMEMO; path = ThirdParty/MartinOMEMO; sourceTree = SOURCE_ROOT; }; + B311D6C0234E1C910457FCE0 /* ChatMessageEntrancePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageEntrancePolicy.swift; sourceTree = ""; }; B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPickerSelectionPolicyTests.swift; sourceTree = ""; }; B8F7B01C59E9667ADAFABBAC /* MediaViewerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaViewerItem.swift; sourceTree = ""; }; B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationMessagePreview.swift; sourceTree = ""; }; @@ -336,33 +446,45 @@ BF0A53D1B25A1923ADF96C35 /* LumaWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaWatchApp.swift; sourceTree = ""; }; C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDraft.swift; sourceTree = ""; }; C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSDPOrderingTests.swift; sourceTree = ""; }; + C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateSyncTests.swift; sourceTree = ""; }; C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTypingPolicyTests.swift; sourceTree = ""; }; C35158C37C26CA9ACB6C9FFB /* WatchViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchViews.swift; sourceTree = ""; }; C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatScrollPositionPolicy.swift; sourceTree = ""; }; + C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslFailureModule.swift; sourceTree = ""; }; + C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallHistorySyncTests.swift; sourceTree = ""; }; C86EBBD15843C6FB110C798E /* MediaFileIO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaFileIO.swift; sourceTree = ""; }; C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTimelineEntryTests.swift; sourceTree = ""; }; + C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRotationPolicy.swift; sourceTree = ""; }; + CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInfoView.swift; sourceTree = ""; }; + CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftChatView.swift; sourceTree = ""; }; CCE381301A55B1C4F333B523 /* CredentialVault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialVault.swift; sourceTree = ""; }; CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionPreferenceTests.swift; sourceTree = ""; }; D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNotePreview.swift; sourceTree = ""; }; D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpointTests.swift; sourceTree = ""; }; - D3AA66547A2AA2D0979DA76E /* MainChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatView.swift; sourceTree = ""; }; D414B62940A9234C51C3D14E /* MessageReaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReaction.swift; sourceTree = ""; }; D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImageProcessor.swift; sourceTree = ""; }; + D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageDeliveryTests.swift; sourceTree = ""; }; D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessagePlayer.swift; sourceTree = ""; }; D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineVideoPlayer.swift; sourceTree = ""; }; D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArchiveSyncCheckpoint.swift; sourceTree = ""; }; D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingLifecycleTests.swift; sourceTree = ""; }; - DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadStateSync.swift; sourceTree = ""; }; + DF8C531E464C9C4EDE2648C6 /* Luma.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E1180AA3C5AC03BB48393D97 /* MessageBubbleEntrance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageBubbleEntrance.swift; sourceTree = ""; }; E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteRecordingCompletionPolicy.swift; sourceTree = ""; }; + E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerInformation.swift; sourceTree = ""; }; E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRoomStore.swift; sourceTree = ""; }; - EA54340DD785335237158BEF /* Luma.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Luma.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyArchiveImporter.swift; sourceTree = ""; }; + EA54340DD785335237158BEF /* LumaMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LumaMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; F0A35C9A7B52458996513382 /* WatchVoiceRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchVoiceRecorder.swift; sourceTree = ""; }; F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionBanner.swift; sourceTree = ""; }; + F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCatalog.swift; sourceTree = ""; }; + F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaSaslChallengeModule.swift; sourceTree = ""; }; F69AB3930D5E93CB77EA9C7D /* CryptoKitAESGCMEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CryptoKitAESGCMEngineTests.swift; sourceTree = ""; }; F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPolicyTests.swift; sourceTree = ""; }; F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticDNSSrvResolver.swift; sourceTree = ""; }; F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LumaRosterStore.swift; sourceTree = ""; }; - FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveTests.swift; sourceTree = ""; }; + FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockPolicyTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -373,6 +495,7 @@ 23AE47B738853110F72C0580 /* Martin in Frameworks */, 992195D6AE64DF2C24A52956 /* MartinOMEMO in Frameworks */, 137816B417A865C97F935A00 /* WebRTC in Frameworks */, + 24E3C4EEC4EB5116982D2F4E /* OpenSSL in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -383,6 +506,7 @@ 86D39FA124355A8B53903164 /* Martin in Frameworks */, 32528D1AA72FB1B1A72389E9 /* MartinOMEMO in Frameworks */, 6B311EF7CF11B52E75D929F0 /* WebRTC in Frameworks */, + 39C3E7AC663AB6C8DACFFAA8 /* OpenSSL in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -392,12 +516,24 @@ 0A7F1E12AC8E617F12AC1410 /* XMPP */ = { isa = PBXGroup; children = ( + 63E4D099BDCB2311ADFE39E6 /* CallHistorySync.swift */, 1A017D8B164B1107299F6781 /* CryptoKitAESGCMEngine.swift */, 72EC7DC2FE873C0BA4321496 /* LumaCallEngine.swift */, + 8F8DE3CE0E26DEA21A27CF67 /* LumaConnectionStatsModule.swift */, + 6C8698ED5D720AC876F51EE3 /* LumaOMEMO2Module.swift */, 24A68154C2D2B3E9AB0C145F /* LumaOMEMOStore.swift */, E9F226A1F8D82B39630F9CEB /* LumaRoomStore.swift */, F92830FB64B5BD0A06BC828D /* LumaRosterStore.swift */, + F4F90741D722FD1A972B4C78 /* LumaSaslChallengeModule.swift */, + C3E21A7D359AF7A7AF97D8DE /* LumaSaslFailureModule.swift */, + 050B83F6DA3A04B661FFA3B1 /* LumaScramPlusMechanism.swift */, + 7C16CFC03E7F478838A704FD /* LumaScramSha512Mechanism.swift */, + 569F2BFFD578A23A99D73248 /* LumaTLSNetworkProcessor.swift */, + DCE53C6D5BAE984F218BFCFB /* ReadStateSync.swift */, + 69F4FFBFD65C218FF6505844 /* SaslFailureMessage.swift */, + B2446F6A1D00ADAD261D4CCD /* SASLprep.swift */, F8CABD9D1B4C987EF6AB19A6 /* StaticDNSSrvResolver.swift */, + 186BB225C4C5E528B3D7C9CE /* TLSVersionProbe.swift */, 0DE298382FC70ECF1513BD4A /* XMPPService.swift */, ); path = XMPP; @@ -407,6 +543,7 @@ isa = PBXGroup; children = ( 2006464EAC88C6E4E2B08AC8 /* AccountConfiguration.swift */, + 47680B7C615374A8C245818F /* AppLockPolicy.swift */, 2F36BB344D0FDF23127DF069 /* AppModel.swift */, 3CDA85EA65BC449733C67084 /* ArchiveMessageBatchPolicy.swift */, D9BBE412EAF54DFE968B2E54 /* ArchiveSyncCheckpoint.swift */, @@ -416,10 +553,13 @@ C04D749B7CA93CB7C59127D6 /* AttachmentDraft.swift */, 6079CE75BAB995515A8D5460 /* CallSnapshot.swift */, 12C6A6B83DA076867481CCF3 /* ChatMessage.swift */, + B311D6C0234E1C910457FCE0 /* ChatMessageEntrancePolicy.swift */, C37AAE886AA3B8782B89E1A0 /* ChatScrollPositionPolicy.swift */, 13E35F7F0CB0243311FCDD61 /* ChatTimelineEntry.swift */, 1C3E51BB890F05FD02C71F6E /* ChatTypingPolicy.swift */, 7846C2EEE2551C6690A4FDC6 /* Conversation.swift */, + 876CDF98063A4C948B00C7CE /* DeviceResource.swift */, + F26F91180E43DE9C135B68C1 /* EmojiCatalog.swift */, 5D187FF75D77DB6CCC811600 /* EncryptionPreference.swift */, BD13FA20B6C2CD8B6718BF86 /* GeoLocation.swift */, 278FC97C7F6D41B638436EA7 /* MediaMetadata.swift */, @@ -430,9 +570,13 @@ 6DDD7A997EF0AF7DA9C3478B /* MessageReplyFallback.swift */, 9CC7DFD3F92530B411DEEC1F /* MessageReplySwipePolicy.swift */, 6E25F876C75CC5FCDDCB5906 /* NotificationPolicy.swift */, + 5955D143DBAF212F68CAEE49 /* OlderHistoryScrollPolicy.swift */, A1ABB449DF3A7746361FC75C /* OMEMODevice.swift */, + 6F8D047FE7E9BA4DAA3DAF80 /* SASLMechanismPreference.swift */, + E6A5C32B4DACD56DA733A0DB /* ServerInformation.swift */, E619B2C85B0DD2E80653D6D3 /* VideoNoteRecordingCompletionPolicy.swift */, 96829BDC257FD8D61083DA4D /* VideoNoteRecordingLifecycle.swift */, + C986DC0BAE0D11816C25E782 /* VideoNoteRotationPolicy.swift */, ); path = Models; sourceTree = ""; @@ -441,8 +585,10 @@ isa = PBXGroup; children = ( 235CA66C5DFB8B6F9472D0C3 /* AccountPreferences.swift */, + 5CC7F3C5D27D08B1B01D712C /* ArchiveMetadataRecord.swift */, + 3BA0E25A2B52BDED1C8B3B1B /* ArchiveStore.swift */, 32DBAB8D25603892C7D67A23 /* AvatarCache.swift */, - 0E9EDF4E846FF431E6498419 /* ChatArchive.swift */, + EA1D94FE541121DAE9DCE3B0 /* LegacyArchiveImporter.swift */, ); path = Persistence; sourceTree = ""; @@ -481,18 +627,30 @@ path = Watch; sourceTree = ""; }; + 307BDB7CACD11611A6CCDC4E /* UITests */ = { + isa = PBXGroup; + children = ( + 6597326A509F73A2993A6B80 /* LayoutCheckUITests.swift */, + 0B5359E7A89ABF58B78CE036 /* TimelineUITests.swift */, + ); + path = UITests; + sourceTree = ""; + }; 4D0D58FD94D94B4D409033AF /* Tests */ = { isa = PBXGroup; children = ( 101587DDD2BB1B2C2073B926 /* AccountConfigurationTests.swift */, + FFFA3AEE3D9FB450B77AEC10 /* AppLockPolicyTests.swift */, 0D93B7CC521421C23459A9A4 /* ArchiveMessageBatchPolicyTests.swift */, + ABB0729C69630AE38C2D7BBF /* ArchiveStoreTests.swift */, D2678D358CD6B7DD331AABD1 /* ArchiveSyncCheckpointTests.swift */, A862822F2507303EBB27EFCC /* ArchiveSyncPaginationTests.swift */, 61380B78824FDB98A933C7E9 /* ArchiveSyncRecoveryPolicyTests.swift */, 22A045770C3C55BC5ADC409B /* ArchiveSyncWorkBudgetTests.swift */, + C3F58259FA8A8DB43464FA0D /* CallHistorySyncTests.swift */, C0590E38A89EC4CC78791C22 /* CallSDPOrderingTests.swift */, 375633EF5E4AEC9D5F284C6C /* CallSnapshotTests.swift */, - FB23218C2ABB17E3AAA50029 /* ChatArchiveTests.swift */, + D6FD23978F7C38C074D4B605 /* ChatMessageDeliveryTests.swift */, 24F0A8A1646FDEE9C07FD9F0 /* ChatScrollPositionPolicyTests.swift */, C90C426133ECC0319483A084 /* ChatTimelineEntryTests.swift */, C235D175D2398E0A7115447D /* ChatTypingPolicyTests.swift */, @@ -501,9 +659,11 @@ CD286F7AF06DFC5488FF0CEA /* EncryptionPreferenceTests.swift */, 37C24C432306C3FDF7F7DB7D /* GeoLocationTests.swift */, 6773F87EE60F91BBDCA4B1F5 /* GroupConversationTests.swift */, + 4170740AD7BA5D0EC8C870F0 /* LumaOMEMO2Tests.swift */, 772A4F24CAF527E328657749 /* MediaFileIOTests.swift */, A127BF01F5C488CD3E053379 /* MediaMetadataTests.swift */, B38702A403DA3E943525FB62 /* MediaPickerSelectionPolicyTests.swift */, + 8E29E7B704EE71C5AC70008E /* MediaPreviewProcessorVideoTests.swift */, 247FBE680C61D12AFB583862 /* MediaSendActivityTrackerTests.swift */, 76FEB73D4B67CB98C93F6244 /* MediaTimeFormatterTests.swift */, 6DCB924748780145A707D890 /* MediaViewerDismissGestureTests.swift */, @@ -513,8 +673,17 @@ BCAACA105F4B9699F59805F9 /* MessageReplyFallbackTests.swift */, 77D3F72BE63D8F360ECA0419 /* MessageReplySwipeTests.swift */, F6C7D0F207377DE99146A0D2 /* NotificationPolicyTests.swift */, + A783C0BA9924A0B7DD225CEB /* OlderHistoryScrollPolicyTests.swift */, + C10ABC46FDDE002240B0B97B /* ReadStateSyncTests.swift */, + 1F7B2B37E9E8E6A46ED7547A /* SaslFailureMessageTests.swift */, + 37D79A5AC35479F474FF3F44 /* SASLMechanismPreferenceTests.swift */, + 13EFA01112297641B9B0EBC5 /* SASLprepTests.swift */, + 4F5C75CB772638C15C54F101 /* SCRAMDowngradeProtectionTests.swift */, + 21D03F8C05843DD139C1A4CE /* SCRAMPlusTests.swift */, + 5078CBF0520AA3BE7D2E65E0 /* SCRAMSHA512Tests.swift */, 9C8B12EFBC83F9235B6AF992 /* VideoNoteRecordingCompletionPolicyTests.swift */, D9FCE089ABBF05EF9F55D451 /* VideoNoteRecordingLifecycleTests.swift */, + 4A1E58CDCC2EF1D72A136A43 /* VideoNoteRotationPolicyTests.swift */, 33F90AC63C822D9DF95D7B5C /* VideoNoteStopPolicyTests.swift */, 1DCD865B7E746D20B142FCE7 /* WatchVoiceMessageTests.swift */, ); @@ -524,6 +693,7 @@ 50062B606E5AC6EF7E5AE47F /* Security */ = { isa = PBXGroup; children = ( + 6EF29003DFA43387FC2FB6F4 /* AppLockVault.swift */, 5886E1E233C129B9317D6059 /* CertificateTrustEvaluator.swift */, CCE381301A55B1C4F333B523 /* CredentialVault.swift */, ); @@ -533,6 +703,7 @@ 6BC9FCFF61F5366ED010F59A /* UI */ = { isa = PBXGroup; children = ( + 3B5AEFC4FD65BA34FF030467 /* AppLockView.swift */, 78E78CC8582C0399901D8A27 /* AttachmentPreviewView.swift */, 9B11FED2D58621C071C3AD14 /* CallView.swift */, 60A97545E6E481D45E0BB20F /* ChatView.swift */, @@ -541,10 +712,12 @@ BAA74044D62409FAC6062BC1 /* GroupInfoView.swift */, 2786A15415F65305F78D892C /* LocationPickerView.swift */, 05EEEE8A125347F7C5014A09 /* LoginView.swift */, - D3AA66547A2AA2D0979DA76E /* MainChatView.swift */, + 99F26F5B024C5B4BF74CF242 /* MainTabView.swift */, 43A4CE3E678096BA76F2988C /* NewChatView.swift */, 99BE34DC082D19007241AA62 /* NewGroupView.swift */, + 686F6A6EB3740DE94310653B /* PreviewSupport.swift */, 515DD6F1F80A848C39EBAE84 /* RootView.swift */, + CBA20B768A9C675FABCDC821 /* ServerInfoView.swift */, 9A338A8EB30206647BDD922F /* SettingsView.swift */, 5198FF1E47790E498CC8C6F3 /* SystemPhotoCameraView.swift */, 0142FF4A1B05C373DAB4CDBE /* VideoNoteCaptureView.swift */, @@ -556,8 +729,11 @@ 751CBF99010567D043B5DA42 = { isa = PBXGroup; children = ( + FFFA7B7564720060678770FC /* Packages */, + D1D74E43299CD30D89B80F74 /* Resources */, 2A98AB59ED6FB8802C301CF4 /* Sources */, 4D0D58FD94D94B4D409033AF /* Tests */, + 307BDB7CACD11611A6CCDC4E /* UITests */, 90CFB4B53B5F9EF91D718FC2 /* Products */, ); indentWidth = 4; @@ -579,6 +755,7 @@ D5746D1E21ABBBCFC6CD9694 /* AvatarImageProcessor.swift */, A438A50BC08940CA1410B4FA /* ChatMediaImageCache.swift */, 1C67A8A07F9DC9DFB7FB35DA /* LocationProvider.swift */, + AB613D3331D85529B5F989D0 /* MediaDownloadService.swift */, C86EBBD15843C6FB110C798E /* MediaFileIO.swift */, 26ADAEFB7CB8441CA1A50C44 /* MediaPlaybackCoordinator.swift */, 3C0E3C6F60C3D4D6C565CA0A /* MediaPreviewProcessor.swift */, @@ -593,34 +770,56 @@ isa = PBXGroup; children = ( DF8C531E464C9C4EDE2648C6 /* Luma.app */, - EA54340DD785335237158BEF /* Luma.app */, + EA54340DD785335237158BEF /* LumaMac.app */, 6317FAA29F22A3EA8450208F /* LumaTests.xctest */, + 96720381802141EBA1DA9772 /* LumaUITests.xctest */, 19D4E9404712E8643E31CB93 /* LumaWatch.app */, ); name = Products; sourceTree = ""; }; + D1D74E43299CD30D89B80F74 /* Resources */ = { + isa = PBXGroup; + children = ( + 184902980B7EE4B8DDEA907D /* AppAssets.xcassets */, + 7C9866558B72CB1909A01EEC /* Shared.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; E27D62EEC5FD9AEBB7057485 /* Components */ = { isa = PBXGroup; children = ( D724713196AD2063C7FAD08F /* AudioMessagePlayer.swift */, 6EA8DF39BD360C0F8BA5F62F /* AvatarView.swift */, 0386C2687C1C60EC734FDBB0 /* BubbleShapes.swift */, + 9D5209441E3CA01DAF4DC6C3 /* ChatBubbleShape.swift */, F207C1B20DE3780F1754F81D /* ConnectionBanner.swift */, 758B448979C845F56168A51F /* ConversationRow.swift */, + 2DA34A969939A03C6226B60E /* EmojiPickerView.swift */, D8C81B9255E9886FB86E0785 /* InlineVideoPlayer.swift */, B96E7970930AB7F09CB5DA9C /* LocationMessagePreview.swift */, 4380219D3AF1D75EDF4D3167 /* MediaViewer.swift */, 03AC6BC7FC7CFFD69A20DA17 /* MessageBubble.swift */, + E1180AA3C5AC03BB48393D97 /* MessageBubbleEntrance.swift */, 8D2294F0FE44B2268A0620BB /* PhotoAttachmentPreview.swift */, 19C556A41E640592AF6376B6 /* ReplyThreadOverlay.swift */, 5003B4F9DF154543555E9825 /* RTCVideoRendererView.swift */, + CCD553E9686CBDAFC13B562A /* SwiftChatView.swift */, A88E48AAC10463764CAD9835 /* VideoAttachmentPreview.swift */, D1407657E9EBF5EC3FC1D327 /* VideoNotePreview.swift */, ); path = Components; sourceTree = ""; }; + FFFA7B7564720060678770FC /* Packages */ = { + isa = PBXGroup; + children = ( + B2DC8B629E8D4F078B192FAB /* MartinOMEMO */, + ); + name = Packages; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -629,19 +828,19 @@ buildConfigurationList = 39082DED094B543B7F177A5B /* Build configuration list for PBXNativeTarget "Luma" */; buildPhases = ( 71162CE529E90DAEDA4BB5A5 /* Sources */, + 988CDDC80A2A68B4C03DF20F /* Resources */, 94D6F6F04126826503BA1E53 /* Frameworks */, - 8C792A6B9B9150263A7D975B /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( - 41772D492E3D837DE18E4556 /* PBXTargetDependency */, ); name = Luma; packageProductDependencies = ( 4BD4324EE01A6DD46EBE7FDA /* Martin */, 371134B6F3E284082E28FE08 /* MartinOMEMO */, EFCAF9B0DE67466AF22E76C3 /* WebRTC */, + 01363A8EB6E9A3C97C1B316D /* OpenSSL */, ); productName = Luma; productReference = DF8C531E464C9C4EDE2648C6 /* Luma.app */; @@ -652,6 +851,7 @@ buildConfigurationList = 23A91CAAA509D787E736CA14 /* Build configuration list for PBXNativeTarget "LumaMac" */; buildPhases = ( A14B6411EF58F6392A388E9C /* Sources */, + A384208D84D36B531C7EE07C /* Resources */, BA7C6C9ED1A007DDBA5E2B35 /* Frameworks */, ); buildRules = ( @@ -663,16 +863,36 @@ 6EDEC6EEE315B77491214398 /* Martin */, 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */, 2A745A82DD6F34F0CB46B52B /* WebRTC */, + A99B4C1B1459E1C34F214164 /* OpenSSL */, ); productName = LumaMac; - productReference = EA54340DD785335237158BEF /* Luma.app */; + productReference = EA54340DD785335237158BEF /* LumaMac.app */; productType = "com.apple.product-type.application"; }; + A086B6D25E7969CB643F1C8A /* LumaUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 06AF122279B2585E145DB210 /* Build configuration list for PBXNativeTarget "LumaUITests" */; + buildPhases = ( + DE1009798E1797BDAC72B608 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + C675A3F8639BBF6792B09F39 /* PBXTargetDependency */, + ); + name = LumaUITests; + packageProductDependencies = ( + ); + productName = LumaUITests; + productReference = 96720381802141EBA1DA9772 /* LumaUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; BAB78463D7DE07A8F7B3D27D /* LumaWatch */ = { isa = PBXNativeTarget; buildConfigurationList = 5D8E754B3FFEE24B370D350E /* Build configuration list for PBXNativeTarget "LumaWatch" */; buildPhases = ( 90BB4F269E01888E36AAD92A /* Sources */, + 20BDAC9BC4C78DB60C5FCD00 /* Resources */, ); buildRules = ( ); @@ -710,7 +930,21 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 2660; + LastUpgradeCheck = 1600; + TargetAttributes = { + 0565F6C7DA26F0482928704E = { + DevelopmentTeam = K3AS449A74; + }; + 74A5CD40283A16247F9F36B2 = { + DevelopmentTeam = K3AS449A74; + }; + A086B6D25E7969CB643F1C8A = { + TestTargetID = 0565F6C7DA26F0482928704E; + }; + BAB78463D7DE07A8F7B3D27D = { + DevelopmentTeam = K3AS449A74; + }; + }; }; buildConfigurationList = DEFB5A06B29D6BCD602A2291 /* Build configuration list for PBXProject "Luma" */; developmentRegion = ru; @@ -723,8 +957,9 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 277642F04D20E1E2B6BE565F /* XCRemoteSwiftPackageReference "Martin" */, - 43A0FD3A38EEEB7A14708794 /* XCRemoteSwiftPackageReference "MartinOMEMO" */, + CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */, 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */, + 3B2A3CB97916A28030D0A4E0 /* XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO" */, ); preferredProjectObjectVersion = 77; productRefGroup = 90CFB4B53B5F9EF91D718FC2 /* Products */; @@ -733,12 +968,43 @@ targets = ( 0565F6C7DA26F0482928704E /* Luma */, 74A5CD40283A16247F9F36B2 /* LumaMac */, - D4CD85C144854C8DA57B3903 /* LumaTests */, BAB78463D7DE07A8F7B3D27D /* LumaWatch */, + D4CD85C144854C8DA57B3903 /* LumaTests */, + A086B6D25E7969CB643F1C8A /* LumaUITests */, ); }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 20BDAC9BC4C78DB60C5FCD00 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2987B54F6E358AAE2EE19993 /* AppAssets.xcassets in Resources */, + E449F2B17E6D5053EBA7F6A8 /* Shared.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 988CDDC80A2A68B4C03DF20F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9B53D50C5EDE4AD507C3F5B6 /* AppAssets.xcassets in Resources */, + 1F467DB76CE93C90F09E2991 /* Shared.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + A384208D84D36B531C7EE07C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2F175DFA6D0FBF31B7EED21F /* AppAssets.xcassets in Resources */, + 7227469C6E25E0F4FF7FC805 /* Shared.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 71162CE529E90DAEDA4BB5A5 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -746,8 +1012,13 @@ files = ( 00FCAB7210D005D2C30747CB /* AccountConfiguration.swift in Sources */, 32790D95A577DCE99937B539 /* AccountPreferences.swift in Sources */, + 2BC210A585AAA9D50ECC9A4B /* AppLockPolicy.swift in Sources */, + 7FBD6C44C2CA9D50E28E823A /* AppLockVault.swift in Sources */, + 920CB33C235AF4BDB4107BB7 /* AppLockView.swift in Sources */, B492852FD89322434B0914AE /* AppModel.swift in Sources */, F057C0C84B541A5564D0E7E7 /* ArchiveMessageBatchPolicy.swift in Sources */, + 7C89D0095051D751FA245324 /* ArchiveMetadataRecord.swift in Sources */, + E6890C743C5BBD0B287DDB53 /* ArchiveStore.swift in Sources */, ED3869D5658BAA59F55B1E3D /* ArchiveSyncCheckpoint.swift in Sources */, 1AC03E6E9EA1D0C1F59F7619 /* ArchiveSyncPagination.swift in Sources */, 088945A14C15828F5265AA29 /* ArchiveSyncRecoveryPolicy.swift in Sources */, @@ -760,12 +1031,14 @@ E9FA22C965454FA6890DC3D5 /* AvatarImageProcessor.swift in Sources */, A2AF2B19A1E9D9B8D6E1CD63 /* AvatarView.swift in Sources */, FC5986CE50C84FA77983767C /* BubbleShapes.swift in Sources */, + 81091B6BA7D793CA233812ED /* CallHistorySync.swift in Sources */, 2C963DA7FDA2A2BCA075507F /* CallSnapshot.swift in Sources */, 6FB79BEEEF151D297F4EBB33 /* CallView.swift in Sources */, 73A21AFC42D6C00E6C2C5082 /* CertificateTrustEvaluator.swift in Sources */, - AEC21F39F40AFBD3B4225B3C /* ChatArchive.swift in Sources */, + 97A3A0635ADA8A8227C94216 /* ChatBubbleShape.swift in Sources */, 7AC93485DA12BC90717739E9 /* ChatMediaImageCache.swift in Sources */, 66489D4D15366683D3095B3D /* ChatMessage.swift in Sources */, + 65D2B572D508EF10931420FD /* ChatMessageEntrancePolicy.swift in Sources */, D5E6B1859101ABBD33D90C3C /* ChatScrollPositionPolicy.swift in Sources */, 1E239B9338CEEA7EA704626C /* ChatTimelineEntry.swift in Sources */, 433A7B65E635FB59021F7D87 /* ChatTypingPolicy.swift in Sources */, @@ -775,22 +1048,34 @@ 1655A2B7ACF4003EF41A1AB8 /* ConversationRow.swift in Sources */, 64F4AD83D86B8900DE69F117 /* CredentialVault.swift in Sources */, EE4DFDBA97562C53C53FBF51 /* CryptoKitAESGCMEngine.swift in Sources */, + 2D32E57F5CE1BF2EAE078492 /* DeviceResource.swift in Sources */, + BEF48E72571CF850AAA7F17F /* EmojiCatalog.swift in Sources */, + 791AB9983E2146DBF1D7B606 /* EmojiPickerView.swift in Sources */, 9CA60ADF5AEFB8CE70B25EE8 /* EncryptionDevicesView.swift in Sources */, 9393464C4C3BE5F9546FE8BB /* EncryptionPreference.swift in Sources */, 8629497F7160C95747886EE4 /* ForwardMessageView.swift in Sources */, D6ED224CC13439A99A388676 /* GeoLocation.swift in Sources */, DBB0216BFAB04C3847FEE95C /* GroupInfoView.swift in Sources */, DA77E8A49D2D091EC0975DD3 /* InlineVideoPlayer.swift in Sources */, + CC51DABF2D4E07795E1B78C0 /* LegacyArchiveImporter.swift in Sources */, 58A606B016094F1845CB0119 /* LocationMessagePreview.swift in Sources */, 814FE6C4CB53207F236E9236 /* LocationPickerView.swift in Sources */, 89712B216A98861B5D994587 /* LocationProvider.swift in Sources */, F80493F220991FF8A7CFF681 /* LoginView.swift in Sources */, 2D83457A8857592E23289EF7 /* LumaApp.swift in Sources */, 5415AF453AB8FCDA55FB59AD /* LumaCallEngine.swift in Sources */, + 5459CF1808474747AF46A366 /* LumaConnectionStatsModule.swift in Sources */, + 1C78234B3FA4E528C7F701B7 /* LumaOMEMO2Module.swift in Sources */, FB1052104B90994CF03FE6A2 /* LumaOMEMOStore.swift in Sources */, CBED8565C1313DC03D21A105 /* LumaRoomStore.swift in Sources */, F1713D1CD4E019540BFA78FE /* LumaRosterStore.swift in Sources */, - 08696800299D2D7BCCBCAA58 /* MainChatView.swift in Sources */, + 059A5169D66AD8F04B341403 /* LumaSaslChallengeModule.swift in Sources */, + 0BD15E9B1C488AA2B7B56AF7 /* LumaSaslFailureModule.swift in Sources */, + ED1C54A2FB844226AA284CCC /* LumaScramPlusMechanism.swift in Sources */, + E6B279889E62AB9AD0C5BCE1 /* LumaScramSha512Mechanism.swift in Sources */, + 670970182A27A26B8E44E687 /* LumaTLSNetworkProcessor.swift in Sources */, + D12F19AE38C13C6F37A36614 /* MainTabView.swift in Sources */, + CA39FA78E8BFE26BEBF976E5 /* MediaDownloadService.swift in Sources */, 0E4CE634E1568EE121B8AF75 /* MediaFileIO.swift in Sources */, 2822384FC6AF9685C4D014C1 /* MediaMetadata.swift in Sources */, 7D93BF350FBBF6F9EB29BA23 /* MediaPickerSelectionPolicy.swift in Sources */, @@ -800,6 +1085,7 @@ B33870CD89289E44882750C3 /* MediaViewer.swift in Sources */, EBAF4861DD93FBEAA4B37D1C /* MediaViewerItem.swift in Sources */, 230E0DAA5396EA507D6F6D47 /* MessageBubble.swift in Sources */, + 33EF454D77366A0E9137D824 /* MessageBubbleEntrance.swift in Sources */, FD9F8640841236BB78563573 /* MessageReaction.swift in Sources */, 30BE19F459FDDAD640F66C68 /* MessageReplyFallback.swift in Sources */, 94FB3597E01FC508F872861E /* MessageReplySwipePolicy.swift in Sources */, @@ -808,20 +1094,31 @@ D246709CE1508C297DD28AE1 /* NotificationCoordinator.swift in Sources */, 66613FF7E056788222552D6C /* NotificationPolicy.swift in Sources */, 7809154E961CC8EFE7EE852F /* OMEMODevice.swift in Sources */, + A73E17CEC3F4E8624102FC17 /* OlderHistoryScrollPolicy.swift in Sources */, 6216C6AA3787BBF776D78E91 /* PhoneWatchBridge.swift in Sources */, C9CB8C50E85BD8F69ED0105F /* PhotoAttachmentPreview.swift in Sources */, + 5C1B4AD1C5FB542B1E7154A7 /* PreviewSupport.swift in Sources */, 067D121E9C8D8EA076D974FB /* RTCVideoRendererView.swift in Sources */, + 1BD08940BB8E5DE4EDB2AF30 /* ReadStateSync.swift in Sources */, 67438C8911F9B7362073B591 /* ReplyThreadOverlay.swift in Sources */, ABAFC50EF551861779FFD9A1 /* RootView.swift in Sources */, + D6718F8B1359CD06F44A91A8 /* SASLMechanismPreference.swift in Sources */, + 9F288ACC92CD62A2E0CCE157 /* SASLprep.swift in Sources */, + A4A742F3BBEFCAFD29975968 /* SaslFailureMessage.swift in Sources */, + 01E4131ED4DBED0BEAD465A2 /* ServerInfoView.swift in Sources */, + 9AB2A519206C5016B0D8C6DE /* ServerInformation.swift in Sources */, CC152AA73AC9347A300BBECC /* SettingsView.swift in Sources */, D3777F3C0A81F08A9983048B /* StaticDNSSrvResolver.swift in Sources */, + 1C948E97A14DBA90C24A68FF /* SwiftChatView.swift in Sources */, 3375874DFCE22B22879112DD /* SystemPhotoCameraView.swift in Sources */, + 64C9A8E42046AC0FCB701613 /* TLSVersionProbe.swift in Sources */, E67E0D2C3FBA2C3739B692E0 /* VideoAttachmentPreview.swift in Sources */, 3C2727D165D2EF1CC742EEF7 /* VideoNoteCaptureView.swift in Sources */, 44AF85896EE023775B91DE8B /* VideoNotePreview.swift in Sources */, 01EBE88C86DB1C4745D1AB08 /* VideoNoteRecorder.swift in Sources */, 275501728E0597A8E53F1D4D /* VideoNoteRecordingCompletionPolicy.swift in Sources */, 1D5B3487BDC63B051A33D874 /* VideoNoteRecordingLifecycle.swift in Sources */, + 32E78EE5035DD08181B50B86 /* VideoNoteRotationPolicy.swift in Sources */, F8B9CBE6849C45F828BE3BD4 /* XMPPService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -843,8 +1140,13 @@ files = ( D551F2AA5DCCCCDD8D8FB9BB /* AccountConfiguration.swift in Sources */, 3245372B071227092B8594C0 /* AccountPreferences.swift in Sources */, + 14059C4EF59FBBDCC5338420 /* AppLockPolicy.swift in Sources */, + C23C74AEDA88885A209C9BB1 /* AppLockVault.swift in Sources */, + 5D80C9CBCE79DE69A7A6ABA4 /* AppLockView.swift in Sources */, 21AAF3FB5846B1B54BBEE7ED /* AppModel.swift in Sources */, 9E71E1DC6BAA1E9CA32C5355 /* ArchiveMessageBatchPolicy.swift in Sources */, + AF45DBB2E59284475C6F6585 /* ArchiveMetadataRecord.swift in Sources */, + 4E1B116771922F92FEEB079B /* ArchiveStore.swift in Sources */, 4EFADFE085B42E0B0593E501 /* ArchiveSyncCheckpoint.swift in Sources */, 79169D5FE931EBE0D0801516 /* ArchiveSyncPagination.swift in Sources */, 496D99C625D771D6B91D99E7 /* ArchiveSyncRecoveryPolicy.swift in Sources */, @@ -857,12 +1159,14 @@ 203D6124DF78461D8E86FDA8 /* AvatarImageProcessor.swift in Sources */, A29C828AC4B571941BF0B8E9 /* AvatarView.swift in Sources */, 6DC36AA33268FC6457FADAE2 /* BubbleShapes.swift in Sources */, + 8831E0A88F3759766F9BCC34 /* CallHistorySync.swift in Sources */, 4CD33DF0D193A7EED84A673F /* CallSnapshot.swift in Sources */, 087E600676B3F6134BB26112 /* CallView.swift in Sources */, 3D74BDDE2D978A2CC795E33A /* CertificateTrustEvaluator.swift in Sources */, - 5FB4DEEA5321D80CCD63B864 /* ChatArchive.swift in Sources */, + 2A7611650AB52EDABB978362 /* ChatBubbleShape.swift in Sources */, 6FCF6A4EFF8AE1FBFC2D81E1 /* ChatMediaImageCache.swift in Sources */, 8714A5A67956D6A78D4D3E4B /* ChatMessage.swift in Sources */, + 4B65093C19D148E04A675440 /* ChatMessageEntrancePolicy.swift in Sources */, 8B98B818AC4379F03907B0E0 /* ChatScrollPositionPolicy.swift in Sources */, 7F7BA6805C1D1EBCEAFDD3AD /* ChatTimelineEntry.swift in Sources */, B4AA654DF3A53AAAAC23915D /* ChatTypingPolicy.swift in Sources */, @@ -872,22 +1176,34 @@ 51AE33BE56ED4543014119D0 /* ConversationRow.swift in Sources */, EE2BF07D86C00AB6358EA5AE /* CredentialVault.swift in Sources */, E582A0424A63CA16DDA4E984 /* CryptoKitAESGCMEngine.swift in Sources */, + D7179E7F8127A702FC58D3D4 /* DeviceResource.swift in Sources */, + FDB78F0A58D66E36B9E25A47 /* EmojiCatalog.swift in Sources */, + 411136B21632A796E26B207C /* EmojiPickerView.swift in Sources */, FCD330D04ED5B87E28883E58 /* EncryptionDevicesView.swift in Sources */, A07ABE478F7F48C3FEDC22D5 /* EncryptionPreference.swift in Sources */, 25A9ADB0FF1496A4D3EF431D /* ForwardMessageView.swift in Sources */, 7BC399937E4586EC5067282C /* GeoLocation.swift in Sources */, D3389B083A5780A9C06B23DF /* GroupInfoView.swift in Sources */, 6AB86E72201EB1A61898AD5F /* InlineVideoPlayer.swift in Sources */, + F881ABC5077A3330B29C0DE6 /* LegacyArchiveImporter.swift in Sources */, 64C8B9876278B711CC6479E0 /* LocationMessagePreview.swift in Sources */, B9CAB8C2C16A5A05CE38E8D7 /* LocationPickerView.swift in Sources */, 49DC659401787CF44A33B620 /* LocationProvider.swift in Sources */, 3D67F14421F89B6DA4400642 /* LoginView.swift in Sources */, 7F012252FA73A8282B92323C /* LumaApp.swift in Sources */, 7F211C3BBA81E5D43D3A88AE /* LumaCallEngine.swift in Sources */, + B13513FE5DAD462086B8B0B0 /* LumaConnectionStatsModule.swift in Sources */, + E4B1FDFFD3BFFC47B5F154DE /* LumaOMEMO2Module.swift in Sources */, 1B20F2D3E4F18E6094B103BF /* LumaOMEMOStore.swift in Sources */, 03C65C3DAA1D8776AF496EFA /* LumaRoomStore.swift in Sources */, 7F4C00E98574C8E5193F442E /* LumaRosterStore.swift in Sources */, - 7DA3177772595E37DC8066E4 /* MainChatView.swift in Sources */, + 7D095DA2AAD5E7D813586E7D /* LumaSaslChallengeModule.swift in Sources */, + C1AB954660804F7925607038 /* LumaSaslFailureModule.swift in Sources */, + FCC5133038CDBEC39B5B42D3 /* LumaScramPlusMechanism.swift in Sources */, + 127C4B7A5083081D22FD21CD /* LumaScramSha512Mechanism.swift in Sources */, + F5121F29EF3BEFD4FD54DD12 /* LumaTLSNetworkProcessor.swift in Sources */, + 6000FF9931D3399B53C8506C /* MainTabView.swift in Sources */, + 523D54621DD4DDD54EA8D510 /* MediaDownloadService.swift in Sources */, EFC29F3543B1371E63D48510 /* MediaFileIO.swift in Sources */, AFCBFB59F729DE95E453A7D3 /* MediaMetadata.swift in Sources */, D80DC91DBAE924BE47EFBD6F /* MediaPickerSelectionPolicy.swift in Sources */, @@ -897,6 +1213,7 @@ CC5297F90A3DBD45C5E41A3A /* MediaViewer.swift in Sources */, 2C5BAAEF4EC7AAE5FE0FEEDB /* MediaViewerItem.swift in Sources */, A90142509385DE6E83818953 /* MessageBubble.swift in Sources */, + 52CA5DEE0CF7697D1B2D1FEA /* MessageBubbleEntrance.swift in Sources */, ECCD95DD4E16E0B393C707A9 /* MessageReaction.swift in Sources */, C2CC9BABAE68FA9258694766 /* MessageReplyFallback.swift in Sources */, D4E1CF1F7D948B2E72C063BE /* MessageReplySwipePolicy.swift in Sources */, @@ -905,20 +1222,31 @@ B91AA414D00C6B54BE32ED25 /* NotificationCoordinator.swift in Sources */, D63F32533880B0D715ECFC79 /* NotificationPolicy.swift in Sources */, 097E9E96B82958C505DCA514 /* OMEMODevice.swift in Sources */, + AF1AE686F7B4915C4636099D /* OlderHistoryScrollPolicy.swift in Sources */, FF07DC53E2877BFDE726132C /* PhoneWatchBridge.swift in Sources */, 46985C2741D425D6486849F3 /* PhotoAttachmentPreview.swift in Sources */, + 6826D225FFEA985B6C7AD184 /* PreviewSupport.swift in Sources */, C7AE34FF0A0A25D0C81D3271 /* RTCVideoRendererView.swift in Sources */, + 9D2352A11D04AB0F2DDBD368 /* ReadStateSync.swift in Sources */, 3CFEAC0C613CC9248DA8E35E /* ReplyThreadOverlay.swift in Sources */, 3E3A82A737D9009D9BDC04E0 /* RootView.swift in Sources */, + 120BD1C8A163015AF27FFA42 /* SASLMechanismPreference.swift in Sources */, + 3BA375F1E0934FB4DD2148F9 /* SASLprep.swift in Sources */, + F80767EC81E8CAFAA280DD6A /* SaslFailureMessage.swift in Sources */, + 3D3DDA2292673CE4FF397174 /* ServerInfoView.swift in Sources */, + 939ABD4DED652588957491CF /* ServerInformation.swift in Sources */, E2245416CB055ECEF0C5F81D /* SettingsView.swift in Sources */, 0ECAB3E52DD12AD78AABE5F5 /* StaticDNSSrvResolver.swift in Sources */, + 945014504AA02172175B48D2 /* SwiftChatView.swift in Sources */, 46924A3275BFEFD2218AD748 /* SystemPhotoCameraView.swift in Sources */, + 2A728A647EDD4F098802C64C /* TLSVersionProbe.swift in Sources */, 6156698A5A514CA01411489D /* VideoAttachmentPreview.swift in Sources */, 640C30ABB02E6486E6B7665E /* VideoNoteCaptureView.swift in Sources */, 03629CA43EB6D367498E90E8 /* VideoNotePreview.swift in Sources */, 1CB1DAF27B8632B2E071338E /* VideoNoteRecorder.swift in Sources */, B429AC460AF49367463F59A8 /* VideoNoteRecordingCompletionPolicy.swift in Sources */, CB66B3FCB7036E9AE50B6BF3 /* VideoNoteRecordingLifecycle.swift in Sources */, + 054AED20075FF49401C3E59C /* VideoNoteRotationPolicy.swift in Sources */, FE958AD8FF4A43DB9A887882 /* XMPPService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -928,14 +1256,17 @@ buildActionMask = 2147483647; files = ( 6B0B8F3CAC1E5854C6F81E70 /* AccountConfigurationTests.swift in Sources */, + 4DE3B27CFA3D524590FB751A /* AppLockPolicyTests.swift in Sources */, 822F3F8A5C87C493A8974D6B /* ArchiveMessageBatchPolicyTests.swift in Sources */, + F320BBE2BE143D15398C46F9 /* ArchiveStoreTests.swift in Sources */, C7B799E6A90CA49208328F15 /* ArchiveSyncCheckpointTests.swift in Sources */, 7425493304D731FA218E9151 /* ArchiveSyncPaginationTests.swift in Sources */, 481F1C57B07AF615A3629C7E /* ArchiveSyncRecoveryPolicyTests.swift in Sources */, 10273AF92A952B707C58AEE6 /* ArchiveSyncWorkBudgetTests.swift in Sources */, + 6950D2E35897C3A6C537AE08 /* CallHistorySyncTests.swift in Sources */, B7ED810A1DD43B717DDC40B3 /* CallSDPOrderingTests.swift in Sources */, 61D41048E5021EEAC434DD90 /* CallSnapshotTests.swift in Sources */, - C557E56AE73731A5BC2FD327 /* ChatArchiveTests.swift in Sources */, + 7B3DD63332C8149FAE907B0E /* ChatMessageDeliveryTests.swift in Sources */, 12D3BEB345B65FB73DD3F560 /* ChatScrollPositionPolicyTests.swift in Sources */, 5409F5C90FBE0817F4AC8176 /* ChatTimelineEntryTests.swift in Sources */, 3F1571511219DBA18F67209F /* ChatTypingPolicyTests.swift in Sources */, @@ -944,9 +1275,11 @@ 03F2CD213153D0EF5CBC8021 /* EncryptionPreferenceTests.swift in Sources */, A69C1CD6CC1FF13631044BA3 /* GeoLocationTests.swift in Sources */, 60CE79BB4D0FB53E126354AA /* GroupConversationTests.swift in Sources */, + A87AB7D40BD589F5EBCCBD6F /* LumaOMEMO2Tests.swift in Sources */, DC4FAE63AB8AF89C30512763 /* MediaFileIOTests.swift in Sources */, 10C5382981A87A2963DD397C /* MediaMetadataTests.swift in Sources */, 958FD3CBEC600651E1171B55 /* MediaPickerSelectionPolicyTests.swift in Sources */, + 312B8D266C5BEC4F7B2F3276 /* MediaPreviewProcessorVideoTests.swift in Sources */, 4B2CFA4008D1234DCE35ADDE /* MediaSendActivityTrackerTests.swift in Sources */, F27ACACEF60AFAB5AF96EF98 /* MediaTimeFormatterTests.swift in Sources */, AC242F69C28F69A001D6559D /* MediaViewerDismissGestureTests.swift in Sources */, @@ -956,26 +1289,44 @@ 5875F7D2870C985287B36AFA /* MessageReplyFallbackTests.swift in Sources */, 0535722A2D4FC893F16D78F1 /* MessageReplySwipeTests.swift in Sources */, 9E98F99F3FBFC9DAB99A3650 /* NotificationPolicyTests.swift in Sources */, + A6B05CBBA8A2B4C33B41F47D /* OlderHistoryScrollPolicyTests.swift in Sources */, + D0EB41F8E01D4B654254E101 /* ReadStateSyncTests.swift in Sources */, + 5A5C811D7D19AE7C694273FC /* SASLMechanismPreferenceTests.swift in Sources */, + 1B7C9F60AA7BFF6A2C0FE45D /* SASLprepTests.swift in Sources */, + DD7E0B9153FCC21BAF6EE655 /* SCRAMDowngradeProtectionTests.swift in Sources */, + 662B8E72F00F5A77DE28819A /* SCRAMPlusTests.swift in Sources */, + 3BF0521699C170F8411386CD /* SCRAMSHA512Tests.swift in Sources */, + 3DDB1B15BCE5B58D12B60558 /* SaslFailureMessageTests.swift in Sources */, FEB3A38F302E95303F0E8773 /* VideoNoteRecordingCompletionPolicyTests.swift in Sources */, E65D356ABC5320BBFB3E2F6E /* VideoNoteRecordingLifecycleTests.swift in Sources */, + 48C34A0CBF309A652AE350B3 /* VideoNoteRotationPolicyTests.swift in Sources */, 89E2B0373E2DA2B25863A866 /* VideoNoteStopPolicyTests.swift in Sources */, 7306BA14F94818248594AF91 /* WatchVoiceMessageTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + DE1009798E1797BDAC72B608 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 9E36AF2F40D4EC3AF5CE1680 /* LayoutCheckUITests.swift in Sources */, + 48812531C1B7DB7752F06DEF /* TimelineUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 41772D492E3D837DE18E4556 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = BAB78463D7DE07A8F7B3D27D /* LumaWatch */; - targetProxy = 0722750D38A3B60127F87761 /* PBXContainerItemProxy */; - }; 451A9C42B8C63659ECBC26D9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 0565F6C7DA26F0482928704E /* Luma */; targetProxy = 277A193FE4C68A777257EF0D /* PBXContainerItemProxy */; }; + C675A3F8639BBF6792B09F39 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0565F6C7DA26F0482928704E /* Luma */; + targetProxy = 5C6218BA484572FDB4727ECA /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -987,15 +1338,14 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Config/LumaMac.entitlements; COMBINE_HIDPI_IMAGES = YES; - DEAD_CODE_STRIPPING = YES; - "DEVELOPMENT_TEAM[sdk=macosx*]" = K3AS449A74; + DEVELOPMENT_TEAM = K3AS449A74; INFOPLIST_FILE = "Config/LumaMac-Info.plist"; INFOPLIST_KEY_CFBundleIconName = AppIcon; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.macos; PRODUCT_NAME = Luma; SDKROOT = macosx; @@ -1010,15 +1360,14 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CODE_SIGN_ENTITLEMENTS = Config/LumaMac.entitlements; COMBINE_HIDPI_IMAGES = YES; - DEAD_CODE_STRIPPING = YES; - "DEVELOPMENT_TEAM[sdk=macosx*]" = K3AS449A74; + DEVELOPMENT_TEAM = K3AS449A74; INFOPLIST_FILE = "Config/LumaMac-Info.plist"; INFOPLIST_KEY_CFBundleIconName = AppIcon; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 14.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.macos; PRODUCT_NAME = Luma; SDKROOT = macosx; @@ -1036,15 +1385,16 @@ DEVELOPMENT_TEAM = K3AS449A74; INFOPLIST_FILE = "Config/Luma-Info.plist"; INFOPLIST_KEY_CFBundleIconName = AppIcon; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat; + PRODUCT_BUNDLE_IDENTIFIER = "$(COMPANION_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = Luma; SDKROOT = iphoneos; SUPPORTS_MACCATALYST = NO; + SWIFT_SUPPRESS_WARNINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1064,7 +1414,7 @@ SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; }; @@ -1073,7 +1423,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1086,12 +1436,30 @@ }; name = Release; }; + 64B3F29554E68D692FBF07CD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Luma; + }; + name = Release; + }; 6A07A459DB18752AD74A145A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1108,7 +1476,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -1139,9 +1506,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COMPANION_BUNDLE_IDENTIFIER = app.luma.chat; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 31; - DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -1160,12 +1527,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MARKETING_VERSION = 0.10.6; + MARKETING_VERSION = 20261013.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.9; @@ -1187,7 +1553,7 @@ SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 10.0; + WATCHOS_DEPLOYMENT_TARGET = 26.0; }; name = Release; }; @@ -1195,7 +1561,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -1226,9 +1591,9 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COMPANION_BUNDLE_IDENTIFIER = app.luma.chat; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 31; - DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1241,17 +1606,34 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MARKETING_VERSION = 0.10.6; + MARKETING_VERSION = 20261013.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; - STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.9; }; name = Release; }; + E381B8E38C4B5D9CBB9E6416 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Luma; + }; + name = Debug; + }; FEECD67107BB7AE74F3574C8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1263,15 +1645,16 @@ DEVELOPMENT_TEAM = K3AS449A74; INFOPLIST_FILE = "Config/Luma-Info.plist"; INFOPLIST_KEY_CFBundleIconName = AppIcon; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = app.luma.chat; + PRODUCT_BUNDLE_IDENTIFIER = "$(COMPANION_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = Luma; SDKROOT = iphoneos; SUPPORTS_MACCATALYST = NO; + SWIFT_SUPPRESS_WARNINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1288,6 +1671,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 06AF122279B2585E145DB210 /* Build configuration list for PBXNativeTarget "LumaUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E381B8E38C4B5D9CBB9E6416 /* Debug */, + 64B3F29554E68D692FBF07CD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 23A91CAAA509D787E736CA14 /* Build configuration list for PBXNativeTarget "LumaMac" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1326,6 +1718,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 3B2A3CB97916A28030D0A4E0 /* XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ThirdParty/MartinOMEMO; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */ = { isa = XCRemoteSwiftPackageReference; @@ -1343,17 +1742,22 @@ version = 3.2.4; }; }; - 43A0FD3A38EEEB7A14708794 /* XCRemoteSwiftPackageReference "MartinOMEMO" */ = { + CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/tigase/MartinOMEMO.git"; + repositoryURL = "https://github.com/krzyzanowskim/OpenSSL.git"; requirement = { - kind = exactVersion; - version = 2.2.3; + kind = upToNextMajorVersion; + minimumVersion = 3.4.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 01363A8EB6E9A3C97C1B316D /* OpenSSL */ = { + isa = XCSwiftPackageProductDependency; + package = CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */; + productName = OpenSSL; + }; 2A745A82DD6F34F0CB46B52B /* WebRTC */ = { isa = XCSwiftPackageProductDependency; package = 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */; @@ -1361,7 +1765,6 @@ }; 371134B6F3E284082E28FE08 /* MartinOMEMO */ = { isa = XCSwiftPackageProductDependency; - package = 43A0FD3A38EEEB7A14708794 /* XCRemoteSwiftPackageReference "MartinOMEMO" */; productName = MartinOMEMO; }; 4BD4324EE01A6DD46EBE7FDA /* Martin */ = { @@ -1376,9 +1779,13 @@ }; 7A670E49149C9C7E13F0A6A2 /* MartinOMEMO */ = { isa = XCSwiftPackageProductDependency; - package = 43A0FD3A38EEEB7A14708794 /* XCRemoteSwiftPackageReference "MartinOMEMO" */; productName = MartinOMEMO; }; + A99B4C1B1459E1C34F214164 /* OpenSSL */ = { + isa = XCSwiftPackageProductDependency; + package = CCC36972B3D047152DDB4A95 /* XCRemoteSwiftPackageReference "OpenSSL" */; + productName = OpenSSL; + }; EFCAF9B0DE67466AF22E76C3 /* WebRTC */ = { isa = XCSwiftPackageProductDependency; package = 251B06A6CDEEC0297E9E6836 /* XCRemoteSwiftPackageReference "WebRTC" */; diff --git a/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8546218..d0b6f4e 100644 --- a/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,4 @@ { - "originHash" : "a9b7decfad90e115a57d2c941faf30bf1f041c5fcd0d87ea51c92b76dfac7ca8", "pins" : [ { "identity" : "libsignal", @@ -20,12 +19,12 @@ } }, { - "identity" : "martinomemo", + "identity" : "openssl", "kind" : "remoteSourceControl", - "location" : "https://github.com/tigase/MartinOMEMO.git", + "location" : "https://github.com/krzyzanowskim/OpenSSL.git", "state" : { - "revision" : "3c162154d646aa258c9a86c0a07655a536e55a94", - "version" : "2.2.3" + "revision" : "4b1188ba947ddc23d03b13096b9e44b804e146c9", + "version" : "3.6.3000" } }, { @@ -47,5 +46,5 @@ } } ], - "version" : 3 + "version" : 2 } diff --git a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme index c16868a..e9e8b78 100644 --- a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme +++ b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme @@ -1,6 +1,6 @@ + + + + @@ -72,7 +71,6 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "BAB78463D7DE07A8F7B3D27D" BuildableName = "LumaWatch.app" - BlueprintName = "LumaWatch" ReferencedContainer = "container:Luma.xcodeproj"> diff --git a/Makefile b/Makefile index dd871b4..14e9b1f 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ -.PHONY: project open verify clean +.PHONY: project open verify clean reset project: @command -v xcodegen >/dev/null || (echo "Install XcodeGen first: brew install xcodegen" && exit 1) xcodegen generate + @python3 Scripts/patch-xcodeproj.py open: project open Luma.xcodeproj @@ -13,3 +14,12 @@ verify: clean: rm -rf Luma.xcodeproj DerivedData .build +# Fixes Xcode GUI "Missing package product" errors: the GUI caches the +# SwiftPM package graph in DerivedData, which goes stale when packages +# change (e.g. the remote -> vendored local MartinOMEMO switch). Quit Xcode +# first, run this, then reopen Luma.xcodeproj and let it re-resolve. +reset: + rm -rf ~/Library/Developer/Xcode/DerivedData/Luma-* + rm -f Luma.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved + @echo "Xcode package state cleared. Reopen Luma.xcodeproj to re-resolve packages." + diff --git a/README.md b/README.md index 2c8b780..1255f8a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Luma — рабочий MVP XMPP-клиента для iOS, iPadOS, macOS и App iPhone, iPad, Mac и Apple Watch; один и тот же catalog явно подключён ко всем app-target’ам, а имя закреплено в build settings и Info.plist; - адаптивный SwiftUI-интерфейс для iPhone, iPad и Mac; +- поддержка портретной и альбомной ориентации на iPhone и всех четырёх ориентаций на iPad; - companion-приложение watchOS: последние чаты, диктовка/ответы, а также запись голосового до 60 секунд с отменой и предпрослушиванием; `.m4a` передаётся на iPhone фоновой файловой очередью WatchConnectivity, после чего iPhone @@ -145,14 +146,18 @@ OMEMO, и для XEP-0084 аватаров. Для медиа и вложени XMPP-сокет. Для production нужны Apple Developer credentials, APNs provider, XEP-0357 push-компонент и регистрация приложения в этом компоненте. - При первом запуске Luma запрашивает ограниченную последнюю страницу - серверного MAM-архива, чтобы огромная история не блокировала интерфейс. + серверного MAM-архива, чтобы огромная история не блокировала интерфейс, и + показывает индикатор «Синхронизация истории…» только на этом первичном этапе. После успешного прохода сохраняются серверный UID последней записи и время; следующие подключения продолжают RSM строго после UID. Временное перекрытие используется только для старых snapshots без UID или один раз, если сервер - удалил сохранённый UID. Сырые stanza собираются вне main queue, расшифровываются - по одной с уступкой UI, а готовый проход публикуется одной пачкой. Один - foreground-проход также ограничен по страницам и времени; после ошибки он не - перезапускает сам себя бесконечно. + удалил сохранённый UID. Сырые stanza собираются вне main queue, а расшифровка + OMEMO выполняется на отдельной фоновой очереди — главный поток освобождается на + время Signal-криптографии, поэтому большая история не лагает интерфейс. + Инкрементальная догрузка backlog продолжается в фоне без вечного спиннера, а + сообщения применяются атомарными пачками с дедупликацией по `origin-id`/stanza + id через хеш-индексы. После сбоя отдельной страницы синхронизация автоматически + повторяется ограниченное число раз, не дожидаясь повторного открытия приложения. - Клиент не вводит собственного ограничения размера вложения: фактический предел сообщает XEP-0363 upload-компонент сервера. Видеосообщение ограничено 60 секундами. diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png new file mode 100644 index 0000000..b3ceff8 Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-Notification-45mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png new file mode 100644 index 0000000..a9b3e5f Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-45mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png new file mode 100644 index 0000000..1f780dc Binary files /dev/null and b/Resources/AppAssets.xcassets/AppIcon.appiconset/AppIcon-Watch-QuickLook-49mm@2x.png differ diff --git a/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json b/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json index 0cfcd9b..70b2d6d 100644 --- a/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json +++ b/Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json @@ -1,47 +1,305 @@ { "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" } + { + "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-Notification-45mm@2x.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "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-Watch-QuickLook-45mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "filename" : "AppIcon-Watch-QuickLook-49mm@2x.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "AppIcon-WatchStore-1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } ], "info" : { "author" : "xcode", diff --git a/Scripts/patch-xcodeproj.py b/Scripts/patch-xcodeproj.py new file mode 100644 index 0000000..0e79a4e --- /dev/null +++ b/Scripts/patch-xcodeproj.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Post-generation fix for XcodeGen issue #1549: XcodeGen does not link +XCSwiftPackageProductDependency entries to local (path-based) Swift packages, +which makes the Xcode GUI report "Missing package product". This script +attaches the MartinOMEMO product dependencies to the vendored local package +reference in the generated project file. + +Run after every `xcodegen generate` (see the Makefile `project` target). +""" +import sys + +PBXPROJ = "Luma.xcodeproj/project.pbxproj" + + +def main() -> int: + with open(PBXPROJ, encoding="utf-8") as handle: + text = handle.read() + + # Find the object id of the local package reference. + ref_id = None + for line in text.splitlines(): + marker = " /* XCLocalSwiftPackageReference \"ThirdParty/MartinOMEMO\" */ = {" + if marker in line: + ref_id = line.split(" /*")[0].strip() + break + if ref_id is None: + print("patch-xcodeproj: local package reference not found; nothing to do") + return 0 + + needle = ( + "/* MartinOMEMO */ = {\n" + "\t\t\tisa = XCSwiftPackageProductDependency;\n" + "\t\t\tproductName = MartinOMEMO;" + ) + patch = ( + "/* MartinOMEMO */ = {\n" + "\t\t\tisa = XCSwiftPackageProductDependency;\n" + "\t\t\tpackage = " + ref_id + " /* XCLocalSwiftPackageReference \"ThirdParty/MartinOMEMO\" */;\n" + "\t\t\tproductName = MartinOMEMO;" + ) + + count = text.count(needle) + if count == 0: + print("patch-xcodeproj: MartinOMEMO product dependencies already linked") + return 0 + patched = text.replace(needle, patch) + with open(PBXPROJ, "w", encoding="utf-8") as handle: + handle.write(patched) + print("patch-xcodeproj: linked " + str(count) + " MartinOMEMO product dependencies") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Scripts/regenerate-project.command b/Scripts/regenerate-project.command index 2e9d5bb..a71511d 100755 --- a/Scripts/regenerate-project.command +++ b/Scripts/regenerate-project.command @@ -10,4 +10,7 @@ if ! command -v xcodegen >/dev/null; then fi xcodegen generate +# XcodeGen issue #1549: link the vendored local package to its product +# dependencies, otherwise the Xcode GUI reports "Missing package product". +python3 Scripts/patch-xcodeproj.py open Luma.xcodeproj diff --git a/Scripts/verify.sh b/Scripts/verify.sh index df7d01d..bf20eb7 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -18,12 +18,14 @@ required=( Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift Sources/Shared/Models/ArchiveSyncWorkBudget.swift Sources/Shared/Models/ArchiveMessageBatchPolicy.swift + Sources/Shared/Models/OlderHistoryScrollPolicy.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/VideoNoteRotationPolicy.swift Sources/Shared/Models/VideoNoteRecordingLifecycle.swift Sources/Shared/Models/MessageReplySwipePolicy.swift Sources/Shared/Models/MessageReplyFallback.swift @@ -66,6 +68,7 @@ required=( Tests/MediaViewerDismissGestureTests.swift Tests/ArchiveSyncCheckpointTests.swift Tests/ArchiveSyncPaginationTests.swift + Tests/OlderHistoryScrollPolicyTests.swift Tests/ArchiveSyncRecoveryPolicyTests.swift Tests/ArchiveSyncWorkBudgetTests.swift Tests/ArchiveMessageBatchPolicyTests.swift @@ -77,9 +80,34 @@ required=( Tests/MediaPickerSelectionPolicyTests.swift Tests/VideoNoteRecordingCompletionPolicyTests.swift Tests/VideoNoteRecordingLifecycleTests.swift + Tests/VideoNoteRotationPolicyTests.swift Tests/VideoNoteStopPolicyTests.swift Tests/MessageReplySwipeTests.swift Tests/WatchVoiceMessageTests.swift + Tests/ArchiveStoreTests.swift + Tests/SASLprepTests.swift + Tests/SaslFailureMessageTests.swift + Tests/MediaPreviewProcessorVideoTests.swift + Tests/SCRAMSHA512Tests.swift + Tests/AppLockPolicyTests.swift + Tests/CallHistorySyncTests.swift + Tests/ReadStateSyncTests.swift + Tests/ChatMessageDeliveryTests.swift + UITests/TimelineUITests.swift + Sources/Shared/XMPP/LumaScramSha512Mechanism.swift + Sources/Shared/XMPP/CallHistorySync.swift + Sources/Shared/XMPP/ReadStateSync.swift + Sources/Shared/Security/AppLockVault.swift + Sources/Shared/Models/AppLockPolicy.swift + Sources/Shared/UI/AppLockView.swift + Sources/Shared/XMPP/SASLprep.swift + Sources/Shared/XMPP/LumaSaslFailureModule.swift + Sources/Shared/XMPP/SaslFailureMessage.swift + Sources/Shared/Models/ChatMessage.swift + Sources/Shared/Models/Conversation.swift + Sources/Shared/Persistence/ArchiveStore.swift + Sources/Shared/Persistence/ArchiveMetadataRecord.swift + Sources/Shared/Persistence/LegacyArchiveImporter.swift Brand/LumaIcon-1024.png Resources/AppAssets.xcassets/AppIcon.appiconset/Contents.json Regenerate-Luma-Project.command @@ -141,7 +169,7 @@ test "$(grep -c -- '- package: WebRTC' project.yml)" -eq 2 || { exit 1 } -if grep -R -q 'ChatBubbleShape\|maximumMediaSize\|25 МБ' Sources/Shared; then +if grep -R -q 'ChatBubbleTailShape\|maximumMediaSize\|25 МБ' Sources/Shared; then echo "Legacy bubble tails or the old client attachment limit are still present" exit 1 fi @@ -225,6 +253,10 @@ grep -q 'archiveWorkBudget.recordCompletedPage' Sources/Shared/XMPP/XMPPService. echo "Incremental MAM passes must have a finite foreground work budget" exit 1 } +grep -q 'OlderHistoryScrollPolicy.page(' Sources/Shared/XMPP/XMPPService.swift || { + echo "Interactive history paging must advance via the server's RSM first cursor" + exit 1 +} if grep -q 'scheduleArchiveRestart\|restartDelayNanoseconds' Sources/Shared/XMPP/XMPPService.swift; then echo "Failed MAM queries must not restart themselves indefinitely" exit 1 @@ -249,12 +281,244 @@ 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" +grep -q 'decodeSliceSize = 8' Sources/Shared/Models/ArchiveMessageBatchPolicy.swift || { + echo "MAM decoding must stay batched in small per-slice groups" exit 1 } -grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ChatArchive.swift || { - echo "The durable archive snapshot must persist the XEP-0313 UID cursor" +grep -q 'lastSuccessfulMAMCursor' Sources/Shared/Persistence/ArchiveStore.swift || { + echo "The durable archive store must persist the XEP-0313 UID cursor" + exit 1 +} +if test -f Sources/Shared/Persistence/ChatArchive.swift; then + echo "The legacy JSON ChatArchive actor must stay removed" + exit 1 +fi +grep -q '@Query' Sources/Shared/UI/MainTabView.swift || { + echo "Chat list must be SwiftData @Query-driven" + exit 1 +} +grep -q 'NavigationSplitView' Sources/Shared/UI/MainTabView.swift || { + echo "macOS must keep the Telegram Desktop-style split view" + exit 1 +} +grep -q 'SearchField' Sources/Shared/UI/MainTabView.swift || { + echo "Search fields must stay inline above the lists" + exit 1 +} +grep -q 'TabView(selection:' Sources/Shared/UI/MainTabView.swift || { + echo "The iOS main screen must use the native TabView tab bar" + exit 1 +} +grep -q 'toolbar(.hidden, for: .tabBar)' Sources/Shared/UI/ChatView.swift || { + echo "The tab bar must be hidden inside a pushed chat" + exit 1 +} +grep -q 'PhoneWatchMessageData' Sources/Shared/Services/PhoneWatchBridge.swift || { + echo "The watch bridge must snapshot SwiftData models on the main actor" + exit 1 +} +grep -q 'urn:xmpp:omemo:2' Sources/Shared/XMPP/LumaOMEMO2Module.swift || { + echo "OMEMO 2 must be implemented by the app-level module" + exit 1 +} +grep -q 'decodeOmemo2OffMain' Sources/Shared/XMPP/XMPPService.swift || { + echo "Incoming messages must route OMEMO 2 payloads to the OMEMO 2 module" + exit 1 +} +grep -q 'XCLocalSwiftPackageReference "ThirdParty/MartinOMEMO"' Luma.xcodeproj/project.pbxproj || { + echo "The vendored MartinOMEMO package must stay linked in the generated project (run make project)" + exit 1 +} +grep -q '@Query' Sources/Shared/UI/ChatView.swift || { + echo "Chat timeline must be SwiftData @Query-driven" + exit 1 +} +grep -q '@Query' Sources/Shared/UI/ForwardMessageView.swift || { + echo "Forward destination list must be SwiftData @Query-driven" + exit 1 +} +grep -q '\.modelContext' Sources/Shared/UI/RootView.swift || { + echo "RootView must inject the SwiftData ModelContext" + exit 1 +} +grep -q 'modelContext.delete(message)' Sources/Shared/Models/AppModel.swift || { + echo "Local message deletion must remove the SwiftData row" + exit 1 +} +grep -q 'purgeLocallyDeletedMessages' Sources/Shared/Persistence/ArchiveStore.swift || { + echo "ArchiveStore must purge locally deleted rows for @Query views" + exit 1 +} +grep -q 'completeUntilFirstUserAuthentication' Sources/Shared/Persistence/ArchiveStore.swift || { + echo "The SwiftData store must keep the data protection attribute" + exit 1 +} +grep -q 'SASLprep' Sources/Shared/XMPP/XMPPService.swift || { + echo "SCRAM passwords must be RFC 4013 (SASLprep) normalized" + exit 1 +} +grep -q 'LumaSaslFailureModule' Sources/Shared/XMPP/XMPPService.swift || { + echo "The raw SASL failure condition must be captured for the UI" + exit 1 +} +grep -q 'Сервер отклонил имя пользователя или пароль' Sources/Shared/XMPP/SaslFailureMessage.swift || { + echo "SASL failures must produce an actionable Russian message" + exit 1 +} +grep -q "doesn't match the one we calculated" Sources/Shared/XMPP/SaslFailureMessage.swift || { + echo "Prosody SCRAM proof mismatches must be explained in Russian" + exit 1 +} +grep -q 'passwordVisible' Sources/Shared/UI/LoginView.swift || { + echo "The login screen must let the user verify the typed password" + exit 1 +} +grep -q 'SCRAM-SHA-512' Sources/Shared/XMPP/LumaScramSha512Mechanism.swift || { + echo "The SCRAM-SHA-512 mechanism must be available" + exit 1 +} +grep -q 'LumaScramSha512Mechanism(channelBindingStore: channelBindingStore)' Sources/Shared/XMPP/XMPPService.swift || { + echo "SCRAM-SHA-512 must be registered ahead of Martin's mechanisms" + exit 1 +} +grep -q 'NSFaceIDUsageDescription' project.yml || { + echo "Biometric unlock must declare its usage description" + exit 1 +} +grep -q 'unlockWithBiometrics' Sources/Shared/Models/AppModel.swift || { + echo "The app lock must support biometric unlock" + exit 1 +} +grep -q 'AppLockView' Sources/Shared/UI/RootView.swift || { + echo "The lock screen must cover every app layer" + exit 1 +} +grep -q 'Блокировка приложения' Sources/Shared/UI/SettingsView.swift || { + echo "Settings must offer the app lock toggle" + exit 1 +} +grep -q 'https://luma.chat/call-history' Sources/Shared/XMPP/CallHistorySync.swift || { + echo "Call history must sync through its service namespace" + exit 1 +} +grep -q 'syncCallHistory' Sources/Shared/Models/AppModel.swift || { + echo "Call cards must be synced to the user's other devices" + exit 1 +} +grep -q 'CallHistorySync.envelope' Sources/Shared/XMPP/XMPPService.swift || { + echo "Incoming call-history payloads must be intercepted" + exit 1 +} +grep -q 'https://luma.chat/read-marker' Sources/Shared/XMPP/ReadStateSync.swift || { + echo "Read state must sync through its service namespace" + exit 1 +} +grep -q 'ReadStateSync.envelope' Sources/Shared/XMPP/XMPPService.swift || { + echo "Incoming read-marker payloads must be intercepted" + exit 1 +} +grep -q 'syncReadState' Sources/Shared/Models/AppModel.swift || { + echo "Read receipts must be synced to the user's other devices" + exit 1 +} +grep -q 'delivery = \$0.delivery.merged(with: .delivered)' Sources/Shared/Models/AppModel.swift || { + echo "Delivery receipts must never downgrade an already read message" + exit 1 +} +if grep -q 'message.body = Self.readMarkerBody' Sources/Shared/XMPP/XMPPService.swift; then + echo "Read-marker service stanzas must not carry a plaintext body (it leaks into self-chat on other clients)" + exit 1 +fi +grep -q 'message.isMarkable = true' Sources/Shared/XMPP/XMPPService.swift || { + echo "Outgoing 1:1 messages must be or peers never answer with " + exit 1 +} +grep -q 'markersPublisher' Sources/Shared/XMPP/XMPPService.swift || { + echo "XEP-0333 displayed markers must mark messages as read" + exit 1 +} +grep -q 'markRead(at: index, cameFromPeer: false)' Sources/Shared/Models/AppModel.swift || { + echo "Self-chat must mark its own outgoing messages as read locally" + exit 1 +} +grep -q 'case read(conversationJID' Sources/Shared/XMPP/XMPPService.swift || { + echo "Read receipts must reference the conversation" + exit 1 +} +grep -q 'merged(with: message.delivery)' Sources/Shared/Models/AppModel.swift || { + echo "MAM replays must not downgrade delivered or read state" + exit 1 +} +grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || { + echo "Group chats must support local deletion" + exit 1 +} +grep -q 'deletedGroupChatJIDs' Sources/Shared/Models/AppModel.swift || { + echo "Deleted group chats must stay suppressed for the session" + exit 1 +} +grep -q 'Удалить чат' Sources/Shared/UI/GroupInfoView.swift || { + echo "Group info must offer deleting the chat" + exit 1 +} +grep -q 'pendingGroupDeletion' Sources/Shared/UI/MainTabView.swift || { + echo "Chat list must offer deleting group chats" + exit 1 +} +grep -q 'pickerResetToken' Sources/Shared/UI/ChatView.swift || { + echo "The photo picker must be recreated after each selection" + exit 1 +} +grep -q 'let items = pickedMediaItems' Sources/Shared/UI/ChatView.swift || { + echo "Gallery staging must run on picker dismissal only" + exit 1 +} +grep -q 'connectionStatus == .connected' Sources/Shared/Models/AppModel.swift || { + echo "Older-history loads must skip silently while disconnected" + exit 1 +} +grep -q 'removeSessionWithOwnDeviceOnce' Sources/Shared/XMPP/LumaOMEMOStore.swift || { + echo "The self-session purge must be a one-time migration" + exit 1 +} +grep -q 'ensureSelfSession' Sources/Shared/XMPP/XMPPService.swift || { + echo "A missing self-session must be rebuilt from the own bundle" + exit 1 +} +grep -q 'urn:xmpp:omemo:2' Sources/Shared/XMPP/XMPPService.swift || { + echo "OMEMO 2 payloads must be shown as undecryptable, not empty" + exit 1 +} +grep -q 'biometricUnlockAvailable' Sources/Shared/Models/AppModel.swift || { + echo "Biometric availability must be cached off the view body" + exit 1 +} +grep -q 'Звонки' Sources/Shared/UI/MainTabView.swift || { + echo "The main screen must offer the Calls tab" + exit 1 +} +grep -q 'Настройки' Sources/Shared/UI/MainTabView.swift || { + echo "The main screen must offer the Settings tab" + exit 1 +} +grep -q 'case .background:' Sources/App/LumaApp.swift || { + echo "The app lock must trigger on background only, not on inactive" + exit 1 +} +grep -q 'onDismissRequest' Sources/Shared/UI/SystemPhotoCameraView.swift || { + echo "The camera picker must request dismissal before background preparation" + exit 1 +} +grep -q 'onDismissRequest' Sources/Shared/UI/ChatView.swift || { + echo "The camera cover must dismiss through the SwiftUI binding" + exit 1 +} +grep -q 'moveItem(at: sourceURL' Sources/Shared/UI/SystemPhotoCameraView.swift || { + echo "The picker movie must be claimed synchronously before dismissal cleanup" + exit 1 +} +grep -q 'video-preview' Sources/Shared/UI/ChatView.swift || { + echo "The camera video path must log diagnostics" exit 1 } grep -q 'ArchiveSyncCursorPolicy.requestPosition' Sources/Shared/XMPP/XMPPService.swift || { @@ -318,7 +582,7 @@ grep -q 'MediaPickerSelectionPolicy.preferredOrder' Sources/Shared/UI/ChatView.s echo "Ambiguous photo/video providers must retain a fallback representation" exit 1 } -grep -q 'item.loadTransferable(type: Data.self)' Sources/Shared/UI/ChatView.swift || { +grep -q 'type: Data.self' Sources/Shared/UI/ChatView.swift || { echo "PhotosPicker images need a data fallback when no file representation is available" exit 1 } @@ -358,7 +622,7 @@ grep -q 'scrollDismissesKeyboard(.interactively)' Sources/Shared/UI/ChatView.swi echo "Interactive keyboard dismissal is missing" exit 1 } -grep -q 'selectedTimelineEntries' Sources/Shared/UI/ChatView.swift || { +grep -q 'rebuildTimelineEntries' Sources/Shared/UI/ChatView.swift || { echo "Chat rows and day boundaries must use the cached stable timeline" exit 1 } @@ -429,6 +693,10 @@ if grep -q 'Режим видеосообщения\|composerCaptureMode.*video' echo "Video-note recording must not reuse the inline voice-message gesture" exit 1 fi +grep -q 'VideoNoteRotationPolicy.currentInterfaceOrientation' Sources/Shared/Services/VideoNoteRecorder.swift || { + echo "Video note capture must rotate by the interface orientation, not the device sensor" + exit 1 +} grep -q 'resetCaptureGraph()' Sources/Shared/Services/VideoNoteRecorder.swift || { echo "Reusable video-note capture cleanup is missing" exit 1 @@ -537,8 +805,12 @@ 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" +grep -q 'onReactPicker' Sources/Shared/UI/Components/MessageBubble.swift || { + echo "Message reaction picker is missing" + exit 1 +} +grep -q 'EmojiPickerView' Sources/Shared/UI/ChatView.swift || { + echo "The emoji picker is not wired into the chat" exit 1 } grep -q 'ChatStateNotificationsModule' Sources/Shared/XMPP/XMPPService.swift || { @@ -554,8 +826,8 @@ grep -q 'snapshotQueue.async' Sources/Shared/Services/PhoneWatchBridge.swift || exit 1 } awk ' - /^#if os\(iOS\)$/ { ios_only = 1; next } - /^#endif$/ && ios_only { ios_only = 0; next } + /^[[:space:]]*#if os\(iOS\)$/ { ios_only = 1; next } + /^[[:space:]]*#endif$/ && ios_only { ios_only = 0; next } /availableVideoCodecTypes/ { found = 1 if (!ios_only) escaped = 1 @@ -593,7 +865,7 @@ 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 || { +grep -q 'rosterContactJIDs' Sources/Shared/UI/MainTabView.swift || { echo "Prosody roster contacts screen is missing" exit 1 } diff --git a/Sources/App/LumaApp.swift b/Sources/App/LumaApp.swift index 99fb20e..35f487d 100644 --- a/Sources/App/LumaApp.swift +++ b/Sources/App/LumaApp.swift @@ -11,7 +11,20 @@ struct LumaApp: App { RootView(model: model) .tint(Color(red: 0.14, green: 0.56, blue: 0.96)) .onChange(of: scenePhase) { _, phase in - model.setApplicationActive(phase == .active) + switch phase { + case .active: + model.setApplicationActive(true) + case .background: + model.setApplicationActive(false) + case .inactive: + // App switcher, Control Center and system pickers + // (photo gallery) make the scene briefly inactive; + // locking here would block the picker and the + // unlock screen would replace the app mid-flow. + break + @unknown default: + break + } } } #if os(macOS) diff --git a/Sources/Shared/Models/AccountConfiguration.swift b/Sources/Shared/Models/AccountConfiguration.swift index d558660..429e575 100644 --- a/Sources/Shared/Models/AccountConfiguration.swift +++ b/Sources/Shared/Models/AccountConfiguration.swift @@ -22,7 +22,10 @@ struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendabl var effectiveResource: String { let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "Luma" : trimmed + if trimmed.isEmpty || trimmed == DeviceResource.legacyDefault { + return DeviceResource.default + } + return trimmed } init( diff --git a/Sources/Shared/Models/AppLockPolicy.swift b/Sources/Shared/Models/AppLockPolicy.swift new file mode 100644 index 0000000..77ff377 --- /dev/null +++ b/Sources/Shared/Models/AppLockPolicy.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Pure policy for the app-lock passcode: minimum length and validation. +/// Kept separate from the Keychain storage so it is unit-testable. +enum AppLockPolicy { + static let minimumLength = 4 + + static func isValid(_ passcode: String) -> Bool { + passcode.count >= minimumLength + } +} + diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift index e23ca77..26ce1a8 100644 --- a/Sources/Shared/Models/AppModel.swift +++ b/Sources/Shared/Models/AppModel.swift @@ -1,13 +1,34 @@ -import Combine import AVFoundation +import Combine import Foundation +import LocalAuthentication +import SwiftData import UniformTypeIdentifiers import WebRTC +enum RuntimeEnvironment { + static var isRunningTests: Bool { + let environment = ProcessInfo.processInfo.environment + return environment["XCTestConfigurationFilePath"] != nil + || environment["XCTestBundlePath"] != nil + } + + /// True inside Xcode preview canvases. The preview host launches the app + /// with `XCODE_RUNNING_FOR_PLAYGROUNDS=1` and shares the real app + /// container, so the model must not load the saved account or start the + /// XMPP/TLS stack there: the JIT executor interposes OpenSSL symbols onto + /// the system BoringSSL and any socket work crashes. + static var isRunningPreviews: Bool { + ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PLAYGROUNDS"] == "1" + } +} + @MainActor final class AppModel: ObservableObject { @Published private(set) var account: AccountConfiguration? - @Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected(reason: nil) + @Published private(set) var accounts: [AccountConfiguration] = [] + @Published private(set) var connectionStatus: XMPPService.ConnectionStatus = .disconnected( + reason: nil) private(set) var conversations: [Conversation] = [] { willSet { if !isApplyingArchiveBatch { @@ -32,7 +53,10 @@ final class AppModel: ObservableObject { @Published var selectedConversationID: String? @Published private(set) var isOMEMOReady = false @Published private(set) var ownFingerprint: String? + @Published private(set) var serverInformation: ServerInformation? @Published private(set) var isArchiveSyncing = false + @Published private(set) var isLoadingOlderHistory = false + @Published private(set) var hasMoreOlderHistory = true @Published private(set) var isAuthenticating = false @Published private(set) var isSendingAttachment = false @Published private(set) var isUpdatingAvatar = false @@ -43,6 +67,14 @@ final class AppModel: ObservableObject { @Published var errorMessage: String? @Published var informationalMessage: String? @Published var previewURL: URL? + @Published private(set) var isAppLocked = false + @Published private(set) var appLockIsEnabled = false + @Published private(set) var appLockBiometricIsEnabled = false + /// Cached once per session: LAContext.canEvaluatePolicy performs IPC with + /// the security daemon and must never run in a view body (it froze the + /// macOS lock screen). + @Published private(set) var biometricUnlockAvailable = false + @Published private(set) var biometricUnlockName = "" @Published private(set) var mediaViewerItem: MediaViewerItem? @Published private(set) var mediaPreviewURLs: [String: URL] = [:] @Published private(set) var mediaThumbnailData: [String: Data] = [:] @@ -55,18 +87,22 @@ final class AppModel: ObservableObject { private let xmpp: XMPPService private let credentials: CredentialVault private let preferences: AccountPreferences + private let appLock = AppLockVault() 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 store: ArchiveStore? + private var storeAccountJID: String? + private var fallbackContext: ModelContext? 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 messageIndexByOriginKey: [String: Int] = [:] private var firstMessageIndexByID: [String: Int] = [:] private var selectedMessagesCacheConversationID: String? private var selectedMessagesCache: [ChatMessage] = [] @@ -78,14 +114,33 @@ final class AppModel: ObservableObject { private var pendingRetractions: [String: XMPPService.RetractionEnvelope] = [:] private var pendingReactions: [String: XMPPService.ReactionEnvelope] = [:] private var correctionReceiptTargets: [String: String] = [:] + + private struct PendingReadMarker { + var envelope: ReadStateSync.Envelope + var cameFromPeer: Bool + } + /// Read markers that arrived before the message they reference. MAM + /// replays newest stanzas first, so a read-marker is routinely applied + /// before its message on a fresh install; the marker waits here until the + /// message itself is upserted. + private var pendingReadMarkers: [String: PendingReadMarker] = [:] + private var lastDisplayedMarkerByConversation: [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 mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:] + private var hasMoreOlderHistoryByConversation: [String: Bool] = [:] + /// Server-side cursor (RSM of the last loaded page) for the next + /// interactive backward-history request, per conversation. The cursor + /// advances even when a page inserted nothing (reactions, duplicates), so + /// scrolling always moves toward genuinely older history. + private var olderHistoryCursorByConversation: [String: String] = [:] private var pendingRoomPasswords: [String: String] = [:] private var joiningRoomJIDs: Set = [] + private var deletedGroupChatJIDs: Set = [] private var mediaSendActivity = MediaSendActivityTracker() private var mediaPreparationTokens: Set = [] private var videoNoteCaptureIsActive = false @@ -110,13 +165,24 @@ final class AppModel: ObservableObject { self.watchBridge = watchBridge self.avatarCache = avatarCache + // The app lock must be initialised before any view reads it; the + // locked state persists only in memory and is re-applied from the + // stored preferences on launch. + appLockIsEnabled = appLock.isEnabled + appLockBiometricIsEnabled = appLock.biometricUnlockEnabled + isAppLocked = + appLock.isEnabled + && !RuntimeEnvironment.isRunningTests + && !RuntimeEnvironment.isRunningPreviews + refreshBiometricAvailability() + 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() }) { + if !self.conversations.contains(where: { $0.jid == jid.lowercased() }) { self.upsertConversation(jid: jid, name: nil) } await self.sendText(text, to: jid) @@ -131,7 +197,8 @@ final class AppModel: ObservableObject { } guard self.account != nil else { - let error = "Войдите в XMPP-аккаунт на iPhone, затем повторите отправку с Apple Watch." + let error = + "Войдите в XMPP-аккаунт на iPhone, затем повторите отправку с Apple Watch." self.errorMessage = error self.watchBridge.reportVoiceResult( transferID: voice.transferID, @@ -155,7 +222,7 @@ final class AppModel: ObservableObject { return } - if !self.conversations.contains(where: { $0.id == voice.jid.lowercased() }) { + if !self.conversations.contains(where: { $0.jid == voice.jid.lowercased() }) { self.upsertConversation(jid: voice.jid, name: nil) } let messageID = await self.sendMedia( @@ -168,7 +235,9 @@ final class AppModel: ObservableObject { messageID: voice.stableMessageID ) let success = messageID != nil - let failure = success ? nil : (self.errorMessage ?? "Не удалось отправить голосовое сообщение.") + let failure = + success + ? nil : (self.errorMessage ?? "Не удалось отправить голосовое сообщение.") if success { self.informationalMessage = "Голосовое сообщение с Apple Watch отправлено." } else if self.errorMessage == nil { @@ -182,9 +251,11 @@ final class AppModel: ObservableObject { } } - bootstrapTask = Task { [weak self] in - guard let self else { return } - await self.bootstrap() + if !RuntimeEnvironment.isRunningTests, !RuntimeEnvironment.isRunningPreviews { + bootstrapTask = Task { [weak self] in + guard let self else { return } + await self.bootstrap() + } } } @@ -198,7 +269,7 @@ final class AppModel: ObservableObject { var selectedConversation: Conversation? { guard let selectedConversationID else { return nil } - return conversations.first { $0.id == selectedConversationID } + return conversations.first { $0.jid == selectedConversationID } } private var durableArchiveSyncCheckpoint: ArchiveSyncCheckpoint? { @@ -209,15 +280,26 @@ final class AppModel: ObservableObject { ) } + /// Aggregated call history for the «Звонки» tab, newest first. + var callHistoryMessages: [ChatMessage] { + messages + .filter { $0.callHistory != nil } + .sorted { lhs, rhs in + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.clientID < rhs.clientID + } + } + var selectedMessages: [ChatMessage] { guard let selectedConversationID else { return [] } if selectedMessagesCacheConversationID == selectedConversationID { return selectedMessagesCache } - let sorted = messages + let sorted = + messages .filter { $0.conversationID == selectedConversationID } .sorted { lhs, rhs in - if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } + if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID } return lhs.timestamp < rhs.timestamp } selectedMessagesCacheConversationID = selectedConversationID @@ -236,8 +318,45 @@ final class AppModel: ObservableObject { return entries } + /// The `ModelContext` injected into the SwiftUI environment so views + /// can read conversations and messages through `@Query`. Falls back to + /// an in-memory context when the on-disk store cannot be opened, keeping + /// the session usable without persistence. + var modelContext: ModelContext { + if let store { return store.context } + if let fallbackContext { return fallbackContext } + let schema = Schema([ + Conversation.self, + ChatMessage.self, + ArchiveMetadata.self, + ]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: schema, configurations: [configuration]) + let context = ModelContext(container) + fallbackContext = context + return context + } + + /// Opens the per-account SwiftData store before `account` is published so + /// the `@Query`-backed screens never render without a usable context. + private func prepareStore(for jid: String) { + guard store == nil || storeAccountJID != jid else { return } + store = try? ArchiveStore(accountJID: jid) + storeAccountJID = jid + } + func bootstrap() async { - guard let saved = preferences.load() else { return } +#if DEBUG + if applyUITestChatIfRequested() { return } +#endif + guard !RuntimeEnvironment.isRunningTests, + !RuntimeEnvironment.isRunningPreviews + else { return } + let configured = preferences.loadAccounts() + accounts = configured + if !configured.isEmpty { try? preferences.saveAccounts(configured) } + guard let saved = preferences.load() ?? configured.first else { return } + prepareStore(for: saved.normalizedJID) account = saved globalEncryptionEnabled = preferences.encryptionEnabled(for: saved.normalizedJID) typingIndicatorsEnabled = preferences.chatStatesEnabled(for: saved.normalizedJID) @@ -247,7 +366,8 @@ final class AppModel: ObservableObject { do { guard let storedPassword = try credentials.password(for: saved.normalizedJID) else { account = nil - archive = nil + store = nil + fallbackContext = nil conversations = [] rosterContactJIDs = [] messages = [] @@ -258,7 +378,8 @@ final class AppModel: ObservableObject { password = storedPassword } catch { account = nil - archive = nil + store = nil + fallbackContext = nil conversations = [] rosterContactJIDs = [] messages = [] @@ -270,7 +391,8 @@ final class AppModel: ObservableObject { try await xmpp.connect( account: saved, password: password, - archiveCheckpoint: durableArchiveSyncCheckpoint + archiveCheckpoint: durableArchiveSyncCheckpoint, + mamCheckpoints: mamCheckpoints ) await notifications.requestAuthorization() } catch { @@ -297,9 +419,11 @@ final class AppModel: ObservableObject { locallyDeletedMessageIDs = [] pendingRoomPasswords = [:] joiningRoomJIDs = [] + deletedGroupChatJIDs = [] rosterContactJIDs = [] resetMediaSendActivity() resetMediaPreviews() + prepareStore(for: account.normalizedJID) self.account = account globalEncryptionEnabled = preferences.encryptionEnabled(for: account.normalizedJID) typingIndicatorsEnabled = preferences.chatStatesEnabled(for: account.normalizedJID) @@ -308,15 +432,19 @@ final class AppModel: ObservableObject { try await xmpp.connect( account: account, password: password, - archiveCheckpoint: durableArchiveSyncCheckpoint + archiveCheckpoint: durableArchiveSyncCheckpoint, + mamCheckpoints: mamCheckpoints ) try credentials.save(password: password, for: account.normalizedJID) + accounts = accounts.filter { $0.normalizedJID != account.normalizedJID } + [account] try preferences.save(account) + try preferences.saveAccounts(accounts) await notifications.requestAuthorization() } catch { await xmpp.disconnect() self.account = nil - archive = nil + store = nil + fallbackContext = nil conversations = [] rosterContactJIDs = [] messages = [] @@ -328,22 +456,27 @@ final class AppModel: ObservableObject { func signOut(forgetHistory: Bool = false) async { let oldAccount = account await xmpp.disconnect() - preferences.clear() + if let oldAccount { + accounts.removeAll { $0.normalizedJID == oldAccount.normalizedJID } + try? preferences.saveAccounts(accounts) + } if let oldAccount { try? credentials.deletePassword(for: oldAccount.normalizedJID) if forgetHistory { - try? await archive?.erase() + try? store?.erase() } } persistTask?.cancel() resetArchiveBatchState() watchSyncTask?.cancel() watchSyncTask = nil - archive = nil + store = nil + fallbackContext = nil account = nil conversations = [] rosterContactJIDs = [] messages = [] + deletedGroupChatJIDs = [] rebuildMessageIndex() selectedConversationID = nil isOMEMOReady = false @@ -368,32 +501,223 @@ final class AppModel: ObservableObject { resetTypingState() resetMediaSendActivity() resetMediaPreviews() + if let next = accounts.first { + await switchAccount(to: next) + } } - func reconnect() async { - guard let account else { return } + /// Adds an account and makes it the active connection. The login screen + /// uses the regular sign-in path, so credentials and per-account archives + /// are handled exactly as they are for the first account. + func addAccount(account: AccountConfiguration, password: String) async { + await signIn(account: account, password: password) + } + + func switchAccount(to target: AccountConfiguration) async { + guard target.normalizedJID != account?.normalizedJID else { return } + let password: String do { - guard let password = try credentials.password(for: account.normalizedJID) else { - throw ReconnectError.passwordMissing + guard let stored = try credentials.password(for: target.normalizedJID) else { + errorMessage = "Для этого аккаунта нет пароля в Keychain. Войдите снова." + return } - try await xmpp.reconnectIfNeeded(password: password) + password = stored + } catch { + errorMessage = "Для этого аккаунта нет пароля в Keychain. Войдите снова." + return + } + await xmpp.disconnect() + prepareStore(for: target.normalizedJID) + account = target + try? preferences.save(target) + globalEncryptionEnabled = preferences.encryptionEnabled(for: target.normalizedJID) + typingIndicatorsEnabled = preferences.chatStatesEnabled(for: target.normalizedJID) + xmpp.setChatStatesEnabled(typingIndicatorsEnabled) + await loadArchive(for: target.normalizedJID) + do { + try await xmpp.connect( + account: target, + password: password, + archiveCheckpoint: durableArchiveSyncCheckpoint, + mamCheckpoints: mamCheckpoints + ) } catch { errorMessage = error.localizedDescription } } + func reconnect() async { + guard !RuntimeEnvironment.isRunningTests else { return } + 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, archiveCheckpoint: durableArchiveSyncCheckpoint) + } catch { + errorMessage = error.localizedDescription + } + } + +#if DEBUG + /// UI-test mode: seeds an in-memory conversation with numbered messages + /// so XCUITests can exercise timeline scrolling and the reply swipe + /// without an XMPP server. Activated with the `-luma-ui-test-chat` + /// launch argument. + private func applyUITestChatIfRequested() -> Bool { + guard ProcessInfo.processInfo.arguments.contains("-luma-ui-test-chat") else { + return false + } + let account = AccountConfiguration( + jid: "uitest@example.org", + displayName: "UI Test", + resource: "uitest" + ) + self.account = account + let conversation = Conversation( + jid: "peer@example.org", + displayName: "uitest-peer", + lastMessage: "Тестовое сообщение номер 60", + lastActivity: Date(), + kind: .direct + ) + modelContext.insert(conversation) + conversations = [conversation] + let base = Date(timeIntervalSince1970: 1_700_000_000) + var seeded: [ChatMessage] = [] + for index in 1...60 { + let outgoing = index.isMultiple(of: 2) + let message = ChatMessage( + id: "uitest-msg-\(index)", + conversationID: conversation.jid, + senderJID: outgoing ? account.normalizedJID : conversation.jid, + body: "Тестовое сообщение номер \(index)", + timestamp: base.addingTimeInterval(TimeInterval(index * 60)), + direction: outgoing ? .outgoing : .incoming, + delivery: .sent, + security: .plaintext, + kind: .text + ) + modelContext.insert(message) + seeded.append(message) + } + messages = seeded + rebuildMessageIndex() + selectedConversationID = conversation.jid + return true + } +#endif + func setApplicationActive(_ active: Bool) { appIsActive = active + guard !RuntimeEnvironment.isRunningTests else { return } xmpp.setApplicationActive(active) if !active { resetTypingState() syncWatch(immediate: true) + if appLockIsEnabled { + isAppLocked = true + } } if active, case .disconnected(_) = connectionStatus { Task { await reconnect() } } } + // MARK: - App lock + + func unlockWithPasscode(_ passcode: String) -> Bool { + guard appLock.verify(passcode: passcode) else { return false } + isAppLocked = false + return true + } + + func refreshBiometricAvailability() { + guard !RuntimeEnvironment.isRunningTests, + !RuntimeEnvironment.isRunningPreviews + else { return } + Task.detached(priority: .utility) { + let context = LAContext() + var error: NSError? + let available = context.canEvaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + error: &error + ) + let name = context.biometryType == .faceID ? "Face ID" : "Touch ID" + await MainActor.run { [weak self] in + self?.biometricUnlockAvailable = available + self?.biometricUnlockName = name + } + } + } + + func unlockWithBiometrics() async -> Bool { + guard appLockIsEnabled, appLockBiometricIsEnabled else { return false } + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) + else { + return false + } + do { + let granted = try await context.evaluatePolicy( + .deviceOwnerAuthenticationWithBiometrics, + localizedReason: "Разблокируйте Luma" + ) + guard granted else { return false } + isAppLocked = false + return true + } catch { + return false + } + } + + func enableAppLock(passcode: String) -> Bool { + guard AppLockPolicy.isValid(passcode) else { return false } + do { + try appLock.save(passcode: passcode) + appLock.isEnabled = true + appLockIsEnabled = true + isAppLocked = false + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func disableAppLock(passcode: String) -> Bool { + guard appLock.verify(passcode: passcode) else { return false } + try? appLock.deletePasscode() + appLock.isEnabled = false + appLock.biometricUnlockEnabled = false + appLockIsEnabled = false + appLockBiometricIsEnabled = false + isAppLocked = false + return true + } + + func setAppLockBiometricUnlock(_ enabled: Bool, passcode: String) -> Bool { + guard appLock.verify(passcode: passcode) else { return false } + appLock.biometricUnlockEnabled = enabled + appLockBiometricIsEnabled = enabled + return true + } + + func changeAppLockPasscode(from oldPasscode: String, to newPasscode: String) -> Bool { + guard AppLockPolicy.isValid(newPasscode), appLock.verify(passcode: oldPasscode) else { + return false + } + do { + try appLock.save(passcode: newPasscode) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + func setVideoNoteCaptureActive(_ active: Bool) { videoNoteCaptureIsActive = active updateArchiveSyncSuspensionForMedia() @@ -402,7 +726,7 @@ final class AppModel: ObservableObject { 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 { + guard conversations.first(where: { $0.jid == jid })?.isGroup != true else { errorMessage = "Звонки в текущем MVP доступны только в личных чатах." return } @@ -466,9 +790,10 @@ final class AppModel: ObservableObject { } upsertConversation(jid: normalized, name: name) selectedConversationID = normalized - if let index = conversations.firstIndex(where: { $0.id == normalized }) { + if let index = conversations.firstIndex(where: { $0.jid == normalized }) { conversations[index].unreadCount = 0 } + sendDisplayedMarkerIfNeeded(for: normalized) if addToRoster { rosterContactJIDs.insert(normalized) xmpp.addToRoster(jid: normalized, name: name) @@ -481,11 +806,96 @@ final class AppModel: ObservableObject { func selectConversation(id: String) { let normalized = id.lowercased() selectedConversationID = normalized - if let index = conversations.firstIndex(where: { $0.id == normalized }) { + // Opening the chat marks it as read, so the unread badge disappears + // immediately and stays in sync with the watch. + if let index = conversations.firstIndex(where: { $0.jid == normalized }) { conversations[index].unreadCount = 0 } + sendDisplayedMarkerIfNeeded(for: normalized) schedulePersist() syncWatch() + if hasMoreOlderHistoryByConversation[normalized] == false, + selectedMessages.isEmpty + { + // An empty conversation must be allowed to retry a load after a + // previous attempt returned zero results. Otherwise it stays empty + // with `hasMore = false` and no way to trigger loading again. + hasMoreOlderHistoryByConversation[normalized] = true + } + hasMoreOlderHistory = hasMoreOlderHistoryByConversation[normalized] ?? true + } + + /// Called when the chat view disappears (back to the list, another chat + /// selected, or a mode switch). Without this, `selectedConversationID` + /// would keep pointing at the last viewed chat forever, and incoming + /// messages for it would never increment the unread badge again. + func endConversationViewing(jid: String) { + let normalized = jid.lowercased() + if selectedConversationID == normalized { + selectedConversationID = nil + } + } + + func loadOlderHistoryForSelectedConversation() { + // While disconnected the request would only fail with + // "not connected", which used to pop the global alert right over + // the timeline and blocked scrolling. Skip silently instead: the + // trigger re-fires once the connection is back. + guard !isLoadingOlderHistory, + hasMoreOlderHistory, + connectionStatus == .connected, + let conversation = selectedConversation else { return } + // let oldestServerID = selectedMessages + // .sorted { lhs, rhs in + // if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } + // return lhs.clientID < rhs.clientID + // } + // .compactMap(\.stanzaID) + // .first + let oldestServerID = selectedMessages + .filter { $0.stanzaID != nil } + .min { lhs, rhs in + if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } + return lhs.clientID < rhs.clientID + }? + .stanzaID + + isLoadingOlderHistory = true + let conversationID = conversation.jid.lowercased() + // Prefer the cursor the server returned with the previous page: it + // always points at genuinely older history. Fall back to the oldest + // local stanza id only when no server cursor is known yet (freshly + // opened conversation in this session). + let before = + olderHistoryCursorByConversation[conversationID] + ?? oldestServerID + xmpp.loadOlderHistory( + conversationJID: conversation.jid, + isGroup: conversation.isGroup, + before: before + ) { [weak self] result in + guard let self else { return } + self.isLoadingOlderHistory = false + switch result { + case .success(let page): + if let nextBefore = page.nextBefore { + self.olderHistoryCursorByConversation[conversationID] = + nextBefore + } else { + // Exhausted or the server stopped echoing RSM: never + // re-request the same page in a loop. + self.olderHistoryCursorByConversation[conversationID] = nil + } + self.hasMoreOlderHistoryByConversation[conversationID] = + page.hasMore + if self.selectedConversationID == conversationID { + self.hasMoreOlderHistory = page.hasMore + } + case .failure(let error): + // Keep the cursor: the next scroll retries the same page. + self.errorMessage = error.localizedDescription + } + } } func createOrJoinGroup( @@ -504,10 +914,12 @@ final class AppModel: ObservableObject { errorMessage = "Введите псевдоним для группового чата." return } - let validInvitees = invitees + let validInvitees = + invitees .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter(Self.isValidJID) + deletedGroupChatJIDs.remove(roomJID) upsertGroupConversation( jid: roomJID, name: name, @@ -524,7 +936,7 @@ final class AppModel: ObservableObject { informationalMessage = "Приглашения отправлены: \(validInvitees.count)." } } catch { - if let index = conversations.firstIndex(where: { $0.id == roomJID }) { + if let index = conversations.firstIndex(where: { $0.jid == roomJID }) { conversations[index].isGroupJoined = false } errorMessage = error.localizedDescription @@ -534,9 +946,12 @@ final class AppModel: ObservableObject { 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 let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else { + return + } guard !conversations[index].isGroupJoined, - joiningRoomJIDs.insert(jid).inserted else { return } + joiningRoomJIDs.insert(jid).inserted + else { return } defer { joiningRoomJIDs.remove(jid) } let trimmedNickname = preferredNickname?.trimmingCharacters(in: .whitespacesAndNewlines) let nickname: String @@ -565,15 +980,77 @@ final class AppModel: ObservableObject { 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 } + guard let index = conversations.firstIndex(where: { $0.jid == jid && $0.isGroup }) else { + return + } conversations[index].isGroupJoined = false conversations[index].shouldAutojoin = false conversations[index].occupantCount = 0 schedulePersist() } + /// Leaves the room on the server (when joined) and removes the chat + /// together with its local history from this device. + func deleteGroupChat(jid rawJID: String) { + let jid = rawJID.lowercased() + // Suppress re-creation first: leaveRoom fires a synchronous + // roomState event, and the server-side state transition would + // otherwise resurrect the conversation later in this session. + deletedGroupChatJIDs.insert(jid) + let wasJoined = + conversations.first(where: { $0.jid == jid && $0.isGroup })?.isGroupJoined + == true + if wasJoined { + xmpp.leaveRoom(roomJID: jid) + } + removeGroupChatLocally(jid: jid) + informationalMessage = + wasJoined + ? "Вы вышли из комнаты, чат удалён с этого устройства." + : "Групповой чат удалён с этого устройства." + } + + private func removeGroupChatLocally(jid: String) { + let normalized = jid.lowercased() + if let conversation = conversations.first(where: { $0.jid == normalized }) { + modelContext.delete(conversation) + } + conversations.removeAll { $0.jid == normalized } + + let removedMessages = messages.filter { $0.conversationID == normalized } + for message in removedMessages { + modelContext.delete(message) + removeCachedMedia(for: message.clientID) + } + messages.removeAll { $0.conversationID == normalized } + rebuildMessageIndex() + + let deletionKeyPrefix = normalized + "\u{1F}" + locallyDeletedMessageIDs = locallyDeletedMessageIDs.filter { + !$0.hasPrefix(deletionKeyPrefix) + } + pendingCorrections = pendingCorrections.filter { !$0.key.hasPrefix(deletionKeyPrefix) } + pendingRetractions = pendingRetractions.filter { !$0.key.hasPrefix(deletionKeyPrefix) } + pendingReactions = pendingReactions.filter { !$0.key.hasPrefix(normalized + "|") } + hasMoreOlderHistoryByConversation.removeValue(forKey: normalized) + olderHistoryCursorByConversation.removeValue(forKey: normalized) + pendingRoomPasswords.removeValue(forKey: normalized) + joiningRoomJIDs.remove(normalized) + localChatStateByConversation.removeValue(forKey: normalized) + localTypingPauseTasks.removeValue(forKey: normalized)?.cancel() + remoteTypingExpiryTasks.removeValue(forKey: normalized)?.cancel() + typingParticipantsByConversation.removeValue(forKey: normalized) + if selectedConversationID == normalized { + selectedConversationID = nil + } + sortConversations() + schedulePersist() + syncWatch() + } + func inviteMembers(_ rawJIDs: [String], to roomJID: String) async { - let jids = rawJIDs + let jids = + rawJIDs .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter(Self.isValidJID) guard !jids.isEmpty else { @@ -597,14 +1074,16 @@ final class AppModel: ObservableObject { ) 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 account, + let peer = explicitJID ?? selectedConversation?.jid + else { return } + let conversation = conversations.first { $0.jid == 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 } + message.conversationID == peer.lowercased() + else { return nil } return message } @@ -636,17 +1115,21 @@ final class AppModel: ObservableObject { return XMPPService.ReplyReference( id: replyID, authorJID: target.senderJID, - fallbackAuthor: target.senderDisplayName ?? displayName(for: target.senderJID), + fallbackAuthor: target.senderDisplayName + ?? displayName(for: target.senderJID), preview: target.quotePreview ) }, isGroup: isGroup ) updateMessage(id: id) { - $0.delivery = .sent + $0.delivery = $0.delivery.merged(with: .sent) $0.encryptionFingerprint = fingerprint } let conversationID = peer.lowercased() + if conversationID == account.normalizedJID { + markMessageAsReadIfOutgoing(id: id) + } localTypingPauseTasks[conversationID]?.cancel() localTypingPauseTasks[conversationID] = nil localChatStateByConversation[conversationID] = .active @@ -660,8 +1143,9 @@ final class AppModel: ObservableObject { 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 peer = explicitJID ?? selectedConversation?.jid + else { return } + let conversation = conversations.first { $0.jid == peer.lowercased() } let isGroup = conversation?.isGroup == true let encrypted = encryptionEnabled(for: peer) let id = UUID().uuidString @@ -688,9 +1172,12 @@ final class AppModel: ObservableObject { isGroup: isGroup ) updateMessage(id: id) { - $0.delivery = .sent + $0.delivery = $0.delivery.merged(with: .sent) $0.encryptionFingerprint = fingerprint } + if peer.lowercased() == account.normalizedJID { + markMessageAsReadIfOutgoing(id: id) + } } catch { updateMessage(id: id) { $0.delivery = .failed } errorMessage = error.localizedDescription @@ -702,8 +1189,9 @@ final class AppModel: ObservableObject { 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 index = messages.firstIndex(where: { $0.clientID == id }), + messages[index].canBeEdited + else { return } let previous = messages[index] guard text != previous.body else { return } @@ -712,7 +1200,8 @@ final class AppModel: ObservableObject { let encrypted = previous.security == .omemo correctionReceiptTargets[correctionID] = id if correctionReceiptTargets.count > 100, - let staleID = correctionReceiptTargets.keys.first(where: { $0 != correctionID }) { + let staleID = correctionReceiptTargets.keys.first(where: { $0 != correctionID }) + { correctionReceiptTargets.removeValue(forKey: staleID) } updateMessage(id: id) { @@ -727,16 +1216,20 @@ final class AppModel: ObservableObject { to: previous.conversationID, messageID: correctionID, encrypted: encrypted, - replacingMessageID: previous.id + replacingMessageID: previous.clientID ) updateMessage(id: id) { - $0.delivery = .sent + $0.delivery = $0.delivery.merged(with: .sent) $0.encryptionFingerprint = fingerprint } + if previous.conversationID == account?.normalizedJID { + markMessageAsReadIfOutgoing(id: id) + } } catch { correctionReceiptTargets.removeValue(forKey: correctionID) - if let currentIndex = messages.firstIndex(where: { $0.id == id }), - messages[currentIndex].delivery == .sending { + if let currentIndex = messages.firstIndex(where: { $0.clientID == id }), + messages[currentIndex].delivery == .sending + { messages[currentIndex] = previous updateConversationPreview(for: previous, incrementUnread: false) } @@ -748,25 +1241,27 @@ final class AppModel: ObservableObject { func retractMessage(id: String) async { guard let account, - let message = messages.first(where: { $0.id == id }), - message.canBeRetracted else { return } + let message = messages.first(where: { $0.clientID == id }), + message.canBeRetracted + else { return } let retractionID = UUID().uuidString do { try await xmpp.sendRetraction( to: message.conversationID, - targetID: message.id, + targetID: message.clientID, retractionID: retractionID, encrypted: message.security == .omemo ) - applyRetraction(XMPPService.RetractionEnvelope( - peerJID: message.conversationID, - senderJID: account.normalizedJID, - targetID: message.id, - retractionID: retractionID, - timestamp: Date(), - isOutgoing: true - )) + applyRetraction( + XMPPService.RetractionEnvelope( + peerJID: message.conversationID, + senderJID: account.normalizedJID, + targetID: message.clientID, + retractionID: retractionID, + timestamp: Date(), + isOutgoing: true + )) } catch { errorMessage = error.localizedDescription } @@ -782,19 +1277,22 @@ final class AppModel: ObservableObject { guard !ids.isEmpty else { return } let normalizedConversationID = conversationID?.lowercased() let selected = messages.filter { message in - ids.contains(message.id) - && (normalizedConversationID == nil || message.conversationID == normalizedConversationID) + ids.contains(message.clientID) + && (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 - )) + modelContext.delete(message) + locallyDeletedMessageIDs.insert( + Self.localDeletionKey( + messageID: message.clientID, + conversationID: message.conversationID + )) let pendingKey = Self.localDeletionKey( - messageID: message.id, + messageID: message.clientID, conversationID: message.conversationID ) pendingCorrections.removeValue(forKey: pendingKey) @@ -803,22 +1301,29 @@ final class AppModel: ObservableObject { guard envelope.peerJID.lowercased() == message.conversationID else { return true } - if envelope.targetID == message.id { return false } + if envelope.targetID == message.clientID { return false } if let stanzaID = message.stanzaID, - envelope.targetID == stanzaID { return false } + envelope.targetID == stanzaID + { + return false + } return true } - removeCachedMedia(for: message.id) + removeCachedMedia(for: message.clientID) } - let selectedKeys = Set(selected.map { Self.localDeletionKey( - messageID: $0.id, - conversationID: $0.conversationID - ) }) + let selectedKeys = Set( + selected.map { + Self.localDeletionKey( + messageID: $0.clientID, + conversationID: $0.conversationID + ) + }) messages.removeAll { message in - selectedKeys.contains(Self.localDeletionKey( - messageID: message.id, - conversationID: message.conversationID - )) + selectedKeys.contains( + Self.localDeletionKey( + messageID: message.clientID, + conversationID: message.conversationID + )) } rebuildMessageIndex() for conversationID in affectedConversations { @@ -835,13 +1340,15 @@ final class AppModel: ObservableObject { @discardableResult func forwardMessages(_ selectedMessages: [ChatMessage], to rawRecipient: String) async -> Bool { - let recipient = rawRecipient + let recipient = + rawRecipient .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() - let forwardableMessages = selectedMessages + let forwardableMessages = + selectedMessages .filter(\.canBeForwarded) .sorted { lhs, rhs in - if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } + if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID } return lhs.timestamp < rhs.timestamp } guard !forwardableMessages.isEmpty else { return false } @@ -857,7 +1364,8 @@ final class AppModel: ObservableObject { for message in forwardableMessages { guard await forwardMessageItem(message, to: recipient) else { if forwardedCount > 0 { - informationalMessage = "Переслано сообщений: \(forwardedCount) из \(forwardableMessages.count)." + informationalMessage = + "Переслано сообщений: \(forwardedCount) из \(forwardableMessages.count)." } return false } @@ -870,7 +1378,8 @@ final class AppModel: ObservableObject { } private func forwardMessageItem(_ message: ChatMessage, to recipient: String) async -> Bool { - let attribution = "↪ Переслано от \(message.senderDisplayName ?? displayName(for: message.senderJID))" + let attribution = + "↪ Переслано от \(message.senderDisplayName ?? displayName(for: message.senderJID))" switch message.kind { case .text: @@ -888,7 +1397,7 @@ final class AppModel: ObservableObject { case .attachment, .photo, .video, .audio, .voice, .videoNote: do { let url: URL - if let cached = mediaPreviewURLs[message.id] { + if let cached = mediaPreviewURLs[message.clientID] { url = cached } else { url = try await localAttachmentURL( @@ -923,17 +1432,20 @@ final class AppModel: ObservableObject { 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 { + messages.indices.contains(index), + messages[index].clientID == 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 } + guard + let index = messageIndex( + referenceID: id, + conversationID: normalized + ) + else { return nil } return messages[index] } @@ -994,7 +1506,8 @@ final class AppModel: ObservableObject { thumbnailData preparedThumbnailData: Data? = nil ) async -> String? { guard let account, - let peer = explicitJID ?? selectedConversation?.jid else { return nil } + let peer = explicitJID ?? selectedConversation?.jid + else { return nil } errorMessage = nil if connectionStatus != .connected { await reconnect() @@ -1004,7 +1517,7 @@ final class AppModel: ObservableObject { 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 conversation = conversations.first { $0.jid == peer.lowercased() } let isGroup = conversation?.isGroup == true let encrypted = encryptionEnabled(for: peer) var duration = suppliedDuration @@ -1059,10 +1572,13 @@ final class AppModel: ObservableObject { isGroup: isGroup ) updateMessage(id: id) { - $0.delivery = .sent + $0.delivery = $0.delivery.merged(with: .sent) $0.remoteAttachmentURL = result.remoteURL $0.encryptionFingerprint = result.fingerprint } + if peer.lowercased() == account.normalizedJID { + markMessageAsReadIfOutgoing(id: id) + } sentID = id } catch { if didInsertPending { @@ -1080,12 +1596,13 @@ final class AppModel: ObservableObject { && message.delivery == .failed && message.kind.isMedia && message.remoteAttachmentURL == nil - && mediaPreviewURLs[message.id] != nil + && mediaPreviewURLs[message.clientID] != nil } func retryMediaMessage(_ message: ChatMessage) async { guard canRetryMediaMessage(message), - let localURL = mediaPreviewURLs[message.id] else { return } + let localURL = mediaPreviewURLs[message.clientID] + else { return } let activityToken = beginMediaSendActivity() defer { endMediaSendActivity(activityToken) } errorMessage = nil @@ -1099,7 +1616,7 @@ final class AppModel: ObservableObject { from: localURL, preferredKind: message.kind ).data - updateMessage(id: message.id) { $0.delivery = .sending } + updateMessage(id: message.clientID) { $0.delivery = .sending } let result = try await uploadAndSendMediaWithRetry( data: data, filename: message.localFilename ?? localURL.lastPathComponent, @@ -1107,17 +1624,20 @@ final class AppModel: ObservableObject { kind: message.kind, duration: message.duration, to: message.conversationID, - messageID: message.id, + messageID: message.clientID, encrypted: message.security == .omemo, isGroup: message.isGroupMessage ) - updateMessage(id: message.id) { - $0.delivery = .sent + updateMessage(id: message.clientID) { + $0.delivery = $0.delivery.merged(with: .sent) $0.remoteAttachmentURL = result.remoteURL $0.encryptionFingerprint = result.fingerprint } + if message.conversationID == account?.normalizedJID { + markMessageAsReadIfOutgoing(id: message.clientID) + } } catch { - updateMessage(id: message.id) { $0.delivery = .failed } + updateMessage(id: message.clientID) { $0.delivery = .failed } errorMessage = error.localizedDescription } schedulePersist() @@ -1139,7 +1659,8 @@ final class AppModel: ObservableObject { do { let staged = try await mediaFileIO.stageAttachmentDraft(from: source) let filename = Self.safeFilename(staged.filename) - let contentType = staged.contentTypeIdentifier.flatMap { UTType($0) } + 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" @@ -1148,15 +1669,16 @@ final class AppModel: ObservableObject { 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 - )) + drafts.append( + AttachmentDraft( + url: staged.url, + filename: filename, + mimeType: mimeType, + kind: kind, + duration: analysis.duration, + byteCount: staged.byteCount, + thumbnailData: analysis.thumbnailData + )) } catch { lastPreparationError = error } @@ -1250,7 +1772,7 @@ final class AppModel: ObservableObject { func previewAttachment(_ message: ChatMessage) async { do { let output: URL - if let cached = mediaPreviewURLs[message.id] { + if let cached = mediaPreviewURLs[message.clientID] { output = cached } else { guard message.remoteAttachmentURL != nil else { return } @@ -1263,34 +1785,36 @@ final class AppModel: ObservableObject { } func mediaPreviewURL(for message: ChatMessage) -> URL? { - mediaPreviewURLs[message.id] + mediaPreviewURLs[message.clientID] } func mediaThumbnail(for message: ChatMessage) -> Data? { - mediaThumbnailData[message.id] + mediaThumbnailData[message.clientID] } func audioWaveform(for message: ChatMessage) -> [Float] { - audioWaveformSamples[message.id] ?? MediaPreviewProcessor.placeholderWaveform + audioWaveformSamples[message.clientID] ?? MediaPreviewProcessor.placeholderWaveform } func isMediaPreviewLoading(_ message: ChatMessage) -> Bool { - loadingMediaIDs.contains(message.id) + loadingMediaIDs.contains(message.clientID) } 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) } + message.remoteAttachmentURL != nil, + mediaPreviewURLs[message.clientID] == nil, + loadingMediaIDs.insert(message.clientID).inserted + else { return } + defer { loadingMediaIDs.remove(message.clientID) } do { - let output = try await localAttachmentURL(for: message, directoryName: "LumaMediaPreviews") - mediaPreviewURLs[message.id] = output + let output = try await localAttachmentURL( + for: message, directoryName: "LumaMediaPreviews") + mediaPreviewURLs[message.clientID] = output await analyzeMediaPreview( at: output, - messageID: message.id, + messageID: message.clientID, kind: message.kind, mimeType: message.mimeType ) @@ -1305,20 +1829,20 @@ final class AppModel: ObservableObject { do { let output: URL - if let cached = mediaPreviewURLs[message.id] { + if let cached = mediaPreviewURLs[message.clientID] { output = cached } else { guard message.remoteAttachmentURL != nil else { return } - loadingMediaIDs.insert(message.id) - defer { loadingMediaIDs.remove(message.id) } + loadingMediaIDs.insert(message.clientID) + defer { loadingMediaIDs.remove(message.clientID) } output = try await localAttachmentURL( for: message, directoryName: "LumaMediaPreviews" ) - mediaPreviewURLs[message.id] = output + mediaPreviewURLs[message.clientID] = output await analyzeMediaPreview( at: output, - messageID: message.id, + messageID: message.clientID, kind: message.kind, mimeType: message.mimeType ) @@ -1327,10 +1851,10 @@ final class AppModel: ObservableObject { audioPlayback.stop() NotificationCenter.default.post( name: .lumaExclusiveMediaPlayback, - object: message.id + object: message.clientID ) mediaViewerItem = MediaViewerItem( - id: message.id, + id: message.clientID, url: output, kind: message.kind, title: message.localFilename ?? (message.kind == .photo ? "Фото" : "Видео") @@ -1350,7 +1874,7 @@ final class AppModel: ObservableObject { func encryptionPreference(for jid: String) -> EncryptionPreference { conversations - .first(where: { $0.id == jid.lowercased() })? + .first(where: { $0.jid == jid.lowercased() })? .encryptionPreference ?? .inheritGlobal } @@ -1373,13 +1897,14 @@ final class AppModel: ObservableObject { } func typingText(for conversation: Conversation) -> String? { - let names = typingParticipantsByConversation[conversation.id.lowercased()] + let names = + typingParticipantsByConversation[conversation.jid.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() + let conversationID = conversation.jid.lowercased() localTypingPauseTasks[conversationID]?.cancel() localTypingPauseTasks[conversationID] = nil guard typingIndicatorsEnabled, appIsActive else { return } @@ -1398,14 +1923,15 @@ final class AppModel: ObservableObject { return } guard let self, - self.localChatStateByConversation[conversationID] == .composing else { return } + 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() + let conversationID = conversation.jid.lowercased() localTypingPauseTasks[conversationID]?.cancel() localTypingPauseTasks[conversationID] = nil transitionLocalChatState(.inactive, in: conversation) @@ -1413,13 +1939,14 @@ final class AppModel: ObservableObject { 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 message = message( + withID: sourceMessage.clientID, + 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 @@ -1467,7 +1994,9 @@ final class AppModel: ObservableObject { } func setEncryptionPreference(_ preference: EncryptionPreference, for jid: String) { - guard let index = conversations.firstIndex(where: { $0.id == jid.lowercased() }) else { return } + guard let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) else { + return + } conversations[index].encryptionPreference = preference schedulePersist() syncWatch() @@ -1501,10 +2030,23 @@ final class AppModel: ObservableObject { objectWillChange.send() } + func refreshServerInformation() async { + do { + serverInformation = try await xmpp.fetchServerInformation() + } catch { + // The screen shows its own error state; keep any previously loaded + // snapshot rather than clearing it on a transient refresh failure. + } + } + func clearError() { errorMessage = nil } + func clearInformationalMessage() { + informationalMessage = nil + } + private func beginMediaSendActivity() -> UUID { let token = mediaSendActivity.begin() isSendingAttachment = mediaSendActivity.isActive @@ -1539,7 +2081,8 @@ final class AppModel: ObservableObject { } private func updateArchiveSyncSuspensionForMedia() { - let shouldSuspend = videoNoteCaptureIsActive + let shouldSuspend = + videoNoteCaptureIsActive || mediaSendActivity.isActive || !mediaPreparationTokens.isEmpty guard shouldSuspend != archiveSyncIsSuspendedForMedia else { return } @@ -1574,7 +2117,8 @@ final class AppModel: ObservableObject { ) } catch { guard attempt < maximumAttempts, - Self.isRetryableMediaSendError(error) else { throw error } + Self.isRetryableMediaSendError(error) + else { throw error } if connectionStatus != .connected { await reconnect() } @@ -1588,7 +2132,7 @@ final class AppModel: ObservableObject { guard let error = error as? LumaXMPPError else { return false } switch error { case .notConnected, .connection, .uploadDiscoveryFailed, - .uploadSlotFailed, .uploadTransportFailed: + .uploadSlotFailed, .uploadTransportFailed: return true case .uploadFailed(let statusCode): guard let statusCode else { return true } @@ -1649,6 +2193,7 @@ final class AppModel: ObservableObject { // UI from displaying a stale spinner even if the archive // completion event was overtaken by a socket state change. isArchiveSyncing = false + isLoadingOlderHistory = false for index in conversations.indices where conversations[index].isGroup { conversations[index].isGroupJoined = false conversations[index].occupantCount = 0 @@ -1664,7 +2209,7 @@ final class AppModel: ObservableObject { // 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() }) { + if let index = conversations.firstIndex(where: { $0.jid == jid.lowercased() }) { conversations[index].isOnline = online } case .message(let envelope): @@ -1680,13 +2225,15 @@ final class AppModel: ObservableObject { return case .groupMessageEcho(let roomJID, let messageID, let stanzaID, let senderJID): if let index = messageIndex(id: messageID, conversationID: roomJID) - ?? messageIndex(referenceID: stanzaID, conversationID: roomJID) { + ?? messageIndex(referenceID: stanzaID, conversationID: roomJID) + { messages[index].stanzaID = stanzaID messages[index].senderJID = senderJID - messageIndexByStanzaKey[Self.localDeletionKey( - messageID: stanzaID, - conversationID: roomJID - )] = index + messageIndexByStanzaKey[ + Self.localDeletionKey( + messageID: stanzaID, + conversationID: roomJID + )] = index if messages[index].delivery == .sending { messages[index].delivery = .sent } @@ -1699,12 +2246,15 @@ final class AppModel: ObservableObject { nickname: envelope.nickname, shouldAutojoin: envelope.joined ) - if let index = conversations.firstIndex(where: { $0.id == envelope.roomJID.lowercased() }) { + if let index = conversations.firstIndex(where: { + $0.jid == envelope.roomJID.lowercased() + }) { conversations[index].isGroupJoined = envelope.joined conversations[index].occupantCount = envelope.occupantCount } case .roomInvitation(let invitation): let jid = invitation.roomJID.lowercased() + deletedGroupChatJIDs.remove(jid) upsertGroupConversation( jid: jid, name: nil, @@ -1726,7 +2276,32 @@ final class AppModel: ObservableObject { 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 } + // A `` receipt can arrive after the chat was already + // opened (or after an XEP-0333 marker marked it read). Merge with + // the monotonic ladder instead of overwriting, so `.read` is never + // downgraded back to `.delivered`. + updateMessage(id: targetID) { + $0.delivery = $0.delivery.merged(with: .delivered) + } + case .read(let conversationID, let messageID): + // Peer-originated read receipt (XEP-0333 displayed marker): apply + // it locally and broadcast it to the user's other devices through + // a self-addressed read-marker message. + let targetID = correctionReceiptTargets.removeValue(forKey: messageID) ?? messageID + applyReadState( + ReadStateSync.Envelope( + id: UUID().uuidString, + conversationJID: conversationID, + messageID: targetID, + stanzaID: nil, + timestamp: Date() + ), + cameFromPeer: true + ) + case .readState(let envelope): + // Read-marker from another of the user's devices (Carbons live or + // MAM replay): apply without re-broadcasting. + applyReadState(envelope, cameFromPeer: false) case .omemo(let ready, let fingerprint): isOMEMOReady = ready ownFingerprint = fingerprint @@ -1738,10 +2313,34 @@ final class AppModel: ObservableObject { case .archiveSyncCompleted(let checkpoint): lastSuccessfulMAMSync = checkpoint.timestamp lastSuccessfulMAMCursor = checkpoint.cursor + if let account { + mamCheckpoints[.account(account.normalizedJID)] = MAMArchiveCheckpoint( + timestamp: checkpoint.timestamp, + cursor: checkpoint.cursor + ) + } + // Синхронно форсируем persist, чтобы checkpoint не потерялся + persistTask?.cancel() + persistTask = nil + Task { @MainActor [weak self] in + guard let self, let store = self.store else { return } + try? store.save( + locallyDeletedMessageIDs: self.locallyDeletedMessageIDs, + rosterContactJIDs: self.rosterContactJIDs, + lastSuccessfulMAMSync: self.lastSuccessfulMAMSync, + lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor, + mamCheckpoints: self.mamCheckpoints + ) + } + case .mucArchiveSyncCompleted(let archiveKey, let checkpoint): + mamCheckpoints[archiveKey] = checkpoint + schedulePersist() case .call(let call): activeCall = call case .callHistory(let entry): recordCallHistory(entry) + case .callHistorySync(let envelope): + recordSyncedCallHistory(envelope) case .callError(let message): errorMessage = message case .recoverableError(let message): @@ -1758,18 +2357,19 @@ final class AppModel: ObservableObject { 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 + 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 + } ?? false isApplyingArchiveBatch = true for mutation in mutations { switch mutation { @@ -1797,6 +2397,19 @@ final class AppModel: ObservableObject { } private func consumeMessage(_ envelope: XMPPService.MessageEnvelope) { + if !envelope.isGroupMessage { + upsertConversation(jid: envelope.peerJID, name: nil) + } + if let stanzaID = envelope.stanzaID, + messageIndex(referenceID: stanzaID, conversationID: envelope.peerJID) != nil + { + return // уже есть это сообщение + } + if let originID = envelope.originID, + messageIndex(originID: originID, conversationID: envelope.peerJID) != nil { + mergeServerIdentity(from: envelope, matchingOriginID: originID) + return + } if envelope.isGroupMessage { upsertGroupConversation( jid: envelope.peerJID, @@ -1809,14 +2422,17 @@ final class AppModel: ObservableObject { messageID: envelope.id, conversationID: envelope.peerJID ) - let correctionWasDeleted = envelope.correctionTargetID.map { - locallyDeletedMessageIDs.contains(Self.localDeletionKey( - messageID: $0, - conversationID: envelope.peerJID - )) - } ?? false + let correctionWasDeleted = + envelope.correctionTargetID.map { + locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: $0, + conversationID: envelope.peerJID + )) + } ?? false guard !locallyDeletedMessageIDs.contains(deletionKey), - !correctionWasDeleted else { return } + !correctionWasDeleted + else { return } if envelope.correctionTargetID != nil { consumeCorrection(envelope) return @@ -1841,6 +2457,7 @@ final class AppModel: ObservableObject { replyToID: envelope.replyToID, replyToJID: envelope.replyToJID, replyPreview: envelope.replyPreview, + originID: envelope.originID, stanzaID: envelope.stanzaID, senderDisplayName: envelope.senderDisplayName, isGroupMessage: envelope.isGroupMessage @@ -1850,7 +2467,7 @@ final class AppModel: ObservableObject { incrementUnread: envelope.isArchived ? false : nil ) let pendingKey = Self.localDeletionKey( - messageID: message.id, + messageID: message.clientID, conversationID: message.conversationID ) if let correction = pendingCorrections.removeValue(forKey: pendingKey) { @@ -1861,17 +2478,19 @@ final class AppModel: ObservableObject { } 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 + 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), @@ -1880,10 +2499,178 @@ final class AppModel: ObservableObject { ) } } + + private func mergeServerIdentity( + from envelope: XMPPService.MessageEnvelope, + matchingOriginID originID: String + ) { + guard let index = messageIndex( + originID: originID, + conversationID: envelope.peerJID + ) else { return } + if messages[index].stanzaID == nil { + messages[index].stanzaID = envelope.stanzaID + } + if messages[index].delivery == .sending { + messages[index].delivery = .sent + } + rebuildMessageIndex() + schedulePersist() + } + + // MARK: - Read receipts (XEP-0333) and cross-device read-state sync + + private func applyReadState(_ envelope: ReadStateSync.Envelope, cameFromPeer: Bool) { + let conversationID = envelope.conversationJID.lowercased() + if let index = + messageIndex(originID: envelope.messageID, conversationID: conversationID) + ?? messageIndex(referenceID: envelope.messageID, conversationID: conversationID) + ?? envelope.stanzaID.flatMap({ + messageIndex(referenceID: $0, conversationID: conversationID) + }) + { + markRead(at: index, cameFromPeer: cameFromPeer) + return + } + // The marker raced ahead of the message (typical for MAM replays, + // which arrive newest first). Apply it when the message lands. + stashPendingReadMarker(envelope, cameFromPeer: cameFromPeer) + } + + /// Promotes the matching outgoing message and every earlier outgoing + /// message in the same conversation to `.read`. XEP-0333 markers are + /// cumulative in practice: a marker for message N means the peer has + /// displayed the whole conversation through N. Failed messages are never + /// promoted by a receipt. + private func markRead(at index: Int, cameFromPeer: Bool) { + guard messages.indices.contains(index) else { return } + let target = messages[index] + guard target.direction == .outgoing, + target.delivery != .failed, + !target.isRetracted + else { return } + + let targetConversation = target.conversationID + var changed = false + for candidateIndex in messages.indices { + let candidate = messages[candidateIndex] + guard candidate.conversationID == targetConversation, + candidate.direction == .outgoing, + candidate.delivery != .read, + candidate.delivery != .failed, + !candidate.isRetracted, + candidate.timestamp < target.timestamp + || (candidate.timestamp == target.timestamp + && candidate.clientID <= target.clientID) + else { continue } + messages[candidateIndex].delivery = .read + changed = true + } + guard changed else { return } + schedulePersist() + guard cameFromPeer else { return } + xmpp.syncReadState( + ReadStateSync.Envelope( + id: UUID().uuidString, + conversationJID: target.conversationID, + messageID: target.originID ?? target.clientID, + stanzaID: target.stanzaID, + timestamp: Date() + )) + } + + private func stashPendingReadMarker(_ envelope: ReadStateSync.Envelope, cameFromPeer: Bool) { + // The marker may reference the message through its origin-id or its + // archived stanza-id; index it under every reference the arriving + // message can expose so `applyPendingReadState` always finds it. + let references = Set([envelope.messageID, envelope.stanzaID].compactMap { $0 }) + for reference in references { + let key = pendingReadMarkerKey( + conversationID: envelope.conversationJID, + reference: reference + ) + // A peer marker must keep its broadcast flag: a device-sync + // envelope must not silently shadow it. + if pendingReadMarkers[key]?.cameFromPeer == true { continue } + pendingReadMarkers[key] = PendingReadMarker(envelope: envelope, cameFromPeer: cameFromPeer) + } + if pendingReadMarkers.count > 200 { + if let oldestKey = pendingReadMarkers.min(by: { + $0.value.envelope.timestamp < $1.value.envelope.timestamp + })?.key { + pendingReadMarkers.removeValue(forKey: oldestKey) + } + } + } + + private func pendingReadMarkerKey(conversationID: String, reference: String) -> String { + "\(conversationID.lowercased())|\(reference)" + } + + /// Applies any read marker that arrived before the message it references. + /// Called from `upsertMessage` once the message itself is in the array. + private func applyPendingReadState(for message: ChatMessage, at index: Int) { + guard message.direction == .outgoing else { return } + let references = Set([message.clientID, message.originID, message.stanzaID].compactMap { $0 }) + for reference in references { + let key = pendingReadMarkerKey( + conversationID: message.conversationID, + reference: reference + ) + guard let pending = pendingReadMarkers.removeValue(forKey: key) else { continue } + markRead(at: index, cameFromPeer: pending.cameFromPeer) + } + } + + /// Self-chat has no peer that could answer with a `` marker, + /// so an outgoing message counts as read the moment the server accepted + /// it. `markRead` keeps `.failed` messages untouched and never re-syncs + /// the state to other devices (cameFromPeer == false). + private func markMessageAsReadIfOutgoing(id: String) { + guard let account, + let index = messageIndex(id: id, conversationID: account.normalizedJID) + else { return } + markRead(at: index, cameFromPeer: false) + } + + /// Sends an XEP-0333 `` marker for the newest incoming 1:1 + /// message once the chat is opened, so the peer's client can show it as + /// read. MUC rooms never get markers (XEP-0333); deduped per message so + /// repeated SwiftUI appearances do not resend. Self-chat has no peer, so + /// its own outgoing messages are promoted to `.read` locally instead of + /// sending a marker to our own bare JID. + private func sendDisplayedMarkerIfNeeded(for conversationID: String) { + guard let account else { return } + let normalized = conversationID.lowercased() + if normalized == account.normalizedJID { + for index in messages.indices { + let message = messages[index] + guard message.conversationID == normalized, + message.direction == .outgoing, + message.delivery != .failed, + !message.isRetracted, + message.kind != .system + else { continue } + markRead(at: index, cameFromPeer: false) + } + return + } + guard let latest = messages + .filter({ $0.conversationID == normalized && $0.direction == .incoming }) + .max(by: { $0.timestamp < $1.timestamp }), + !latest.isGroupMessage, + !latest.isRetracted, + latest.kind != .system + else { return } + guard lastDisplayedMarkerByConversation[normalized] != latest.clientID else { return } + lastDisplayedMarkerByConversation[normalized] = latest.clientID + xmpp.sendDisplayedMarker(messageID: latest.clientID, to: normalized) + } private func recordCallHistory(_ entry: CallHistoryEntry) { guard let account else { return } - let direction: ChatMessage.Direction = entry.direction == .outgoing + let direction: ChatMessage.Direction = + entry.direction == .outgoing ? .outgoing : .incoming let metadata = CallHistoryMetadata( @@ -1903,7 +2690,44 @@ final class AppModel: ObservableObject { duration: entry.duration, callHistory: metadata ) - let isUnreadMissedCall = direction == .incoming + let isUnreadMissedCall = + direction == .incoming + && entry.outcome == .missed + && selectedConversationID != entry.peerJID.lowercased() + _ = upsertMessage(message, incrementUnread: isUnreadMissedCall) + // Sync the card to the user's other devices via Carbons/MAM. + xmpp.syncCallHistory(entry) + } + + /// Applies a call card received from another of the user's devices. The + /// envelope id equals the origin-id of the sync message, which also equals + /// the originating device's local clientID, so Carbons and MAM copies + /// deduplicate into a single card. + private func recordSyncedCallHistory(_ entry: CallHistorySync.Envelope) { + 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) @@ -1911,14 +2735,16 @@ final class AppModel: ObservableObject { private func upsertConversation(jid: String, name: String?) { let normalized = jid.lowercased() - if let index = conversations.firstIndex(where: { $0.id == normalized }) { + if let index = conversations.firstIndex(where: { $0.jid == normalized }) { if let name, !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { conversations[index].displayName = name } } else { - conversations.append(Conversation(jid: normalized, displayName: name)) + let conversation = Conversation(jid: normalized, displayName: name) + modelContext.insert(conversation) + conversations.append(conversation) } - if conversations.first(where: { $0.id == normalized })?.isGroup != true { + if conversations.first(where: { $0.jid == normalized })?.isGroup != true { if !isApplyingArchiveBatch { xmpp.prepareDirectChat(with: normalized) } @@ -1937,6 +2763,7 @@ final class AppModel: ObservableObject { invitedBy: String? = nil ) { let normalized = jid.lowercased() + guard !deletedGroupChatJIDs.contains(normalized) else { return } let fallbackName = normalized.split(separator: "@").first.map(String.init) ?? normalized let resolvedName = name?.trimmingCharacters(in: .whitespacesAndNewlines) let initialDisplayName: String @@ -1945,7 +2772,7 @@ final class AppModel: ObservableObject { } else { initialDisplayName = fallbackName } - if let index = conversations.firstIndex(where: { $0.id == normalized }) { + if let index = conversations.firstIndex(where: { $0.jid == normalized }) { conversations[index].kind = .group if let resolvedName, !resolvedName.isEmpty { conversations[index].displayName = resolvedName @@ -1955,10 +2782,11 @@ final class AppModel: ObservableObject { if let nickname, !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { conversations[index].groupNickname = nickname } - conversations[index].shouldAutojoin = conversations[index].shouldAutojoin || shouldAutojoin + conversations[index].shouldAutojoin = + conversations[index].shouldAutojoin || shouldAutojoin if let invitedBy { conversations[index].invitedBy = invitedBy } } else { - conversations.append(Conversation( + let conversation = Conversation( jid: normalized, displayName: initialDisplayName, encryptionPreference: .inheritGlobal, @@ -1966,7 +2794,9 @@ final class AppModel: ObservableObject { groupNickname: nickname, shouldAutojoin: shouldAutojoin, invitedBy: invitedBy - )) + ) + modelContext.insert(conversation) + conversations.append(conversation) } if !isApplyingArchiveBatch { sortConversations() @@ -1975,7 +2805,8 @@ final class AppModel: ObservableObject { private var defaultGroupNickname: String { if let displayName = account?.displayName.trimmingCharacters(in: .whitespacesAndNewlines), - !displayName.isEmpty { + !displayName.isEmpty + { return displayName } return account?.normalizedJID.split(separator: "@").first.map(String.init) ?? "Luma" @@ -1983,17 +2814,21 @@ final class AppModel: ObservableObject { 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 } + !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 { + guard + let index = messageIndex( + id: targetID, + conversationID: envelope.peerJID + ) + else { let pendingKey = Self.localDeletionKey( messageID: targetID, conversationID: envelope.peerJID @@ -2007,18 +2842,19 @@ final class AppModel: ObservableObject { 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 } + 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 + messages[index].delivery = messages[index].delivery.merged(with: .sent) } updateConversationPreview(for: messages[index], incrementUnread: false) } @@ -2036,25 +2872,31 @@ final class AppModel: ObservableObject { guard allowedSenders.contains(MessageReactionPolicy.bareJID(senderJID)) else { return } } - guard let index = messageIndex( - referenceID: envelope.targetID, - conversationID: conversationID - ) else { + guard + let index = messageIndex( + referenceID: envelope.targetID, + conversationID: conversationID + ) + else { let key = pendingReactionKey(envelope) if let previous = pendingReactions[key], - previous.timestamp > envelope.timestamp { return } + previous.timestamp > envelope.timestamp + { + return + } pendingReactions[key] = envelope if pendingReactions.count > 200, - let staleKey = pendingReactions.min(by: { - $0.value.timestamp < $1.value.timestamp - })?.key { + 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 updatedMessage = messages[index] let existingIndex = updatedMessage.reactions.firstIndex { reaction in reactionSenderMatches( reaction.senderJID, @@ -2063,7 +2905,8 @@ final class AppModel: ObservableObject { ) } if let existingIndex, - updatedMessage.reactions[existingIndex].updatedAt > envelope.timestamp { + updatedMessage.reactions[existingIndex].updatedAt > envelope.timestamp + { return } @@ -2079,11 +2922,12 @@ final class AppModel: ObservableObject { ) } } else if !emojis.isEmpty { - updatedMessage.reactions.append(MessageReaction( - senderJID: senderJID, - emojis: emojis, - updatedAt: envelope.timestamp - )) + updatedMessage.reactions.append( + MessageReaction( + senderJID: senderJID, + emojis: emojis, + updatedAt: envelope.timestamp + )) } else { return } @@ -2092,7 +2936,7 @@ final class AppModel: ObservableObject { } private func applyPendingReactions(for message: ChatMessage) { - let identifiers = Set([message.id, message.stanzaID].compactMap { $0 }) + let identifiers = Set([message.clientID, message.stanzaID].compactMap { $0 }) guard !identifiers.isEmpty else { return } let matches = pendingReactions.filter { _, envelope in envelope.peerJID.lowercased() == message.conversationID @@ -2108,7 +2952,7 @@ final class AppModel: ObservableObject { [ envelope.peerJID.lowercased(), envelope.targetID, - envelope.senderJID.lowercased() + envelope.senderJID.lowercased(), ].joined(separator: "|") } @@ -2123,7 +2967,8 @@ final class AppModel: ObservableObject { private func applyRemoteChatState(_ envelope: XMPPService.ChatStateEnvelope) { let conversationID = envelope.peerJID.lowercased() - let senderKey = envelope.isGroupMessage + let senderKey = + envelope.isGroupMessage ? envelope.senderJID.lowercased() : MessageReactionPolicy.bareJID(envelope.senderJID) let expiryKey = "\(conversationID)|\(senderKey)" @@ -2139,7 +2984,8 @@ final class AppModel: ObservableObject { } var participants = typingParticipantsByConversation[conversationID] ?? [:] - participants[senderKey] = envelope.senderDisplayName + participants[senderKey] = + envelope.senderDisplayName ?? displayName(for: envelope.senderJID) typingParticipantsByConversation[conversationID] = participants remoteTypingExpiryTasks[expiryKey] = Task { @MainActor [weak self] in @@ -2174,7 +3020,7 @@ final class AppModel: ObservableObject { in conversation: Conversation ) { guard typingIndicatorsEnabled, connectionStatus == .connected else { return } - let conversationID = conversation.id.lowercased() + let conversationID = conversation.jid.lowercased() let previous = localChatStateByConversation[conversationID] if state == .paused, previous != .composing { return } if state == .inactive, previous == nil { return } @@ -2200,14 +3046,19 @@ final class AppModel: ObservableObject { } 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 { + 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 @@ -2221,10 +3072,11 @@ final class AppModel: ObservableObject { let original = messages[index] guard original.conversationID == envelope.peerJID.lowercased(), - original.senderJID == envelope.senderJID.lowercased(), - original.retractedAt == nil || envelope.timestamp >= original.retractedAt! else { return } + original.senderJID == envelope.senderJID.lowercased(), + original.retractedAt == nil || envelope.timestamp >= original.retractedAt! + else { return } - removeCachedMedia(for: original.id) + removeCachedMedia(for: original.clientID) messages[index].body = "Сообщение удалено" messages[index].kind = .system messages[index].remoteAttachmentURL = nil @@ -2243,10 +3095,11 @@ final class AppModel: ObservableObject { if envelope.isOutgoing, messages[index].delivery == .sending { messages[index].delivery = .sent } - pendingCorrections.removeValue(forKey: Self.localDeletionKey( - messageID: original.id, - conversationID: original.conversationID - )) + pendingCorrections.removeValue( + forKey: Self.localDeletionKey( + messageID: original.clientID, + conversationID: original.conversationID + )) updateConversationPreview(for: messages[index], incrementUnread: false) } @@ -2255,32 +3108,33 @@ final class AppModel: ObservableObject { _ message: ChatMessage, incrementUnread unreadOverride: Bool? = nil ) -> Bool { - guard !locallyDeletedMessageIDs.contains(Self.localDeletionKey( - messageID: message.id, - conversationID: message.conversationID - )) else { return false } + guard + !locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: message.clientID, + conversationID: message.conversationID + )) + else { return false } upsertConversation(jid: message.conversationID, name: nil) if let index = messageIndex( - id: message.id, + id: message.clientID, conversationID: message.conversationID ) { let previous = messages[index] if previous.isRetracted { - if message.delivery == .delivered { - messages[index].delivery = .delivered - } + messages[index].delivery = previous.delivery.merged(with: message.delivery) updateConversationPreview(for: messages[index], incrementUnread: false) return false } - var merged = message + let 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 - } + // MAM/MUC-MAM replays and Carbon copies carry `.sent`; a + // locally delivered or read copy must never be downgraded. + merged.delivery = previous.delivery.merged(with: message.delivery) } merged.replyToID = message.replyToID ?? previous.replyToID merged.replyToJID = message.replyToJID ?? previous.replyToJID @@ -2294,53 +3148,103 @@ final class AppModel: ObservableObject { merged.reactions = previous.reactions } if let editedAt = previous.editedAt, - message.editedAt == nil || editedAt > message.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 + if message.security == .decryptionFailed, + previous.security != .decryptionFailed, + !previous.body.isEmpty + { + // Our own outgoing message was rendered optimistically with the + // text the user typed. Its server echo (carbon or MAM) cannot + // be decrypted back (no encrypt-to-self key, or a consumed + // session), so keep the original content instead of replacing + // it with a "failed to decrypt" placeholder. + merged.body = previous.body + merged.security = previous.security + merged.encryptionFingerprint = previous.encryptionFingerprint + merged.kind = previous.kind + merged.remoteAttachmentURL = previous.remoteAttachmentURL + merged.localFilename = previous.localFilename + merged.mimeType = previous.mimeType + merged.duration = previous.duration + merged.byteCount = previous.byteCount } + messages[index] = merged + if previous !== merged { + // `merged` is the freshly-created incoming message; replace the + // previously managed object with it so SwiftData does not keep + // an orphaned duplicate of the same clientID. + modelContext.delete(previous) + modelContext.insert(merged) + } + if let stanzaID = merged.stanzaID { + messageIndexByStanzaKey[ + Self.localDeletionKey( + messageID: stanzaID, + conversationID: merged.conversationID + )] = index + } + if let originID = merged.originID { + messageIndexByOriginKey[ + Self.localDeletionKey( + messageID: originID, + conversationID: merged.conversationID + )] = index + } + applyPendingReadState(for: merged, at: index) updateConversationPreview(for: merged, incrementUnread: false) return false } + modelContext.insert(message) 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, + messageIndexByStorageKey[ + Self.localDeletionKey( + messageID: message.clientID, conversationID: message.conversationID )] = insertedIndex + if firstMessageIndexByID[message.clientID] == nil { + firstMessageIndexByID[message.clientID] = insertedIndex } - let shouldIncrement = unreadOverride + if let stanzaID = message.stanzaID { + messageIndexByStanzaKey[ + Self.localDeletionKey( + messageID: stanzaID, + conversationID: message.conversationID + )] = insertedIndex + } + if let originID = message.originID { + messageIndexByOriginKey[ + Self.localDeletionKey( + messageID: originID, + conversationID: message.conversationID + )] = insertedIndex + } + let shouldIncrement = + unreadOverride ?? (message.direction == .incoming && selectedConversationID != message.conversationID) + applyPendingReadState(for: message, at: insertedIndex) 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 } + messages.indices.contains(index), + messages[index].clientID == 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 } + guard let index = conversations.firstIndex(where: { $0.jid == message.conversationID }) + else { return } if message.timestamp >= conversations[index].lastActivity { conversations[index].lastActivity = message.timestamp conversations[index].lastMessage = message.previewText @@ -2354,11 +3258,14 @@ final class AppModel: ObservableObject { } private func rebuildConversationPreview(for conversationID: String) { - guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } - let latest = messages + guard let index = conversations.firstIndex(where: { $0.jid == conversationID }) else { + return + } + let latest = + messages .filter { $0.conversationID == conversationID } .max { lhs, rhs in - if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } + if lhs.timestamp == rhs.timestamp { return lhs.clientID < rhs.clientID } return lhs.timestamp < rhs.timestamp } conversations[index].lastActivity = latest?.timestamp ?? .distantPast @@ -2367,14 +3274,15 @@ final class AppModel: ObservableObject { } private func conversationName(for jid: String) -> String { - conversations.first(where: { $0.id == jid.lowercased() })?.displayName ?? jid + conversations.first(where: { $0.jid == 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 + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + == .orderedAscending } } @@ -2386,8 +3294,9 @@ final class AppModel: ObservableObject { ) guard let index = messageIndexByStorageKey[key] else { return nil } if messages.indices.contains(index), - messages[index].id == id, - messages[index].conversationID == normalizedConversationID { + messages[index].clientID == id, + messages[index].conversationID == normalizedConversationID + { return index } rebuildMessageIndex() @@ -2403,30 +3312,52 @@ final class AppModel: ObservableObject { messageID: referenceID, conversationID: normalizedConversationID ) - if let index = messageIndexByStanzaKey[key], - messages.indices.contains(index), - messages[index].conversationID == normalizedConversationID, - messages[index].stanzaID == referenceID { + guard let index = messageIndexByStanzaKey[key] else { return nil } + if messages.indices.contains(index), + messages[index].conversationID == normalizedConversationID, + messages[index].stanzaID == referenceID + { + return index + } + // Only rebuild when the key exists but points at a stale entry. A + // genuine miss is expected during archive catch-up for every new + // stanza-id and must not trigger an O(n) rebuild per message. + rebuildMessageIndex() + return messageIndexByStanzaKey[key] + } + + private func messageIndex(originID: String, conversationID: String) -> Int? { + let normalizedConversationID = conversationID.lowercased() + let key = Self.localDeletionKey( + messageID: originID, + conversationID: normalizedConversationID + ) + guard let index = messageIndexByOriginKey[key] else { return nil } + if messages.indices.contains(index), + messages[index].conversationID == normalizedConversationID, + messages[index].originID == originID + { return index } rebuildMessageIndex() - return messageIndexByStanzaKey[key] + return messageIndexByOriginKey[key] } private func rebuildMessageIndex() { messageIndexByStorageKey.removeAll(keepingCapacity: true) messageIndexByStanzaKey.removeAll(keepingCapacity: true) + messageIndexByOriginKey.removeAll(keepingCapacity: true) firstMessageIndexByID.removeAll(keepingCapacity: true) for (index, message) in messages.enumerated() { let key = Self.localDeletionKey( - messageID: message.id, + messageID: message.clientID, conversationID: message.conversationID ) if messageIndexByStorageKey[key] == nil { messageIndexByStorageKey[key] = index } - if firstMessageIndexByID[message.id] == nil { - firstMessageIndexByID[message.id] = index + if firstMessageIndexByID[message.clientID] == nil { + firstMessageIndexByID[message.clientID] = index } if let stanzaID = message.stanzaID { let stanzaKey = Self.localDeletionKey( @@ -2437,28 +3368,32 @@ final class AppModel: ObservableObject { messageIndexByStanzaKey[stanzaKey] = index } } + if let originID = message.originID { + let originKey = Self.localDeletionKey( + messageID: originID, + conversationID: message.conversationID + ) + if messageIndexByOriginKey[originKey] == nil { + messageIndexByOriginKey[originKey] = 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 + prepareStore(for: jid) + guard let store else { + fallbackContext = nil + return + } + self.store = store + let snapshot = store.load() + conversations = snapshot.conversations.map { conversation in 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 } @@ -2466,13 +3401,39 @@ final class AppModel: ObservableObject { locallyDeletedMessageIDs = snapshot.locallyDeletedMessageIDs lastSuccessfulMAMSync = snapshot.lastSuccessfulMAMSync lastSuccessfulMAMCursor = snapshot.lastSuccessfulMAMCursor + mamCheckpoints = snapshot.mamCheckpoints + if mamCheckpoints.isEmpty, let lastSuccessfulMAMSync { + mamCheckpoints[.account(jid)] = MAMArchiveCheckpoint( + timestamp: lastSuccessfulMAMSync, + cursor: lastSuccessfulMAMCursor + ) + } + // One-time cleanup of phantom «Не удалось расшифровать сообщение» + // placeholders: outgoing echoes the client could not decrypt back + // (broken encrypt-to-self sessions) used to be rendered as + // undecryptable bubbles. XMPPService now drops those echoes, so purge + // the leftovers from the local store instead of keeping the noise. + let phantomPlaceholderIDs = Set( + snapshot.messages + .filter { $0.direction == .outgoing && $0.security == .decryptionFailed } + .map(\.clientID) + ) + for message in snapshot.messages + where phantomPlaceholderIDs.contains(message.clientID) { + modelContext.delete(message) + } messages = snapshot.messages.filter { - !locallyDeletedMessageIDs.contains(Self.localDeletionKey( - messageID: $0.id, - conversationID: $0.conversationID - )) + !phantomPlaceholderIDs.contains($0.clientID) + && !locallyDeletedMessageIDs.contains( + Self.localDeletionKey( + messageID: $0.clientID, + conversationID: $0.conversationID + )) } rebuildMessageIndex() + if !phantomPlaceholderIDs.isEmpty { + schedulePersist() + } let avatarJIDs = Set(snapshot.conversations.filter { !$0.isGroup }.map(\.jid) + [jid]) for avatarJID in avatarJIDs { if let data = await avatarCache.data(for: avatarJID) { @@ -2484,20 +3445,18 @@ final class AppModel: ObservableObject { } private func schedulePersist() { - guard let archive else { return } + guard let store 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, + try? store.save( locallyDeletedMessageIDs: self.locallyDeletedMessageIDs, rosterContactJIDs: self.rosterContactJIDs, lastSuccessfulMAMSync: self.lastSuccessfulMAMSync, - lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor + lastSuccessfulMAMCursor: self.lastSuccessfulMAMCursor, + mamCheckpoints: self.mamCheckpoints ) - try? await archive.save(snapshot) } } @@ -2522,7 +3481,8 @@ final class AppModel: ObservableObject { private func loadCachedAvatarIfNeeded(for jid: String) { let normalized = jid.lowercased() guard avatarDataByJID[normalized] == nil, - avatarCacheRequests.insert(normalized).inserted else { return } + 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 @@ -2536,7 +3496,7 @@ final class AppModel: ObservableObject { "LumaForwarding", "LumaAttachmentDrafts", "LumaPhotoPicker", - "LumaDuration" + "LumaDuration", ] { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(directoryName, isDirectory: true) @@ -2559,10 +3519,11 @@ final class AppModel: ObservableObject { 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 - )) ?? [] + 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) @@ -2596,7 +3557,8 @@ final class AppModel: ObservableObject { if let preparedThumbnailData { mediaThumbnailData[messageID] = preparedThumbnailData } - let hasCompletePreparedPreview = preparedThumbnailData != nil + let hasCompletePreparedPreview = + preparedThumbnailData != nil && (kind == .photo || kind == .video) guard !hasCompletePreparedPreview else { return } Task { @MainActor [weak self] in @@ -2630,10 +3592,11 @@ final class AppModel: ObservableObject { audioWaveformSamples[messageID] = waveform } if let duration = analysis.duration, - let index = firstMessageIndexByID[messageID], - messages.indices.contains(index), - messages[index].id == messageID, - messages[index].duration == nil { + let index = firstMessageIndexByID[messageID], + messages.indices.contains(index), + messages[index].clientID == messageID, + messages[index].duration == nil + { messages[index].duration = duration schedulePersist() } @@ -2647,7 +3610,7 @@ final class AppModel: ObservableObject { throw AttachmentError.missingRemoteURL } let output = try Self.previewFileURL( - messageID: message.id, + messageID: message.clientID, filename: message.localFilename ?? "attachment", directoryName: directoryName ) @@ -2678,7 +3641,8 @@ final class AppModel: ObservableObject { 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 }) + 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 { @@ -2739,7 +3703,7 @@ final class AppModel: ObservableObject { } private static func localDeletionKey(messageID: String, conversationID: String) -> String { - conversationID.lowercased() + "\u{1F}" + messageID + ArchiveStore.deletionKey(messageID: messageID, conversationID: conversationID) } } diff --git a/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift b/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift index fbef6d5..2493b68 100644 --- a/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift +++ b/Sources/Shared/Models/ArchiveMessageBatchPolicy.swift @@ -4,12 +4,16 @@ import Foundation /// still published atomically; these values only bound network and decryption /// work performed before that publication. enum ArchiveMessageBatchPolicy { - static let pageSize = 24 + static let pageSize = 40 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 + /// Decrypt this many archived stanzas per main-actor slice before yielding. + /// One-by-one decryption turns a large MAM catch-up into hours of serialized + /// work; a small batch keeps frames mostly intact while cutting the number + /// of run-loop hand-offs by the same factor. + static let decodeSliceSize = 8 + static let maximumBufferedStanzas = 96 + /// A short yield between archived decryption slices keeps touch handling + /// responsive without the 8 ms per single message that previously dominated + /// catch-up time. + static let interSliceDelayNanoseconds: UInt64 = 1_000_000 } diff --git a/Sources/Shared/Models/ArchiveSyncCheckpoint.swift b/Sources/Shared/Models/ArchiveSyncCheckpoint.swift index 9401ca4..4eb60c0 100644 --- a/Sources/Shared/Models/ArchiveSyncCheckpoint.swift +++ b/Sources/Shared/Models/ArchiveSyncCheckpoint.swift @@ -1,5 +1,40 @@ import Foundation +/// A MAM UID is only meaningful inside one archive. Account MAM and every MUC +/// therefore get their own namespace and durable checkpoint. +struct MAMArchiveKey: Codable, Hashable, Sendable { + enum Kind: String, Codable, Sendable { + case account + case muc + } + + let kind: Kind + let jid: String + + init(kind: Kind, jid: String) { + self.kind = kind + self.jid = jid.lowercased() + } + + static func account(_ jid: String) -> Self { + Self(kind: .account, jid: jid) + } + + static func muc(_ jid: String) -> Self { + Self(kind: .muc, jid: jid) + } +} + +struct MAMArchiveCheckpoint: Codable, Equatable, Sendable { + let timestamp: Date + let cursor: String? + + init(timestamp: Date, cursor: String? = nil) { + self.timestamp = timestamp + self.cursor = ArchiveSyncCheckpoint.normalizedCursor(cursor) + } +} + /// 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. diff --git a/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift b/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift index 0098e4a..5452327 100644 --- a/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift +++ b/Sources/Shared/Models/ArchiveSyncRecoveryPolicy.swift @@ -5,8 +5,15 @@ import Foundation 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 queryTimeoutNanoseconds: UInt64 = 20_000_000_000 + static let pageApplyTimeoutNanoseconds: UInt64 = 20_000_000_000 static let pageRetryLimit = 1 static let resumeAfterCaptureDelayNanoseconds: UInt64 = 3_000_000_000 + /// Delay before automatically resuming a failed catch-up pass while the + /// app stays connected and foregrounded. Prevents a single slow page from + /// silently leaving history unloaded until the next app activation. + static let retryAfterFailureDelayNanoseconds: UInt64 = 20_000_000_000 + /// Consecutive automatic catch-up retries before falling back to + /// resume-on-activation, so a persistently broken server cannot loop. + static let maximumAutomaticRetries = 3 } diff --git a/Sources/Shared/Models/ChatMessage.swift b/Sources/Shared/Models/ChatMessage.swift index 073af59..9ed4af1 100644 --- a/Sources/Shared/Models/ChatMessage.swift +++ b/Sources/Shared/Models/ChatMessage.swift @@ -1,25 +1,49 @@ import Foundation +import SwiftData -struct ChatMessage: Codable, Identifiable, Hashable, Sendable { - enum Direction: String, Codable, Sendable { +@Model +final class ChatMessage { + enum Direction: String, Codable { case incoming case outgoing } - enum Delivery: String, Codable, Sendable { + enum Delivery: String, Codable { case sending case sent case delivered case failed + case read + + /// Monotonic promotion used when the same outgoing message is replayed + /// from MAM/MUC-MAM or Carbons: a copy that was already delivered or + /// read locally must never be downgraded by an archive replay carrying + /// `.sent`. `.failed` is sticky in both directions — an archive + /// replay must not silently "fix" a failed send; the user resends it + /// explicitly. + func merged(with other: Delivery) -> Delivery { + guard self != .failed, other != .failed else { return .failed } + return other.rank > self.rank ? other : self + } + + private var rank: Int { + switch self { + case .sending: return 0 + case .sent: return 1 + case .delivered: return 2 + case .read: return 3 + case .failed: return -1 + } + } } - enum Security: String, Codable, Sendable { + enum Security: String, Codable { case omemo case plaintext case decryptionFailed } - enum Kind: String, Codable, Sendable { + enum Kind: String, Codable { case text case attachment case photo @@ -40,7 +64,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable { } } - let id: String + @Attribute(.unique) var clientID: String var conversationID: String var senderJID: String var body: String @@ -61,12 +85,15 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable { var replyPreview: String? var forwardedFrom: String? var retractedAt: Date? + var originID: String? var stanzaID: String? var senderDisplayName: String? var isGroupMessage: Bool var callHistory: CallHistoryMetadata? var reactions: [MessageReaction] + var conversation: Conversation? + init( id: String = UUID().uuidString, conversationID: String, @@ -89,13 +116,14 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable { replyPreview: String? = nil, forwardedFrom: String? = nil, retractedAt: Date? = nil, + originID: String? = nil, stanzaID: String? = nil, senderDisplayName: String? = nil, isGroupMessage: Bool = false, callHistory: CallHistoryMetadata? = nil, reactions: [MessageReaction] = [] ) { - self.id = id + self.clientID = id self.conversationID = conversationID.lowercased() self.senderJID = Self.normalizedSenderJID(senderJID) self.body = body @@ -116,6 +144,7 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable { self.replyPreview = replyPreview self.forwardedFrom = forwardedFrom self.retractedAt = retractedAt + self.originID = originID ?? (direction == .outgoing ? id : nil) self.stanzaID = stanzaID self.senderDisplayName = senderDisplayName self.isGroupMessage = isGroupMessage @@ -123,71 +152,12 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable { 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) + && (delivery == .sent || delivery == .delivered || delivery == .read) } var isRetracted: Bool { @@ -209,17 +179,18 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable { && !isGroupMessage && !isRetracted && kind != .system - && (delivery == .sent || delivery == .delivered) + && (delivery == .sent || delivery == .delivered || delivery == .read) } var replyIdentifier: String? { - isGroupMessage ? stanzaID : id + isGroupMessage ? stanzaID : clientID } var quotePreview: String { if isRetracted { return "Сообщение удалено" } let value = kind == .text ? body : previewText - let normalized = value + let normalized = + value .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) guard normalized.count > 140 else { return normalized } @@ -316,4 +287,15 @@ struct ChatMessage: Codable, Identifiable, Hashable, Sendable { let bare = value[.. [String: TimeInterval] { + let last = ids.count - 1 + guard last >= 0 else { return [:] } + let firstAnimated = max(0, ids.count - entranceCascadeLimit) + var delays: [String: TimeInterval] = [:] + delays.reserveCapacity(ids.count - firstAnimated) + for index in firstAnimated...last { + delays[ids[index]] = TimeInterval(last - index) * entranceStagger + } + return delays + } + + /// Rows a query refresh added below everything already on screen. + /// + /// A MAM page paged in above the viewport is deliberately ignored: replaying + /// the entrance mid-scroll would yank the timeline the user is reading. A + /// burst of rows is treated as the same kind of archive delivery. + static func appendedLaunchIDs( + in ids: [String], + after previous: Set + ) -> [String] { + guard !previous.isEmpty else { return [] } + guard let lastKnownIndex = ids.lastIndex(where: previous.contains) + else { return [] } + let appended = ids.dropFirst(lastKnownIndex + 1).filter { + !previous.contains($0) + } + guard appended.count <= appendAnimationLimit else { return [] } + return Array(appended) + } +} \ No newline at end of file diff --git a/Sources/Shared/Models/ChatTimelineEntry.swift b/Sources/Shared/Models/ChatTimelineEntry.swift index aa15796..7188d20 100644 --- a/Sources/Shared/Models/ChatTimelineEntry.swift +++ b/Sources/Shared/Models/ChatTimelineEntry.swift @@ -3,11 +3,21 @@ 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 { +struct ChatTimelineEntry: Identifiable, Hashable { let message: ChatMessage let startsNewDay: Bool - var id: String { message.id } + var id: String { message.clientID } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.message.clientID == rhs.message.clientID + && lhs.startsNewDay == rhs.startsNewDay + } + + func hash(into hasher: inout Hasher) { + hasher.combine(message.clientID) + hasher.combine(startsNewDay) + } static func make( from messages: [ChatMessage], diff --git a/Sources/Shared/Models/Conversation.swift b/Sources/Shared/Models/Conversation.swift index dab4911..a662e77 100644 --- a/Sources/Shared/Models/Conversation.swift +++ b/Sources/Shared/Models/Conversation.swift @@ -1,13 +1,14 @@ import Foundation +import SwiftData -struct Conversation: Codable, Identifiable, Hashable, Sendable { - enum Kind: String, Codable, Sendable { +@Model +final class Conversation { + enum Kind: String, Codable { case direct case group } - let id: String - var jid: String + @Attribute(.unique) var jid: String var displayName: String var lastMessage: String var lastActivity: Date @@ -22,6 +23,9 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable { var occupantCount: Int var invitedBy: String? + @Relationship(deleteRule: .cascade, inverse: \ChatMessage.conversation) + var messages: [ChatMessage] = [] + init( jid: String, displayName: String? = nil, @@ -39,7 +43,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable { invitedBy: String? = nil ) { let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - self.id = normalized self.jid = normalized self.displayName = displayName?.nilIfBlank ?? normalized self.lastMessage = lastMessage @@ -56,46 +59,6 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable { 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 { @@ -115,6 +78,15 @@ struct Conversation: Codable, Identifiable, Hashable, Sendable { } } +/// A row in the cross-account inbox. JID alone is not a stable identity when +/// two logged-in accounts have the same contact or a self-chat. +struct AccountConversation: Identifiable { + let account: AccountConfiguration + let conversation: Conversation + + var id: String { "\(account.normalizedJID)|\(conversation.jid)" } +} + private extension String { var nilIfBlank: String? { trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self diff --git a/Sources/Shared/Models/DeviceResource.swift b/Sources/Shared/Models/DeviceResource.swift new file mode 100644 index 0000000..3cb7dcb --- /dev/null +++ b/Sources/Shared/Models/DeviceResource.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Stable, per-installation XMPP resource. Two devices that both fall back to +/// the default must never share a resource: an XMPP server resolves a resource +/// collision with a `conflict` stream error, which disconnects the older +/// session. A UUID persisted in UserDefaults keeps the value stable across +/// launches (so presence and stream-management resume stay consistent) while +/// remaining distinct between macOS and iOS installs. +enum DeviceResource { + /// The legacy hard-coded default that older Luma builds sent from every + /// device. Treated as "automatic" so existing accounts migrate without the + /// user having to re-enter credentials. + static let legacyDefault = "Luma" + + private static let storageKey = "app.luma.xmpp.resource" + + static var `default`: String { + let defaults = UserDefaults.standard + if let existing = defaults.string(forKey: storageKey), !existing.isEmpty { + return existing + } + let suffix = UUID().uuidString + .replacingOccurrences(of: "-", with: "") + .prefix(8) + .lowercased() + let resource = "Luma-\(suffix)" + defaults.set(resource, forKey: storageKey) + return resource + } +} diff --git a/Sources/Shared/Models/EmojiCatalog.swift b/Sources/Shared/Models/EmojiCatalog.swift new file mode 100644 index 0000000..5883659 --- /dev/null +++ b/Sources/Shared/Models/EmojiCatalog.swift @@ -0,0 +1,200 @@ +import Foundation + +/// A category of emojis shown in the emoji picker. +struct EmojiCategory: Identifiable, Equatable, Sendable { + let title: String + let symbol: String + let emojis: [String] + + var id: String { title } +} + +/// Curated, widely-supported emoji set grouped into categories. Used by both +/// the reaction picker and the composer's emoji picker. +enum EmojiCatalog { + static let categories: [EmojiCategory] = [ + EmojiCategory( + title: "Смайлы", + symbol: "face.smiling", + emojis: [ + "😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "🥲", "☺️", + "😊", "😇", "🙂", "🙃", "😉", "😌", "😍", "🥰", "😘", "😗", + "😙", "😚", "😋", "😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", + "😎", "🥸", "🤩", "🥳", "😏", "😒", "😞", "😔", "😟", "😕", + "🙁", "☹️", "😣", "😖", "😫", "😩", "🥺", "😢", "😭", "😤", + "😠", "😡", "🤬", "🤯", "😳", "🥵", "🥶", "😱", "😨", "😰", + "😥", "😓", "🤗", "🤔", "🫣", "🤭", "🫢", "🤫", "🤥", "😶", + "😐", "😑", "😬", "🙄", "😯", "😦", "😧", "😮", "😲", "🥱", + "😴", "🤤", "😪", "😵", "🤐", "🥴", "🤢", "🤮", "🤧", "😷", + "🤒", "🤕", "🤑", "🤠", "😈", "👿", "👹", "👺", "🤡", "💩", + "👻", "💀", "☠️", "👽", "👾", "🤖", "🎃", "😺", "😸", "😹", + "😻", "😼", "😽", "🙀", "😿", "😾", "❤️", "🧡", "💛", "💚", + "💙", "💜", "🖤", "🤍", "🤎", "💔", "❤️‍🔥", "❤️‍🩹", "💕", "💞", + "💓", "💗", "💖", "💘", "💝", "💟", "❣️", "💌", "💢", "💥", + "💫", "💦", "💨", "🕳️", "💬", "💭", "🗯️", + ] + ), + EmojiCategory( + title: "Жесты", + symbol: "hand.raised", + emojis: [ + "👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞", + "🫰", "🤟", "🤘", "🤙", "👈", "👉", "👆", "🖕", "👇", "☝️", + "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "🫶", "👐", + "🤲", "🤝", "🙏", "✍️", "💅", "🤳", "💪", "🦾", "🦵", "🦶", + "👂", "🦻", "👃", "🧠", "🦷", "🦴", "👀", "👁️", "👅", "👄", + "👶", "🧒", "👦", "👧", "🧑", "👱", "👨", "🧔", "👩", "🧓", + "👴", "👵", "🙍", "🙎", "🙅", "🙆", "💁", "🙋", "🧏", "🙇", + "🤦", "🤷", "💆", "💇", "🚶", "🧍", "🧎", "🏃", "💃", "🕺", + "🧖", "🧗", "🤺", "🏇", "⛷️", "🏂", "🏌️", "🏄", "🚣", "🏊", + "⛹️", "🏋️", "🚴", "🚵", "🤸", "🤼", "🤽", "🤾", "🤹", "🧘", + "🛀", "🛌", "🧑‍🤝‍🧑", "👭", "👫", "👬", "💏", "💑", "👪", + ] + ), + EmojiCategory( + title: "Животные и природа", + symbol: "leaf", + emojis: [ + "🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐻‍❄️", "🐨", + "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", "🙉", "🙊", "🐔", + "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺", + "🐗", "🐴", "🦄", "🐝", "🪱", "🐛", "🦋", "🐌", "🐞", "🐜", + "🪰", "🪲", "🪳", "🦟", "🦗", "🕷️", "🕸️", "🦂", "🐢", "🐍", + "🦎", "🦖", "🦕", "🐙", "🦑", "🦐", "🦞", "🦀", "🐡", "🐠", + "🐟", "🐬", "🐳", "🐋", "🦈", "🐊", "🐅", "🐆", "🦓", "🦍", + "🦧", "🦣", "🐘", "🦛", "🦏", "🐪", "🐫", "🦒", "🦘", "🦬", + "🐃", "🐂", "🐄", "🐎", "🐖", "🐏", "🐑", "🦙", "🐐", "🦌", + "🐕", "🐩", "🦮", "🐈", "🐈‍⬛", "🪶", "🐓", "🦃", "🦤", "🦚", + "🦜", "🦢", "🦩", "🕊️", "🐇", "🦝", "🦨", "🦡", "🦫", "🦦", + "🦥", "🐁", "🐀", "🐿️", "🦔", "🐾", "🐉", "🐲", "🌵", "🎄", + "🌲", "🌳", "🌴", "🌱", "🌿", "☘️", "🍀", "🎍", "🎋", "🍃", + "🍂", "🍁", "🍄", "🐚", "🌾", "💐", "🌷", "🌹", "🥀", "🌺", + "🌸", "🌼", "🌻", "🌞", "🌝", "🌛", "🌜", "🌚", "🌕", "🌖", + "🌗", "🌘", "🌑", "🌒", "🌓", "🌔", "🌙", "🌎", "🌍", "🌏", + "💫", "⭐", "🌟", "✨", "⚡", "☄️", "💥", "🔥", "🌪️", "🌈", + "☀️", "🌤️", "⛅", "🌥️", "☁️", "🌦️", "🌧️", "⛈️", "🌩️", "🌨️", + "❄️", "☃️", "⛄", "🌬️", "💨", "💧", "💦", "☔", "☂️", "🌊", "🌫️", + ] + ), + EmojiCategory( + title: "Еда и напитки", + symbol: "fork.knife", + emojis: [ + "🍏", "🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", + "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥝", "🍅", "🍆", "🥑", + "🥦", "🥬", "🥒", "🌶️", "🫑", "🌽", "🥕", "🫒", "🧄", "🧅", + "🥔", "🍠", "🥐", "🥯", "🍞", "🥖", "🥨", "🧀", "🥚", "🍳", + "🧈", "🥞", "🧇", "🥓", "🥩", "🍗", "🍖", "🦴", "🌭", "🍔", + "🍟", "🍕", "🫓", "🥪", "🥙", "🧆", "🌮", "🌯", "🫔", "🥗", + "🥘", "🫕", "🥫", "🍝", "🍜", "🍲", "🍛", "🍣", "🍱", "🥟", + "🦪", "🍤", "🍙", "🍚", "🍘", "🍥", "🥠", "🥮", "🍢", "🍡", + "🍧", "🍨", "🍦", "🥧", "🧁", "🍰", "🎂", "🍮", "🍭", "🍬", + "🍫", "🍿", "🍩", "🍪", "🌰", "🥜", "🍯", "🥛", "🍼", "🫖", + "☕", "🍵", "🧃", "🥤", "🧋", "🍶", "🍺", "🍻", "🥂", "🍷", + "🥃", "🍸", "🍹", "🧉", "🍾", "🧊", "🥄", "🍴", "🍽️", "🥣", + "🥡", "🥢", + ] + ), + EmojiCategory( + title: "Активности", + symbol: "soccerball", + emojis: [ + "⚽", "🏀", "🏈", "⚾", "🥎", "🎾", "🏐", "🏉", "🥏", "🎱", + "🪀", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🪃", "🥅", "⛳", + "🪁", "🏹", "🎣", "🤿", "🥊", "🥋", "🎽", "🛹", "🛼", "🛷", + "⛸️", "🥌", "🎿", "⛷️", "🏂", "🪂", "🏋️", "🤼", "🤸", "⛹️", + "🤺", "🤾", "🏌️", "🏇", "🧘", "🏄", "🏊", "🤽", "🚣", "🧗", + "🚵", "🚴", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖️", "🏵️", "🎗️", + "🎫", "🎟️", "🎪", "🤹", "🎭", "🩰", "🎨", "🎬", "🎤", "🎧", + "🎼", "🎹", "🥁", "🪘", "🎷", "🎺", "🪗", "🎸", "🪕", "🎻", + "🎲", "♟️", "🎯", "🎳", "🎮", "🎰", "🧩", + ] + ), + EmojiCategory( + title: "Путешествия", + symbol: "airplane", + emojis: [ + "🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", + "🛻", "🚚", "🚛", "🚜", "🛵", "🏍️", "🛺", "🚲", "🛴", "🛹", + "🚨", "🚔", "🚍", "🚘", "🚖", "🚡", "🚠", "🚟", "🚃", "🚋", + "🚞", "🚝", "🚄", "🚅", "🚈", "🚂", "🚆", "🚇", "🚊", "🚉", + "✈️", "🛫", "🛬", "🛩️", "💺", "🛰️", "🚀", "🛸", "🚁", "🛶", + "⛵", "🚤", "🛥️", "🛳️", "⛴️", "🚢", "⚓", "🪝", "🚧", "🚦", + "🚥", "🚏", "🗺️", "🗿", "🗽", "🗼", "🏰", "🏯", "🏟️", "🎡", + "🎢", "🎠", "⛲", "⛱️", "🏖️", "🏝️", "🏜️", "🌋", "⛰️", "🏔️", + "🗻", "🏕️", "⛺", "🛖", "🏠", "🏡", "🏘️", "🏚️", "🏗️", "🏭", + "🏢", "🏬", "🏣", "🏤", "🏥", "🏦", "🏨", "🏪", "🏫", "🏩", + "💒", "🏛️", "⛪", "🕌", "🕍", "🛕", "🕋", "⛩️", "🛤️", "🛣️", + "🗾", "🎑", "🏞️", "🌅", "🌄", "🌠", "🎇", "🎆", "🌇", "🌆", + "🏙️", "🌃", "🌌", "🌉", "🌁", + ] + ), + EmojiCategory( + title: "Предметы", + symbol: "lightbulb", + emojis: [ + "⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", "🖲️", "🕹️", + "🗜️", "💽", "💾", "💿", "📀", "📼", "📷", "📸", "📹", "🎥", + "📽️", "🎞️", "📞", "☎️", "📟", "📠", "📺", "📻", "🎙️", "🎚️", + "🎛️", "🧭", "⏱️", "⏲️", "⏰", "🕰️", "⌛", "⏳", "📡", "🔋", + "🔌", "💡", "🔦", "🕯️", "🪔", "🧯", "🛢️", "💸", "💵", "💴", + "💶", "💷", "🪙", "💰", "💳", "💎", "⚖️", "🪜", "🧰", "🪛", + "🔧", "🔨", "⚒️", "🛠️", "⛏️", "🪚", "🔩", "⚙️", "🪤", "🧱", + "⛓️", "🧲", "🔫", "💣", "🧨", "🪓", "🔪", "🗡️", "⚔️", "🛡️", + "🚬", "⚰️", "🪦", "⚱️", "🏺", "🔮", "📿", "🧿", "💈", "⚗️", + "🔭", "🔬", "🕳️", "🩹", "🩺", "💊", "💉", "🩸", "🧬", "🦠", + "🧫", "🧪", "🌡️", "🧹", "🪠", "🧺", "🧻", "🚽", "🚰", "🚿", + "🛁", "🛀", "🧼", "🪥", "🪒", "🧽", "🪣", "🧴", "🛎️", "🔑", + "🗝️", "🚪", "🪑", "🛋️", "🛏️", "🛌", "🧸", "🪆", "🖼️", "🪞", + "🪟", "🛍️", "🛒", "🎁", "🎈", "🎏", "🎀", "🪄", "🪅", "🎊", + "🎉", "🎎", "🏮", "🎐", "🧧", "✉️", "📩", "📨", "📧", "💌", + "📥", "📤", "📦", "🏷️", "🪧", "📪", "📫", "📬", "📭", "📮", + "📯", "📜", "📃", "📄", "📑", "🧾", "📊", "📈", "📉", "🗒️", + "🗓️", "📆", "📅", "🗑️", "📇", "🗃️", "🗳️", "🗄️", "📋", "📁", + "📂", "🗂️", "🗞️", "📰", "📓", "📔", "📒", "📕", "📗", "📘", + "📙", "📚", "📖", "🔖", "🧷", "🔗", "📎", "🖇️", "📐", "📏", + "🧮", "📌", "📍", "✂️", "🖊️", "🖋️", "✒️", "🖌️", "🖍️", "📝", + "✏️", "🔍", "🔎", "🔏", "🔐", "🔒", "🔓", + ] + ), + EmojiCategory( + title: "Символы", + symbol: "heart", + emojis: [ + "❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", + "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "♥️", + "♠️", "♣️", "♦️", "🃏", "🀄", "🎴", "🔇", "🔈", "🔉", "🔊", + "📢", "📣", "📯", "🔔", "🔕", "🎼", "🎵", "🎶", "🎙️", "🎚️", + "🎛️", "🎤", "🎧", "📻", "🎷", "🪗", "🎸", "🎹", "🎺", "🎻", + "🥁", "🪘", "📱", "📲", "☎️", "📞", "📟", "📠", "🔋", "🔌", + "💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "💽", "💾", "💿", "📀", + "🧮", "🎥", "🎞️", "📽️", "🎬", "📺", "📷", "📸", "📹", "📼", + "🔍", "🔎", "🕯️", "💡", "🔦", "🏮", "🪔", "📔", "📕", "📖", + "📗", "📘", "📙", "📚", "📓", "📒", "📃", "📜", "📄", "📰", + "🗞️", "📑", "🔖", "🏷️", "💰", "🪙", "💴", "💵", "💶", "💷", + "💸", "💳", "🧾", "✉️", "📧", "📨", "📩", "📤", "📥", "📦", + "📫", "📪", "📬", "📭", "📮", "🗳️", "✏️", "✒️", "🖋️", "🖊️", + "🖌️", "🖍️", "📝", "💼", "📁", "📂", "🗂️", "📅", "📆", "🗒️", + "🗓️", "📇", "📈", "📉", "📊", "📋", "📌", "📍", "📎", "🖇️", + "📏", "📐", "✂️", "🗃️", "🗄️", "🗑️", "🔒", "🔓", "🔏", "🔐", + "🔑", "🗝️", "🔨", "🪓", "⛏️", "⚒️", "🛠️", "🗡️", "⚔️", "🔫", + "🪃", "🏹", "🛡️", "🪚", "🔧", "🪛", "🔩", "⚙️", "🗜️", "⚖️", + "🦯", "🔗", "⛓️", "🪝", "🧰", "🧲", "🪜", "⚗️", "🧪", "🧫", + "🧬", "🔬", "🔭", "📡", "💉", "🩸", "💊", "🩹", "🩺", "🚪", + "🛏️", "🛋️", "🪑", "🚽", "🪠", "🚿", "🛁", "🪒", "🧴", "🧷", + "🧹", "🧺", "🧻", "🧼", "🪥", "🧽", "🧯", "🛒", "🚬", "⚰️", + "🪦", "⚱️", "🗿", "🪧", "🪪", "🆗", "🆕", "🆒", "🆓", "🆙", + "🆖", "🆚", "🈁", "🈂️", "🈷️", "🈶", "🈯", "🉐", "🈹", "🈚", + "🈲", "🉑", "🈸", "🈴", "🈳", "㊗️", "㊙️", "🈺", "🈵", "🔴", + "🟠", "🟡", "🟢", "🔵", "🟣", "🟤", "⚫", "⚪", "🟥", "🟧", + "🟨", "🟩", "🟦", "🟪", "🟫", "⬛", "⬜", "◼️", "◻️", "◾", + "◽", "▪️", "▫️", "🔶", "🔷", "🔸", "🔹", "🔺", "🔻", "💠", + "🔘", "🔳", "🔲", + ] + ), + ] + + static var allEmojis: [String] { + categories.flatMap(\.emojis) + } +} diff --git a/Sources/Shared/Models/MessageReaction.swift b/Sources/Shared/Models/MessageReaction.swift index 6090b53..4fc2cec 100644 --- a/Sources/Shared/Models/MessageReaction.swift +++ b/Sources/Shared/Models/MessageReaction.swift @@ -80,7 +80,7 @@ enum MessageReactionPolicy { extension ChatMessage { var reactionIdentifier: String? { - isGroupMessage ? stanzaID : id + isGroupMessage ? stanzaID : clientID } var canBeReactedTo: Bool { diff --git a/Sources/Shared/Models/MessageReplySwipePolicy.swift b/Sources/Shared/Models/MessageReplySwipePolicy.swift index 0eb0230..e8e23d8 100644 --- a/Sources/Shared/Models/MessageReplySwipePolicy.swift +++ b/Sources/Shared/Models/MessageReplySwipePolicy.swift @@ -2,30 +2,62 @@ import CoreGraphics struct MessageReplySwipePolicy { static let triggerDistance: CGFloat = 64 + /// Horizontal travel required before the indicator starts following the + /// finger; keeps per-cell state untouched during ordinary scrolling. + static let activationDistance: CGFloat = 24 static let maximumOffset: CGFloat = 76 - static let horizontalDominance: CGFloat = 1.4 + /// Ratio required to lock the gesture into a reply swipe. Mostly vertical + /// timeline scrolls (even diagonal ones) never reach it. + static let lockDominance: CGFloat = 1.6 + /// Relaxed ratio once locked, so the finger can drift while finishing + /// the swipe without it being cancelled by the scroll view. + static let followDominance: CGFloat = 1.05 - static func offset(for translation: CGSize) -> CGFloat { - guard isHorizontal(translation) else { return 0 } - let distance = min(abs(translation.width) * 0.72, maximumOffset) + /// Whether this translation can lock the gesture as a horizontal swipe. + /// Both directions are accepted, and the lock is only available early: + /// once the finger has travelled mostly vertically the scroll view owns + /// the touch and the swipe must never activate for the rest of that + /// gesture. + static func canLock(_ translation: CGSize) -> Bool { + abs(translation.height) <= 24 + && isHorizontal(translation, dominance: lockDominance) + && abs(translation.width) >= activationDistance * 0.5 + } + + static func isHorizontal(_ translation: CGSize) -> Bool { + isHorizontal(translation, dominance: lockDominance) + } + + /// Visual offset of the bubble: negative for a left swipe, positive for + /// a right swipe, zero while the movement is not clearly horizontal. + static func offset(locked: Bool, translation: CGSize) -> CGFloat { + let dominance = locked ? followDominance : lockDominance + guard isHorizontal(translation, dominance: dominance) else { return 0 } + let width = abs(translation.width) + guard width >= activationDistance else { return 0 } + let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset) return translation.width < 0 ? -distance : distance } static func shouldReply( + locked: Bool, translation: CGSize, predictedEndTranslation: CGSize ) -> Bool { let candidate = abs(predictedEndTranslation.width) > abs(translation.width) ? predictedEndTranslation : translation - return isHorizontal(candidate) && abs(candidate.width) >= triggerDistance + let dominance = locked ? followDominance : lockDominance + return isHorizontal(candidate, dominance: dominance) + && 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 + private static func isHorizontal(_ translation: CGSize, dominance: CGFloat) -> Bool { + abs(translation.width) > abs(translation.height) * dominance } } + diff --git a/Sources/Shared/Models/OMEMODevice.swift b/Sources/Shared/Models/OMEMODevice.swift index be68db1..ff218f9 100644 --- a/Sources/Shared/Models/OMEMODevice.swift +++ b/Sources/Shared/Models/OMEMODevice.swift @@ -14,8 +14,14 @@ struct OMEMODevice: Identifiable, Hashable, Sendable { let trust: Trust let isActive: Bool let isOwn: Bool + let isOMEMO2: Bool - var id: String { "\(jid)|\(deviceID)" } + var id: String { "\(jid)|\(deviceID)|\(isOMEMO2 ? "omemo2" : "legacy")" } + + /// Short protocol label for the encryption screen badge. + var protocolName: String { + isOMEMO2 ? "OMEMO 2" : "OMEMO" + } var formattedFingerprint: String { fingerprint.chunked(every: 8).joined(separator: " ") diff --git a/Sources/Shared/Models/OlderHistoryScrollPolicy.swift b/Sources/Shared/Models/OlderHistoryScrollPolicy.swift new file mode 100644 index 0000000..95e7c87 --- /dev/null +++ b/Sources/Shared/Models/OlderHistoryScrollPolicy.swift @@ -0,0 +1,30 @@ +import Foundation + +/// One interactive backward-history page handed to the UI: whether older +/// history remains and the cursor the next request must use. +struct OlderHistoryScrollPage: Equatable, Sendable { + let hasMore: Bool + let nextBefore: String? +} + +/// Interactive scroll-back policy. The cursor for the next page must come +/// from the server's RSM of the page that was just returned, not from +/// the oldest local message: a page can consist entirely of reactions, +/// retractions or already-known stanzas, which insert no new message rows, so +/// a locally computed anchor would never advance and every subsequent scroll +/// would re-request the same page forever (spinner spins, nothing loads). +enum OlderHistoryScrollPolicy { + /// - Parameter complete: the server's flag. + /// - Parameter pageFirstID: RSM UID of the returned page — the + /// oldest item in it, i.e. the anchor for the next, older page. + static func page( + complete: Bool, + pageFirstID: String? + ) -> OlderHistoryScrollPage { + let nextBefore = ArchiveSyncCheckpoint.normalizedCursor(pageFirstID) + // An empty page (or one without RSM) cannot advance the cursor, so it + // must never report "more" — otherwise the UI would loop on it. + let hasMore = !complete && nextBefore != nil + return OlderHistoryScrollPage(hasMore: hasMore, nextBefore: nextBefore) + } +} diff --git a/Sources/Shared/Models/SASLMechanismPreference.swift b/Sources/Shared/Models/SASLMechanismPreference.swift new file mode 100644 index 0000000..67e0125 --- /dev/null +++ b/Sources/Shared/Models/SASLMechanismPreference.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Order in which Luma prefers SASL mechanisms during authentication. +/// Channel-binding SCRAM first (tls-exporter / tls-server-end-point prove +/// the exchange runs over the client's own TLS channel, defeating MITM +/// relays), then the strongest plain SCRAM variant, and PLAIN as the +/// last-resort fallback. The server must advertise a mechanism for it to be +/// eligible — Martin's `SaslModule.guessSaslMechanism` walks exactly this +/// order, and the PLUS mechanisms additionally gate on available binding +/// data in `isAllowedToUse`. +enum SASLMechanismPreference { + static let order: [String] = [ + "SCRAM-SHA-512-PLUS", + "SCRAM-SHA-256-PLUS", + "SCRAM-SHA-1-PLUS", + "SCRAM-SHA-512", + "SCRAM-SHA-256", + "SCRAM-SHA-1", + "PLAIN", + ] + + /// The most secure mechanism from `order` that the server advertises, + /// or nil when no password-based mechanism is offered. When + /// `allowsChannelBinding` is false the -PLUS variants are skipped, since + /// without binding data the exchange would fail. + static func selected( + among offered: [String], + allowsChannelBinding: Bool = true + ) -> String? { + order.first { candidate in + guard offered.contains(candidate) else { return false } + return allowsChannelBinding || !candidate.hasSuffix("-PLUS") + } + } + + /// Mechanisms that send credentials in a way a TLS MITM can replay or + /// read directly; shown with a warning on the server-information screen. + static let weakMechanisms: Set = ["PLAIN", "OAUTHBEARER"] + + /// True when `mechanism` is the strongest one available among + /// `offered`: the server offers nothing better than the chosen one. + static func isStrongestAvailable(_ mechanism: String, among offered: [String]) -> Bool { + guard let chosenRank = order.firstIndex(of: mechanism) else { return false } + for stronger in order[.. acknowledged ? sent - acknowledged : 0 + } + } + + struct Capability: Equatable, Sendable, Identifiable { + let title: String + let detail: String + let status: CapabilityStatus + + var id: String { title } + } + + let serverDomain: String + let software: Software + let identities: [Identity] + let serverFeatures: [String] + let accountFeatures: [String] + let conferenceServers: [ConferenceServer] + let externalServices: [ExternalService] + let connectionStats: ConnectionStats + let saslMethods: [String] + /// SASL2 (RFC 9050) mechanisms advertised in ``. + let sasl2Methods: [String] + /// XEP-0440 channel-binding types advertised by the server, used to + /// detect MITM attacks on the TLS layer during SCRAM-PLUS. + let channelBindingTypes: [String] + /// The channel-binding type actually used for this connection's SASL + /// exchange, when a -PLUS mechanism was selected. + let usedChannelBindingType: String? + /// True when the server included its XEP-0474 downgrade-protection + /// hash in the SCRAM exchange (`h` attribute of the server-first message). + let supportsSCRAMDowngradeProtection: Bool + /// Raw name of the SASL mechanism used for this connection's auth + /// (without the channel-binding suffix), highlighted in the method lists. + let usedSASLMechanism: String? + /// TLS version negotiated with the server during this connection + /// (probed from a parallel handshake when the library does not expose it). + let tlsVersion: String? + /// Cipher suite negotiated alongside the TLS version. + let tlsCipher: String? + /// SASL mechanism actually selected for authentication (the strongest + /// one the server advertises). + let saslMechanism: String? + let supportsStreamManagement: Bool + let supportsCarbons: Bool + let supportsClientState: Bool + let supportsHTTPUpload: Bool + let supportsRosterVersioning: Bool + let supportsRosterPreApproval: Bool + + /// The curated XEP list Monal shows, with a status derived from the server + /// and account disco features plus the connection-level flags. + var capabilities: [Capability] { + let server = Set(serverFeatures) + let account = Set(accountFeatures) + let supportsPubSub = account.contains { $0.hasPrefix("http://jabber.org/protocol/pubsub") } + return [ + Capability( + title: "XEP-0163 Personal Eventing Protocol", + detail: "Публикация событий состояния аккаунта (аватары, статусы) через publish-subscribe.", + status: supportsPubSub ? .success : .error + ), + Capability( + title: "XEP-0191 Blocking Command", + detail: "Расширение XMPP для блокировки контактов.", + status: server.contains("urn:xmpp:blocking") ? .success : .error + ), + Capability( + title: "XEP-0198 Stream Management", + detail: "Возобновление потока при обрыве соединения — быстрее переподключение и экономия заряда.", + status: supportsStreamManagement ? .success : .error + ), + Capability( + title: "XEP-0199 XMPP Ping", + detail: "Пинг уровня приложения поверх XML-потока.", + status: server.contains("urn:xmpp:ping") ? .success : .error + ), + Capability( + title: "XEP-0215 External Service Discovery", + detail: "Обнаружение внешних сервисов (STUN/TURN) для аудио- и видеозвонков.", + status: server.contains("urn:xmpp:extdisco:2") ? .success : .error + ), + Capability( + title: "XEP-0237 Roster Versioning", + detail: "Версионирование ростера, чтобы сервер не отправлял его повторно, если он не менялся.", + status: supportsRosterVersioning ? .success : .error + ), + Capability( + title: "XEP-0280 Message Carbons", + detail: "Синхронизация сообщений между всеми вашими устройствами.", + status: supportsCarbons ? .success : .error + ), + Capability( + title: "XEP-0313 Message Archive Management", + detail: "Доступ к архиву сообщений на сервере.", + status: account.contains("urn:xmpp:mam:2") ? .success : .error + ), + Capability( + title: "XEP-0352 Client State Indication", + detail: "Указывает, активно ли устройство. Экономит заряд.", + status: supportsClientState ? .success : .error + ), + Capability( + title: "XEP-0357 Push Notifications", + detail: "Push-уведомления через Apple даже когда приложение выгружено.", + status: account.contains("urn:xmpp:push:0") ? .success : .error + ), + Capability( + title: "XEP-0363 HTTP File Upload", + detail: "Загрузка файлов на сервер для обмена с собеседниками.", + status: supportsHTTPUpload ? .success : .error + ), + Capability( + title: "XEP-0379 Pre-Authenticated Roster Subscription", + detail: "Ссылки для предварительно аутентифицированной подписки на присутствие.", + status: supportsRosterPreApproval ? .success : .error + ), + Capability( + title: "XEP-0474 SASL SCRAM Downgrade Protection", + detail: supportsSCRAMDowngradeProtection + ? "Сервер включил downgrade-protection hash в SCRAM-обмен (атрибут h): списки SASL-механизмов и channel-binding защищены от подмены MITM." + : "Сервер не включил downgrade-protection hash в SCRAM-обмен (или вход выполнен через PLAIN): списки механизмов и channel-binding не защищены от подмены.", + status: supportsSCRAMDowngradeProtection ? .success : .error + ), + ] + } +} diff --git a/Sources/Shared/Models/VideoNoteRotationPolicy.swift b/Sources/Shared/Models/VideoNoteRotationPolicy.swift new file mode 100644 index 0000000..d811a41 --- /dev/null +++ b/Sources/Shared/Models/VideoNoteRotationPolicy.swift @@ -0,0 +1,48 @@ +import CoreGraphics + +#if os(iOS) + import UIKit +#endif + +/// Clockwise rotation, in degrees, applied to the video-note capture so the +/// recorded QuickTime track plays upright. The camera sensor is +/// landscape-native: per Apple's videoOrientation<->videoRotationAngle +/// equivalence a portrait recording needs a 90-degree clockwise rotation +/// (a QuickTime track matrix), never 0 — 0 leaves the circle sideways. +enum VideoNoteRotationPolicy { + enum InterfaceOrientation: Equatable, Sendable { + case portrait + case portraitUpsideDown + case landscapeLeft + case landscapeRight + } + + static func angle(for orientation: InterfaceOrientation) -> CGFloat { + switch orientation { + case .portrait: return 90 + case .portraitUpsideDown: return 270 + case .landscapeLeft: return 180 + case .landscapeRight: return 0 + } + } + + #if os(iOS) + /// The orientation of the interface the user actually sees. The device + /// sensor (UIDevice.current.orientation) can report landscape or + /// face-up while the app stays portrait, which is exactly how a + /// portrait circle ended up encoded sideways. + @MainActor + static var currentInterfaceOrientation: InterfaceOrientation { + let orientation = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .effectiveGeometry.interfaceOrientation + switch orientation { + case .portraitUpsideDown: return .portraitUpsideDown + case .landscapeLeft: return .landscapeLeft + case .landscapeRight: return .landscapeRight + default: return .portrait + } + } + #endif +} diff --git a/Sources/Shared/Persistence/AccountPreferences.swift b/Sources/Shared/Persistence/AccountPreferences.swift index 5485161..3fdb73c 100644 --- a/Sources/Shared/Persistence/AccountPreferences.swift +++ b/Sources/Shared/Persistence/AccountPreferences.swift @@ -3,6 +3,7 @@ import Foundation final class AccountPreferences { private let defaults: UserDefaults private let accountKey = "luma.active-account.v1" + private let accountsKey = "luma.accounts.v1" private let encryptionSettingsKey = "luma.encryption-settings.v1" private let chatStateSettingsKey = "luma.chat-state-settings.v1" @@ -15,6 +16,39 @@ final class AccountPreferences { return try? JSONDecoder().decode(AccountConfiguration.self, from: data) } + /// Returns every configured account. The original single-account key is + /// migrated lazily so existing installations keep working. + func loadAccounts() -> [AccountConfiguration] { + if let data = defaults.data(forKey: accountsKey), + let accounts = try? JSONDecoder().decode([AccountConfiguration].self, from: data) { + return accounts + } + guard let account = load() else { return [] } + return [account] + } + + func saveAccounts(_ accounts: [AccountConfiguration]) throws { + let normalized = accounts.reduce(into: [String: AccountConfiguration]()) { result, account in + result[account.normalizedJID] = account + }.values.sorted { $0.normalizedJID < $1.normalizedJID } + defaults.set(try JSONEncoder().encode(normalized), forKey: accountsKey) + if let active = load(), normalized.contains(where: { $0.normalizedJID == active.normalizedJID }) { + try save(active) + } else if let first = normalized.first { + try save(first) + } else { + clear() + } + } + + func add(_ account: AccountConfiguration) throws { + try saveAccounts(loadAccounts() + [account]) + } + + func remove(jid: String) throws { + try saveAccounts(loadAccounts().filter { $0.normalizedJID != jid.lowercased() }) + } + func save(_ account: AccountConfiguration) throws { let data = try JSONEncoder().encode(account) defaults.set(data, forKey: accountKey) diff --git a/Sources/Shared/Persistence/ArchiveMetadataRecord.swift b/Sources/Shared/Persistence/ArchiveMetadataRecord.swift new file mode 100644 index 0000000..aff9d47 --- /dev/null +++ b/Sources/Shared/Persistence/ArchiveMetadataRecord.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftData + +/// Codable representation of a single durable MAM checkpoint, keyed by the +/// archive it belongs to (account or a specific MUC). +struct MAMCheckpointEntry: Codable, Equatable, Sendable { + let key: MAMArchiveKey + let checkpoint: MAMArchiveCheckpoint +} + +/// Singleton per-account metadata that used to live in the JSON snapshot: +/// locally deleted message IDs, roster contact JIDs and MAM checkpoints. +@Model +final class ArchiveMetadata { + @Attribute(.unique) var accountJID: String + var locallyDeletedMessageIDs: [String] + var rosterContactJIDs: [String] + var lastSuccessfulMAMSync: Date? + var lastSuccessfulMAMCursor: String? + var mamCheckpoints: [MAMCheckpointEntry] + + init( + accountJID: String, + locallyDeletedMessageIDs: [String] = [], + rosterContactJIDs: [String] = [], + lastSuccessfulMAMSync: Date? = nil, + lastSuccessfulMAMCursor: String? = nil, + mamCheckpoints: [MAMCheckpointEntry] = [] + ) { + self.accountJID = accountJID + self.locallyDeletedMessageIDs = locallyDeletedMessageIDs + self.rosterContactJIDs = rosterContactJIDs + self.lastSuccessfulMAMSync = lastSuccessfulMAMSync + self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor + self.mamCheckpoints = mamCheckpoints + } +} diff --git a/Sources/Shared/Persistence/ArchiveStore.swift b/Sources/Shared/Persistence/ArchiveStore.swift new file mode 100644 index 0000000..eb11a2d --- /dev/null +++ b/Sources/Shared/Persistence/ArchiveStore.swift @@ -0,0 +1,184 @@ +import Foundation +import SwiftData + +/// SwiftData-backed replacement for the legacy JSON `ChatArchive`. Owns the +/// per-account `ModelContainer`/`ModelContext`, imports the old JSON snapshot +/// once, and exposes load/save/erase. +@MainActor +final class ArchiveStore { + struct Loaded { + var conversations: [Conversation] + var messages: [ChatMessage] + var locallyDeletedMessageIDs: Set + var rosterContactJIDs: Set + var lastSuccessfulMAMSync: Date? + var lastSuccessfulMAMCursor: String? + var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] + } + + let container: ModelContainer + let context: ModelContext + private let accountJID: String + private let storeURL: URL + private let legacyJSONURL: URL + private(set) var metadata: ArchiveMetadata? + + init(accountJID: String) throws { + self.accountJID = accountJID + 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) + let hash = Self.stableHash(accountJID) + storeURL = directory.appendingPathComponent("\(hash).store") + legacyJSONURL = directory.appendingPathComponent("\(hash).json") + + let schema = Schema([ + Conversation.self, + ChatMessage.self, + ArchiveMetadata.self, + ]) + let configuration = ModelConfiguration(schema: schema, url: storeURL) + container = try ModelContainer(for: schema, configurations: [configuration]) + context = ModelContext(container) + context.autosaveEnabled = false + applyDataProtection() + + migrateLegacyJSONIfNeeded() + purgeLocallyDeletedMessages() + } + + func load() -> Loaded { + let conversationSort = [SortDescriptor(\Conversation.lastActivity, order: .reverse)] + let conversations = (try? context.fetch(FetchDescriptor(sortBy: conversationSort))) ?? [] + let messages = (try? context.fetch(FetchDescriptor())) ?? [] + + let metadata = fetchMetadata() + let mamCheckpoints = Dictionary( + metadata.mamCheckpoints.map { ($0.key, $0.checkpoint) }, + uniquingKeysWith: { first, _ in first } + ) + + return Loaded( + conversations: conversations, + messages: messages, + locallyDeletedMessageIDs: Set(metadata.locallyDeletedMessageIDs), + rosterContactJIDs: Set(metadata.rosterContactJIDs), + lastSuccessfulMAMSync: metadata.lastSuccessfulMAMSync, + lastSuccessfulMAMCursor: metadata.lastSuccessfulMAMCursor, + mamCheckpoints: mamCheckpoints + ) + } + + /// Persists the passed metadata plus all pending model mutations. + func save( + locallyDeletedMessageIDs: Set, + rosterContactJIDs: Set, + lastSuccessfulMAMSync: Date?, + lastSuccessfulMAMCursor: String?, + mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] + ) throws { + let metadata = fetchMetadata() + metadata.locallyDeletedMessageIDs = Array(locallyDeletedMessageIDs) + metadata.rosterContactJIDs = Array(rosterContactJIDs) + metadata.lastSuccessfulMAMSync = lastSuccessfulMAMSync + metadata.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(lastSuccessfulMAMCursor) + metadata.mamCheckpoints = mamCheckpoints.map { + MAMCheckpointEntry(key: $0.key, checkpoint: $0.value) + } + try context.save() + applyDataProtection() + } + + func erase() throws { + try context.delete(model: Conversation.self) + try context.delete(model: ChatMessage.self) + try context.delete(model: ArchiveMetadata.self) + try context.save() + } + + private func fetchMetadata() -> ArchiveMetadata { + if let metadata { return metadata } + let existing = try? context.fetch(FetchDescriptor()).first + let metadata = existing ?? ArchiveMetadata(accountJID: accountJID) + context.insert(metadata) + self.metadata = metadata + return metadata + } + + private func migrateLegacyJSONIfNeeded() { + guard FileManager.default.fileExists(atPath: legacyJSONURL.path) else { return } + guard let imported = try? LegacyArchiveImporter.importIfNeeded(from: legacyJSONURL) else { + return + } + for conversation in imported.conversations { + context.insert(conversation) + } + for message in imported.messages { + context.insert(message) + } + let metadata = fetchMetadata() + metadata.locallyDeletedMessageIDs = imported.locallyDeletedMessageIDs + metadata.rosterContactJIDs = imported.rosterContactJIDs + metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync + metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor + metadata.mamCheckpoints = imported.mamCheckpoints + do { + try context.save() + } catch { + // The imported rows stay pending in memory so this session keeps + // the history, but the legacy JSON remains on disk as the only + // durable copy until a save actually succeeds. + return + } + applyDataProtection() + try? FileManager.default.removeItem(at: legacyJSONURL) + } + + /// Removes rows that older builds only marked as locally deleted (the + /// legacy snapshot filtered them at load time). `@Query`-based views read + /// the store directly, so these rows must physically disappear. + func purgeLocallyDeletedMessages() { + let metadata = fetchMetadata() + let deletedKeys = Set(metadata.locallyDeletedMessageIDs) + guard !deletedKeys.isEmpty else { return } + let messages = (try? context.fetch(FetchDescriptor())) ?? [] + var removedAny = false + for message in messages { + let key = Self.deletionKey( + messageID: message.clientID, + conversationID: message.conversationID + ) + guard deletedKeys.contains(key) else { continue } + context.delete(message) + removedAny = true + } + if removedAny { + try? context.save() + applyDataProtection() + } + } + + /// Key format shared with `AppModel.localDeletionKey`: + /// `\u{1F}`. + static func deletionKey(messageID: String, conversationID: String) -> String { + conversationID.lowercased() + "\u{1F}" + messageID + } + + private func applyDataProtection() { +#if os(iOS) + try? FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: storeURL.path + ) +#endif + } + + 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 deleted file mode 100644 index 4ffc700..0000000 --- a/Sources/Shared/Persistence/ChatArchive.swift +++ /dev/null @@ -1,118 +0,0 @@ -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/Persistence/LegacyArchiveImporter.swift b/Sources/Shared/Persistence/LegacyArchiveImporter.swift new file mode 100644 index 0000000..bed14e1 --- /dev/null +++ b/Sources/Shared/Persistence/LegacyArchiveImporter.swift @@ -0,0 +1,417 @@ +import Foundation + +/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model` +/// objects once, so existing history survives the migration from the JSON file. +/// Decoding mirrors the tolerant decoders of the removed `ChatArchive`: +/// every field that was ever optional or added later falls back to a default, +/// so snapshots written by older schema versions still import. +enum LegacyArchiveImporter { + struct LegacyConversation: Codable { + enum Kind: String, Codable { case direct, group } + var 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( + id: String, + jid: String, + displayName: String, + lastMessage: String, + lastActivity: Date, + unreadCount: Int, + isOnline: Bool, + isPinned: Bool, + encryptionPreference: EncryptionPreference, + kind: Kind, + groupNickname: String? = nil, + isGroupJoined: Bool, + shouldAutojoin: Bool, + occupantCount: Int, + invitedBy: String? = nil + ) { + self.id = id + self.jid = jid + self.displayName = displayName + self.lastMessage = lastMessage + self.lastActivity = lastActivity + self.unreadCount = unreadCount + self.isOnline = isOnline + self.isPinned = isPinned + self.encryptionPreference = encryptionPreference + self.kind = kind + self.groupNickname = groupNickname + self.isGroupJoined = isGroupJoined + self.shouldAutojoin = shouldAutojoin + self.occupantCount = occupantCount + self.invitedBy = invitedBy + } + + 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) + } + } + + struct LegacyMessage: Codable { + enum Direction: String, Codable { case incoming, outgoing } + enum Delivery: String, Codable { case sending, sent, delivered, failed } + enum Security: String, Codable { case omemo, plaintext, decryptionFailed } + enum Kind: String, Codable { + case text, attachment, photo, video, audio, voice, videoNote, location, system + } + var 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 originID: String? + var stanzaID: String? + var senderDisplayName: String? + var isGroupMessage: Bool + var callHistory: CallHistoryMetadata? + var reactions: [MessageReaction] + + init( + id: String, + conversationID: String, + senderJID: String, + body: String, + timestamp: Date, + direction: Direction, + delivery: Delivery, + security: Security, + kind: Kind, + 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, + originID: String? = nil, + stanzaID: String? = nil, + senderDisplayName: String? = nil, + isGroupMessage: Bool = false, + callHistory: CallHistoryMetadata? = nil, + reactions: [MessageReaction] = [] + ) { + self.id = id + self.conversationID = conversationID + self.senderJID = 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 + self.replyPreview = replyPreview + self.forwardedFrom = forwardedFrom + self.retractedAt = retractedAt + self.originID = originID + 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 originID + 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 = 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) + replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview) + forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom) + retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt) + originID = try values.decodeIfPresent(String.self, forKey: .originID) + 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) ?? [] + } + } + + struct Snapshot: Codable { + static let currentSchemaVersion = 6 + + var schemaVersion: Int + var conversations: [LegacyConversation] + var messages: [LegacyMessage] + var locallyDeletedMessageIDs: Set + var rosterContactJIDs: Set + var lastSuccessfulMAMSync: Date? + var lastSuccessfulMAMCursor: String? + var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] + + init( + conversations: [LegacyConversation], + messages: [LegacyMessage], + locallyDeletedMessageIDs: Set = [], + rosterContactJIDs: Set = [], + lastSuccessfulMAMSync: Date? = nil, + lastSuccessfulMAMCursor: String? = nil, + mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:] + ) { + self.schemaVersion = Self.currentSchemaVersion + self.conversations = conversations + self.messages = messages + self.locallyDeletedMessageIDs = locallyDeletedMessageIDs + self.rosterContactJIDs = rosterContactJIDs + self.lastSuccessfulMAMSync = lastSuccessfulMAMSync + self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor + self.mamCheckpoints = mamCheckpoints + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case conversations + case messages + case locallyDeletedMessageIDs + case rosterContactJIDs + case lastSuccessfulMAMSync + case lastSuccessfulMAMCursor + case mamCheckpoints + } + + 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([LegacyConversation].self, forKey: .conversations) + messages = try values.decode([LegacyMessage].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) + ) + mamCheckpoints = try values.decodeIfPresent( + [MAMArchiveKey: MAMArchiveCheckpoint].self, + forKey: .mamCheckpoints + ) ?? [:] + } + } + + struct Imported { + let conversations: [Conversation] + let messages: [ChatMessage] + let locallyDeletedMessageIDs: [String] + let rosterContactJIDs: [String] + let lastSuccessfulMAMSync: Date? + let lastSuccessfulMAMCursor: String? + let mamCheckpoints: [MAMCheckpointEntry] + } + + static func importIfNeeded(from url: URL) throws -> Imported? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + let snapshot = try decoder.decode(Snapshot.self, from: data) + + let conversations = snapshot.conversations.map { legacy -> Conversation in + let conversation = Conversation( + jid: legacy.jid, + displayName: legacy.displayName, + lastMessage: legacy.lastMessage, + lastActivity: legacy.lastActivity, + unreadCount: legacy.unreadCount, + isOnline: legacy.isOnline, + isPinned: legacy.isPinned, + encryptionPreference: legacy.encryptionPreference, + kind: legacy.kind == .group ? .group : .direct, + groupNickname: legacy.groupNickname, + isGroupJoined: legacy.isGroupJoined, + shouldAutojoin: legacy.shouldAutojoin, + occupantCount: legacy.occupantCount, + invitedBy: legacy.invitedBy + ) + return conversation + } + + let conversationByJID = Dictionary( + conversations.map { ($0.jid, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + let messages = snapshot.messages.map { legacy -> ChatMessage in + let message = ChatMessage( + id: legacy.id, + conversationID: legacy.conversationID, + senderJID: legacy.senderJID, + body: legacy.body, + timestamp: legacy.timestamp, + direction: legacy.direction == .outgoing ? .outgoing : .incoming, + delivery: ChatMessage.Delivery(rawValue: legacy.delivery.rawValue) ?? .sent, + security: ChatMessage.Security(rawValue: legacy.security.rawValue) ?? .plaintext, + kind: ChatMessage.Kind(rawValue: legacy.kind.rawValue) ?? .text, + remoteAttachmentURL: legacy.remoteAttachmentURL, + localFilename: legacy.localFilename, + mimeType: legacy.mimeType, + duration: legacy.duration, + byteCount: legacy.byteCount, + encryptionFingerprint: legacy.encryptionFingerprint, + editedAt: legacy.editedAt, + replyToID: legacy.replyToID, + replyToJID: legacy.replyToJID, + replyPreview: legacy.replyPreview, + forwardedFrom: legacy.forwardedFrom, + retractedAt: legacy.retractedAt, + originID: legacy.originID, + stanzaID: legacy.stanzaID, + senderDisplayName: legacy.senderDisplayName, + isGroupMessage: legacy.isGroupMessage, + callHistory: legacy.callHistory, + reactions: legacy.reactions + ) + message.conversation = conversationByJID[legacy.conversationID.lowercased()] + return message + } + + return Imported( + conversations: conversations, + messages: messages, + locallyDeletedMessageIDs: Array(snapshot.locallyDeletedMessageIDs), + rosterContactJIDs: Array(snapshot.rosterContactJIDs), + lastSuccessfulMAMSync: snapshot.lastSuccessfulMAMSync, + lastSuccessfulMAMCursor: snapshot.lastSuccessfulMAMCursor, + mamCheckpoints: snapshot.mamCheckpoints.map { + MAMCheckpointEntry(key: $0.key, checkpoint: $0.value) + } + ) + } +} diff --git a/Sources/Shared/Security/AppLockVault.swift b/Sources/Shared/Security/AppLockVault.swift new file mode 100644 index 0000000..3a7ad1f --- /dev/null +++ b/Sources/Shared/Security/AppLockVault.swift @@ -0,0 +1,103 @@ +import Foundation +import Security + +/// Stores the app-lock passcode in the Keychain and the lock preferences in +/// UserDefaults. The lock is a UI-level gate: the passcode is kept only in +/// the Keychain and is never mirrored into memory beyond verification. +final class AppLockVault { + private let service = "app.luma.chat.applock" + private let account = "applock-passcode" + private let enabledKey = "appLockEnabled" + private let biometricKey = "appLockBiometricUnlock" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var isEnabled: Bool { + get { defaults.bool(forKey: enabledKey) } + set { defaults.set(newValue, forKey: enabledKey) } + } + + var biometricUnlockEnabled: Bool { + get { defaults.bool(forKey: biometricKey) } + set { defaults.set(newValue, forKey: biometricKey) } + } + + func save(passcode: String) throws { + let data = Data(passcode.utf8) + let baseQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + let updateStatus = SecItemUpdate( + baseQuery as CFDictionary, + [kSecValueData: data] as CFDictionary + ) + if updateStatus == errSecSuccess { + return + } + guard updateStatus == errSecItemNotFound else { + throw AppLockVaultError.unhandled(updateStatus) + } + var insertion = baseQuery + insertion[kSecValueData] = data +#if !os(macOS) + insertion[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly +#endif + let addStatus = SecItemAdd(insertion as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw AppLockVaultError.unhandled(addStatus) + } + } + + func deletePasscode() throws { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw AppLockVaultError.unhandled(status) + } + } + + func verify(passcode: String) -> Bool { + guard let stored = storedPasscode() else { return false } + return passcode == stored + } + + private func storedPasscode() -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value + } +} + +enum AppLockVaultError: 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/ChatMediaImageCache.swift b/Sources/Shared/Services/ChatMediaImageCache.swift index e1b9b0e..eaac45f 100644 --- a/Sources/Shared/Services/ChatMediaImageCache.swift +++ b/Sources/Shared/Services/ChatMediaImageCache.swift @@ -24,7 +24,7 @@ enum ChatMediaImageCache { for message: ChatMessage, data: Data ) -> ChatMediaPlatformImage? { - let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString + let cacheKey = "\(message.conversationID)\u{1F}\(message.clientID)" as NSString if let cached = cache.object(forKey: cacheKey) { return cached } diff --git a/Sources/Shared/Services/MediaDownloadService.swift b/Sources/Shared/Services/MediaDownloadService.swift new file mode 100644 index 0000000..e85eebe --- /dev/null +++ b/Sources/Shared/Services/MediaDownloadService.swift @@ -0,0 +1,247 @@ +import Foundation +import UniformTypeIdentifiers + +#if os(iOS) +import Photos +import UIKit +#elseif os(macOS) +import AppKit +#endif + +/// Saves a message's cached media to the right destination: photos, videos and +/// video notes go to the Photos library on iOS (or Downloads on macOS, which +/// would otherwise need a Photo Library entitlement), while files, audio and +/// voice messages go to the user's Downloads location. Kept dependency-free so +/// every bubble, the full-screen viewer and the audio player share one path. +@MainActor +enum MediaDownloadService { + enum SaveError: LocalizedError { + case missingLocalFile + case unsupportedKind + + var errorDescription: String? { + switch self { + case .missingLocalFile: + return "Не удалось загрузить файл для сохранения." + case .unsupportedKind: + return "Этот тип сообщения нельзя сохранить." + } + } + } + + // MARK: - Entry points + + /// Resolve the cached copy for `message` (downloading through AppModel when + /// needed) and save it. Failures are surfaced through AppModel's error alert. + static func save(_ message: ChatMessage, model: AppModel) async { + do { + guard let sourceURL = await resolvedLocalURL(for: message, model: model) else { + throw SaveError.missingLocalFile + } + let filename = message.localFilename ?? Self.defaultFilename(for: message) + let didSave = try await save( + url: sourceURL, + kind: message.kind, + filename: filename + ) + if didSave { + model.informationalMessage = "Сохранено." + } + } catch { + model.errorMessage = error.localizedDescription + } + } + + /// Save an already-downloaded file, routing by kind and platform. Returns + /// `false` when the user cancelled an interactive save sheet. + @discardableResult + static func save( + url: URL, + kind: ChatMessage.Kind, + filename: String + ) async throws -> Bool { + switch kind { + case .photo, .video, .videoNote: + #if os(iOS) + try await saveToPhotos(url: url, kind: kind) + return true + #else + return try await saveToDownloads(url: url, filename: filename) + #endif + case .attachment, .audio, .voice: + return try await saveToDownloads(url: url, filename: filename) + case .text, .location, .system: + throw SaveError.unsupportedKind + } + } + + // MARK: - Local file resolution + + private static func resolvedLocalURL( + for message: ChatMessage, + model: AppModel + ) async -> URL? { + if let cached = model.mediaPreviewURL(for: message) { + return cached + } + // Inline media (photo/video/audio/voice/video note) is fetched by + // AppModel into its preview cache; files are not auto-previewed and fall + // through to the QuickLook handoff below. + await model.prepareMediaPreview(message) + if let cached = model.mediaPreviewURL(for: message) { + return cached + } + // Attachments are only fetched on demand via previewAttachment(_:), + // which parks the result in the single-shot previewURL property. Clear it + // before and after so the QuickLook sheet does not flash open. + model.previewURL = nil + await model.previewAttachment(message) + let url = model.previewURL + model.previewURL = nil + return url + } + + // MARK: - Photos + + #if os(iOS) + private static func saveToPhotos(url: URL, kind: ChatMessage.Kind) async throws { + try await PHPhotoLibrary.shared().performChanges { + if kind == .photo { + _ = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: url) + } else { + _ = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: url) + } + } + } + #endif + + // MARK: - Downloads + + private static func saveToDownloads(url: URL, filename: String) async throws -> Bool { + #if os(macOS) + return try await saveWithPanel(url: url, filename: filename) + #else + return await FileExportPresenter.shared.present(url: url) + #endif + } + + #if os(macOS) + /// Present a save panel defaulting to ~/Downloads. The app sandbox grants + /// write access to the location the user picks, so no Downloads entitlement + /// is required. + private static func saveWithPanel(url: URL, filename: String) async throws -> Bool { + let panel = NSSavePanel() + panel.nameFieldStringValue = filename + panel.canCreateDirectories = true + if let downloads = FileManager.default.urls( + for: .downloadsDirectory, + in: .userDomainMask + ).first { + panel.directoryURL = downloads + } + guard panel.runModal() == .OK, let destination = panel.url else { + return false + } + try FileManager.default.copyItem(at: url, to: destination) + return true + } + #endif + + // MARK: - Helpers + + private static func defaultFilename(for message: ChatMessage) -> String { + let ext = Self.fileExtension(for: message) + let base: String + switch message.kind { + case .photo: base = "Фото" + case .video: base = "Видео" + case .videoNote: base = "Видеосообщение" + case .voice: base = "Голосовое сообщение" + case .audio: base = "Аудио" + case .attachment: base = "Вложение" + default: base = "Файл" + } + return ext.isEmpty ? base : "\(base).\(ext)" + } + + private static func fileExtension(for message: ChatMessage) -> String { + if let filename = message.localFilename { + let ext = (filename as NSString).pathExtension + if !ext.isEmpty { return ext } + } + if let mime = message.mimeType, + let type = UTType(mimeType: mime), + let ext = type.preferredFilenameExtension + { + return ext + } + switch message.kind { + case .video, .videoNote: return "mp4" + case .voice, .audio: return "m4a" + case .photo: return "jpg" + default: return "" + } + } +} + +#if os(iOS) +/// Presents the system "save to Files" sheet for a cached file and reports +/// whether the user completed the save or cancelled. Held alive by the +/// continuation until the delegate fires. +@MainActor +private final class FileExportPresenter: NSObject, UIDocumentPickerDelegate { + static let shared = FileExportPresenter() + + private var continuation: CheckedContinuation? + + func present(url: URL) async -> Bool { + await withCheckedContinuation { continuation in + self.continuation = continuation + let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true) + picker.delegate = self + Task { @MainActor in + // Let a just-dismissed context menu finish its transition before + // presenting, otherwise the sheet can be rejected. + try? await Task.sleep(nanoseconds: 350_000_000) + guard let presenter = Self.topPresenter(), + presenter.presentedViewController == nil + else { + self.finish(false) + return + } + presenter.present(picker, animated: true) + } + } + } + + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + finish(true) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + finish(false) + } + + private func finish(_ result: Bool) { + continuation?.resume(returning: result) + continuation = nil + } + + private static func topPresenter() -> UIViewController? { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let window = scene.windows.first(where: { $0.isKeyWindow }), + let root = window.rootViewController + else { return nil } + var top = root + while let presented = top.presentedViewController { + top = presented + } + return top + } +} +#endif diff --git a/Sources/Shared/Services/MediaPreviewProcessor.swift b/Sources/Shared/Services/MediaPreviewProcessor.swift index 9a004ec..c59d7c2 100644 --- a/Sources/Shared/Services/MediaPreviewProcessor.swift +++ b/Sources/Shared/Services/MediaPreviewProcessor.swift @@ -1,6 +1,7 @@ import AVFoundation import Foundation import ImageIO +import os import UniformTypeIdentifiers actor MediaPreviewProcessor { @@ -44,6 +45,12 @@ actor MediaPreviewProcessor { } let waveform = kind == .voice ? voiceWaveform(for: url) : nil + if kind == .video { + let logger = Logger(subsystem: "Luma", category: "video-preview") + logger.info( + "analyze video: \(url.lastPathComponent) duration=\(String(describing: duration)) thumbnailBytes=\(thumbnailData?.count ?? 0)" + ) + } return Analysis( duration: duration, thumbnailData: thumbnailData, @@ -94,6 +101,8 @@ actor MediaPreviewProcessor { return data } } catch { + Logger(subsystem: "Luma", category: "video-preview") + .error("image generator failed at \(second)s: \(error.localizedDescription)") continue } } diff --git a/Sources/Shared/Services/PhoneWatchBridge.swift b/Sources/Shared/Services/PhoneWatchBridge.swift index 523e1b8..a23c26b 100644 --- a/Sources/Shared/Services/PhoneWatchBridge.swift +++ b/Sources/Shared/Services/PhoneWatchBridge.swift @@ -19,6 +19,24 @@ struct PhoneWatchSnapshot: Codable { let chats: [Chat] } +/// Plain-value snapshots of the SwiftData models, taken on the main actor +/// before the background queue builds the watch payload. Reading @Model +/// properties off the main actor crashes with EXC_BAD_ACCESS. +private struct PhoneWatchChatData { + let jid: String + let name: String + let unread: Int +} + +private struct PhoneWatchMessageData { + let id: String + let conversationID: String + let body: String + let timestamp: Date + let outgoing: Bool + let encrypted: Bool +} + struct WatchVoiceMessage: Sendable { let transferID: String let jid: String @@ -32,9 +50,9 @@ struct WatchVoiceMessage: Sendable { } #if os(iOS) && canImport(WatchConnectivity) -import WatchConnectivity +@preconcurrency import WatchConnectivity -final class PhoneWatchBridge: NSObject, WCSessionDelegate { +final class PhoneWatchBridge: NSObject, WCSessionDelegate, @unchecked Sendable { var onReply: ((String, String) -> Void)? var onVoiceMessage: ((WatchVoiceMessage) -> Void)? { didSet { deliverPendingVoiceMessages() } @@ -49,25 +67,43 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate { private var deliveredVoiceTransferIDs: Set = [] override init() { - session = WCSession.isSupported() ? .default : nil + session = + !RuntimeEnvironment.isRunningTests && WCSession.isSupported() + ? .default + : nil super.init() pendingVoiceMessages = Self.loadPersistedVoiceMessages() session?.delegate = self session?.activate() } + @MainActor 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. + // SwiftData models must never be read off the main actor: snapshot + // the fields the watch needs here, then hand plain values to the + // background queue for the filtering, sorting and JSON encoding. + let chats = conversations.map { conversation in + PhoneWatchChatData( + jid: conversation.jid, + name: conversation.displayName, + unread: conversation.unreadCount + ) + } + let entries = messages.map { message in + PhoneWatchMessageData( + id: message.clientID, + conversationID: message.conversationID, + body: message.previewText, + timestamp: message.timestamp, + outgoing: message.direction == .outgoing, + encrypted: message.security == .omemo + ) + } snapshotQueue.async { [weak self, weak session] in guard let self, let session, - let data = Self.makeSnapshotData( - conversations: conversations, - messages: messages - ), + let data = Self.makeSnapshotData(chats: chats, entries: entries), data != self.latestSnapshot else { return } self.latestSnapshot = data self.publishLatestSnapshot(using: session) @@ -75,57 +111,56 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate { } private static func makeSnapshotData( - conversations: [Conversation], - messages: [ChatMessage] + chats: [PhoneWatchChatData], + entries: [PhoneWatchMessageData] ) -> Data? { - let visibleConversations = Array(conversations.prefix(20)) - let visibleConversationIDs = Set(visibleConversations.map(\.id)) - var recentMessagesByConversation: [String: [ChatMessage]] = [:] + let visibleChats = Array(chats.prefix(20)) + let visibleChatIDs = Set(visibleChats.map(\.jid)) + var recentMessagesByChat: [String: [PhoneWatchMessageData]] = [:] // 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) + for entry in entries where visibleChatIDs.contains(entry.conversationID) { + var recent = recentMessagesByChat[entry.conversationID] ?? [] + let insertionIndex = Self.insertionIndex(for: entry, in: recent) + recent.insert(entry, at: insertionIndex) if recent.count > 20 { recent.removeFirst(recent.count - 20) } - recentMessagesByConversation[message.conversationID] = recent + recentMessagesByChat[entry.conversationID] = recent } - let chats = visibleConversations.map { conversation in + let snapshotChats = visibleChats.map { chat 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 - ) - } + jid: chat.jid, + name: chat.name, + unread: chat.unread, + messages: (recentMessagesByChat[chat.jid] ?? []).map { entry in + PhoneWatchSnapshot.Message( + id: entry.id, + body: entry.body, + timestamp: entry.timestamp, + outgoing: entry.outgoing, + encrypted: entry.encrypted + ) + } ) } - return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats)) + return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: snapshotChats)) } private static func insertionIndex( - for message: ChatMessage, - in sortedMessages: [ChatMessage] + for entry: PhoneWatchMessageData, + in sorted: [PhoneWatchMessageData] ) -> Int { var lowerBound = 0 - var upperBound = sortedMessages.count + var upperBound = sorted.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) + let existing = sorted[middle] + let existingComesFirst = existing.timestamp < entry.timestamp + || (existing.timestamp == entry.timestamp && existing.id < entry.id) if existingComesFirst { lowerBound = middle + 1 } else { @@ -150,6 +185,7 @@ final class PhoneWatchBridge: NSObject, WCSessionDelegate { func sessionDidBecomeInactive(_ session: WCSession) {} func sessionDidDeactivate(_ session: WCSession) { + guard !RuntimeEnvironment.isRunningTests else { return } session.activate() } diff --git a/Sources/Shared/Services/VideoNoteRecorder.swift b/Sources/Shared/Services/VideoNoteRecorder.swift index aa96fb1..777159f 100644 --- a/Sources/Shared/Services/VideoNoteRecorder.swift +++ b/Sources/Shared/Services/VideoNoteRecorder.swift @@ -1,9 +1,16 @@ @preconcurrency import AVFoundation import Combine +import CoreImage import Foundation +import os + +private let videoNoteFinalizeLogger = Logger( + subsystem: "Luma", + category: "video-note-finalize" +) #if os(iOS) -import UIKit + import UIKit #endif @MainActor @@ -17,6 +24,7 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe @Published private(set) var isRecording = false @Published private(set) var elapsed: TimeInterval = 0 @Published private(set) var isUsingFrontCamera = true + @Published private(set) var hasAlternateCamera = false @Published private(set) var isMicrophoneMuted = false let session = AVCaptureSession() @@ -47,6 +55,16 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe private var startupTimeoutTask: Task? private var finalizationTimeoutTask: Task? private var fileValidationTask: Task? + private var cameraSwitchContinuation: CheckedContinuation? + private var cameraFlipTimeoutTask: Task? + private var restoreAudioRouteTask: Task? + private var cameraFlipRequested = false + private var restartingSegmentForCameraFlip = false + private var recordedSegments: [URL] = [] + private var segmentsTotalDuration: TimeInterval = 0 + /// The iOS front camera routes the quiet earpiece microphone, so the + /// finalized audio gets a gain boost when any segment used it. + private var usedFrontCamera = false private struct CaptureGraph { let cameraInput: AVCaptureDeviceInput @@ -92,14 +110,18 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } } -#if os(iOS) - try AVAudioSession.sharedInstance().setCategory( - .playAndRecord, - mode: .videoRecording, - options: [.defaultToSpeaker, .allowBluetoothHFP] - ) - try AVAudioSession.sharedInstance().setActive(true) -#endif + #if os(iOS) + try AVAudioSession.sharedInstance().setCategory( + .playAndRecord, + // .default keeps voice processing and automatic gain control: + // a talking circle recorded at arm's length (front camera) + // stays loud and consistent, unlike .videoRecording which + // disables AGC and records the raw microphone level. + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothHFP] + ) + try AVAudioSession.sharedInstance().setActive(true) + #endif let graph = try await configureCaptureGraph() guard !Task.isCancelled, sessionGeneration == generation else { @@ -108,6 +130,8 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe cameraInput = graph.cameraInput microphoneInput = graph.microphoneInput isUsingFrontCamera = graph.usesFrontCamera + hasAlternateCamera = + hasCamera(at: graph.usesFrontCamera ? .back : .front) configureVideoConnection() try await startCaptureSession(generation: generation) @@ -118,10 +142,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe func start() throws { guard lifecycle == .prepared, - isPrepared, - session.isRunning else { throw VideoNoteRecorderError.notPrepared } + isPrepared, + session.isRunning + else { throw VideoNoteRecorderError.notPrepared } guard !movieOutput.isRecording, - activeRecordingURL == nil else { + activeRecordingURL == nil + else { throw VideoNoteRecorderError.recordingBusy } @@ -135,8 +161,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe requestedMinimumDuration = 0 activeRecordingURL = url lifecycle = .starting - startedAt = nil - elapsed = 0 + if !restartingSegmentForCameraFlip { + startedAt = nil + elapsed = 0 + } + restartingSegmentForCameraFlip = false + if isUsingFrontCamera { + usedFrontCamera = true + } isRecording = true let output = movieOutput @@ -168,8 +200,9 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe guard let self else { return } await self.stopMovieOutput() guard !Task.isCancelled, - self.lifecycle == .stopping, - let url = self.activeRecordingURL else { return } + self.lifecycle == .stopping, + let url = self.activeRecordingURL + else { return } self.scheduleFinalizationTimeout(for: url) } case .stopping: @@ -179,19 +212,120 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } } + /// Flips between the front and the back camera. While recording, + /// AVFoundation tears the active movie down when its input is removed, so + /// the current segment is closed first, the camera is swapped, and a new + /// segment starts; the segments are merged back together on finalization. func switchCamera() async throws { -#if os(iOS) - guard isPrepared, - lifecycle == .prepared, - let currentInput = cameraInput else { - throw VideoNoteRecorderError.notPrepared + #if os(iOS) + guard isPrepared, + hasAlternateCamera, + let currentInput = cameraInput + else { + throw VideoNoteRecorderError.notPrepared + } + switch lifecycle { + case .prepared: + try await swapCameraInput(replacing: currentInput) + case .starting, .recording: + try await stopSegmentForCameraFlip() + guard let input = cameraInput else { + throw VideoNoteRecorderError.notPrepared + } + try await swapCameraInput(replacing: input) + restartingSegmentForCameraFlip = true + try start() + case .stopping, .idle: + throw VideoNoteRecorderError.notPrepared + } + #else + throw VideoNoteRecorderError.alternateCameraUnavailable + #endif + } + + /// Closes the in-flight segment without finishing the whole recording: + /// the file is kept (when non-empty), per-segment state resets, and the + /// pending switchCamera call resumes. + private func stopSegmentForCameraFlip() async throws { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + cameraSwitchContinuation = continuation + cameraFlipRequested = true + requestStop(afterMinimumDuration: 0) + scheduleCameraFlipTimeout() } - let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front - guard let camera = AVCaptureDevice.default( - .builtInWideAngleCamera, - for: .video, - position: nextPosition - ) else { + } + + private func scheduleCameraFlipTimeout() { + cameraFlipTimeoutTask?.cancel() + cameraFlipTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 8_000_000_000) + guard !Task.isCancelled, + let self, + let continuation = self.cameraSwitchContinuation + else { return } + self.cameraSwitchContinuation = nil + self.cameraFlipRequested = false + continuation.resume( + throwing: VideoNoteRecorderError.finalizationTimedOut + ) + } + } + + private func handleCameraFlipSegment(url: URL) { + cameraFlipTimeoutTask?.cancel() + cameraFlipTimeoutTask = nil + let continuation = cameraSwitchContinuation + cameraSwitchContinuation = nil + + fileValidationTask?.cancel() + fileValidationTask = Task { @MainActor [weak self] in + guard let self else { return } + let inspection = await self.fileInspector.inspect( + url: url, + fallbackDuration: self.measuredWallClockDuration + ) + guard !Task.isCancelled, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) + else { return } + self.fileValidationTask = nil + if let inspection, inspection.duration > 0.05 { + self.recordedSegments.append(url) + self.segmentsTotalDuration += inspection.duration + } else { + try? FileManager.default.removeItem(at: url) + } + self.resetForNextSegment() + continuation?.resume(returning: ()) + } + } + + private func resetForNextSegment() { + lifecycle = .prepared + activeRecordingURL = nil + discardCurrentRecording = false + stopRequested = false + requestedMinimumDuration = 0 + isRecording = false + timer?.invalidate() + timer = nil + } + + private func swapCameraInput( + replacing currentInput: AVCaptureDeviceInput + ) async throws { + let nextPosition: AVCaptureDevice.Position = + isUsingFrontCamera ? .back : .front + guard + let camera = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: nextPosition + ) + else { throw VideoNoteRecorderError.alternateCameraUnavailable } @@ -207,7 +341,9 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe session.addInput(currentInput) } session.commitConfiguration() - continuation.resume(throwing: VideoNoteRecorderError.configurationFailed) + continuation.resume( + throwing: VideoNoteRecorderError.configurationFailed + ) return } session.addInput(nextInput) @@ -218,10 +354,30 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe cameraInput = nextInput isUsingFrontCamera = nextPosition == .front + hasAlternateCamera = + hasCamera(at: nextPosition == .front ? .back : .front) configureVideoConnection() -#else - throw VideoNoteRecorderError.alternateCameraUnavailable -#endif + #if os(iOS) + // Flipping to the front camera makes iOS re-route the audio to + // the earpiece and switch the voice processing into the quiet + // receiver mode — but the system performs that re-routing + // asynchronously AFTER the swap commits. Restore the speaker + // route with a short delay so it wins the race. + restoreAudioRouteTask?.cancel() + restoreAudioRouteTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 800_000_000) + guard !Task.isCancelled, let self, self.isPrepared else { + return + } + let audioSession = AVAudioSession.sharedInstance() + try? audioSession.setCategory( + .playAndRecord, + mode: .default, + options: [.defaultToSpeaker, .allowBluetoothHFP] + ) + try? audioSession.overrideOutputAudioPort(.speaker) + } + #endif } func toggleMicrophoneMuted() { @@ -255,13 +411,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe error: Error? ) { Task { @MainActor [weak self] in - self?.recordingDidFinish(url: outputFileURL, error: error) + await self?.recordingDidFinish(url: outputFileURL, error: error) } } private func requestStop(afterMinimumDuration minimumDuration: TimeInterval) { guard isRecording, - lifecycle == .starting || lifecycle == .recording else { return } + lifecycle == .starting || lifecycle == .recording + else { return } stopRequested = true requestedMinimumDuration = max( requestedMinimumDuration, @@ -271,11 +428,13 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } private func recordingDidStart(url: URL) { - guard VideoNoteRecordingLifecycle.acceptsCompletion( - activeURL: activeRecordingURL, - outputURL: url - ), isRecording, - lifecycle == .starting || lifecycle == .stopping else { return } + guard + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: activeRecordingURL, + outputURL: url + ), isRecording, + lifecycle == .starting || lifecycle == .stopping + else { return } startupTimeoutTask?.cancel() startupTimeoutTask = nil @@ -293,11 +452,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe guard let self else { return } await self.stopMovieOutput() guard !Task.isCancelled, - self.lifecycle == .stopping, - VideoNoteRecordingLifecycle.acceptsCompletion( + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } self.scheduleFinalizationTimeout(for: url) } return @@ -319,9 +479,10 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe private func scheduleStopAfterActualStartIfNeeded() { guard stopRequested, - lifecycle == .recording, - startedAt != nil, - let url = activeRecordingURL else { return } + lifecycle == .recording, + startedAt != nil, + let url = activeRecordingURL + else { return } stopRequestTask?.cancel() let minimumDuration = requestedMinimumDuration @@ -331,20 +492,22 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe stopRequestTask = Task { @MainActor [weak self] in while true { guard !Task.isCancelled, - let self, - self.lifecycle == .recording, - VideoNoteRecordingLifecycle.acceptsCompletion( + let self, + self.lifecycle == .recording, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } let snapshot = await self.movieOutputSnapshot() guard !Task.isCancelled, - self.lifecycle == .recording, - VideoNoteRecordingLifecycle.acceptsCompletion( + self.lifecycle == .recording, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } if !snapshot.isRecording { // AVFoundation stopped without the user's stop reaching @@ -356,7 +519,8 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } let remaining = VideoNoteStopPolicy.remainingRecordedDuration( - recordedDuration: snapshot.recordedDuration, + recordedDuration: snapshot.recordedDuration + + self.segmentsTotalDuration, minimumDuration: minimumDuration ) if remaining <= 0 || Date() >= mediaDeadline { break } @@ -374,30 +538,34 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } guard !Task.isCancelled, - let self, - self.lifecycle == .recording, - VideoNoteRecordingLifecycle.acceptsCompletion( + let self, + self.lifecycle == .recording, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } self.lifecycle = .stopping await self.stopMovieOutput() guard !Task.isCancelled, - self.lifecycle == .stopping, - VideoNoteRecordingLifecycle.acceptsCompletion( + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } self.scheduleFinalizationTimeout(for: url) } } - private func recordingDidFinish(url: URL, error: Error?) { - guard VideoNoteRecordingLifecycle.acceptsCompletion( - activeURL: activeRecordingURL, - outputURL: url - ) else { + private func recordingDidFinish(url: URL, error: Error?) async { + 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 @@ -416,6 +584,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe isRecording = false lifecycle = .stopping + if cameraFlipRequested { + // A flip during recording first closes the current segment: keep + // it and hand control back to the pending switchCamera call. + cameraFlipRequested = false + handleCameraFlipSegment(url: url) + return + } + let fallbackDuration = measuredWallClockDuration let shouldDiscard = discardCurrentRecording let wasStopRequested = stopRequested @@ -425,6 +601,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe return } + // Release the camera BEFORE the finalize re-encode runs. While the + // capture session is still live, the writer's encoder contends with + // it for the media hardware and its input can block forever in + // isReadyForMoreMediaData — the «Завершение…» hang. + await flushCaptureSessionForFinalization() + fileValidationTask?.cancel() fileValidationTask = Task { @MainActor [weak self] in guard let self else { return } @@ -433,10 +615,11 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe fallbackDuration: fallbackDuration ) guard !Task.isCancelled, - VideoNoteRecordingLifecycle.acceptsCompletion( + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } self.fileValidationTask = nil if self.discardCurrentRecording { @@ -446,29 +629,63 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } 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 - ))) + VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit( + stopRequested: wasStopRequested, + error: error, + recordedDuration: self.segmentsTotalDuration + + inspection.duration, + maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration + ), + VideoNoteStopPolicy.isValidFinalDuration( + self.segmentsTotalDuration + inspection.duration + ) + { + let totalDuration = + self.segmentsTotalDuration + inspection.duration + // Merge the flip segments back together (passthrough, no + // re-encode). The circle bubble crops the picture visually. + let finalization = await self.fileInspector.finalizeVideoNote( + segments: self.recordedSegments + [url], + frontCameraUsed: self.usedFrontCamera + ) + guard !Task.isCancelled, + VideoNoteRecordingLifecycle.acceptsCompletion( + activeURL: self.activeRecordingURL, + outputURL: url + ) + else { return } + if let finalization { + if finalization.url != url { + try? FileManager.default.removeItem(at: url) + } + self.finishAndReset( + .success( + Recording( + url: finalization.url, + duration: finalization.duration + ))) + } else { + // The transcode fell through; the raw file was already + // validated, so send it uncropped instead of losing the + // recording entirely. + self.finishAndReset( + .success( + Recording(url: url, duration: totalDuration) + )) + } 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 - ) { + !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 @@ -501,10 +718,11 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe 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 - )) + continuation.resume( + returning: MovieOutputSnapshot( + isRecording: output.isRecording, + recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0 + )) } } } @@ -523,6 +741,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe continuation.resume() } } + // stopRunning is asynchronous: wait until the camera pipeline really + // released its media resources. The finalize re-encode must not start + // while the capture session still holds the encoder/muxer, otherwise + // the writer's inputs never become ready. + let stopDeadline = Date().addingTimeInterval(10) + while session.isRunning, Date() < stopDeadline { + try? await Task.sleep(nanoseconds: 100_000_000) + } } private func scheduleStartupTimeout(for url: URL) { @@ -532,21 +758,23 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe nanoseconds: VideoNoteStopPolicy.startupTimeoutNanoseconds ) guard !Task.isCancelled, - let self, - self.lifecycle == .starting, - VideoNoteRecordingLifecycle.acceptsCompletion( + let self, + self.lifecycle == .starting, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } self.lifecycle = .stopping await self.stopMovieOutput() guard !Task.isCancelled, - self.lifecycle == .stopping, - VideoNoteRecordingLifecycle.acceptsCompletion( + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } self.scheduleFinalizationTimeout(for: url) } } @@ -558,12 +786,13 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe nanoseconds: VideoNoteStopPolicy.finalizationTimeoutNanoseconds ) guard !Task.isCancelled, - let self, - self.lifecycle == .stopping, - VideoNoteRecordingLifecycle.acceptsCompletion( + let self, + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } // A few macOS camera drivers stop the file output but delay its // delegate callback until the capture session itself is flushed. @@ -574,42 +803,47 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe nanoseconds: VideoNoteStopPolicy.finalizationRecoveryGraceNanoseconds ) guard !Task.isCancelled, - self.lifecycle == .stopping, - VideoNoteRecordingLifecycle.acceptsCompletion( + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + else { return } let fallbackDuration = self.measuredWallClockDuration let inspection = await self.fileInspector.inspect( url: url, fallbackDuration: fallbackDuration ) guard !Task.isCancelled, - self.lifecycle == .stopping, - VideoNoteRecordingLifecycle.acceptsCompletion( + self.lifecycle == .stopping, + VideoNoteRecordingLifecycle.acceptsCompletion( activeURL: self.activeRecordingURL, outputURL: url - ) else { return } + ) + 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) { + 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 - ))) + self.finishAndReset( + .success( + Recording( + url: url, + duration: inspection.duration + ))) } else if !self.stopRequested { try? FileManager.default.removeItem(at: url) self.finishAndReset(.failure(VideoNoteRecorderError.recordingInterrupted)) @@ -639,6 +873,18 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe finalizationTimeoutTask = nil fileValidationTask?.cancel() fileValidationTask = nil + cameraSwitchContinuation = nil + cameraFlipTimeoutTask?.cancel() + cameraFlipTimeoutTask = nil + cameraFlipRequested = false + restartingSegmentForCameraFlip = false + usedFrontCamera = false + let segments = recordedSegments + recordedSegments = [] + segmentsTotalDuration = 0 + for segment in segments { + try? FileManager.default.removeItem(at: segment) + } timer?.invalidate() timer = nil resetCaptureGraph() @@ -652,10 +898,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe isPrepared = false isRecording = false isUsingFrontCamera = true + hasAlternateCamera = false isMicrophoneMuted = false -#if os(iOS) - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) -#endif + #if os(iOS) + try? AVAudioSession.sharedInstance().setActive( + false, options: .notifyOthersOnDeactivation) + #endif } private func waitForPreviousRecordingToFinish() async throws { @@ -673,29 +921,46 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe private func configureVideoConnection() { guard let connection = movieOutput.connection(with: .video) else { return } -#if os(iOS) - configureInteroperableVideoCodec(for: connection) -#endif + #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) + // iOS records upright portrait at capture time; macOS keeps the + // camera's native landscape orientation (the circle bubble crops + // it visually). + let rotationAngle = videoRotationAngle + if connection.isVideoRotationAngleSupported(rotationAngle) { + connection.videoRotationAngle = rotationAngle + } + #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 + #if os(iOS) + private var videoRotationAngle: CGFloat { + // The interface orientation is authoritative: it is what the user + // actually sees. The device sensor can claim landscape (or + // face-up) while the app stays portrait, and recording with the + // sensor value rotated portrait circles by 90 degrees. + VideoNoteRotationPolicy.angle( + for: VideoNoteRotationPolicy.currentInterfaceOrientation + ) + } + + #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 @@ -721,6 +986,14 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } } + private func hasCamera(at position: AVCaptureDevice.Position) -> Bool { + AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: position + ) != nil + } + private func configureCaptureGraph() async throws -> CaptureGraph { let session = session let movieOutput = movieOutput @@ -728,19 +1001,21 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe sessionQueue.async { session.beginConfiguration() do { - session.sessionPreset = session.canSetSessionPreset(.vga640x480) + 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 + #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 } @@ -750,7 +1025,23 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } session.addInput(cameraInput) - guard let microphone = AVCaptureDevice.default(for: .audio) else { + #if os(iOS) + // iOS routes the microphone by the active camera: + // the front camera gets the quiet earpiece mic and the + // back camera the loud bottom mic. Pin the bottom + // microphone explicitly so the voice level stays + // consistent no matter which camera is recording. + let microphone = + AVCaptureDevice.default( + .microphone, + for: .audio, + position: .back + ) + ?? AVCaptureDevice.default(for: .audio) + #else + let microphone = AVCaptureDevice.default(for: .audio) + #endif + guard let microphone else { throw VideoNoteRecorderError.microphoneUnavailable } let microphoneInput = try AVCaptureDeviceInput(device: microphone) @@ -768,11 +1059,12 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe preferredTimescale: 600 ) session.commitConfiguration() - continuation.resume(returning: CaptureGraph( - cameraInput: cameraInput, - microphoneInput: microphoneInput, - usesFrontCamera: camera.position == .front - )) + continuation.resume( + returning: CaptureGraph( + cameraInput: cameraInput, + microphoneInput: microphoneInput, + usesFrontCamera: camera.position == .front + )) } catch { session.commitConfiguration() continuation.resume(throwing: error) @@ -792,8 +1084,9 @@ final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRe } } guard !Task.isCancelled, - sessionGeneration == generation, - session.isRunning else { + sessionGeneration == generation, + session.isRunning + else { throw CancellationError() } } @@ -817,6 +1110,346 @@ private actor VideoNoteFileInspector { let duration: TimeInterval } + struct Finalization: Sendable { + let url: URL + let duration: TimeInterval + } + + private struct Composition { + let asset: AVAsset + let videoTrack: AVMutableCompositionTrack + let transform: CGAffineTransform + let duration: TimeInterval + } + + /// Hands the finished recording back for sending. The capture is + /// already rotated to portrait at record time, so no re-encode is needed: + /// the raw file is sent as-is and camera flips are merged with a + /// passthrough export (audio and video stay bit-exact). On iOS, when the + /// quiet front camera was used, the audio gets a deterministic volume + /// boost (see boostFrontCameraAudio). + func finalizeVideoNote( + segments: [URL], + frontCameraUsed: Bool + ) async -> Finalization? { + videoNoteFinalizeLogger.info( + "finalize start segments=\(segments.count) front=\(frontCameraUsed)" + ) + #if os(iOS) + if frontCameraUsed, segments.count == 1 { + if let boosted = await Self.boostFrontCameraAudio( + segment: segments[0] + ) { + videoNoteFinalizeLogger.info("finalize boosted OK") + return boosted + } + videoNoteFinalizeLogger.info("finalize boost failed, raw") + } + #endif + if segments.count == 1 { + let duration = + (try? await AVURLAsset(url: segments[0]).load(.duration) + .seconds) ?? 0 + videoNoteFinalizeLogger.info("finalize raw") + return Finalization(url: segments[0], duration: max(0, duration)) + } + videoNoteFinalizeLogger.info("finalize passthrough") + return await Self.passthroughMerge(segments: segments) + } + + /// Re-encodes a single segment with a fixed audio gain. The iOS front + /// camera routes the quiet earpiece microphone, and the system offers no + /// app-level way to override that routing — so the finished recording + /// gets a deterministic volume boost instead. The video frames are + /// copied verbatim (same dimensions and rotation transform); the audio + /// goes through an AVAudioMix that applies the gain without manual + /// sample math. + private static func boostFrontCameraAudio( + segment: URL + ) async -> Finalization? { + let asset = AVURLAsset(url: segment) + guard + let videoTrack = try? await asset.loadTracks(withMediaType: .video) + .first, + let audioTrack = try? await asset.loadTracks(withMediaType: .audio) + .first, + let duration = try? await asset.load(.duration) + else { return nil } + guard let outputURL = finalizationOutputURL() else { return nil } + let writer: AVAssetWriter + do { + writer = try AVAssetWriter(outputURL: outputURL, fileType: .mov) + } catch { + return nil + } + let size = try? await videoTrack.load(.naturalSize) + let width = max(1, Int(size?.width ?? 640)) + let height = max(1, Int(size?.height ?? 480)) + let videoInput = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264, + AVVideoWidthKey: width, + AVVideoHeightKey: height, + ] + ) + videoInput.expectsMediaDataInRealTime = false + videoInput.transform = (try? await videoTrack.load(.preferredTransform)) ?? .identity + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: videoInput, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: + kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: width, + kCVPixelBufferHeightKey as String: height, + ] + ) + guard writer.canAdd(videoInput) else { return nil } + writer.add(videoInput) + let audioInput = AVAssetWriterInput( + mediaType: .audio, + outputSettings: [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 44_100, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 96_000, + ] + ) + audioInput.expectsMediaDataInRealTime = false + guard writer.canAdd(audioInput) else { return nil } + writer.add(audioInput) + + let reader: AVAssetReader + do { + reader = try AVAssetReader(asset: asset) + } catch { + return nil + } + let videoOutput = AVAssetReaderTrackOutput( + track: videoTrack, + outputSettings: [ + kCVPixelBufferPixelFormatTypeKey as String: + kCVPixelFormatType_32BGRA + ] + ) + guard reader.canAdd(videoOutput) else { return nil } + reader.add(videoOutput) + // The reader applies the gain via the audio mix. + let mix = AVMutableAudioMix() + let parameters = AVMutableAudioMixInputParameters(track: audioTrack) + parameters.setVolume(3.5, at: .zero) + mix.inputParameters = [parameters] + let audioOutput = AVAssetReaderAudioMixOutput( + audioTracks: [audioTrack], + audioSettings: [ + AVFormatIDKey: kAudioFormatLinearPCM + ] + ) + audioOutput.audioMix = mix + guard reader.canAdd(audioOutput) else { return nil } + reader.add(audioOutput) + + guard reader.startReading() else { return nil } + guard writer.startWriting() else { + try? FileManager.default.removeItem(at: outputURL) + return nil + } + guard let pool = adaptor.pixelBufferPool else { + writer.cancelWriting() + try? FileManager.default.removeItem(at: outputURL) + return nil + } + + var sessionStarted = false + var videoSample = videoOutput.copyNextSampleBuffer() + var audioSample = audioOutput.copyNextSampleBuffer() + while videoSample != nil || audioSample != nil { + let videoPTS = videoSample.map { + CMSampleBufferGetPresentationTimeStamp($0) + } + let audioPTS = audioSample.map { + CMSampleBufferGetPresentationTimeStamp($0) + } + let takeVideo: Bool + switch (videoPTS, audioPTS) { + case let (video?, audio?): takeVideo = video <= audio + case (_?, nil): takeVideo = true + default: takeVideo = false + } + if takeVideo, let sample = videoSample { + videoSample = videoOutput.copyNextSampleBuffer() + guard let source = CMSampleBufferGetImageBuffer(sample) + else { continue } + let pts = CMSampleBufferGetPresentationTimeStamp(sample) + if !sessionStarted { + writer.startSession(atSourceTime: pts) + sessionStarted = true + } + while !videoInput.isReadyForMoreMediaData { + try? await Task.sleep(nanoseconds: 2_000_000) + } + var buffer: CVPixelBuffer? + CVPixelBufferPoolCreatePixelBuffer( + kCFAllocatorDefault, pool, &buffer + ) + guard let buffer else { continue } + CVPixelBufferLockBaseAddress(source, .readOnly) + CVPixelBufferLockBaseAddress(buffer, []) + let srcBase = CVPixelBufferGetBaseAddress(source)! + let dstBase = CVPixelBufferGetBaseAddress(buffer)! + let srcBytes = CVPixelBufferGetBytesPerRow(source) + let dstBytes = CVPixelBufferGetBytesPerRow(buffer) + let copyHeight = min( + CVPixelBufferGetHeight(source), + CVPixelBufferGetHeight(buffer) + ) + let copyBytes = min(srcBytes, dstBytes) + for row in 0.. Composition? { + let composition = AVMutableComposition() + guard + let videoTrack = composition.addMutableTrack( + withMediaType: .video, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + else { return nil } + // The audio track is created lazily and only when a segment really + // has audio. An empty audio track breaks the macOS exporter. + var compositionAudioTrack: AVMutableCompositionTrack? + var cursor = CMTime.zero + var transform = CGAffineTransform.identity + var hasVideo = false + for url in urls { + let asset = AVURLAsset(url: url) + guard + let duration = try? await asset.load(.duration), + let source = try? await asset.loadTracks(withMediaType: .video) + .first + else { return nil } + let range = CMTimeRange(start: .zero, duration: duration) + do { + try videoTrack.insertTimeRange(range, of: source, at: cursor) + if let audio = try? await asset.loadTracks( + withMediaType: .audio + ).first { + if compositionAudioTrack == nil { + compositionAudioTrack = composition.addMutableTrack( + withMediaType: .audio, + preferredTrackID: kCMPersistentTrackID_Invalid + ) + } + if let compositionAudioTrack { + try? compositionAudioTrack.insertTimeRange( + range, + of: audio, + at: cursor + ) + } + } + } catch { + return nil + } + if !hasVideo { + transform = (try? await source.load(.preferredTransform)) ?? .identity + hasVideo = true + } + cursor = cursor + duration + } + videoTrack.preferredTransform = transform + return Composition( + asset: composition, + videoTrack: videoTrack, + transform: transform, + duration: cursor.seconds + ) + } + + private static func passthroughMerge( + segments: [URL] + ) async -> Finalization? { + guard let composition = await composition(from: segments) else { + return nil + } + guard let outputURL = finalizationOutputURL() else { return nil } + guard let exporter = AVAssetExportSession( + asset: composition.asset, + presetName: AVAssetExportPresetPassthrough + ) else { return nil } + exporter.outputURL = outputURL + exporter.outputFileType = .mov + exporter.shouldOptimizeForNetworkUse = true + do { + try await exporter.export(to: outputURL, as: .mov) + } catch { + try? FileManager.default.removeItem(at: outputURL) + return nil + } + return Finalization(url: outputURL, duration: composition.duration) + } + + private static func finalizationOutputURL() -> URL? { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LumaRecordings", isDirectory: true) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + return directory.appendingPathComponent( + "video-note-final-\(UUID().uuidString).mov" + ) + } catch { + return nil + } + } + func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? { let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000] for delay in retryDelays { @@ -824,16 +1457,18 @@ private actor VideoNoteFileInspector { 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 } + 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 } + duration.seconds.isFinite, + duration.seconds > 0.04, + let tracks = try? await asset.loadTracks(withMediaType: .video), + !tracks.isEmpty + else { continue } return Inspection(duration: duration.seconds) } @@ -842,9 +1477,10 @@ private actor VideoNoteFileInspector { // 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 } + let values = try? url.resourceValues(forKeys: [.fileSizeKey]), + let size = values.fileSize, + size > 16_384 + else { return nil } return Inspection(duration: fallbackDuration) } } @@ -871,22 +1507,22 @@ enum VideoNoteStopPolicy { } } -#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 +// #if os(iOS) +// extension VideoNoteRecorder { +// fileprivate 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 @@ -920,7 +1556,8 @@ private enum VideoNoteRecorderError: LocalizedError { case .recordingBusy: return "Предыдущая запись ещё завершается. Подождите секунду и повторите." case .recordingInterrupted: - return "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись." + return + "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись." case .finalizationTimedOut: return "Камера не смогла завершить видеофайл. Повторите запись." case .emptyRecording: diff --git a/Sources/Shared/UI/AppLockView.swift b/Sources/Shared/UI/AppLockView.swift new file mode 100644 index 0000000..4f1bf1d --- /dev/null +++ b/Sources/Shared/UI/AppLockView.swift @@ -0,0 +1,334 @@ +import LocalAuthentication +import SwiftUI + +@MainActor +struct AppLockView: View { + @ObservedObject var model: AppModel + @State private var passcode = "" + @State private var attemptFailed = false + @FocusState private var isFocused: Bool + + 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() + + VStack(spacing: 22) { + Image(systemName: "lock.fill") + .font(.system(size: 44, weight: .semibold)) + .foregroundStyle(.white) + + Text("Luma заблокирован") + .font(.title2.weight(.bold)) + .foregroundStyle(.white) + + SecureField("Пароль", text: $passcode) + .focused($isFocused) + .textContentType(.password) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.center) + .frame(maxWidth: 260) + .onSubmit(submit) + + if attemptFailed { + Text("Неверный пароль") + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.95)) + } + + Button(action: submit) { + Text("Разблокировать") + .fontWeight(.semibold) + .frame(maxWidth: 260) + .frame(height: 34) + } + .buttonStyle(.borderedProminent) + .disabled(passcode.isEmpty) + + if model.biometricUnlockAvailable, + model.appLockBiometricIsEnabled + { + Button { + Task { await biometricUnlock() } + } label: { + Label( + "Войти по \(model.biometricUnlockName)", + systemImage: model.biometricUnlockName == "Face ID" + ? "faceid" : "touchid" + ) + .foregroundStyle(.white) + } + } + } + .padding(28) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 28, style: .continuous) + ) + .padding(24) + } + .onAppear { + isFocused = true + } + #if os(iOS) + .task { + // Auto-prompt biometrics once when the lock screen appears. iOS + // only: on macOS the system Touch ID dialog blocks the window + // and freezes the lock screen. + guard model.biometricUnlockAvailable, + model.appLockBiometricIsEnabled + else { + return + } + await biometricUnlock() + } + #endif + } + + private func submit() { + guard !passcode.isEmpty else { return } + if model.unlockWithPasscode(passcode) { + attemptFailed = false + passcode = "" + } else { + attemptFailed = true + passcode = "" + } + } + + private func biometricUnlock() async { + _ = await model.unlockWithBiometrics() + } +} + +/// Configurable passcode sheet: setting up a new passcode, changing it, or +/// verifying the current one to disable the lock / toggle biometrics. +@MainActor +struct AppLockPasscodeSheet: View { + enum Mode: Identifiable { + case setup + case change + case verify(VerificationAction) + + enum VerificationAction: String { + case disableLock + case enableBiometrics + case disableBiometrics + } + + var id: String { + switch self { + case .setup: return "setup" + case .change: return "change" + case .verify(let action): return "verify-\(action.rawValue)" + } + } + } + + let model: AppModel + let mode: Mode + @Environment(\.dismiss) private var dismiss + + @State private var oldPasscode = "" + @State private var passcode = "" + @State private var repeatedPasscode = "" + @State private var errorText: String? + + var body: some View { + #if os(iOS) + NavigationStack { + Form { + formcontent() + } + .navigationTitle(navigationTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Готово", action: submit) + .disabled(!canSubmit) + } + } + } + #else + List { + formcontent() + } + .navigationTitle(navigationTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Готово", action: submit) + .disabled(!canSubmit) + } + } + .frame(minWidth: 420, minHeight: 320) + #endif + } + + @ViewBuilder + private func formcontent() -> some View { + switch mode { + case .setup: + Section(title) { + SecureField(fieldLabel, text: $passcode) + .textContentType(.password) + SecureField("Повторите пароль", text: $repeatedPasscode) + .textContentType(.password) + } + case .verify: + Section("Текущий пароль") { + SecureField("Текущий пароль", text: $oldPasscode) + .textContentType(.password) + } + case .change: + Section("Текущий пароль") { + SecureField("Текущий пароль", text: $oldPasscode) + .textContentType(.password) + } + Section(title) { + SecureField(fieldLabel, text: $passcode) + .textContentType(.password) + SecureField("Повторите пароль", text: $repeatedPasscode) + .textContentType(.password) + } + } + if let errorText { + Section { + Text(errorText) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + private var title: String { + switch mode { + case .setup: return "Новый пароль" + case .change: return "Новый пароль" + case .verify: return "Пароль" + } + } + + private var navigationTitle: String { + switch mode { + case .setup: return "Включить блокировку" + case .change: return "Сменить пароль" + case .verify(let action): + switch action { + case .disableLock: return "Выключить блокировку" + case .enableBiometrics, .disableBiometrics: + return "Подтвердите пароль" + } + } + } + + private var fieldLabel: String { + switch mode { + case .setup, .change: + return "Пароль (минимум \(AppLockPolicy.minimumLength) символа)" + case .verify: return "Пароль" + } + } + + private var canSubmit: Bool { + switch mode { + case .setup: + return !passcode.isEmpty && !repeatedPasscode.isEmpty + case .change: + return !oldPasscode.isEmpty && !passcode.isEmpty + && !repeatedPasscode.isEmpty + case .verify: + return !oldPasscode.isEmpty + } + } + + private func submit() { + errorText = nil + switch mode { + case .setup: + guard AppLockPolicy.isValid(passcode) else { + errorText = + "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа." + return + } + guard passcode == repeatedPasscode else { + errorText = "Пароли не совпадают." + repeatedPasscode = "" + return + } + if model.enableAppLock(passcode: passcode) { + dismiss() + } else { + errorText = + model.errorMessage ?? "Не удалось включить блокировку." + } + case .change: + guard AppLockPolicy.isValid(passcode) else { + errorText = + "Пароль слишком короткий: минимум \(AppLockPolicy.minimumLength) символа." + return + } + guard passcode == repeatedPasscode else { + errorText = "Пароли не совпадают." + repeatedPasscode = "" + return + } + if model.changeAppLockPasscode(from: oldPasscode, to: passcode) { + dismiss() + } else { + errorText = "Неверный текущий пароль." + oldPasscode = "" + } + case .verify(let action): + switch action { + case .disableLock: + if model.disableAppLock(passcode: oldPasscode) { + dismiss() + } else { + errorText = "Неверный пароль." + oldPasscode = "" + } + case .enableBiometrics: + if model.setAppLockBiometricUnlock(true, passcode: oldPasscode) + { + dismiss() + } else { + errorText = "Неверный пароль." + oldPasscode = "" + } + case .disableBiometrics: + if model.setAppLockBiometricUnlock(false, passcode: oldPasscode) + { + dismiss() + } else { + errorText = "Неверный пароль." + oldPasscode = "" + } + } + } + } +} + +#Preview("Экран блокировки") { + AppLockView(model: PreviewSupport.model) +} + +#Preview("Настройка пароля") { + AppLockPasscodeSheet(model: PreviewSupport.model, mode: .setup) +} diff --git a/Sources/Shared/UI/AttachmentPreviewView.swift b/Sources/Shared/UI/AttachmentPreviewView.swift index 6bc5525..f428d0a 100644 --- a/Sources/Shared/UI/AttachmentPreviewView.swift +++ b/Sources/Shared/UI/AttachmentPreviewView.swift @@ -1,4 +1,5 @@ import Foundation +import os import SwiftUI #if os(iOS) @@ -239,7 +240,13 @@ struct AttachmentPreviewView: View { 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 } + let image = DraftThumbnailImage(data: data) else { + if draft.kind == .video { + Logger(subsystem: "Luma", category: "video-preview") + .warning("video draft has no decodable thumbnail: \(draft.filename) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)") + } + continue + } thumbnailImages[draft.id] = image await Task.yield() } @@ -291,3 +298,19 @@ struct AttachmentPreviewView: View { } } } + +private struct AttachmentPreviewHost: View { + @State private var drafts = PreviewSupport.drafts() + + var body: some View { + AttachmentPreviewView( + model: PreviewSupport.model, + conversation: PreviewSupport.conversation(displayName: "Алиса"), + drafts: $drafts + ) + } +} + +#Preview { + AttachmentPreviewHost() +} diff --git a/Sources/Shared/UI/CallView.swift b/Sources/Shared/UI/CallView.swift index 4ea1957..72576bb 100644 --- a/Sources/Shared/UI/CallView.swift +++ b/Sources/Shared/UI/CallView.swift @@ -8,7 +8,7 @@ struct CallView: View { let call: CallSnapshot private var conversation: Conversation { - model.conversations.first(where: { $0.id == call.peerJID.lowercased() }) + model.conversations.first(where: { $0.jid == call.peerJID.lowercased() }) ?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID)) } @@ -241,3 +241,18 @@ private struct CallControlButton: View { .accessibilityLabel(title) } } + +#Preview("Входящий видеозвонок") { + CallView(model: PreviewSupport.model, call: PreviewSupport.callSnapshot()) +} + +#Preview("Аудиозвонок") { + CallView( + model: PreviewSupport.model, + call: PreviewSupport.callSnapshot( + media: [.audio], + phase: .connected, + isSpeakerEnabled: true + ) + ) +} diff --git a/Sources/Shared/UI/ChatView.swift b/Sources/Shared/UI/ChatView.swift index 748dd32..6bbc99d 100644 --- a/Sources/Shared/UI/ChatView.swift +++ b/Sources/Shared/UI/ChatView.swift @@ -2,17 +2,32 @@ import CoreTransferable import Foundation import ImageIO import PhotosUI +import SwiftData import SwiftUI import UniformTypeIdentifiers +import os #if os(iOS) -import UIKit + import UIKit #endif +private enum EmojiPickerPresentation: Identifiable { + case reaction(ChatMessage) + case composer + + var id: String { + switch self { + case .reaction(let message): return "reaction-\(message.clientID)" + case .composer: return "composer" + } + } +} + @MainActor struct ChatView: View { private static let bottomAnchorID = "luma-chat-timeline-bottom" private static let timelineCoordinateSpace = "luma-chat-timeline-space" + private static let localHistoryPageSize = 60 @ObservedObject var model: AppModel let conversation: Conversation @@ -42,8 +57,15 @@ struct ChatView: View { @State private var selectedMessageIDs: Set = [] @State private var destructiveAction: MessageDestructiveAction? @State private var replyThreadSelection: ReplyThreadSelection? + @State private var emojiPickerPresentation: EmojiPickerPresentation? @State private var hasCompletedInitialScroll = false @State private var isNearTimelineBottom = true + @State private var historyLoadAnchorID: String? + @State private var historyTopTriggerArmed = true + @State private var historyTopTriggerVisible = false + @State private var historyLoadEntryCount = 0 + @State private var historyAutoContinueCount = 0 + @State private var emptyHistoryRetryCount = 0 @State private var activeCaptureMode: ComposerCaptureMode? @State private var preparingCaptureMode: ComposerCaptureMode? @State private var captureGestureIsActive = false @@ -52,6 +74,94 @@ struct ChatView: View { @State private var captureAttemptID = UUID() @State private var captureStartTask: Task? @State private var captureSuspendsArchiveSync = false + @State private var pickerResetToken = UUID() + @State private var timelineEntries: [ChatTimelineEntry] = [] + @State private var visibleLocalMessageCount = 0 + @State private var localHistoryExhausted = false + /// Messages-style entrance state: which rows currently animate, which IDs + /// the timeline already showed, and whether the opening cascade of this + /// conversation has played. + @State private var entranceModes: [String: MessageBubbleEntrance.Mode] = [:] + @State private var knownEntranceIDs: Set = [] + @State private var hasPlayedOpeningEntrance = false + @State private var entranceReleaseToken = UUID() + + /// Messages of this conversation straight from the SwiftData store, + /// ordered like `AppModel.selectedMessages`: timestamp, then clientID. + @Query private var messages: [ChatMessage] + + init(model: AppModel, conversation: Conversation) { + self.model = model + self.conversation = conversation + let conversationJID = conversation.jid + _messages = Query( + filter: #Predicate { message in + message.conversationID == conversationJID + }, + sort: [ + SortDescriptor(\ChatMessage.timestamp, order: .forward), + SortDescriptor(\ChatMessage.clientID, order: .forward), + ] + ) + _audioRecorder = StateObject(wrappedValue: AudioMessageRecorder()) + _isComposerFocused = FocusState() + _draft = State(initialValue: "") + _showingFileImporter = State(initialValue: false) + _fileImportMode = State(initialValue: FileImportMode.files) + _showingEncryption = State(initialValue: false) + _showingVideoNoteRecorder = State(initialValue: false) + _videoNoteIsSending = State(initialValue: false) + _showingMediaPicker = State(initialValue: false) + _showingPhotoCamera = State(initialValue: false) + _photoCameraIsPreparingResult = State(initialValue: false) + _showingLocationPicker = State(initialValue: false) + _showingGroupInfo = State(initialValue: false) + _showingAttachmentPreview = State(initialValue: false) + _attachmentPreviewPresentationPending = State(initialValue: false) + _attachmentPreviewPresentationTask = State?>( + initialValue: nil + ) + _pickedMediaItems = State<[PhotosPickerItem]>(initialValue: []) + _attachmentDrafts = State<[AttachmentDraft]>(initialValue: []) + _isPreparingAttachments = State(initialValue: false) + _editingMessageID = State(initialValue: nil) + _replyingToMessageID = State(initialValue: nil) + _forwardingSelection = State( + initialValue: nil + ) + _selectedMessageIDs = State>(initialValue: []) + _destructiveAction = State(initialValue: nil) + _replyThreadSelection = State(initialValue: nil) + _emojiPickerPresentation = State( + initialValue: nil + ) + _hasCompletedInitialScroll = State(initialValue: false) + _historyTopTriggerVisible = State(initialValue: false) + _historyLoadEntryCount = State(initialValue: 0) + _historyAutoContinueCount = State(initialValue: 0) + _emptyHistoryRetryCount = State(initialValue: 0) + _isNearTimelineBottom = State(initialValue: true) + _historyLoadAnchorID = State(initialValue: nil) + _historyTopTriggerArmed = State(initialValue: true) + _activeCaptureMode = State(initialValue: nil) + _preparingCaptureMode = State(initialValue: nil) + _captureGestureIsActive = State(initialValue: false) + _captureIsLocked = State(initialValue: false) + _captureDragTranslation = State(initialValue: CGSize.zero) + _captureAttemptID = State(initialValue: UUID()) + _captureStartTask = State?>(initialValue: nil) + _captureSuspendsArchiveSync = State(initialValue: false) + _pickerResetToken = State(initialValue: UUID()) + _timelineEntries = State<[ChatTimelineEntry]>(initialValue: []) + _visibleLocalMessageCount = State(initialValue: 0) + _localHistoryExhausted = State(initialValue: false) + _entranceModes = State<[String: MessageBubbleEntrance.Mode]>( + initialValue: [:] + ) + _knownEntranceIDs = State>(initialValue: []) + _hasPlayedOpeningEntrance = State(initialValue: false) + _entranceReleaseToken = State(initialValue: UUID()) + } var body: some View { VStack(spacing: 0) { @@ -68,10 +178,10 @@ struct ChatView: View { } } .background(Color.secondary.opacity(0.035)) - .navigationTitle(conversation.displayName) -#if os(iOS) - .navigationBarTitleDisplayMode(.inline) -#endif + .navigationTitle("") + // #if os(iOS) + // .navigationBarTitleDisplayMode(.inline) + // #endif .toolbar { ToolbarItem(placement: .principal) { chatNavigationTitle @@ -85,42 +195,61 @@ struct ChatView: View { .font(.subheadline.weight(.semibold)) } } else { - ToolbarItemGroup(placement: .primaryAction) { + ToolbarItem(placement: .primaryAction) { if !conversation.isGroup { - Button { - Task { await model.startCall(to: conversation.jid, withVideo: false) } - } label: { - Image(systemName: "phone") - } - .disabled(!canStartCall) - .help("Аудиозвонок") + Menu { + Button { + Task { + await model.startCall( + to: conversation.jid, + withVideo: false + ) + } + } label: { + Label("Аудиозвонок", systemImage: "phone") + } + .disabled(!canStartCall) + .help("Аудиозвонок") - Button { - Task { await model.startCall(to: conversation.jid, withVideo: true) } + Button { + Task { + await model.startCall( + to: conversation.jid, + withVideo: true + ) + } + } label: { + Label("Видеозвонок", systemImage: "video") + } + .disabled(!canStartCall) + .help("Видеозвонок") + encryptionMenu } label: { - Image(systemName: "video") + Image(systemName: "ellipsis.circle") } + .menuIndicator(.hidden) .disabled(!canStartCall) - .help("Видеозвонок") } - AvatarView( - conversation: liveConversation, - imageData: model.avatarData(for: conversation.jid), - size: 28 - ) if conversation.isGroup { - Button { showingGroupInfo = true } label: { + Button { + showingGroupInfo = true + } label: { Image(systemName: "person.3") } .help("Информация о группе") } - encryptionMenu } } } -#if os(iOS) - .toolbar(replyThreadSelection == nil ? .visible : .hidden, for: .navigationBar) -#endif + #if os(iOS) + .toolbar( + replyThreadSelection == nil ? .visible : .hidden, + for: .navigationBar + ) + // A pushed conversation takes the whole screen; the tab bar + // comes back when the user pops back to the list. + .toolbar(.hidden, for: .tabBar) + #endif .fileImporter( isPresented: $showingFileImporter, allowedContentTypes: fileImportMode.allowedContentTypes, @@ -128,7 +257,9 @@ struct ChatView: View { ) { result in guard case .success(let urls) = result else { return } let preferredKind = fileImportMode.preferredKind - Task { await stageImportedFiles(urls, preferredKind: preferredKind) } + Task { + await stageImportedFiles(urls, preferredKind: preferredKind) + } } .photosPicker( isPresented: $showingMediaPicker, @@ -137,43 +268,59 @@ struct ChatView: View { matching: .any(of: [.images, .videos]), preferredItemEncoding: .current ) + // A fresh picker identity per attempt: clearing the selection while + // the picker is presented desynchronizes its internal state, and the + // stale selection then leaks into the next presentation. + .id(pickerResetToken) .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 !isPresented else { return } + // On Done the binding holds the chosen items; on Cancel SwiftUI + // restores the pre-presentation value (kept empty below), so + // staging runs only for a confirmed selection. + let items = pickedMediaItems + pickedMediaItems = [] + pickerResetToken = UUID() guard !items.isEmpty else { return } Task { await stagePickedMedia(items) } } + #if os(iOS) + .fullScreenCover( + isPresented: $showingPhotoCamera, + onDismiss: { + schedulePendingAttachmentPreview() + if !photoCameraIsPreparingResult { + releaseArchiveSyncAfterCapture() + } + } + ) { + SystemPhotoCameraView( + onDismissRequest: { + // Runs synchronously in the picker delegate while the + // camera is still presented: dismiss through the + // binding so SwiftUI owns the transition, and reserve + // the archive-sync suspension across the dismissal + // while the file is prepared in the background. + showingPhotoCamera = false + photoCameraIsPreparingResult = true + }, + onMedia: { result in + switch result { + case .success(let media): + photoCameraIsPreparingResult = true + Task { await stageCapturedMedia(media) } + case .failure(let error): + photoCameraIsPreparingResult = false + releaseArchiveSyncAfterCapture() + model.errorMessage = error.localizedDescription + } + }, + onCancel: { + showingPhotoCamera = false + } + ) + .ignoresSafeArea() + } + #endif .onChange(of: draft) { _, text in model.updateComposerActivity(text, in: liveConversation) } @@ -221,6 +368,11 @@ struct ChatView: View { clearMessageSelection() } } + .sheet(item: $emojiPickerPresentation) { presentation in + EmojiPickerView { emoji in + handleEmojiSelection(emoji, for: presentation) + } + } .alert(item: $destructiveAction) { action in Alert( title: Text(action.title), @@ -234,11 +386,23 @@ struct ChatView: View { .overlay { replyThreadOverlay } - .onChange(of: conversation.id) { _, _ in + .onChange(of: conversation.jid) { _, _ in hasCompletedInitialScroll = false isNearTimelineBottom = true + historyLoadAnchorID = nil + historyTopTriggerArmed = true + historyTopTriggerVisible = false + historyAutoContinueCount = 0 + emptyHistoryRetryCount = 0 + visibleLocalMessageCount = 0 + localHistoryExhausted = false + entranceModes = [:] + knownEntranceIDs = [] + hasPlayedOpeningEntrance = false + entranceReleaseToken = UUID() } .onDisappear { + model.endConversationViewing(jid: conversation.jid) model.endComposerActivity(in: liveConversation) attachmentPreviewPresentationTask?.cancel() attachmentPreviewPresentationTask = nil @@ -247,9 +411,10 @@ struct ChatView: View { clearMessageSelection() cancelComposerRecording(feedback: false) if !showingVideoNoteRecorder, - !showingPhotoCamera, - !videoNoteIsSending, - !photoCameraIsPreparingResult { + !showingPhotoCamera, + !videoNoteIsSending, + !photoCameraIsPreparingResult + { releaseArchiveSyncAfterCapture() } if !showingAttachmentPreview { @@ -262,7 +427,11 @@ struct ChatView: View { @ViewBuilder private var replyThreadOverlay: some View { if let selection = replyThreadSelection, - let root = model.message(withID: selection.rootID, in: conversation.id) { + let root = model.message( + withID: selection.rootID, + in: conversation.jid + ) + { ReplyThreadOverlay( model: model, rootMessage: root, @@ -279,12 +448,21 @@ struct ChatView: View { private var encryptionMenu: some View { Menu { - Section(conversation.isGroup ? "Шифрование этой группы" : "Шифрование этого чата") { - ForEach(EncryptionPreference.allCases, id: \.rawValue) { preference in + Section( + conversation.isGroup + ? "Шифрование этой группы" : "Шифрование этого чата" + ) { + ForEach(EncryptionPreference.allCases, id: \.rawValue) { + preference in Button { - model.setEncryptionPreference(preference, for: conversation.jid) + model.setEncryptionPreference( + preference, + for: conversation.jid + ) } label: { - if model.encryptionPreference(for: conversation.jid) == preference { + if model.encryptionPreference(for: conversation.jid) + == preference + { Label(preference.title, systemImage: "checkmark") } else { Text(preference.title) @@ -294,15 +472,22 @@ struct ChatView: View { } if !conversation.isGroup { Divider() - Button { showingEncryption = true } label: { - Label("Устройства и отпечатки", systemImage: "checkmark.shield") + Button { + showingEncryption = true + } label: { + Label( + "Устройства и отпечатки", + systemImage: "checkmark.shield" + ) } } } label: { - Image(systemName: encryptionIcon) - .foregroundStyle(encryptionEnabled ? Color.primary : Color.orange) + Label("Шифрование", systemImage: encryptionIcon) } - .help(encryptionEnabled ? "OMEMO включено" : "Сообщения отправляются без OMEMO") + .help( + encryptionEnabled + ? "OMEMO включено" : "Сообщения отправляются без OMEMO" + ) } private var canStartCall: Bool { @@ -310,7 +495,106 @@ struct ChatView: View { } private var selectedTimelineMessages: [ChatMessage] { - model.selectedMessages.filter { selectedMessageIDs.contains($0.id) } + model.selectedMessages.filter { + selectedMessageIDs.contains($0.clientID) + } + } + + private func rebuildTimelineEntries(from newMessages: [ChatMessage]? = nil) { + let source = newMessages ?? messages + if visibleLocalMessageCount == 0 { + visibleLocalMessageCount = min(Self.localHistoryPageSize, source.count) + } else if localHistoryExhausted { + visibleLocalMessageCount = source.count + } else { + visibleLocalMessageCount = min(visibleLocalMessageCount, source.count) + } + localHistoryExhausted = visibleLocalMessageCount >= source.count + let visible = Array(source.suffix(visibleLocalMessageCount)) + timelineEntries = ChatTimelineEntry.make(from: visible) + updateEntranceModes(for: visible) + } + + private var hasMoreLocalHistory: Bool { + visibleLocalMessageCount < messages.count + } + + /// Decides which rows play a Messages-style entrance after a rebuild. + /// The first page of an opened conversation cascades bottom-up; rows that + /// appear later (a send, an incoming message) launch from the composer's + /// edge. Modes are released once the animation window closes so a row + /// recycled by the lazy stack during scrolling reappears statically. + private func updateEntranceModes(for newMessages: [ChatMessage]) { + let ids = newMessages.map(\.clientID) + let known = knownEntranceIDs + knownEntranceIDs = Set(ids) + + guard hasPlayedOpeningEntrance else { + guard !ids.isEmpty else { return } + hasPlayedOpeningEntrance = true + // The first message ever in an empty chat should launch out of + // the composer like any other send, not cascade in. + if ids.count == 1, known.isEmpty, + newMessages.first?.direction == .outgoing + { + applyLaunch(for: newMessages, ids: ids) + return + } + let delays = ChatMessageEntrancePolicy.entranceDelays( + forIDs: ids + ) + for (id, delay) in delays { + entranceModes[id] = .cascade(delay: delay) + } + scheduleEntranceRelease( + ids: Set(delays.keys), + after: ChatMessageEntrancePolicy.entranceCascadeDuration + ) + return + } + + applyLaunch( + for: newMessages, + ids: ChatMessageEntrancePolicy.appendedLaunchIDs( + in: ids, + after: known + ) + ) + } + + private func applyLaunch(for messages: [ChatMessage], ids: [String]) { + guard !ids.isEmpty else { return } + let launched = Set(ids) + var applied: Set = [] + for message in messages where launched.contains(message.clientID) { + entranceModes[message.clientID] = .launch( + outgoing: message.direction == .outgoing + ) + applied.insert(message.clientID) + } + scheduleEntranceRelease( + ids: applied, + after: ChatMessageEntrancePolicy.entranceAnimationDuration + ) + } + + private func scheduleEntranceRelease( + ids: Set, + after delay: TimeInterval + ) { + guard !ids.isEmpty else { return } + let token = entranceReleaseToken + Task { @MainActor in + try? await Task.sleep( + nanoseconds: UInt64(delay * 1_000_000_000) + ) + guard !Task.isCancelled, token == entranceReleaseToken else { + return + } + for id in ids { + entranceModes[id] = nil + } + } } private var isSelectingMessages: Bool { @@ -319,20 +603,54 @@ struct ChatView: View { private var messageTimeline: some View { ScrollViewReader { proxy in - let timelineEntries = model.selectedTimelineEntries GeometryReader { viewport in ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack(spacing: 5) { + if hasCompletedInitialScroll, + (hasMoreLocalHistory || model.hasMoreOlderHistory), + !timelineEntries.isEmpty + { + Color.clear + .frame(height: 1) + .accessibilityHidden(true) + .onAppear { + historyTopTriggerVisible = true + guard historyTopTriggerArmed, + !model.isLoadingOlderHistory + else { return } + triggerOlderHistoryLoad() + } + .onDisappear { + historyTopTriggerVisible = false + historyTopTriggerArmed = true + historyAutoContinueCount = 0 + } + if model.isLoadingOlderHistory { + ProgressView() + .controlSize(.small) + .padding(.vertical, 6) + } + } if timelineEntries.isEmpty { ContentUnavailableView( emptyChatTitle, systemImage: conversation.isGroup ? "person.3.fill" - : (encryptionEnabled ? "lock.shield" : "lock.open"), + : (encryptionEnabled + ? "lock.shield" : "lock.open"), description: Text(emptyChatDescription) ) .padding(.top, 80) + .onAppear { + // Empty timeline has no scroll gesture, so load + // the first page directly when it appears. + guard hasMoreLocalHistory || model.hasMoreOlderHistory, + !model.isLoadingOlderHistory + else { return } + model + .loadOlderHistoryForSelectedConversation() + } } else { ForEach(timelineEntries) { entry in let message = entry.message @@ -343,89 +661,203 @@ struct ChatView: View { model: model, message: message, isSelectionMode: isSelectingMessages, - isSelected: selectedMessageIDs.contains(message.id), + isSelected: selectedMessageIDs.contains( + message.clientID + ), onAttachmentTap: { - Task { await model.previewAttachment(message) } + Task { + await model.previewAttachment( + message + ) + } }, onEdit: { startEditing(message) }, onReply: { startReplying(to: message) }, - onForward: { presentForwarding([message]) }, + onForward: { + presentForwarding([message]) + }, onRetry: { - Task { await model.retryMediaMessage(message) } + Task { + await model.retryMediaMessage( + message + ) + } + }, + onToggleSelection: { + toggleMessageSelection(message) + }, + onBeginSelection: { + beginMessageSelection(with: message) }, - onToggleSelection: { toggleMessageSelection(message) }, - onBeginSelection: { beginMessageSelection(with: message) }, onRetract: { - destructiveAction = .init(kind: .retract, messages: [message]) + destructiveAction = .init( + kind: .retract, + messages: [message] + ) }, onDelete: { - destructiveAction = .init(kind: .localDelete, messages: [message]) + destructiveAction = .init( + kind: .localDelete, + messages: [message] + ) }, onReplyTap: { targetID in presentReplyThread( rootID: targetID, - selectedReplyID: message.id + selectedReplyID: message + .clientID ) }, onReact: { emoji in - Task { await model.toggleReaction(emoji, on: message) } + Task { + await model.toggleReaction( + emoji, + on: message + ) + } + }, + onReactPicker: { + emojiPickerPresentation = .reaction( + message + ) } ) - .id(message.id) + .modifier( + MessageBubbleEntrance( + mode: entranceModes[ + message.clientID + ] ?? .settled + ) + ) + .id(message.clientID) } } } .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 + #if os(iOS) + ChatScrollMetricsObserver( + identity: conversation.jid + ) { isNearBottom in + guard isNearTimelineBottom != isNearBottom + else { return } + isNearTimelineBottom = isNearBottom + } + .frame(height: 0) + .accessibilityHidden(true) + #endif } .defaultScrollAnchor(.bottom) -#if os(iOS) - .scrollDismissesKeyboard(.interactively) -#endif + #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 - ) + .onAppear { + rebuildTimelineEntries() } -#endif + .onChange(of: messages) { _, newMessages in + rebuildTimelineEntries(from: newMessages) + } + .accessibilityIdentifier("chat-timeline") + #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 { + let newID, + newID != oldID, + isNearTimelineBottom + || timelineEntries.last?.message.direction + == .outgoing + else { return } scrollToBottom(proxy, animated: true) } + .onChange(of: model.isLoadingOlderHistory) { + wasLoading, + isLoading in + guard wasLoading, !isLoading else { return } + // Restore the scroll position so the newly loaded + // page appears above the previously visible one. Only + // while the user is still at the top: a slow page must + // never yank the timeline away from where the user + // scrolled while it was loading. + if let anchor = historyLoadAnchorID, + historyTopTriggerVisible + { + Task { @MainActor in + await Task.yield() + proxy.scrollTo(anchor, anchor: .top) + historyLoadAnchorID = nil + } + } else { + historyLoadAnchorID = nil + } + // The trigger is one-shot per visibility cycle. + // Re-arm it whenever a load settles so the next + // scroll gesture can fire again even if this page + // inserted nothing. + historyTopTriggerArmed = true + if timelineEntries.isEmpty { + handleEmptyHistoryLoadCompleted() + } else if historyTopTriggerVisible { + // The page left the sentinel on screen: it either + // inserted no rows (reactions, duplicates) or the + // anchor scroll did not push it away. While the + // user stays at the top, continue through such + // pages so scroll-up never appears dead. Every + // page advances the server cursor, so this loop + // is bounded by the archive itself. + if timelineEntries.count + == historyLoadEntryCount, + model.hasMoreOlderHistory, + historyAutoContinueCount < 6 + { + historyAutoContinueCount += 1 + triggerOlderHistoryLoad() + } else { + historyAutoContinueCount = 0 + } + } + } .onChange(of: timelineEntries.count) { _, _ in selectedMessageIDs.formIntersection( Set(timelineEntries.map(\.id)) ) + guard historyLoadAnchorID != nil, + !model.isLoadingOlderHistory, + historyTopTriggerVisible, + let anchor = historyLoadAnchorID + else { return } + Task { @MainActor in + await Task.yield() + proxy.scrollTo(anchor, anchor: .top) + historyLoadAnchorID = nil + historyTopTriggerArmed = true + } } if hasCompletedInitialScroll, - !timelineEntries.isEmpty, - !isNearTimelineBottom { + !timelineEntries.isEmpty, + !isNearTimelineBottom + { jumpToLatestButton(using: proxy) .padding(.trailing, 14) .padding(.bottom, 12) - .transition(.scale(scale: 0.82).combined(with: .opacity)) + .transition( + .scale(scale: 0.82).combined(with: .opacity) + ) .zIndex(10) } } @@ -439,27 +871,27 @@ struct ChatView: View { @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 - ) + #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 + .accessibilityHidden(true) + .id(Self.bottomAnchorID) + #endif } private var selectionActionBar: some View { @@ -495,7 +927,8 @@ struct ChatView: View { } if !selectedTimelineMessages.isEmpty, - selectedTimelineMessages.allSatisfy(\.canBeRetracted) { + selectedTimelineMessages.allSatisfy(\.canBeRetracted) + { Button(role: .destructive) { destructiveAction = .init( kind: .retract, @@ -521,12 +954,6 @@ struct ChatView: View { 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 { @@ -548,46 +975,49 @@ struct ChatView: View { } .padding(.top, 6) .background(Color.clear) - .animation(.easeInOut(duration: 0.18), value: isCapturePresentationVisible) + .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() } + VStack { + if let editingMessage { + editingBanner(editingMessage) + } else if let replyingToMessage { + replyBanner(replyingToMessage) + } + HStack(alignment: .center, 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) + Button { + emojiPickerPresentation = .composer + } label: { + Image(systemName: "face.smiling") + .font(.system(size: 20, weight: .regular)) + .foregroundStyle(.secondary) + .padding(.bottom, 1) + } + .buttonStyle(.plain) + .accessibilityLabel("Эмодзи") + } } .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)) + .glassEffect(in: .rect(cornerRadius: 21)) } @ViewBuilder @@ -596,18 +1026,18 @@ struct ChatView: View { 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) + .foregroundStyle( + canUseComposer ? Color.accentColor : Color.secondary ) - .shadow(color: .black.opacity(0.075), radius: 8, y: 2) + .frame(width: 42, height: 42) + // .background(.ultraThinMaterial, in: Circle()) } .buttonStyle(.plain) .disabled(!canUseComposer) - .accessibilityLabel(editingMessageID == nil ? "Отправить" : "Сохранить исправление") + .accessibilityLabel( + editingMessageID == nil ? "Отправить" : "Сохранить исправление" + ) + .glassEffect() } else { TelegramRecordButton( mode: .voice, @@ -626,7 +1056,16 @@ struct ChatView: View { .overlay(alignment: .top) { if isCapturePresentationVisible, !captureIsLocked { recordingLockIndicator - .offset(y: -74 + min(12, max(-14, captureDragTranslation.height * 0.12))) + .offset( + y: -74 + + min( + 12, + max( + -14, + captureDragTranslation.height * 0.12 + ) + ) + ) .transition(.scale.combined(with: .opacity)) } } @@ -635,21 +1074,26 @@ struct ChatView: View { private var attachmentButton: some View { Menu { -#if os(iOS) - Button { - presentCamera() - } label: { - Label("Камера", systemImage: "camera.fill") - } - .disabled(!SystemPhotoCameraView.isAvailable) -#endif + #if os(iOS) + Button { + presentCamera() + } label: { + Label("Камера", systemImage: "camera.fill") + } + .disabled(!SystemPhotoCameraView.isAvailable) + #endif Button { dismissKeyboard() showingMediaPicker = true } label: { - Label("Фото или видео (несколько)", systemImage: "photo.on.rectangle") + Label( + "Фото или видео (несколько)", + systemImage: "photo.on.rectangle" + ) } - Button { presentFileImporter(.audio) } label: { + Button { + presentFileImporter(.audio) + } label: { Label("Музыка", systemImage: "music.note") } Button { @@ -665,17 +1109,13 @@ struct ChatView: View { Label("Отправить геопозицию", systemImage: "location.fill") } Divider() - Button { presentFileImporter(.files) } label: { + 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) @@ -686,28 +1126,31 @@ struct ChatView: View { } } .frame(width: 42, height: 42) - .shadow(color: .black.opacity(0.075), radius: 8, y: 2) .contentShape(Circle()) + .glassEffect() } .buttonStyle(.plain) .accessibilityLabel("Добавить вложение") - .help("Открыть камеру или добавить фото, видео, файлы, музыку и геопозицию") + .help( + "Открыть камеру или добавить фото, видео, файлы, музыку и геопозицию" + ) } private var editingMessage: ChatMessage? { guard let editingMessageID else { return nil } - return model.selectedMessages.first { $0.id == editingMessageID } + return model.selectedMessages.first { $0.clientID == editingMessageID } } private var replyingToMessage: ChatMessage? { guard let replyingToMessageID else { return nil } return model.selectedMessages.first { - $0.id == replyingToMessageID && $0.canBeRepliedTo + $0.clientID == replyingToMessageID && $0.canBeRepliedTo } } private var requiresOMEMO: Bool { - editingMessage?.security == .omemo || (editingMessageID == nil && encryptionEnabled) + editingMessage?.security == .omemo + || (editingMessageID == nil && encryptionEnabled) } private var encryptionEnabled: Bool { @@ -716,28 +1159,17 @@ struct ChatView: View { private var encryptionIcon: String { if !encryptionEnabled { return "lock.open" } - return model.isOMEMOReady ? "lock.fill" : "lock.trianglebadge.exclamationmark" + return model.isOMEMOReady + ? "lock.fill" : "lock.trianglebadge.exclamationmark" } private var liveConversation: Conversation { - model.conversations.first(where: { $0.id == conversation.id }) ?? conversation + model.conversations.first(where: { $0.jid == conversation.jid }) + ?? 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) + ChatNavigationTitle(model: model, conversation: conversation) } private var canSendToConversation: Bool { @@ -746,7 +1178,8 @@ struct ChatView: View { private var emptyChatTitle: String { if conversation.isGroup { return "Групповая комната" } - return encryptionEnabled ? "Новый защищённый чат" : "Новый чат без шифрования" + return encryptionEnabled + ? "Новый защищённый чат" : "Новый чат без шифрования" } private var emptyChatDescription: String { @@ -762,11 +1195,17 @@ struct ChatView: View { private var groupJoinBanner: some View { HStack(spacing: 10) { - Image(systemName: liveConversation.invitedBy == nil ? "person.3" : "envelope.badge") - .foregroundStyle(.tint) + 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 == nil + ? "Комната не подключена" : "Вас пригласили в комнату" + ) + .font(.subheadline.weight(.semibold)) Text(liveConversation.invitedBy ?? conversation.jid) .font(.caption) .foregroundStyle(.secondary) @@ -813,7 +1252,9 @@ struct ChatView: View { } private var recordingCancelHintOpacity: Double { - ComposerRecordingGesturePolicy.cancelHintOpacity(for: captureDragTranslation) + ComposerRecordingGesturePolicy.cancelHintOpacity( + for: captureDragTranslation + ) } private var recordingStatusStrip: some View { @@ -847,11 +1288,7 @@ struct ChatView: View { } .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) - ) + .glassEffect() } private var recordingLockIndicator: some View { @@ -863,8 +1300,7 @@ struct ChatView: View { } .foregroundStyle(.primary) .frame(width: 38, height: 55) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().stroke(Color.secondary.opacity(0.22), lineWidth: 0.75)) + .glassEffect(in: Capsule()) .accessibilityLabel("Проведите вверх, чтобы зафиксировать запись") } @@ -876,9 +1312,10 @@ struct ChatView: View { private func beginCaptureGesture() { guard canUseComposer, - draftIsEmpty, - editingMessageID == nil, - !isCapturePresentationVisible else { return } + draftIsEmpty, + editingMessageID == nil, + !isCapturePresentationVisible + else { return } dismissKeyboard() model.audioPlayback.stop() @@ -896,8 +1333,9 @@ struct ChatView: View { do { try await audioRecorder.start() guard !Task.isCancelled, - captureAttemptID == attemptID, - captureGestureIsActive || captureIsLocked else { + captureAttemptID == attemptID, + captureGestureIsActive || captureIsLocked + else { audioRecorder.cancel() return } @@ -1008,30 +1446,33 @@ struct ChatView: View { private func dismissKeyboard() { isComposerFocused = false -#if os(iOS) - UIApplication.shared.sendAction( - #selector(UIResponder.resignFirstResponder), - to: nil, - from: nil, - for: nil - ) -#endif + #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 + #if os(iOS) + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + #endif } private func sendDraft() { let outgoing = draft guard canSendToConversation, - !outgoing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + !outgoing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return } draft = "" if let editingMessageID { self.editingMessageID = nil - Task { await model.editMessage(id: editingMessageID, newBody: outgoing) } + Task { + await model.editMessage(id: editingMessageID, newBody: outgoing) + } } else { let replyTarget = replyingToMessage replyingToMessageID = nil @@ -1045,11 +1486,12 @@ struct ChatView: View { } } + @discardableResult private func stageImportedFiles( _ urls: [URL], preferredKind: ChatMessage.Kind? = nil - ) async { - guard !urls.isEmpty else { return } + ) async -> Bool { + guard !urls.isEmpty else { return false } isPreparingAttachments = true defer { isPreparingAttachments = false } let prepared = await model.prepareAttachmentDrafts( @@ -1057,10 +1499,11 @@ struct ChatView: View { preferredKind: preferredKind, for: conversation.jid ) - guard !prepared.isEmpty else { return } + guard !prepared.isEmpty else { return false } model.discardAttachmentDrafts(attachmentDrafts) attachmentDrafts = prepared presentAttachmentPreviewAfterPickerDismissal() + return true } private func stageCapturedMedia(_ media: CapturedCameraMedia) async { @@ -1069,27 +1512,72 @@ struct ChatView: View { photoCameraIsPreparingResult = false releaseArchiveSyncAfterCapture() } - await stageImportedFiles([media.url], preferredKind: media.kind) + let logger = Logger(subsystem: "Luma", category: "video-preview") + await waitForCameraFileSettling(at: media.url, logger: logger) + let staged = await stageImportedFiles( + [media.url], + preferredKind: media.kind + ) + if staged { + if let draft = attachmentDrafts.last { + logger.info( + "camera draft staged: kind=\(draft.kind.rawValue) mime=\(draft.mimeType) size=\(draft.byteCount) duration=\(String(describing: draft.duration)) thumbnailBytes=\(draft.thumbnailData?.count ?? 0)" + ) + } + } else { + logger.error("camera staging produced no drafts") + if model.errorMessage == nil { + model.errorMessage = + "Камера вернула файл, который не удалось подготовить к отправке." + } + } + } + + /// The picker can hand over a movie whose final bytes are still being + /// flushed. Wait until the file size stops changing so staging never + /// copies a truncated file. + private func waitForCameraFileSettling(at url: URL, logger: Logger) async { + guard + let initial = try? url.resourceValues(forKeys: [.fileSizeKey]) + .fileSize + else { + return + } + var previous = initial + for _ in 0..<10 { + try? await Task.sleep(nanoseconds: 250_000_000) + guard + let current = try? url.resourceValues(forKeys: [.fileSizeKey]) + .fileSize + else { + return + } + if current == previous { + return + } + previous = current + } + logger.warning("camera file size kept changing; staging anyway") } 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 + #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() { @@ -1141,8 +1629,9 @@ struct ChatView: View { } private func presentReplyThread(rootID: String, selectedReplyID: String) { - guard model.message(withID: rootID, in: conversation.id) != nil else { - model.errorMessage = "Исходное сообщение ещё не загружено в локальную историю." + guard model.message(withID: rootID, in: conversation.jid) != nil else { + model.errorMessage = + "Исходное сообщение ещё не загружено в локальную историю." return } withAnimation(.easeOut(duration: 0.2)) { @@ -1163,7 +1652,7 @@ struct ChatView: View { for root: ChatMessage, selectedReplyID: String ) -> [ChatMessage] { - var sourceIdentifiers: Set = [root.id] + var sourceIdentifiers: Set = [root.clientID] if let stanzaID = root.stanzaID { sourceIdentifiers.insert(stanzaID) } if let replyIdentifier = root.replyIdentifier { sourceIdentifiers.insert(replyIdentifier) @@ -1173,12 +1662,18 @@ struct ChatView: View { 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) { + if !replies.contains(where: { $0.clientID == selectedReplyID }), + let selected = model.message( + withID: selectedReplyID, + in: conversation.jid + ) + { replies.append(selected) } return replies.sorted { lhs, rhs in - if lhs.timestamp == rhs.timestamp { return lhs.id < rhs.id } + if lhs.timestamp == rhs.timestamp { + return lhs.clientID < rhs.clientID + } return lhs.timestamp < rhs.timestamp } } @@ -1189,7 +1684,6 @@ struct ChatView: View { var sourceURLs: [URL] = [] var failedItemCount = 0 defer { - pickedMediaItems = [] isPreparingAttachments = false sourceURLs.forEach(discardPickedMediaFile) } @@ -1211,7 +1705,8 @@ struct ChatView: View { } guard !sourceURLs.isEmpty else { - model.errorMessage = MediaPickerError.cannotReadSelection.errorDescription + model.errorMessage = + MediaPickerError.cannotReadSelection.errorDescription return } failedItemCount += max(0, sourceURLs.count - prepared.count) @@ -1220,7 +1715,8 @@ struct ChatView: View { attachmentDrafts = prepared presentAttachmentPreviewAfterPickerDismissal() if failedItemCount > 0 { - model.errorMessage = "Не удалось подготовить \(failedItemCount) из \(items.prefix(20).count) выбранных фото или видео. Остальные готовы к отправке." + model.errorMessage = + "Не удалось подготовить \(failedItemCount) из \(items.prefix(20).count) выбранных фото или видео. Остальные готовы к отправке." } } @@ -1246,13 +1742,15 @@ struct ChatView: View { for _ in 0..<120 { guard !Task.isCancelled, - attachmentPreviewPresentationPending, - !attachmentDrafts.isEmpty else { return } + attachmentPreviewPresentationPending, + !attachmentDrafts.isEmpty + else { return } if !showingMediaPicker, - !showingPhotoCamera, - !showingFileImporter, - !isPreparingAttachments, - !showingAttachmentPreview { + !showingPhotoCamera, + !showingFileImporter, + !isPreparingAttachments, + !showingAttachmentPreview + { attachmentPreviewPresentationPending = false showingAttachmentPreview = true attachmentPreviewPresentationTask = nil @@ -1268,7 +1766,9 @@ struct ChatView: View { } } - private func loadPickedMediaFile(_ item: PhotosPickerItem) async throws -> URL { + private func loadPickedMediaFile(_ item: PhotosPickerItem) async throws + -> URL + { let supportsImage = item.supportedContentTypes.contains { $0.conforms(to: .image) } @@ -1299,12 +1799,16 @@ struct ChatView: View { } private func loadPickedPhoto(_ item: PhotosPickerItem) async throws -> URL { - let fileRepresentation = try? await item.loadTransferable(type: PickedPhoto.self) + 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) + let dataRepresentation = try await item.loadTransferable( + type: Data.self + ) guard let data = dataRepresentation, !data.isEmpty else { throw MediaPickerError.cannotReadSelection } @@ -1314,7 +1818,9 @@ struct ChatView: View { } private func loadPickedVideo(_ item: PhotosPickerItem) async throws -> URL { - guard let video = try await item.loadTransferable(type: PickedVideo.self) else { + guard + let video = try await item.loadTransferable(type: PickedVideo.self) + else { throw MediaPickerError.cannotReadSelection } return video.url @@ -1336,24 +1842,36 @@ struct ChatView: View { guard message.canBeEdited else { return } cancelComposerRecording(feedback: false) replyingToMessageID = nil - editingMessageID = message.id + editingMessageID = message.clientID draft = message.body isComposerFocused = true } + private func handleEmojiSelection( + _ emoji: String, + for presentation: EmojiPickerPresentation + ) { + switch presentation { + case .reaction(let message): + Task { await model.toggleReaction(emoji, on: message) } + case .composer: + draft.append(emoji) + } + } + private func beginMessageSelection(with message: ChatMessage) { cancelComposerRecording(feedback: false) dismissKeyboard() if editingMessageID != nil { cancelEditing() } cancelReplying() - selectedMessageIDs.insert(message.id) + selectedMessageIDs.insert(message.clientID) } private func toggleMessageSelection(_ message: ChatMessage) { - if selectedMessageIDs.contains(message.id) { - selectedMessageIDs.remove(message.id) + if selectedMessageIDs.contains(message.clientID) { + selectedMessageIDs.remove(message.clientID) } else { - selectedMessageIDs.insert(message.id) + selectedMessageIDs.insert(message.clientID) } } @@ -1374,7 +1892,7 @@ struct ChatView: View { editingMessageID = nil draft = "" } - replyingToMessageID = message.id + replyingToMessageID = message.clientID isComposerFocused = true } @@ -1392,8 +1910,6 @@ struct ChatView: View { Capsule() .fill(Color.accentColor) .frame(width: 3, height: 34) - Image(systemName: "pencil") - .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 1) { Text("Редактирование") .font(.caption.weight(.semibold)) @@ -1405,45 +1921,49 @@ struct ChatView: View { } Spacer() Button(action: cancelEditing) { - Image(systemName: "xmark.circle.fill") + Image(systemName: "xmark") .foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Отменить редактирование") } - .padding(.horizontal, 14) - .padding(.top, 8) } + /// Telegram-style reply plate: a rounded card above the composer with + /// the reply header, a close button and the quoted message preview. private func replyBanner(_ message: ChatMessage) -> some View { - HStack(spacing: 10) { + HStack(alignment: .center) { Capsule() .fill(Color.accentColor) - .frame(width: 3, height: 36) - VStack(alignment: .leading, spacing: 1) { - Text("Ответ: \(message.senderDisplayName ?? model.displayName(for: message.senderJID))") + .frame(width: 3, height: 34) + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + Text( + "Ответ: \(message.senderDisplayName ?? model.displayName(for: message.senderJID))" + ) .font(.caption.weight(.semibold)) .foregroundStyle(.tint) + .lineLimit(1) + } Text(message.quotePreview) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(.primary.opacity(0.8)) .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) } Spacer() Button(action: cancelReplying) { - Image(systemName: "xmark.circle.fill") + Image(systemName: "xmark") .foregroundStyle(.secondary) } .buttonStyle(.plain) .accessibilityLabel("Отменить ответ") } - .padding(.horizontal, 14) - .padding(.top, 8) - .padding(.bottom, 3) + .accessibilityIdentifier("reply-banner") } private func perform(_ action: MessageDestructiveAction) { - let ids = Set(action.messages.map(\.id)) + let ids = Set(action.messages.map(\.clientID)) if let editingMessageID, ids.contains(editingMessageID) { cancelEditing() } @@ -1453,11 +1973,11 @@ struct ChatView: View { selectedMessageIDs.subtract(ids) switch action.kind { case .localDelete: - model.deleteMessagesLocally(ids: ids, in: conversation.id) + model.deleteMessagesLocally(ids: ids, in: conversation.jid) case .retract: Task { for message in action.messages where message.canBeRetracted { - await model.retractMessage(id: message.id) + await model.retractMessage(id: message.clientID) } } } @@ -1481,15 +2001,10 @@ struct ChatView: View { .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()) + .glassEffect() .accessibilityLabel("Перейти к последнему сообщению") .help("Перейти в конец истории") } @@ -1504,9 +2019,60 @@ struct ChatView: View { } } - private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy) async { - guard !hasCompletedInitialScroll, - !model.selectedMessages.isEmpty else { return } + /// Starts one interactive older-history page load from the top sentinel. + /// Remembers the first visible entry so the viewport can be restored when + /// the page arrives. + private func triggerOlderHistoryLoad() { + historyTopTriggerArmed = false + historyLoadAnchorID = timelineEntries.first?.id + historyLoadEntryCount = timelineEntries.count + if hasMoreLocalHistory { + visibleLocalMessageCount = min( + messages.count, + visibleLocalMessageCount + Self.localHistoryPageSize + ) + localHistoryExhausted = visibleLocalMessageCount >= messages.count + rebuildTimelineEntries(from: messages) + historyTopTriggerArmed = true + return + } + model.loadOlderHistoryForSelectedConversation() + } + + /// A finished load left the timeline empty. Retry a bounded number of + /// times with a short delay: the first page of an empty chat can fail + /// transiently (timeout, reconnect), and an empty timeline has no scroll + /// gesture that could retry it manually. + private func handleEmptyHistoryLoadCompleted() { + guard model.hasMoreOlderHistory, + !model.isLoadingOlderHistory, + emptyHistoryRetryCount < 3 + else { return } + emptyHistoryRetryCount += 1 + Task { @MainActor [weak model] in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled, + timelineEntries.isEmpty, + model?.hasMoreOlderHistory == true, + model?.isLoadingOlderHistory == false + else { return } + model?.loadOlderHistoryForSelectedConversation() + } + } + + private func performInitialScrollAfterLayout(using proxy: ScrollViewProxy) + async + { + guard !hasCompletedInitialScroll else { return } + guard !model.selectedMessages.isEmpty else { + // Empty conversation: there is nothing to scroll to, so the + // scroll-to-top gesture can never fire. Load the first page of + // history directly so a newly-created or emptied chat still fills. + if model.hasMoreOlderHistory, !model.isLoadingOlderHistory { + model.loadOlderHistoryForSelectedConversation() + } + return + } // ScrollViewReader can receive its first command before a long // LazyVStack has published the bottom anchor. Yield once for layout, @@ -1549,144 +2115,151 @@ private struct TimelineBottomYPreferenceKey: PreferenceKey { } #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 + /// 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 + init(identity: String, onNearBottomChange: @escaping (Bool) -> Void) { + self.identity = identity + self.onNearBottomChange = onNearBottomChange } - required init?(coder: NSCoder) { - nil + func makeUIView(context: Context) -> ObserverView { + let view = ObserverView(frame: .zero) + view.identity = identity + view.onNearBottomChange = onNearBottomChange + return view } - override func didMoveToWindow() { - super.didMoveToWindow() - attachToAncestorScrollViewIfNeeded() + func updateUIView(_ uiView: ObserverView, context: Context) { + uiView.onNearBottomChange = onNearBottomChange + if uiView.identity != identity { + uiView.identity = identity + uiView.resetReportedState() + } + uiView.attachToAncestorScrollViewIfNeeded() } - override func didMoveToSuperview() { - super.didMoveToSuperview() - attachToAncestorScrollViewIfNeeded() + static func dismantleUIView(_ uiView: ObserverView, coordinator: ()) { + uiView.detach() } - 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 + 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) } - guard let scrollView = ancestor as? UIScrollView else { - self.detach() - return + } + + 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() } - self.observe(scrollView) + } + + 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) } } - - 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 { @@ -1703,7 +2276,9 @@ struct ComposerRecordingGesturePolicy { static let longPressDelayNanoseconds: UInt64 = 240_000_000 static let minimumLongPressDuration: TimeInterval = 0.22 - static func resolution(for translation: CGSize) -> ComposerRecordingGestureResolution { + 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 { @@ -1717,7 +2292,10 @@ struct ComposerRecordingGesturePolicy { static func cancelHintOpacity(for translation: CGSize) -> Double { let leftwardDistance = max(CGFloat.zero, -translation.width) - let fadeAmount = min(maximumCancelHintFade, leftwardDistance / cancelHintFadeDistance) + let fadeAmount = min( + maximumCancelHintFade, + leftwardDistance / cancelHintFadeDistance + ) return Double(CGFloat(1) - fadeAmount) } @@ -1762,22 +2340,6 @@ private struct TelegramRecordButton: View { 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) @@ -1791,34 +2353,35 @@ private struct TelegramRecordButton: View { } .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 + .glassEffect() + #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( + .spring(response: 0.24, dampingFraction: 0.78), + value: isRecording + ) .animation(.easeOut(duration: 0.12), value: isPressing) .accessibilityLabel(accessibilityLabel) - .accessibilityHint("Во время записи проведите влево для отмены или вверх для фиксации") + .accessibilityHint( + "Во время записи проведите влево для отмены или вверх для фиксации" + ) .onDisappear { longPressTask?.cancel() longPressTask = nil @@ -1832,7 +2395,8 @@ private struct TelegramRecordButton: View { private var accessibilityLabel: String { if isLocked { return "Отправить записанное сообщение" } - return isRecording ? mode.recordingAccessibilityLabel : mode.idleAccessibilityLabel + return isRecording + ? mode.recordingAccessibilityLabel : mode.idleAccessibilityLabel } private var recordGesture: some Gesture { @@ -1873,9 +2437,10 @@ private struct TelegramRecordButton: View { longPressTask?.cancel() longPressTask = nil let wasLongPress = didBeginLongPress - let elapsed = pressStartedAt.map { - eventTime.timeIntervalSince($0) - } ?? 0 + let elapsed = + pressStartedAt.map { + eventTime.timeIntervalSince($0) + } ?? 0 isPressing = false didBeginLongPress = false pressStartedAt = nil @@ -1912,7 +2477,8 @@ private struct TelegramRecordButton: View { longPressTask = Task { @MainActor in do { try await Task.sleep( - nanoseconds: ComposerRecordingGesturePolicy.longPressDelayNanoseconds + nanoseconds: ComposerRecordingGesturePolicy + .longPressDelayNanoseconds ) } catch { return @@ -1925,102 +2491,116 @@ private struct TelegramRecordButton: View { } #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 + /// 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 + func makeUIView(context: Context) -> TouchView { + let view = TouchView(frame: .zero) + update(view) + return view } - required init?(coder: NSCoder) { - nil + func updateUIView(_ uiView: TouchView, context: Context) { + update(uiView) } - 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)) + private func update(_ view: TouchView) { + view.onBegan = onBegan + view.onMoved = onMoved + view.onEnded = onEnded + view.onCancelled = onCancelled } - override func touchesMoved(_ touches: Set, with event: UIEvent?) { - guard let touch = trackedTouch(in: touches) else { return } - onMoved?(translation(for: touch)) - } + final class TouchView: UIView { + var onBegan: ((Date) -> Void)? + var onMoved: ((CGSize) -> Void)? + var onEnded: ((CGSize, Date) -> Void)? + var onCancelled: (() -> Void)? - 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) - } + private var activeTouch: UITouch? + private var origin = CGPoint.zero - override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - guard trackedTouch(in: touches) != nil else { return } - activeTouch = nil - onCancelled?() - } + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + isOpaque = false + isExclusiveTouch = true + isMultipleTouchEnabled = false + isAccessibilityElement = false + } - private func trackedTouch(in touches: Set) -> UITouch? { - guard let activeTouch else { return nil } - return touches.first(where: { $0 === activeTouch }) - } + required init?(coder: NSCoder) { + nil + } - 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 - ) - } + 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)) + } - private func eventDate(for touch: UITouch) -> Date { - Date().addingTimeInterval( - touch.timestamp - ProcessInfo.processInfo.systemUptime - ) + 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 { @@ -2070,7 +2650,9 @@ private struct MessageDestructiveAction: Identifiable { let messages: [ChatMessage] var id: String { - let messageIDs = messages.map(\.id).sorted().joined(separator: "-") + let messageIDs = messages.map(\.clientID).sorted().joined( + separator: "-" + ) return "\(kind == .localDelete ? "local" : "retract")-\(messageIDs)" } @@ -2078,9 +2660,13 @@ private struct MessageDestructiveAction: Identifiable { let plural = messages.count > 1 switch kind { case .localDelete: - return plural ? "Удалить выбранные сообщения у себя?" : "Удалить сообщение у себя?" + return plural + ? "Удалить выбранные сообщения у себя?" + : "Удалить сообщение у себя?" case .retract: - return plural ? "Удалить выбранные сообщения у всех?" : "Удалить сообщение у всех?" + return plural + ? "Удалить выбранные сообщения у всех?" + : "Удалить сообщение у всех?" } } @@ -2091,7 +2677,8 @@ private struct MessageDestructiveAction: Identifiable { ? "\(messageCountText) исчезнут только из истории Luma на этом устройстве." : "Сообщение исчезнет только из истории Luma на этом устройстве." case .retract: - return "Luma отправит XMPP-запрос retract для \(messageCountText). Сервер и другие клиенты могут не удалить уже полученные копии." + return + "Luma отправит XMPP-запрос retract для \(messageCountText). Сервер и другие клиенты могут не удалить уже полученные копии." } } @@ -2124,7 +2711,12 @@ private struct PickedPhoto: Transferable { FileRepresentation(contentType: .image) { photo in SentTransferredFile(photo.url) } importing: { received in - PickedPhoto(url: try copyPickedMediaFile(received.file, fallbackExtension: "jpg")) + PickedPhoto( + url: try copyPickedMediaFile( + received.file, + fallbackExtension: "jpg" + ) + ) } } } @@ -2136,24 +2728,35 @@ private struct PickedVideo: Transferable { FileRepresentation(contentType: .movie) { video in SentTransferredFile(video.url) } importing: { received in - PickedVideo(url: try copyPickedMediaFile(received.file, fallbackExtension: "mov")) + PickedVideo( + url: try copyPickedMediaFile( + received.file, + fallbackExtension: "mov" + ) + ) } } } -private func copyPickedMediaFile(_ source: URL, fallbackExtension: String) throws -> URL { +private func copyPickedMediaFile(_ source: URL, fallbackExtension: String) + throws -> URL +{ let fileManager = FileManager.default let rootDirectory = fileManager.temporaryDirectory .appendingPathComponent("LumaPhotoPicker", isDirectory: true) - let selectionDirectory = rootDirectory + 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 + 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 @@ -2164,7 +2767,9 @@ private func copyPickedMediaFile(_ source: URL, fallbackExtension: String) throw } let forbidden = CharacterSet(charactersIn: "/\\:") .union(.controlCharacters) - filename = filename.components(separatedBy: forbidden).joined(separator: "_") + filename = filename.components(separatedBy: forbidden).joined( + separator: "_" + ) let destination = selectionDirectory.appendingPathComponent(filename) let accessed = source.startAccessingSecurityScopedResource() defer { @@ -2185,7 +2790,8 @@ private func copyPickedMediaFile(_ source: URL, fallbackExtension: String) throw forKeys: [.fileSizeKey, .isRegularFileKey] ) guard stagedValues.isRegularFile == true, - fileManager.isReadableFile(atPath: destination.path) else { + fileManager.isReadableFile(atPath: destination.path) + else { throw MediaFileIOError.unreadableFile(filename) } guard (stagedValues.fileSize ?? 0) > 0 else { @@ -2213,7 +2819,8 @@ private func writePickedImageData(_ data: Data) throws -> URL { } let contentType: UTType? if let imageSource = CGImageSourceCreateWithData(data as CFData, nil), - let typeIdentifier = CGImageSourceGetType(imageSource) { + let typeIdentifier = CGImageSourceGetType(imageSource) + { contentType = UTType(typeIdentifier as String) } else { contentType = nil @@ -2221,7 +2828,8 @@ private func writePickedImageData(_ data: Data) throws -> URL { let fileExtension = contentType?.preferredFilenameExtension ?? "jpg" let rootDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("LumaPhotoPicker", isDirectory: true) - let selectionDirectory = rootDirectory + let selectionDirectory = + rootDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( at: selectionDirectory, @@ -2238,3 +2846,73 @@ private func writePickedImageData(_ data: Data) throws -> URL { throw error } } + +#Preview { + PreviewSupport.chatPreview() +} + +/// Extracted chat navigation title: avatar, display name and the transient +/// «печатает…» line. Kept as its own view so its layout can be previewed on +/// its own without opening a full chat. +private struct ChatNavigationTitle: View { + @ObservedObject var model: AppModel + let conversation: Conversation + + private var liveConversation: Conversation { + model.conversations.first(where: { $0.jid == conversation.jid }) + ?? conversation + } + + var body: some View { + Button { + } label: { + titleContent + } + .buttonStyle(.plain) + } + + private var titleContent: some View { + HStack { + AvatarView( + conversation: liveConversation, + imageData: model.avatarData(for: conversation.jid), + size: 25 + ) + .padding(4) + 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) + } + } + .padding(.top, 5) + .padding(.bottom, 5) + .padding(.trailing, 15) + .animation( + .easeInOut(duration: 0.16), + value: model.typingText(for: liveConversation) + ) + .accessibilityElement(children: .combine) + } + #if os(iOS) + .glassEffect() + #endif + } +} + +#Preview("Заголовок чата") { + ChatNavigationTitle( + model: PreviewSupport.model, + conversation: PreviewSupport.conversation( + displayName: "Алиса", + isOnline: true + ) + ) + .padding() +} diff --git a/Sources/Shared/UI/Components/AudioMessagePlayer.swift b/Sources/Shared/UI/Components/AudioMessagePlayer.swift index efe6331..c4cb216 100644 --- a/Sources/Shared/UI/Components/AudioMessagePlayer.swift +++ b/Sources/Shared/UI/Components/AudioMessagePlayer.swift @@ -53,6 +53,16 @@ struct AudioMessagePlayer: View { .font(.caption2.monospacedDigit()) .foregroundStyle(secondaryText) Spacer() + Button(action: saveToFiles) { + Image(systemName: "square.and.arrow.down") + .font(.caption.weight(.bold)) + .foregroundStyle(accent) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(accent.opacity(0.14), in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Сохранить в Файлы") Button(action: playback.cycleVoiceRate) { Text(rateText) .font(.caption2.monospacedDigit().weight(.bold)) @@ -95,6 +105,13 @@ struct AudioMessagePlayer: View { countStyle: .file )) } + Spacer() + Button(action: saveToFiles) { + Image(systemName: "square.and.arrow.down") + .foregroundStyle(accent) + } + .buttonStyle(.plain) + .accessibilityLabel("Сохранить в Файлы") } .font(.caption2.monospacedDigit()) .foregroundStyle(secondaryText) @@ -129,7 +146,7 @@ struct AudioMessagePlayer: View { } private var isCurrent: Bool { - playback.currentMessageID == message.id + playback.currentMessageID == message.clientID } private var isPlaying: Bool { @@ -185,6 +202,10 @@ struct AudioMessagePlayer: View { message.direction == .outgoing ? .white.opacity(0.78) : .secondary } + private func saveToFiles() { + Task { await MediaDownloadService.save(message, model: model) } + } + private func togglePlayback() { guard let localURL else { Task { @@ -194,7 +215,7 @@ struct AudioMessagePlayer: View { return } playback.toggle( - messageID: message.id, + messageID: message.clientID, url: downloadedURL, durationHint: message.duration, isVoice: message.kind == .voice @@ -203,7 +224,7 @@ struct AudioMessagePlayer: View { return } playback.toggle( - messageID: message.id, + messageID: message.clientID, url: localURL, durationHint: message.duration, isVoice: message.kind == .voice @@ -213,7 +234,7 @@ struct AudioMessagePlayer: View { private func seek(_ fraction: Double) { guard let localURL else { return } playback.seek( - messageID: message.id, + messageID: message.clientID, url: localURL, durationHint: message.duration, isVoice: message.kind == .voice, @@ -305,3 +326,28 @@ enum MediaTimeFormatter { return String(format: "%d:%02d", seconds / 60, seconds % 60) } } + +#Preview("Голосовое") { + AudioMessagePlayer( + model: PreviewSupport.model, + message: PreviewSupport.voiceMessage(), + onFallbackTap: {} + ) + .padding() +} + +#Preview("Аудиофайл") { + AudioMessagePlayer( + model: PreviewSupport.model, + message: PreviewSupport.message( + body: "", + kind: .audio, + localFilename: "track.mp3", + mimeType: "audio/mpeg", + duration: 214, + byteCount: 8_550_000 + ), + onFallbackTap: {} + ) + .padding() +} diff --git a/Sources/Shared/UI/Components/AvatarView.swift b/Sources/Shared/UI/Components/AvatarView.swift index 7e716a8..c7ebaac 100644 --- a/Sources/Shared/UI/Components/AvatarView.swift +++ b/Sources/Shared/UI/Components/AvatarView.swift @@ -90,3 +90,21 @@ struct AvatarView: View { ) } } + +#Preview("Контакт") { + AvatarView( + conversation: PreviewSupport.conversation(displayName: "Алиса", isOnline: true) + ) +} + +#Preview("Группа") { + AvatarView( + conversation: PreviewSupport.conversation( + jid: "team@conference.example.org", + displayName: "Команда Luma", + kind: .group, + isGroupJoined: true, + occupantCount: 7 + ) + ) +} diff --git a/Sources/Shared/UI/Components/BubbleShapes.swift b/Sources/Shared/UI/Components/BubbleShapes.swift index c2a23c8..9a2bbb3 100644 --- a/Sources/Shared/UI/Components/BubbleShapes.swift +++ b/Sources/Shared/UI/Components/BubbleShapes.swift @@ -50,3 +50,27 @@ struct ReplyThreadBracket: Shape { return path } } + +#Preview("Кривая ответа") { + VStack(spacing: 24) { + ReplyThreadCurve(sourceDirection: .incoming) + .stroke(Color.blue, lineWidth: 2) + .frame(width: 90, height: 26) + ReplyThreadCurve(sourceDirection: .outgoing) + .stroke(Color.blue, lineWidth: 2) + .frame(width: 90, height: 26) + } + .padding() +} + +#Preview("Скобка ветки") { + HStack(spacing: 40) { + ReplyThreadBracket(opensToTrailing: true) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .frame(width: 38, height: 260) + ReplyThreadBracket(opensToTrailing: false) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .frame(width: 38, height: 260) + } + .padding() +} diff --git a/Sources/Shared/UI/Components/ChatBubbleShape.swift b/Sources/Shared/UI/Components/ChatBubbleShape.swift new file mode 100644 index 0000000..5afe0b1 --- /dev/null +++ b/Sources/Shared/UI/Components/ChatBubbleShape.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct ChatBubbleShape: Shape { + let isOutgoing: Bool + func path(in rect: CGRect) -> Path { + let width = rect.width + let height = rect.height + var p = Path() + if isOutgoing { + p.move(to: CGPoint(x: 25, y: height)) + p.addLine(to: CGPoint(x: 20, y: height)) + p.addCurve( + to: CGPoint(x: 0, y: height - 20), + control1: CGPoint(x: 8, y: height), + control2: CGPoint(x: 0, y: height - 8) + ) + p.addLine(to: CGPoint(x: 0, y: 20)) + p.addCurve( + to: CGPoint(x: 20, y: 0), + control1: CGPoint(x: 0, y: 8), + control2: CGPoint(x: 8, y: 0) + ) + p.addLine(to: CGPoint(x: width - 21, y: 0)) + p.addCurve( + to: CGPoint(x: width - 4, y: 20), + control1: CGPoint(x: width - 12, y: 0), + control2: CGPoint(x: width - 4, y: 8) + ) + p.addLine(to: CGPoint(x: width - 4, y: height - 11)) + p.addCurve( + to: CGPoint(x: width, y: height), + control1: CGPoint(x: width - 4, y: height - 1), + control2: CGPoint(x: width, y: height) + ) + p.addLine(to: CGPoint(x: width + 0.05, y: height - 0.01)) + p.addCurve( + to: CGPoint(x: width - 11, y: height - 4), + control1: CGPoint(x: width - 4, y: height + 0.5), + control2: CGPoint(x: width - 8, y: height - 1) + ) + p.addCurve( + to: CGPoint(x: width - 25, y: height), + control1: CGPoint(x: width - 16, y: height), + control2: CGPoint(x: width - 20, y: height) + ) + } else { + p.move(to: CGPoint(x: 25, y: height)) + p.addLine(to: CGPoint(x: width - 20, y: height)) + p.addCurve( + to: CGPoint(x: width, y: height - 20), + control1: CGPoint(x: width - 8, y: height), + control2: CGPoint(x: width, y: height - 8) + ) + p.addLine(to: CGPoint(x: width, y: 20)) + p.addCurve( + to: CGPoint(x: width - 20, y: 0), + control1: CGPoint(x: width, y: 8), + control2: CGPoint(x: width - 8, y: 0) + ) + p.addLine(to: CGPoint(x: 21, y: 0)) + p.addCurve( + to: CGPoint(x: 4, y: 20), + control1: CGPoint(x: 12, y: 0), + control2: CGPoint(x: 4, y: 8) + ) + p.addLine(to: CGPoint(x: 4, y: height - 11)) + p.addCurve( + to: CGPoint(x: 0, y: height), + control1: CGPoint(x: 4, y: height - 1), + control2: CGPoint(x: 0, y: height) + ) + p.addLine(to: CGPoint(x: -0.05, y: height - 0.01)) + p.addCurve( + to: CGPoint(x: 11.0, y: height - 4.0), + control1: CGPoint(x: 4.0, y: height + 0.5), + control2: CGPoint(x: 8, y: height - 1) + ) + p.addCurve( + to: CGPoint(x: 25, y: height), + control1: CGPoint(x: 16, y: height), + control2: CGPoint(x: 20, y: height) + ) + } + return p + } +} diff --git a/Sources/Shared/UI/Components/ConnectionBanner.swift b/Sources/Shared/UI/Components/ConnectionBanner.swift index 305ae1b..0685527 100644 --- a/Sources/Shared/UI/Components/ConnectionBanner.swift +++ b/Sources/Shared/UI/Components/ConnectionBanner.swift @@ -2,53 +2,42 @@ import SwiftUI struct ConnectionBanner: View { @ObservedObject var model: AppModel + var text: String 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, + text: "Обновление", + color: .clear, progress: true ) + } else { + Text(text) + .font(.headline) } 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)) + banner(text: "Подключение", color: .clear, progress: true) + case .disconnected: + banner(text: "Ожидание сети", color: .clear, progress: true) } } - private func banner(icon: String, text: String, color: Color, progress: Bool) -> some View { + private func banner(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)) } } +#Preview { + ConnectionBanner(model: PreviewSupport.model, text: "Чаты") + .padding() +} + diff --git a/Sources/Shared/UI/Components/ConversationRow.swift b/Sources/Shared/UI/Components/ConversationRow.swift index bc314e4..d6c1d7b 100644 --- a/Sources/Shared/UI/Components/ConversationRow.swift +++ b/Sources/Shared/UI/Components/ConversationRow.swift @@ -58,3 +58,36 @@ struct ConversationRow: View { .padding(.vertical, 2) } } + +#Preview("Прямой чат") { + ConversationRow( + conversation: PreviewSupport.conversation( + displayName: "Алиса", + lastMessage: "Привет! Как дела?", + lastActivity: Date().addingTimeInterval(-120), + unreadCount: 2, + isOnline: true + ), + imageData: nil, + isEncrypted: true + ) + .padding() +} + +#Preview("Групповой чат") { + ConversationRow( + conversation: PreviewSupport.conversation( + jid: "team@conference.example.org", + displayName: "Команда Luma", + lastMessage: "Иван: созвон в 15:00", + lastActivity: Date().addingTimeInterval(-3_600), + unreadCount: 5, + kind: .group, + isGroupJoined: true, + occupantCount: 7 + ), + imageData: nil, + isEncrypted: true + ) + .padding() +} diff --git a/Sources/Shared/UI/Components/EmojiPickerView.swift b/Sources/Shared/UI/Components/EmojiPickerView.swift new file mode 100644 index 0000000..bba0f89 --- /dev/null +++ b/Sources/Shared/UI/Components/EmojiPickerView.swift @@ -0,0 +1,96 @@ +import SwiftUI + +/// A reusable emoji picker: category tabs on the bottom and a grid of emojis +/// for the selected category. Used both for reactions and for inserting an +/// emoji into the composer. +struct EmojiPickerView: View { + let onSelect: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var selectedCategoryID = EmojiCatalog.categories.first?.id ?? "" + + private let columns = [GridItem(.adaptive(minimum: 44), spacing: 4)] + + private var selectedCategory: EmojiCategory { + EmojiCatalog.categories.first { $0.id == selectedCategoryID } + ?? EmojiCatalog.categories[0] + } + + var body: some View { + VStack(spacing: 0) { + header + + ScrollView { + LazyVGrid(columns: columns, spacing: 6) { + ForEach(selectedCategory.emojis, id: \.self) { emoji in + Button { + onSelect(emoji) + dismiss() + } label: { + Text(emoji) + .font(.system(size: 28)) + .frame(width: 46, height: 46) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(emoji) + } + } + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 12) + } + + Divider() + + categorySelector + .padding(.vertical, 8) + } + .frame(minHeight: 360) +#if os(macOS) + .frame(minWidth: 380) +#endif + } + + private var header: some View { + HStack { + Text("Выберите эмодзи") + .font(.headline) + Spacer() + Button("Готово") { dismiss() } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var categorySelector: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(EmojiCatalog.categories) { category in + let isSelected = category.id == selectedCategoryID + Button { + selectedCategoryID = category.id + } label: { + Image(systemName: category.symbol) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .frame(width: 44, height: 44) + .background( + isSelected + ? Color.accentColor.opacity(0.15) + : Color.clear, + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(category.title) + } + } + .padding(.horizontal, 12) + } + } +} + +#Preview { + EmojiPickerView { _ in } +} diff --git a/Sources/Shared/UI/Components/InlineVideoPlayer.swift b/Sources/Shared/UI/Components/InlineVideoPlayer.swift index 697b92d..2490e76 100644 --- a/Sources/Shared/UI/Components/InlineVideoPlayer.swift +++ b/Sources/Shared/UI/Components/InlineVideoPlayer.swift @@ -71,3 +71,10 @@ struct InlineVideoPlayer: NSViewRepresentable { } } #endif + +#Preview { + InlineVideoPlayer(player: AVPlayer()) + .frame(width: 260, height: 260) + .background(Color.black) + .clipShape(Circle()) +} diff --git a/Sources/Shared/UI/Components/LocationMessagePreview.swift b/Sources/Shared/UI/Components/LocationMessagePreview.swift index d3b1198..c64f979 100644 --- a/Sources/Shared/UI/Components/LocationMessagePreview.swift +++ b/Sources/Shared/UI/Components/LocationMessagePreview.swift @@ -56,8 +56,14 @@ struct LocationMessagePreview: View { } private func openInMaps() { - let item = MKMapItem(placemark: MKPlacemark(coordinate: coordinate)) + let item = MKMapItem(location: CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude), address: nil) item.name = "Геопозиция из Luma" item.openInMaps() } } + +#Preview { + LocationMessagePreview( + location: GeoLocation(latitude: 55.7558, longitude: 37.6173)! + ) +} diff --git a/Sources/Shared/UI/Components/MediaViewer.swift b/Sources/Shared/UI/Components/MediaViewer.swift index 88cf5b3..1a14fa2 100644 --- a/Sources/Shared/UI/Components/MediaViewer.swift +++ b/Sources/Shared/UI/Components/MediaViewer.swift @@ -45,6 +45,7 @@ struct MediaViewer: View { @State private var isTrackingDismissGesture = false @State private var isCompletingDismissal = false @State private var isPhotoZoomed = false + @State private var saveErrorMessage: String? var body: some View { GeometryReader { geometry in @@ -58,13 +59,24 @@ struct MediaViewer: View { .ignoresSafeArea(edges: .bottom) VStack(spacing: 0) { + Spacer() HStack { - Text(item.title) - .font(.headline) - .foregroundStyle(.white) - .lineLimit(1) +// Text(item.title) +// .font(.headline) +// .foregroundStyle(.white) +// .lineLimit(1) Spacer() + + Button(action: save) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(.white.opacity(0.16), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Сохранить") } .padding(.horizontal, 16) .padding(.top, 12) @@ -77,8 +89,6 @@ struct MediaViewer: View { ) .ignoresSafeArea() ) - - Spacer() } } .offset(y: dragOffset) @@ -96,6 +106,11 @@ struct MediaViewer: View { .accessibilityAction(.escape) { closeImmediately() } + .alert("Ошибка сохранения", isPresented: saveErrorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(saveErrorMessage ?? "Не удалось сохранить медиа.") + } } #if os(macOS) .onExitCommand { @@ -183,6 +198,27 @@ struct MediaViewer: View { onClose() } + private var saveErrorBinding: Binding { + Binding( + get: { saveErrorMessage != nil }, + set: { visible in if !visible { saveErrorMessage = nil } } + ) + } + + private func save() { + Task { + do { + try await MediaDownloadService.save( + url: item.url, + kind: item.kind, + filename: item.title + ) + } catch { + saveErrorMessage = error.localizedDescription + } + } + } + private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat { min(1, abs(dragOffset) / max(1, containerHeight * 0.62)) } @@ -361,3 +397,15 @@ private struct ZoomablePhotoView: View { } } } + +#Preview { + MediaViewer( + item: MediaViewerItem( + id: "preview", + url: URL(fileURLWithPath: "/tmp/preview-photo.jpg"), + kind: .photo, + title: "IMG_1234.jpg" + ), + onClose: {} + ) +} diff --git a/Sources/Shared/UI/Components/MessageBubble.swift b/Sources/Shared/UI/Components/MessageBubble.swift index 7207025..3f69a5a 100644 --- a/Sources/Shared/UI/Components/MessageBubble.swift +++ b/Sources/Shared/UI/Components/MessageBubble.swift @@ -1,9 +1,9 @@ import SwiftUI #if os(iOS) -import UIKit + import UIKit #elseif os(macOS) -import AppKit + import AppKit #endif @MainActor @@ -23,9 +23,12 @@ struct MessageBubble: View { let onDelete: () -> Void let onReplyTap: (String) -> Void let onReact: (String) -> Void + let onReactPicker: () -> Void @State private var replySwipeOffset: CGFloat = 0 @State private var replySwipeArmed = false + @State private var replySwipeLocked = false + @State private var scrollOwnsGesture = false var body: some View { ZStack { @@ -34,8 +37,8 @@ struct MessageBubble: View { .offset(x: replySwipeOffset) } .frame(maxWidth: .infinity) - .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear) .contentShape(Rectangle()) + .accessibilityIdentifier("bubble-\(message.clientID)") .onTapGesture { guard isSelectionMode else { return } onToggleSelection() @@ -44,39 +47,59 @@ struct MessageBubble: View { .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 + replySwipeLocked = false + scrollOwnsGesture = false } .contextMenu { if !isSelectionMode { if message.canBeReactedTo { - Menu { - ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in - Button(emoji) { onReact(emoji) } - } - } label: { + Button(action: onReactPicker) { Label("Реакция", systemImage: "face.smiling") } } if message.canBeRepliedTo { Button(action: onReply) { - Label("Ответить", systemImage: "arrowshape.turn.up.left") + Label( + "Ответить", + systemImage: "arrowshape.turn.up.left" + ) } } if message.canBeForwarded { Button(action: onForward) { - Label("Переслать", systemImage: "arrowshape.turn.up.right") + Label( + "Переслать", + systemImage: "arrowshape.turn.up.right" + ) + } + } + if message.hasText { + Button(action: copyText) { + Label("Копировать текст", systemImage: "doc.on.doc") + } + } + if message.kind.isMedia, !message.isRetracted { + Button { + Task { + await MediaDownloadService.save( + message, + model: model + ) + } + } label: { + Label("Сохранить", systemImage: "square.and.arrow.down") } } if model.canRetryMediaMessage(message) { Button(action: onRetry) { - Label("Повторить отправку", systemImage: "arrow.clockwise") + Label( + "Повторить отправку", + systemImage: "arrow.clockwise" + ) } } if message.canBeEdited { @@ -99,7 +122,9 @@ struct MessageBubble: View { } } .accessibilityLabel(Text(selectionAccessibilityLabel)) - .accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) { + .accessibilityAction( + named: Text(isSelected ? "Убрать из выбранных" : "Выбрать") + ) { if isSelectionMode { onToggleSelection() } else { @@ -108,15 +133,28 @@ struct MessageBubble: View { } } + private func copyText() { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(message.body, forType: .string) + #else + UIPasteboard.general.string = message.body + #endif + } + 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) + 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) { @@ -145,30 +183,65 @@ struct MessageBubble: View { } 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) + Image( + systemName: replySwipeArmed + ? "arrowshape.turn.up.left.fill" + : "arrowshape.turn.up.left" + ) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .scaleEffect(x: replySwipeOffset > 0 ? -1 : 1, y: 1) + .scaleEffect( + CGFloat(0.8) + CGFloat(0.2) + * MessageReplySwipePolicy.progress(for: replySwipeOffset) + ) + .opacity( + Double( + CGFloat(0.35) + CGFloat(0.65) + * 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. + // Arbitration: once the touch is clearly vertical the scroll + // view owns it for good, so a later horizontal arc can never + // move bubbles mid-scroll. A reply swipe locks in only while + // the finger is still moving mostly horizontally. + if !replySwipeLocked, !scrollOwnsGesture { + if MessageReplySwipePolicy.canLock(value.translation) { + replySwipeLocked = true + } else if abs(value.translation.height) > 16, + abs(value.translation.width) + < abs(value.translation.height) + { + scrollOwnsGesture = true + } + } + guard replySwipeLocked, !scrollOwnsGesture else { return } + let offset = MessageReplySwipePolicy.offset( + locked: replySwipeLocked, + translation: value.translation + ) + guard offset != 0 else { + // Vertical (or not clearly horizontal) movement: snap the + // indicator back and never publish per-cell state changes + // during an ordinary timeline scroll. + if replySwipeOffset != 0 || replySwipeArmed { + withAnimation( + .spring(response: 0.24, dampingFraction: 0.82) + ) { + replySwipeOffset = 0 + } + replySwipeArmed = false + } return } replySwipeOffset = offset let armed = MessageReplySwipePolicy.shouldReply( + locked: replySwipeLocked, translation: value.translation, predictedEndTranslation: value.translation ) @@ -178,12 +251,23 @@ struct MessageBubble: View { replySwipeArmed = armed } .onEnded { value in - let shouldReply = MessageReplySwipePolicy.shouldReply( - translation: value.translation, - predictedEndTranslation: value.predictedEndTranslation - ) + // The lock is a hint, not a requirement: a fast flick may + // deliver no intermediate frame that passes canLock, yet its + // end state is still a clear horizontal swipe. scrollOwnsGesture + // alone protects the timeline from reply activation. + let shouldReply = + !scrollOwnsGesture + && MessageReplySwipePolicy.shouldReply( + locked: replySwipeLocked, + translation: value.translation, + predictedEndTranslation: value.predictedEndTranslation + ) + replySwipeLocked = false + scrollOwnsGesture = false if replySwipeOffset != 0 || replySwipeArmed { - withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) { + withAnimation( + .spring(response: 0.24, dampingFraction: 0.82) + ) { replySwipeOffset = 0 replySwipeArmed = false } @@ -195,28 +279,47 @@ struct MessageBubble: View { } private func replySwipeFeedback() { -#if os(iOS) - UIImpactFeedbackGenerator(style: .light).impactOccurred() -#endif + #if os(iOS) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + #endif } private var selectionAccessibilityLabel: String { guard isSelectionMode else { return message.previewText } - return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")" + return + "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")" } private var messageRow: some View { HStack(alignment: .bottom, spacing: 8) { - if message.direction == .outgoing { Spacer(minLength: sideSpacerMinimum) } + if message.direction == .outgoing { + Spacer(minLength: sideSpacerMinimum) + } - VStack(alignment: message.direction == .outgoing ? .trailing : .leading, spacing: 4) { + 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) + Text( + message.senderDisplayName + ?? model.displayName(for: message.senderJID) + ) + .font(.caption.weight(.semibold)) + .foregroundStyle(senderColor) + .padding(.horizontal, 8) } + // The drag lives on the bubble itself so a swipe can only start + // a reply when it begins on the bubble — not on the empty + // space beside it, the sender name, or the metadata row. messageContent + .contentShape(Rectangle()) + .simultaneousGesture( + replySwipeGesture, + including: !isSelectionMode && message.canBeRepliedTo + ? .gesture : .none + ) if !reactionSummaries.isEmpty { reactionStrip @@ -229,13 +332,16 @@ struct MessageBubble: View { } } - if message.direction == .incoming { Spacer(minLength: sideSpacerMinimum) } + 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 } + message.kind == .text || message.kind == .system + else { return } onReplyTap(targetID) } } @@ -249,8 +355,13 @@ struct MessageBubble: 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) + Image( + systemName: message.security == .plaintext + ? "lock.open" : "exclamationmark.shield" + ) + .foregroundStyle( + message.security == .plaintext ? .orange : .red + ) } if message.delivery == .failed { Image(systemName: "exclamationmark.circle.fill") @@ -265,8 +376,13 @@ struct MessageBubble: View { 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) + Image( + systemName: message.security == .plaintext + ? "lock.open" : "exclamationmark.shield" + ) + .foregroundStyle( + message.security == .plaintext ? .orange : .red + ) } if message.editedAt != nil { Text("изм.") @@ -274,7 +390,10 @@ struct MessageBubble: View { Text(message.timestamp, format: .dateTime.hour().minute()) if message.direction == .outgoing, message.callHistory == nil { Image(systemName: deliveryIcon) - .foregroundStyle(message.delivery == .failed ? .red : .secondary) + .foregroundStyle( + message.delivery == .failed ? .red : .secondary + ) + Text(deliveryText) } } .font(.caption2) @@ -293,7 +412,10 @@ struct MessageBubble: View { .font(.subheadline) .padding(.horizontal, 13) .padding(.vertical, 9) - .foregroundStyle(message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary) + .foregroundStyle( + message.direction == .outgoing + ? Color.white.opacity(0.86) : Color.secondary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) } else { @@ -340,7 +462,10 @@ struct MessageBubble: View { .lumaTextSelection() .padding(.horizontal, 13) .padding(.vertical, 9) - .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .foregroundStyle( + message.direction == .outgoing + ? Color.white : Color.primary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) } @@ -351,7 +476,9 @@ struct MessageBubble: View { .buttonStyle(.plain) .padding(.horizontal, 13) .padding(.vertical, 9) - .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .foregroundStyle( + message.direction == .outgoing ? Color.white : Color.primary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) case .text, .system: @@ -362,27 +489,36 @@ struct MessageBubble: View { .lumaTextSelection() .padding(.horizontal, 13) .padding(.vertical, 9) - .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .foregroundStyle( + message.direction == .outgoing + ? Color.white : Color.primary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) } } } - private func callHistoryContent(_ history: CallHistoryMetadata) -> some View { + private func callHistoryContent(_ history: CallHistoryMetadata) -> some View + { HStack(spacing: 12) { ZStack { Circle() - .fill(callAccentColor(for: history).opacity( - message.direction == .outgoing ? 0.28 : 0.14 - )) + .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) + 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)) @@ -402,7 +538,9 @@ struct MessageBubble: View { .padding(.horizontal, 14) .padding(.vertical, 10) .frame(maxWidth: 300, alignment: .leading) - .foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary) + .foregroundStyle( + message.direction == .outgoing ? Color.white : Color.primary + ) .background(bubbleBackground) .clipShape(messageBubbleShape) .accessibilityElement(children: .combine) @@ -463,7 +601,9 @@ struct MessageBubble: View { } } .buttonStyle(.plain) - .accessibilityLabel("\(summary.emoji), реакций: \(summary.count)") + .accessibilityLabel( + "\(summary.emoji), реакций: \(summary.count)" + ) } } .padding(.horizontal, 4) @@ -491,9 +631,10 @@ struct MessageBubble: View { .padding(.bottom, -2) .task(id: replyTarget?.remoteAttachmentURL) { if let replyTarget, - replyTarget.kind == .photo + replyTarget.kind == .photo || replyTarget.kind == .video - || replyTarget.kind == .videoNote { + || replyTarget.kind == .videoNote + { await model.prepareMediaPreview(replyTarget) } } @@ -506,7 +647,8 @@ struct MessageBubble: View { } VStack( - alignment: compactTargetDirection == .outgoing ? .trailing : .leading, + alignment: compactTargetDirection == .outgoing + ? .trailing : .leading, spacing: -1 ) { compactSourceBubble @@ -550,7 +692,9 @@ struct MessageBubble: View { @ViewBuilder private var compactReplyThumbnail: some View { - if let target = replyTarget, target.kind != .text, target.kind != .system { + if let target = replyTarget, target.kind != .text, + target.kind != .system + { ZStack { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color.primary.opacity(0.08)) @@ -564,25 +708,31 @@ struct MessageBubble: View { @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 + #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)) @@ -591,7 +741,9 @@ struct MessageBubble: View { } private var replyTarget: ChatMessage? { - message.replyToID.flatMap { model.message(withID: $0, in: message.conversationID) } + message.replyToID.flatMap { + model.message(withID: $0, in: message.conversationID) + } } private var compactTargetDirection: ChatMessage.Direction { @@ -606,15 +758,23 @@ struct MessageBubble: View { } private var replyText: String { - replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно" + 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) + .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)) } @@ -652,9 +812,15 @@ struct MessageBubble: View { parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60)) } if let byteCount = message.byteCount { - parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)) + parts.append( + ByteCountFormatter.string( + fromByteCount: Int64(byteCount), + countStyle: .file + ) + ) } - return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ") + return parts.isEmpty + ? "Нажмите для просмотра" : parts.joined(separator: " · ") } private var mediaIcon: String { @@ -687,7 +853,10 @@ struct MessageBubble: View { 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)], + 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 ) @@ -696,16 +865,27 @@ struct MessageBubble: View { } } - private var messageBubbleShape: RoundedRectangle { - RoundedRectangle(cornerRadius: 18, style: .continuous) + private var messageBubbleShape: ChatBubbleShape { + ChatBubbleShape(isOutgoing: message.direction == .outgoing) } private var deliveryIcon: String { switch message.delivery { case .sending: return "clock" case .sent: return "checkmark" - case .delivered: return "checkmark.circle.fill" + case .delivered: return "checkmark.circle.dotted" case .failed: return "exclamationmark.circle.fill" + case .read: return "checkmark.circle.fill" + } + } + + private var deliveryText: String { + switch message.delivery { + case .sending: return "Sending..." + case .sent: return "Sended" + case .delivered: return "Delivered" + case .failed: return "Error" + case .read: return "Read" } } @@ -725,13 +905,84 @@ struct MessageBubble: View { } } -private extension View { +extension View { @ViewBuilder - func lumaTextSelection() -> some View { -#if os(macOS) - textSelection(.enabled) -#else - self -#endif + fileprivate func lumaTextSelection() -> some View { + #if os(macOS) + textSelection(.enabled) + #else + self + #endif } } + +#Preview("Входящее") { + MessageBubble( + model: PreviewSupport.model, + message: PreviewSupport.message(), + isSelectionMode: false, + isSelected: false, + onAttachmentTap: {}, + onEdit: {}, + onReply: {}, + onForward: {}, + onRetry: {}, + onToggleSelection: {}, + onBeginSelection: {}, + onRetract: {}, + onDelete: {}, + onReplyTap: { _ in }, + onReact: { _ in }, + onReactPicker: {} + ) + .padding() +} + +#Preview("Исходящее") { + MessageBubble( + model: PreviewSupport.model, + message: PreviewSupport.message( + senderJID: "me@example.org", + body: "Отлично! Созвонимся вечером?", + direction: .outgoing, + delivery: .delivered + ), + isSelectionMode: false, + isSelected: false, + onAttachmentTap: {}, + onEdit: {}, + onReply: {}, + onForward: {}, + onRetry: {}, + onToggleSelection: {}, + onBeginSelection: {}, + onRetract: {}, + onDelete: {}, + onReplyTap: { _ in }, + onReact: { _ in }, + onReactPicker: {} + ) + .padding() +} + +#Preview("Фото") { + MessageBubble( + model: PreviewSupport.model, + message: PreviewSupport.photoMessage(), + isSelectionMode: false, + isSelected: false, + onAttachmentTap: {}, + onEdit: {}, + onReply: {}, + onForward: {}, + onRetry: {}, + onToggleSelection: {}, + onBeginSelection: {}, + onRetract: {}, + onDelete: {}, + onReplyTap: { _ in }, + onReact: { _ in }, + onReactPicker: {} + ) + .padding() +} diff --git a/Sources/Shared/UI/Components/MessageBubbleEntrance.swift b/Sources/Shared/UI/Components/MessageBubbleEntrance.swift new file mode 100644 index 0000000..e6b352e --- /dev/null +++ b/Sources/Shared/UI/Components/MessageBubbleEntrance.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// Messages-style entrance for chat rows. +/// +/// Opening a conversation reveals its first page as a bottom-up cascade +/// (`.cascade`), and a row appended while the timeline is already rendered +/// springs out of the composer's send control when outgoing (`.launch`). +/// Everything else stays `.settled`, which keeps history scrolling, reactions +/// and selection updates free of animation. +struct MessageBubbleEntrance: ViewModifier { + enum Mode: Equatable { + case settled + case cascade(delay: TimeInterval) + case launch(outgoing: Bool) + + var isSettled: Bool { + if case .settled = self { return true } + return false + } + } + + let mode: Mode + + // A settled row must never pass through an invisible frame, so only the + // rows that actually animate start hidden. + @State private var isRevealed: Bool + @State private var hasStarted = false + + init(mode: Mode) { + self.mode = mode + _isRevealed = State(initialValue: mode.isSettled) + } + + func body(content: Content) -> some View { + content + .opacity(isRevealed ? 1 : 0) + .scaleEffect(isRevealed ? 1 : displacedScale, anchor: scaleAnchor) + .offset(y: isRevealed ? 0 : displacedRise) + .onAppear(perform: reveal) + } + + /// Outgoing bubbles shrink toward the trailing bottom corner, where the + /// composer's send control sits; incoming ones grow from their own edge. + private var displacedScale: CGFloat { + switch mode { + case .settled, .cascade: + return ChatMessageEntrancePolicy.entranceScale + case .launch(let outgoing): + return outgoing + ? ChatMessageEntrancePolicy.sendScale + : ChatMessageEntrancePolicy.receiveScale + } + } + + private var scaleAnchor: UnitPoint { + if case .launch(let outgoing) = mode, outgoing { return .bottomTrailing } + return .bottom + } + + private var displacedRise: CGFloat { + switch mode { + case .settled, .cascade: + return ChatMessageEntrancePolicy.entranceRise + case .launch(let outgoing): + return outgoing + ? ChatMessageEntrancePolicy.sendRise + : ChatMessageEntrancePolicy.receiveRise + } + } + + private func reveal() { + guard !hasStarted else { return } + hasStarted = true + guard !isRevealed else { return } + let delay: TimeInterval + if case .cascade(let cascadeDelay) = mode { + delay = cascadeDelay + } else { + delay = 0 + } + withAnimation( + ChatMessageEntrancePolicy.appendAnimation.delay(delay) + ) { + isRevealed = true + } + } +} diff --git a/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift b/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift index 965fa48..f8f98e3 100644 --- a/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift +++ b/Sources/Shared/UI/Components/PhotoAttachmentPreview.swift @@ -124,10 +124,19 @@ struct PhotoAttachmentPreview: View { private func openViewer() { Task { await model.presentMediaViewer(message) - if model.mediaViewerItem?.id != message.id, + if model.mediaViewerItem?.id != message.clientID, message.remoteAttachmentURL == nil { onFallbackTap() } } } } + +#Preview { + PhotoAttachmentPreview( + model: PreviewSupport.model, + message: PreviewSupport.photoMessage(), + onFallbackTap: {} + ) + .padding() +} diff --git a/Sources/Shared/UI/Components/RTCVideoRendererView.swift b/Sources/Shared/UI/Components/RTCVideoRendererView.swift index 246094e..edbf7db 100644 --- a/Sources/Shared/UI/Components/RTCVideoRendererView.swift +++ b/Sources/Shared/UI/Components/RTCVideoRendererView.swift @@ -97,3 +97,9 @@ struct RTCVideoRendererView: NSViewRepresentable { } } #endif + +#Preview { + RTCVideoRendererView(track: nil) + .frame(width: 280, height: 320) + .background(Color.black) +} diff --git a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift index 728e2ab..c87a158 100644 --- a/Sources/Shared/UI/Components/ReplyThreadOverlay.swift +++ b/Sources/Shared/UI/Components/ReplyThreadOverlay.swift @@ -71,12 +71,13 @@ struct ReplyThreadOverlay: View { Button(action: onDismiss) { Image(systemName: "xmark") - .font(.system(size: 13, weight: .bold)) + .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white.opacity(0.82)) - .frame(width: 32, height: 32) - .background(.white.opacity(0.12), in: Circle()) + .padding(7) } - .buttonStyle(.plain) + .buttonStyle(.glass) + .buttonBorderShape(.circle) + .background(.clear) .keyboardShortcut(.cancelAction) .accessibilityLabel("Закрыть ветку ответов") .padding(.top, max(12, geometry.safeAreaInsets.top + 6)) @@ -112,10 +113,10 @@ struct ReplyThreadOverlay: View { model: model, message: reply, maximumBubbleWidth: maximumBubbleWidth, - isSelected: reply.id == selectedReplyID, - showsDeliveryStatus: reply.id == selectedReplyID + isSelected: reply.clientID == selectedReplyID, + showsDeliveryStatus: reply.clientID == selectedReplyID ) - .id(reply.id) + .id(reply.clientID) } } .padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0) @@ -338,6 +339,7 @@ private struct ReplyThreadMessageRow: View { case .sent: state = "Отправлено" case .delivered: state = "Доставлено" case .failed: state = "Не отправлено" + case .read: state = "Прочитано" } let value = Calendar.current.isDateInToday(message.timestamp) ? message.timestamp.formatted(date: .omitted, time: .shortened) @@ -345,3 +347,27 @@ private struct ReplyThreadMessageRow: View { return "\(state) \(value)" } } + +#Preview { + ReplyThreadOverlay( + model: PreviewSupport.model, + rootMessage: PreviewSupport.message( + body: "Кто пойдёт на конференцию?", + timestamp: Date().addingTimeInterval(-3_600) + ), + replies: [ + PreviewSupport.message( + id: "preview-reply-1", + body: "Я пойду!", + timestamp: Date().addingTimeInterval(-2_400) + ), + PreviewSupport.message( + id: "preview-reply-2", + body: "Я тоже, но опоздаю на полчаса", + timestamp: Date().addingTimeInterval(-1_800) + ), + ], + selectedReplyID: "preview-reply-1", + onDismiss: {} + ) +} diff --git a/Sources/Shared/UI/Components/SwiftChatView.swift b/Sources/Shared/UI/Components/SwiftChatView.swift new file mode 100644 index 0000000..08b70bd --- /dev/null +++ b/Sources/Shared/UI/Components/SwiftChatView.swift @@ -0,0 +1,17 @@ +// +// SwiftChatView.swift +// Luma +// +// Created by tola on 9/14/26. +// + +//import SwiftUI +//import SwiftChat +// +//struct SwiftChatView: View { +// var body: some View { +// Chat(messages) { message in +// Message(...) +// } +// } +//} diff --git a/Sources/Shared/UI/Components/VideoAttachmentPreview.swift b/Sources/Shared/UI/Components/VideoAttachmentPreview.swift index 7d49694..f06d13e 100644 --- a/Sources/Shared/UI/Components/VideoAttachmentPreview.swift +++ b/Sources/Shared/UI/Components/VideoAttachmentPreview.swift @@ -142,10 +142,19 @@ struct VideoAttachmentPreview: View { private func openViewer() { Task { await model.presentMediaViewer(message) - if model.mediaViewerItem?.id != message.id, + if model.mediaViewerItem?.id != message.clientID, message.remoteAttachmentURL == nil { onFallbackTap() } } } } + +#Preview { + VideoAttachmentPreview( + model: PreviewSupport.model, + message: PreviewSupport.videoMessage(), + onFallbackTap: {} + ) + .padding() +} diff --git a/Sources/Shared/UI/Components/VideoNotePreview.swift b/Sources/Shared/UI/Components/VideoNotePreview.swift index 977f20c..3a3a4fd 100644 --- a/Sources/Shared/UI/Components/VideoNotePreview.swift +++ b/Sources/Shared/UI/Components/VideoNotePreview.swift @@ -50,7 +50,7 @@ struct VideoNotePreview: View { VStack { Spacer() HStack { - Spacer() +// Spacer() Text(durationText) .font(.caption2.monospacedDigit().weight(.semibold)) .foregroundStyle(.white) @@ -82,7 +82,7 @@ struct VideoNotePreview: View { } .onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in guard let activeID = notification.object as? String, - activeID != message.id else { return } + activeID != message.clientID else { return } player?.pause() isPlaying = false } @@ -146,10 +146,19 @@ struct VideoNotePreview: View { model.audioPlayback.stop() NotificationCenter.default.post( name: .lumaExclusiveMediaPlayback, - object: message.id + object: message.clientID ) player.play() } isPlaying.toggle() } } + +#Preview { + VideoNotePreview( + model: PreviewSupport.model, + message: PreviewSupport.videoNoteMessage(), + onFallbackTap: {} + ) + .padding() +} diff --git a/Sources/Shared/UI/EncryptionDevicesView.swift b/Sources/Shared/UI/EncryptionDevicesView.swift index 1562a41..211ab9a 100644 --- a/Sources/Shared/UI/EncryptionDevicesView.swift +++ b/Sources/Shared/UI/EncryptionDevicesView.swift @@ -16,19 +16,21 @@ struct EncryptionDevicesView: View { ) .foregroundStyle(statusColor) } footer: { - Text("Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное.") + Text( + "Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное." + ) } - if devices.isEmpty { - Section("Устройства контакта") { + Section("Устройства контакта") { + if devices.isEmpty { ContentUnavailableView( "Ключи ещё не получены", systemImage: "key.horizontal", - description: Text("Они появятся после первой попытки отправки или получения OMEMO-сообщения.") + description: Text( + "Они появятся после первой попытки отправки или получения OMEMO-сообщения." + ) ) - } - } else { - Section("Устройства контакта") { + } else { ForEach(devices) { device in DeviceRow(device: device) { model.setDeviceVerified( @@ -58,9 +60,9 @@ struct EncryptionDevicesView: View { } .onAppear(perform: reload) } -#if os(macOS) - .frame(minWidth: 480, minHeight: 500) -#endif + #if os(macOS) + .frame(minWidth: 480, minHeight: 500) + #endif } private func reload() { @@ -68,7 +70,9 @@ struct EncryptionDevicesView: View { } private var statusTitle: String { - guard model.encryptionEnabled(for: conversation.jid) else { return "OMEMO выключено для этого чата" } + guard model.encryptionEnabled(for: conversation.jid) else { + return "OMEMO выключено для этого чата" + } return model.isOMEMOReady ? "OMEMO активно" : "OMEMO подготавливается" } @@ -89,8 +93,20 @@ private struct DeviceRow: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { - Label("Устройство \(device.deviceID)", systemImage: device.isActive ? "iphone" : "iphone.slash") - .font(.headline) + Label( + "Устройство \(device.deviceID)", + systemImage: device.isActive ? "iphone" : "iphone.slash" + ) + .font(.headline) + Text(device.protocolName) + .font(.caption2.weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(device.isOMEMO2 ? Color.indigo : Color.secondary) + .background( + (device.isOMEMO2 ? Color.indigo : Color.secondary).opacity(0.14), + in: Capsule() + ) Spacer() Button(action: toggleVerification) { Label(trustTitle, systemImage: trustIcon) @@ -134,8 +150,8 @@ private struct DeviceRow: View { } } -private extension String { - func chunked(every size: Int) -> [String] { +extension String { + fileprivate func chunked(every size: Int) -> [String] { guard size > 0 else { return [self] } var result: [String] = [] var cursor = startIndex @@ -147,3 +163,10 @@ private extension String { return result } } + +#Preview { + EncryptionDevicesView( + model: PreviewSupport.model, + conversation: PreviewSupport.conversation(displayName: "Алиса") + ) +} diff --git a/Sources/Shared/UI/ForwardMessageView.swift b/Sources/Shared/UI/ForwardMessageView.swift index c285ace..7a61c95 100644 --- a/Sources/Shared/UI/ForwardMessageView.swift +++ b/Sources/Shared/UI/ForwardMessageView.swift @@ -1,3 +1,4 @@ +import SwiftData import SwiftUI @MainActor @@ -10,6 +11,9 @@ struct ForwardMessageView: View { @State private var destinationJID = "" @State private var isForwarding = false + /// SwiftData-backed destination list, ordered like the main chat list. + @Query private var conversations: [Conversation] + init( model: AppModel, messages: [ChatMessage], @@ -18,6 +22,11 @@ struct ForwardMessageView: View { _model = ObservedObject(wrappedValue: model) self.messages = messages.filter(\.canBeForwarded) self.onComplete = onComplete + _conversations = Query( + sort: [ + SortDescriptor(\Conversation.lastActivity, order: .reverse), + ] + ) } var body: some View { @@ -39,9 +48,9 @@ struct ForwardMessageView: View { } } - if !model.conversations.isEmpty { + if !orderedConversations.isEmpty { Section("Чаты") { - ForEach(model.conversations) { conversation in + ForEach(orderedConversations) { conversation in Button { forward(to: conversation.jid) } label: { @@ -105,6 +114,14 @@ struct ForwardMessageView: View { #endif } + /// Pinned-first ordering, matching the main chat list. + private var orderedConversations: [Conversation] { + conversations.sorted { lhs, rhs in + if lhs.isPinned != rhs.isPinned { return lhs.isPinned } + return lhs.lastActivity > rhs.lastActivity + } + } + private func forward(to jid: String) { guard !isForwarding, !messages.isEmpty else { return } isForwarding = true @@ -135,3 +152,19 @@ struct ForwardMessageView: View { return "\(count) \(word)" } } + +#Preview { + ForwardMessageView( + model: PreviewSupport.model, + messages: [ + PreviewSupport.message(body: "Привет! Как дела?"), + PreviewSupport.message( + senderJID: "me@example.org", + body: "Созвонимся вечером?", + direction: .outgoing, + delivery: .sent + ), + ] + ) + .modelContainer(PreviewSupport.container) +} diff --git a/Sources/Shared/UI/GroupInfoView.swift b/Sources/Shared/UI/GroupInfoView.swift index 5961956..40e20ee 100644 --- a/Sources/Shared/UI/GroupInfoView.swift +++ b/Sources/Shared/UI/GroupInfoView.swift @@ -11,6 +11,7 @@ struct GroupInfoView: View { @State private var invitees = "" @State private var isJoining = false @State private var isInviting = false + @State private var confirmingDeletion = false init(model: AppModel, conversation: Conversation) { self.model = model @@ -68,6 +69,16 @@ struct GroupInfoView: View { } } + Section { + Button(role: .destructive) { + confirmingDeletion = true + } label: { + Label("Удалить чат", systemImage: "trash") + } + } footer: { + Text("Чат и его история будут удалены с этого устройства.") + } + Section { Label( model.encryptionEnabled(for: conversation.jid) @@ -85,6 +96,15 @@ struct GroupInfoView: View { Button("Готово") { dismiss() } } } + .alert("Удалить групповой чат?", isPresented: $confirmingDeletion) { + Button("Удалить", role: .destructive) { + model.deleteGroupChat(jid: conversation.jid) + dismiss() + } + Button("Отмена", role: .cancel) {} + } message: { + Text("Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней.") + } } #if os(macOS) .frame(minWidth: 480, minHeight: 500) @@ -92,7 +112,7 @@ struct GroupInfoView: View { } private var liveConversation: Conversation { - model.conversations.first(where: { $0.id == conversation.id }) ?? conversation + model.conversations.first(where: { $0.jid == conversation.jid }) ?? conversation } private var parsedInvitees: [String] { @@ -119,3 +139,16 @@ struct GroupInfoView: View { } } } + +#Preview { + GroupInfoView( + model: PreviewSupport.model, + conversation: PreviewSupport.conversation( + jid: "team@conference.example.org", + displayName: "Команда Luma", + kind: .group, + isGroupJoined: true, + occupantCount: 7 + ) + ) +} diff --git a/Sources/Shared/UI/LocationPickerView.swift b/Sources/Shared/UI/LocationPickerView.swift index 83cc303..18b401e 100644 --- a/Sources/Shared/UI/LocationPickerView.swift +++ b/Sources/Shared/UI/LocationPickerView.swift @@ -146,3 +146,7 @@ struct LocationPickerView: View { dismiss() } } + +#Preview { + LocationPickerView { _ in } +} diff --git a/Sources/Shared/UI/LoginView.swift b/Sources/Shared/UI/LoginView.swift index 2d1adb5..f7c3b37 100644 --- a/Sources/Shared/UI/LoginView.swift +++ b/Sources/Shared/UI/LoginView.swift @@ -2,10 +2,12 @@ import SwiftUI struct LoginView: View { @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss @State private var jid = "" @State private var password = "" + @State private var passwordVisible = false @State private var displayName = "" - @State private var resource = "Luma" + @State private var resource = "" @State private var showAdvanced = false @State private var manualHost = "" @State private var manualPort = "" @@ -17,7 +19,7 @@ struct LoginView: View { 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) + Color(red: 0.44, green: 0.84, blue: 0.96), ], startPoint: .topLeading, endPoint: .bottomTrailing @@ -76,32 +78,85 @@ struct LoginView: View { Text("XMPP-аккаунт") .font(.headline) TextField("you@example.org", text: $jid) - .textFieldStyle(.roundedBorder) + #if os(iOS) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + #endif + .textContentType(.username) + .autocorrectionDisabled() + .textFieldStyle(.plain) .disableAutocorrection(true) + .padding() + .glassEffect() } VStack(alignment: .leading, spacing: 7) { Text("Пароль") .font(.headline) - SecureField("Пароль", text: $password) + HStack(spacing: 8) { + Group { + if passwordVisible { + TextField("Пароль", text: $password) + .textContentType(.oneTimeCode) + .textFieldStyle(.plain) + } else { + SecureField("Пароль", text: $password) + .textContentType(.password) + .textFieldStyle(.plain) + } + } + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif .textFieldStyle(.roundedBorder) + + Button { + passwordVisible.toggle() + } label: { + Image(systemName: passwordVisible ? "eye.slash.fill" : "eye.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(passwordVisible ? "Скрыть пароль" : "Показать пароль") + } + .padding() + .glassEffect() } DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) { VStack(spacing: 14) { TextField("Отображаемое имя", text: $displayName) - .textFieldStyle(.roundedBorder) - TextField("Ресурс устройства", text: $resource) - .textFieldStyle(.roundedBorder) + .textFieldStyle(.plain) + .padding() + .glassEffect() + TextField("Автоматически (Luma-XXXX)", text: $resource) + .textFieldStyle(.plain) + .padding() + .glassEffect() + Text( + "Оставьте пустым — Luma создаст уникальный ресурс для этого устройства, чтобы macOS и iOS не конфликтовали между собой." + ) + .font(.caption) + .foregroundStyle(.secondary) TextField("Сервер вручную (необязательно)", text: $manualHost) - .textFieldStyle(.roundedBorder) - TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort) - .textFieldStyle(.roundedBorder) + .textFieldStyle(.plain) + .padding() + .glassEffect() + TextField( + directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort + ) + .textFieldStyle(.plain) + .padding() + .glassEffect() Toggle("Direct TLS", isOn: $directTLS) - Text("Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS.") - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) + .toggleStyle(.switch) + Text( + "Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS." + ) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.top, 14) } @@ -119,14 +174,17 @@ struct LoginView: View { .frame(maxWidth: .infinity) .frame(height: 34) } - .buttonStyle(.borderedProminent) + .buttonStyle(.glassProminent) .controlSize(.large) .disabled(model.isAuthenticating) - Label("Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.", systemImage: "lock.shield.fill") - .font(.caption) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + Label( + "Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.", + systemImage: "lock.shield.fill" + ) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } .padding(26) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) @@ -144,6 +202,16 @@ struct LoginView: View { usesDirectTLS: directTLS ) await model.signIn(account: account, password: password) + + // When LoginView is presented from Settings, close the sheet only + // after AppModel has completed a successful sign-in. On failure the + // model clears the active account, so the form remains available for + // correcting the credentials. + guard model.account?.normalizedJID == account.normalizedJID else { return } + dismiss() } } +#Preview { + LoginView(model: PreviewSupport.model) +} diff --git a/Sources/Shared/UI/MainChatView.swift b/Sources/Shared/UI/MainChatView.swift deleted file mode 100644 index 2843079..0000000 --- a/Sources/Shared/UI/MainChatView.swift +++ /dev/null @@ -1,268 +0,0 @@ -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/MainTabView.swift b/Sources/Shared/UI/MainTabView.swift new file mode 100644 index 0000000..39296fd --- /dev/null +++ b/Sources/Shared/UI/MainTabView.swift @@ -0,0 +1,1092 @@ +import Foundation +import SwiftData +import SwiftUI + +/// Telegram-style main screen. On iOS the root is a bottom tab bar +/// (Контакты / Звонки / Чаты / Настройки) with an inline search field at +/// the top of the chat and contact lists. On macOS the root is a Telegram +/// Desktop-style split view: a sidebar (menu, search, list) plus a detail +/// pane with the selected chat. +struct MainTabView: View { + @ObservedObject var model: AppModel + + var body: some View { + #if os(macOS) + MainSplitView(model: model) + #else + MainTabBarView(model: model) + #endif + } +} + +// MARK: - Shared search field + +/// Inline rounded search field, placed explicitly above the list it +/// filters. Used on both platforms so the position never depends on +/// navigation-bar search placement. +private struct SearchField: View { + @Binding var text: String + let prompt: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + TextField(prompt, text: $text) + .textFieldStyle(.plain) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Очистить поиск") + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .glassEffect() + .padding(.horizontal, 12) + .padding(.vertical, 6) + } +} + +// MARK: - iOS: bottom tab bar + +#if os(iOS) + private struct MainTabBarView: View { + enum Tab: String, CaseIterable, Identifiable { + case contacts + case calls + case chats + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .contacts: return "Контакты" + case .calls: return "Звонки" + case .chats: return "Чаты" + case .settings: return "Настройки" + } + } + + var icon: String { + switch self { + case .contacts: return "person.circle.fill" + case .calls: return "phone.fill" + case .chats: return "bubble.left.and.bubble.right.fill" + case .settings: return "gearshape.fill" + } + } + + var selectedIcon: String { + switch self { + case .contacts: return "person.circle.fill" + case .calls: return "phone.fill" + case .chats: return "bubble.left.and.bubble.right.fill" + case .settings: return "gearshape.fill" + } + } + } + + @ObservedObject var model: AppModel + @State private var selectedTab: Tab = .chats + + var body: some View { + // Native TabView: on iOS 26 it renders the system floating + // translucent tab bar (Liquid Glass). + TabView(selection: $selectedTab) { + ForEach(Tab.allCases) { tab in + tabContent(for: tab) + .tabItem { + Label( + tab.title, + systemImage: selectedTab == tab + ? tab.selectedIcon : tab.icon + ) + } + .tag(tab) + } + } + } + + @ViewBuilder + private func tabContent(for tab: Tab) -> some View { + switch tab { + case .contacts: + ContactsTab(model: model) + case .calls: + CallsTab(model: model) + case .chats: + ChatsTab(model: model) + case .settings: + NavigationStack { + SettingsView(model: model, presentedAsTab: true) + } + } + } + } +#endif + +// MARK: - Chats + +private struct ChatsTab: View { + @ObservedObject var model: AppModel + @State private var searchText = "" + @State private var showingNewChat = false + @State private var showingNewGroup = false + @State private var pendingGroupDeletion: Conversation? + @FocusState private var searchFocused: Bool + + /// SwiftData-backed chat list. The query orders by last activity; + /// `filteredConversations` applies the pinned-first ordering that + /// matches `AppModel.sortConversations` (`Bool` is not `Comparable`, + /// so pinning cannot be a `SortDescriptor`). + @Query( + sort: [ + SortDescriptor(\Conversation.lastActivity, order: .reverse) + ] + ) + private var storedConversations: [Conversation] + + private var conversations: [AccountConversation] { + let source = model.conversations.isEmpty ? storedConversations : model.conversations + guard let account = model.account else { + return source.map { + AccountConversation( + account: AccountConfiguration(jid: "preview@example.org"), + conversation: $0 + ) + } + } + return source.map { AccountConversation(account: account, conversation: $0) } + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + chatList + } + .navigationTitle("") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Поиск чатов" + ) + #endif + .toolbar { + ToolbarItem(placement: .principal) { + ConnectionBanner(model: model, text: "Чаты") + } + ToolbarItemGroup(placement: .primaryAction) { + Button { + showingNewGroup = true + } label: { + Image(systemName: "person.3.fill") + } + .accessibilityLabel("Новый групповой чат") + .help("Новый групповой чат") + Button { + showingNewChat = true + } label: { + Image(systemName: "square.and.pencil") + } + .accessibilityLabel("Новый личный чат") + .help("Новый личный чат") + } + } + .sheet(isPresented: $showingNewChat) { + NewChatView(model: model) + } + .sheet(isPresented: $showingNewGroup) { + NewGroupView(model: model) + } + .alert( + "Удалить групповой чат?", + isPresented: Binding( + get: { pendingGroupDeletion != nil }, + set: { if !$0 { pendingGroupDeletion = nil } } + ) + ) { + Button("Удалить", role: .destructive) { + if let pendingGroupDeletion { + model.deleteGroupChat(jid: pendingGroupDeletion.jid) + } + pendingGroupDeletion = nil + } + Button("Отмена", role: .cancel) { pendingGroupDeletion = nil } + } message: { + Text( + "Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней." + ) + } + } + + } + + private var filteredConversations: [AccountConversation] { + let source = + searchText.isEmpty + ? conversations : conversations.filter(matchesSearch) + return source.sorted(by: conversationSort) + } + + private var chatList: some View { + List { + ForEach(filteredConversations) { item in + let conversation = item.conversation + NavigationLink { + ChatView(model: model, conversation: conversation) + .id(item.id) + .onAppear { + if item.account.normalizedJID != model.account?.normalizedJID { + Task { + await model.switchAccount(to: item.account) + model.selectConversation(id: conversation.jid) + } + } else { + model.selectConversation(id: conversation.jid) + } + } + } label: { + VStack(alignment: .leading, spacing: 2) { + ConversationRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid), + isEncrypted: model.encryptionEnabled( + for: conversation.jid + ) + ) + if model.accounts.count > 1 { + Text(item.account.normalizedJID) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.leading, 52) + } + } + } + .listRowInsets( + EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12) + ) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if conversation.isGroup { + Button(role: .destructive) { + pendingGroupDeletion = conversation + } label: { + Label("Удалить", systemImage: "trash") + } + } + } + } + } + .listStyle(.plain) + .overlay { + if filteredConversations.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет чатов" : "Ничего не найдено", + systemImage: searchText.isEmpty + ? "bubble.left" : "magnifyingglass", + description: Text( + searchText.isEmpty + ? "Создайте личный или групповой чат." + : "Попробуйте другой JID или имя." + ) + ) + } + } + } + + private func conversationSort(_ lhs: AccountConversation, _ rhs: AccountConversation) + -> Bool + { + let lhs = lhs.conversation + let rhs = rhs.conversation + 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 matchesSearch(_ item: AccountConversation) -> Bool { + let conversation = item.conversation + return conversation.displayName.localizedCaseInsensitiveContains(searchText) + || conversation.jid.localizedCaseInsensitiveContains(searchText) + || item.account.normalizedJID.localizedCaseInsensitiveContains(searchText) + } +} + +// MARK: - Contacts + +private struct ContactsTab: View { + @ObservedObject var model: AppModel + @State private var searchText = "" + + @Query( + sort: [ + SortDescriptor(\Conversation.lastActivity, order: .reverse) + ] + ) + private var conversations: [Conversation] + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + contactsList + } + .navigationTitle("") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .searchable( + text: $searchText, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Поиск контактов" + ) + #endif + .toolbar { + ToolbarItem(placement: .principal) { + ConnectionBanner(model: model, text: "Контакты") + } + } + } + } + + private var contactsList: some View { + List { + if !filteredRosterContacts.isEmpty { + Section { + ForEach(filteredRosterContacts) { conversation in + contactLink(conversation) + .listRowInsets( + EdgeInsets( + top: 7, + leading: 12, + bottom: 7, + trailing: 12 + ) + ) + } + } + } + + if !filteredGroupContacts.isEmpty { + Section { + ForEach(filteredGroupContacts) { conversation in + contactLink(conversation) + .listRowInsets( + EdgeInsets( + top: 7, + leading: 12, + bottom: 7, + trailing: 12 + ) + ) + } + } + } + } + .listStyle(.plain) + .overlay { + if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет контактов" : "Ничего не найдено", + systemImage: searchText.isEmpty + ? "person.2" : "magnifyingglass", + description: Text(contactListDescription), + ) + } + } + } + + @ViewBuilder + private func contactLink(_ conversation: Conversation) -> some View { + NavigationLink { + ChatView(model: model, conversation: conversation) + .id(conversation.jid) + .onAppear { model.selectConversation(id: conversation.jid) } + } label: { + ContactRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid) + ) + } + } + + private var filteredRosterContacts: [Conversation] { + conversations + .filter { !$0.isGroup && model.rosterContactJIDs.contains($0.jid) } + .filter { searchText.isEmpty || matchesSearch($0) } + .sorted(by: contactSort) + } + + private var filteredGroupContacts: [Conversation] { + conversations + .filter { $0.isGroup } + .filter { searchText.isEmpty || matchesSearch($0) } + .sorted(by: contactSort) + } + + private var hasStoredContacts: Bool { + !model.rosterContactJIDs.isEmpty + || conversations.contains { $0.isGroup } + } + + 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 + } +} + +// MARK: - Calls + +private struct CallsTab: View { + @ObservedObject var model: AppModel + + var body: some View { + NavigationStack { + Group { + if model.callHistoryMessages.isEmpty { + ContentUnavailableView( + "Нет звонков", + systemImage: "phone", + description: Text( + "История звонков появится здесь после первого вызова." + ) + ) + } else { + List(model.callHistoryMessages) { message in + if let conversation = model.conversations.first(where: { + $0.jid == message.conversationID + }) { + NavigationLink { + ChatView( + model: model, + conversation: conversation + ) + .id(conversation.jid) + .onAppear { + model.selectConversation( + id: conversation.jid + ) + } + } label: { + CallHistoryRow( + model: model, + message: message, + conversation: conversation + ) + } + .listRowInsets( + EdgeInsets( + top: 7, + leading: 12, + bottom: 7, + trailing: 12 + ) + ) + } else { + CallHistoryRow( + model: model, + message: message, + conversation: nil + ) + .listRowInsets( + EdgeInsets( + top: 7, + leading: 12, + bottom: 7, + trailing: 12 + ) + ) + } + } + .listStyle(.plain) + } + } + .navigationTitle("") + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .principal) { + Text("Звонки") + .font(.headline) + } + } + } + } +} + +private struct CallHistoryRow: View { + @ObservedObject var model: AppModel + let message: ChatMessage + let conversation: Conversation? + + var body: some View { + HStack(spacing: 12) { + Image( + systemName: message.callHistory?.isVideo == true + ? "video.fill" : "phone.fill" + ) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle( + message.direction == .incoming + && message.callHistory?.outcome == .missed + ? Color.red : Color.accentColor + ) + .frame(width: 34, height: 34) + .background(Color.secondary.opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(conversation?.displayName ?? message.conversationID) + .font(.headline) + .lineLimit(1) + Text(message.callTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 8) + Text(message.timestamp, format: .dateTime.hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } +} + +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)" + } +} + +// MARK: - macOS: Telegram Desktop-style split view + +#if os(macOS) + private struct MainSplitView: View { + private enum Mode: String, CaseIterable, Identifiable { + case contacts + case calls + case chats + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .chats: return "Чаты" + case .contacts: return "Контакты" + case .calls: return "Звонки" + case .settings: return "Настройки" + } + } + + var icon: String { + switch self { + case .chats: return "bubble.left.and.bubble.right.fill" + case .contacts: return "person.circle.fill" + case .calls: return "phone.fill" + case .settings: return "gearshape.fill" + } + } + + var searchPrompt: String { + switch self { + case .chats: return "Поиск чатов" + case .contacts: return "Поиск контактов" + case .calls: return "Поиск звонков" + case .settings: return "Поиск" + } + } + } + + @ObservedObject var model: AppModel + @State private var mode: Mode = .chats + @State private var selectedJID: String? + @State private var searchText = "" + @State private var showingNewChat = false + @State private var showingNewGroup = false + @State private var pendingGroupDeletion: Conversation? + + /// SwiftData-backed chat list; pinned-first ordering is applied in + /// `sortedChats` because `Bool` is not `Comparable` and cannot be a + /// `SortDescriptor`. + @Query( + sort: [ + SortDescriptor(\Conversation.lastActivity, order: .reverse) + ] + ) + private var conversations: [Conversation] + + private var chatItems: [AccountConversation] { + let source = model.conversations.isEmpty ? conversations : model.conversations + guard let account = model.account else { + return source.map { + AccountConversation( + account: AccountConfiguration(jid: "preview@example.org"), + conversation: $0 + ) + } + } + return source.map { AccountConversation(account: account, conversation: $0) } + } + + var body: some View { + NavigationSplitView { + sidebar + .navigationSplitViewColumnWidth( + min: 240, + ideal: 290, + max: 380 + ) + .toolbar { + ToolbarItem(placement: .principal) { + Text(mode.title) + .font(.headline) + } + if mode != .settings { + ToolbarItemGroup(placement: .primaryAction) { + Button { + showingNewGroup = true + } label: { + Image(systemName: "person.3.fill") + } + .accessibilityLabel("Новый групповой чат") + .help("Новый групповой чат") + Button { + showingNewChat = true + } label: { + Image(systemName: "square.and.pencil") + } + .accessibilityLabel("Новый личный чат") + .help("Новый личный чат") + } + } + } + } detail: { + detailPane + } + .frame(minWidth: 840, minHeight: 560) + .sheet(isPresented: $showingNewChat) { + NewChatView(model: model) + } + .sheet(isPresented: $showingNewGroup) { + NewGroupView(model: model) + } + .alert( + "Удалить групповой чат?", + isPresented: Binding( + get: { pendingGroupDeletion != nil }, + set: { if !$0 { pendingGroupDeletion = nil } } + ) + ) { + Button("Удалить", role: .destructive) { + if let pendingGroupDeletion { + model.deleteGroupChat(jid: pendingGroupDeletion.jid) + } + pendingGroupDeletion = nil + } + Button("Отмена", role: .cancel) { pendingGroupDeletion = nil } + } message: { + Text( + "Чат и его история будут удалены с этого устройства. Luma выйдет из комнаты, если вы в ней." + ) + } + .onAppear { + ensureSelection() + } + } + + // MARK: Sidebar + + private var sidebar: some View { + VStack(spacing: 0) { + if mode != .settings { + ConnectionBanner(model: model, text: "") + SearchField( + text: $searchText, + prompt: mode.searchPrompt + ) + } + sidebarList + } + .safeAreaInset(edge: .bottom) { + HStack(spacing: 0) { + ForEach(Mode.allCases) { item in + Button { + withAnimation(.easeInOut) { + switchMode(item) + } + } label: { + VStack { + Image(systemName: item.icon) + .font(.largeTitle) + } + } + .frame(maxWidth: .infinity) + .foregroundColor( + mode == item ? .accentColor : .secondary + ) + .padding(.vertical, 8) + .buttonStyle(.plain) + } + } + .animation( + .spring(response: 0.35, dampingFraction: 0.82), + value: mode + ) + .glassEffect(in: .rect(cornerRadius: 20)) + } + } + + private var sidebarList: some View { + List(selection: $selectedJID) { + switch mode { + case .chats: + chatRows + case .contacts: + contactRows + case .calls: + callRows + case .settings: + // NavigationStack { + SettingsView(model: model, presentedAsTab: true) + // } + // EmptyView() + } + } + .listStyle(.sidebar) + .overlay { + if mode == .chats && filteredChats.isEmpty { + ContentUnavailableView( + searchText.isEmpty ? "Нет чатов" : "Ничего не найдено", + systemImage: searchText.isEmpty + ? "bubble.left" : "magnifyingglass", + description: Text( + searchText.isEmpty + ? "Создайте личный или групповой чат." + : "Попробуйте другой JID или имя." + ) + ) + } + if mode == .contacts && filteredRoster.isEmpty + && filteredGroups.isEmpty + { + ContentUnavailableView( + searchText.isEmpty + ? "Нет контактов" : "Ничего не найдено", + systemImage: searchText.isEmpty + ? "person.2" : "magnifyingglass", + description: Text( + searchText.isEmpty + ? "Подключитесь к серверу, чтобы загрузить roster, или создайте групповой чат." + : "Попробуйте другой JID или имя." + ) + ) + } + } + } + + @ViewBuilder + private var chatRows: some View { + ForEach(filteredChats) { item in + let conversation = item.conversation + ConversationRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid), + isEncrypted: model.encryptionEnabled(for: conversation.jid) + ) + .tag(item.id) + .contextMenu { + if conversation.isGroup { + Button("Удалить", role: .destructive) { + pendingGroupDeletion = conversation + } + } + } + } + } + + @ViewBuilder + private var contactRows: some View { + if !filteredRoster.isEmpty { + Section { + ForEach(filteredRoster) { conversation in + ContactRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid) + ) + .tag(conversation.jid) + } + } + } + if !filteredGroups.isEmpty { + Section { + ForEach(filteredGroups) { conversation in + ContactRow( + conversation: conversation, + imageData: model.avatarData(for: conversation.jid) + ) + .tag(conversation.jid) + } + } + } + } + + @ViewBuilder + private var callRows: some View { + ForEach(filteredCalls) { message in + if let conversation = conversations.first(where: { + $0.jid == message.conversationID + }) { + CallHistoryRow( + model: model, + message: message, + conversation: conversation + ) + .tag(message.clientID) + } else { + CallHistoryRow( + model: model, + message: message, + conversation: nil + ) + .tag(message.clientID) + } + } + } + + // MARK: Detail pane + + @ViewBuilder + private var detailPane: some View { + if mode == .settings { + ContentUnavailableView( + emptyTitle, + systemImage: "gearshape.fill", + description: Text("Выберите пункт из списка слева.") + ) + } else if let conversation = selectedConversation + ?? defaultConversation + { + ChatView(model: model, conversation: conversation) + .id(conversation.jid) + .onAppear { + if let item = chatItems.first(where: { $0.id == selectedJID }), + item.account.normalizedJID != model.account?.normalizedJID { + Task { + await model.switchAccount(to: item.account) + model.selectConversation(id: conversation.jid) + } + } else { + model.selectConversation(id: conversation.jid) + } + } + } else { + ContentUnavailableView( + emptyTitle, + systemImage: "bubble.left.and.bubble.right", + description: Text("Выберите пункт из списка слева.") + ) + } + } + + private var emptyTitle: String { + switch mode { + case .chats: return "Выберите чат" + case .contacts: return "Выберите контакт" + case .calls: return "Выберите звонок" + case .settings: return "Настройки" + } + } + + private var selectedConversation: Conversation? { + guard let selectedJID else { return nil } + if mode == .calls { + guard + let message = model.callHistoryMessages.first(where: { + $0.clientID == selectedJID + }) + else { + return nil + } + return chatItems.first(where: { $0.conversation.jid == message.conversationID })?.conversation + } + return chatItems.first(where: { $0.id == selectedJID || $0.conversation.jid == selectedJID })?.conversation + } + + private var defaultConversation: Conversation? { + mode == .chats ? sortedChats.first?.conversation : nil + } + + // MARK: Filtering and sorting + + private var sortedChats: [AccountConversation] { + chatItems.sorted(by: conversationSort) + } + + private var filteredChats: [AccountConversation] { + searchText.isEmpty ? sortedChats : sortedChats.filter(matchesChat) + } + + private func matchesChat(_ item: AccountConversation) -> Bool { + let conversation = item.conversation + return conversation.displayName.localizedCaseInsensitiveContains( + searchText + ) + || conversation.jid.localizedCaseInsensitiveContains(searchText) + || item.account.normalizedJID.localizedCaseInsensitiveContains(searchText) + } + + private func conversationSort(_ lhs: AccountConversation, _ rhs: AccountConversation) + -> Bool + { + let lhs = lhs.conversation + let rhs = rhs.conversation + 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 var filteredRoster: [Conversation] { + conversations + .filter { + !$0.isGroup && model.rosterContactJIDs.contains($0.jid) + } + .filter { searchText.isEmpty || matchesChat(AccountConversation(account: model.account ?? AccountConfiguration(jid: "preview@example.org"), conversation: $0)) } + .sorted(by: contactSort) + } + + private var filteredGroups: [Conversation] { + conversations + .filter { $0.isGroup } + .filter { searchText.isEmpty || matchesChat(AccountConversation(account: model.account ?? AccountConfiguration(jid: "preview@example.org"), conversation: $0)) } + .sorted(by: contactSort) + } + + private func contactSort(_ lhs: Conversation, _ rhs: Conversation) + -> Bool + { + lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + == .orderedAscending + } + + private var filteredCalls: [ChatMessage] { + guard !searchText.isEmpty else { return model.callHistoryMessages } + return model.callHistoryMessages.filter { message in + message.conversationID.localizedCaseInsensitiveContains( + searchText + ) + || (conversations.first(where: { + $0.jid == message.conversationID + })?.displayName + .localizedCaseInsensitiveContains(searchText) ?? false) + } + } + + // MARK: Selection + + private func switchMode(_ newMode: Mode) { + mode = newMode + selectedJID = nil + searchText = "" + if newMode == .chats { + selectedJID = sortedChats.first?.id + } + } + + private func ensureSelection() { + if mode == .chats, selectedJID == nil { + selectedJID = sortedChats.first?.id + } + } + } +#endif + +#Preview { + PreviewSupport.mainPreview() +} diff --git a/Sources/Shared/UI/NewChatView.swift b/Sources/Shared/UI/NewChatView.swift index 7905d5b..dd4e804 100644 --- a/Sources/Shared/UI/NewChatView.swift +++ b/Sources/Shared/UI/NewChatView.swift @@ -8,18 +8,10 @@ struct NewChatView: View { @State private var addToRoster = true var body: some View { +#if os(iOS) NavigationStack { Form { - Section("Контакт") { - TextField("user@example.org", text: $jid) - .disableAutocorrection(true) - TextField("Имя (необязательно)", text: $name) - } - Section { - Toggle("Добавить в roster", isOn: $addToRoster) - } footer: { - Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.") - } + formcontent() } .navigationTitle("Новый чат") .toolbar { @@ -40,8 +32,52 @@ struct NewChatView: View { } } } -#if os(macOS) - .frame(minWidth: 380, minHeight: 300) +#else + List { + formcontent() + HStack { + Button("Отмена") { dismiss() } + .buttonStyle(.glass) + .controlSize(.large) + .tint(.secondary) + Spacer() + 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) + .buttonStyle(.glassProminent) + .controlSize(.large) + } + } + .navigationTitle("Новый чат") + .frame(minWidth: 380, minHeight: 220) #endif } + @ViewBuilder + private func formcontent() -> some View { + Section("Контакт") { + TextField("user@example.org", text: $jid) + .disableAutocorrection(true) + #if os(iOS) + .keyboardType(.emailAddress) + #endif + TextField("Имя (необязательно)", text: $name) + } + Section { + Toggle("Добавить в roster", isOn: $addToRoster) + .toggleStyle(.switch) + } footer: { + Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.") + } + } +} + +#Preview { + NewChatView(model: PreviewSupport.model) } diff --git a/Sources/Shared/UI/NewGroupView.swift b/Sources/Shared/UI/NewGroupView.swift index 571236d..f2325e8 100644 --- a/Sources/Shared/UI/NewGroupView.swift +++ b/Sources/Shared/UI/NewGroupView.swift @@ -13,59 +13,87 @@ struct NewGroupView: View { @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) + #if os(iOS) + NavigationStack { + Form { + formcontent() } - - 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) + .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 } - } label: { - Text("Пригласить") - .font(.headline) } - - Label( - "Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.", - systemImage: "lock.shield.fill" - ) - .font(.footnote) - .foregroundStyle(.secondary) } - .navigationTitle("Новая группа") - .toolbar { - ToolbarItem(placement: .cancellationAction) { + #else + List { + formcontent() + HStack { Button("Отмена") { dismiss() } .disabled(isJoining) - } - ToolbarItem(placement: .confirmationAction) { + .buttonStyle(.glass) + .tint(.secondary) + .controlSize(.large) + Spacer() Button("Создать / войти") { join() } .fontWeight(.semibold) .disabled(!canJoin || isJoining) + .buttonStyle(.glassProminent) + .controlSize(.large) } } + .navigationTitle("Новая группа") .onAppear { if nickname.isEmpty { nickname = model.suggestedGroupNickname } } + .frame(minWidth: 480, minHeight: 330) + #endif + } + + @ViewBuilder + private func formcontent() -> some View { + Section("Комната") { + TextField("room@conference.example.org", text: $roomJID) + .disableAutocorrection(true) + #if os(iOS) + .keyboardType(.emailAddress) + #endif + TextField("Название (необязательно)", text: $name) + TextField("Ваш псевдоним", text: $nickname) } -#if os(macOS) - .frame(minWidth: 480, minHeight: 470) -#endif + Section("Пригласить") { + TextField( + "alice@example.org, bob@example.org", + text: $invitees, + axis: .vertical + ) + .lineLimit(2...5) + #if os(iOS) + .keyboardType(.emailAddress) + #endif + Text( + "Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию." + ) + .font(.footnote) + .foregroundStyle(.secondary) + } + Label( + "Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.", + systemImage: "lock.shield.fill" + ) + .font(.footnote) + .foregroundStyle(.secondary) } private var canJoin: Bool { @@ -92,3 +120,7 @@ struct NewGroupView: View { .filter { !$0.isEmpty } } } + +#Preview { + NewGroupView(model: PreviewSupport.model) +} diff --git a/Sources/Shared/UI/PreviewSupport.swift b/Sources/Shared/UI/PreviewSupport.swift new file mode 100644 index 0000000..2f10035 --- /dev/null +++ b/Sources/Shared/UI/PreviewSupport.swift @@ -0,0 +1,300 @@ +import Foundation +import SwiftData +import SwiftUI + +/// Shared fixtures for SwiftUI previews. +/// +/// Building an `AppModel` wires up the XMPP service, the WebRTC call engine +/// and the keychain-backed stores, so previews share one instance instead of +/// rebuilding it per canvas (the model skips account bootstrap inside the +/// preview host). SwiftData screens share one stable in-memory container and +/// seeded context so `@Query`-backed lists render sample data without +/// touching the real archive or re-fetching on every body evaluation. +enum PreviewSupport { + /// Single shared model: no account, no network traffic, connection is + /// "disconnected" — every screen renders in its resting state. + static let model: AppModel = MainActor.assumeIsolated { AppModel() } + + // MARK: - SwiftData + + /// Shared in-memory container for `@Query`-backed previews. One stable + /// container/context pair keeps SwiftUI list diffing from seeing a + /// brand-new fetch result on every body evaluation. + static let container: ModelContainer = { + do { + return try ModelContainer( + for: Conversation.self, ChatMessage.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + } catch { + fatalError("Failed to create the preview container: \(error)") + } + }() + + /// Seeded context: two direct chats, one group and a short Alice thread. + @MainActor + static let previewContext: ModelContext = { + let context = container.mainContext + + let alice = conversation( + jid: "alice@example.org", + displayName: "Алиса", + lastMessage: "Договорились 👌", + lastActivity: Date().addingTimeInterval(-120), + unreadCount: 2, + isOnline: true + ) + let bob = conversation( + jid: "bob@example.org", + displayName: "Боб", + lastMessage: "Файл: отчёт.pdf", + lastActivity: Date().addingTimeInterval(-3_600) + ) + let team = conversation( + jid: "team@conference.example.org", + displayName: "Команда Luma", + lastMessage: "Иван: созвон в 15:00", + lastActivity: Date().addingTimeInterval(-86_400), + unreadCount: 5, + kind: .group, + isGroupJoined: true, + occupantCount: 7 + ) + context.insert(alice) + context.insert(bob) + context.insert(team) + + let aliceJID = alice.jid + context.insert( + message( + conversationID: aliceJID, + senderJID: aliceJID, + body: "Привет! Как дела?", + timestamp: Date().addingTimeInterval(-600), + direction: .incoming, + delivery: .delivered + ) + ) + context.insert( + message( + conversationID: aliceJID, + senderJID: "me@example.org", + body: "Отлично! Созвонимся вечером?", + timestamp: Date().addingTimeInterval(-420), + direction: .outgoing, + delivery: .sent + ) + ) + context.insert( + message( + conversationID: aliceJID, + senderJID: aliceJID, + body: "Договорились 👌", + timestamp: Date().addingTimeInterval(-60), + direction: .incoming, + delivery: .delivered + ) + ) + return context + }() + + // MARK: - Models + + static func conversation( + jid: String = "alice@example.org", + displayName: String? = nil, + lastMessage: String = "", + lastActivity: Date = .distantPast, + unreadCount: Int = 0, + isOnline: Bool = false, + isPinned: Bool = false, + encryptionPreference: EncryptionPreference = .inheritGlobal, + kind: Conversation.Kind = .direct, + groupNickname: String? = nil, + isGroupJoined: Bool = false, + shouldAutojoin: Bool = false, + occupantCount: Int = 0, + invitedBy: String? = nil + ) -> Conversation { + Conversation( + jid: jid, + displayName: displayName, + lastMessage: lastMessage, + lastActivity: lastActivity, + unreadCount: unreadCount, + isOnline: isOnline, + isPinned: isPinned, + encryptionPreference: encryptionPreference, + kind: kind, + groupNickname: groupNickname, + isGroupJoined: isGroupJoined, + shouldAutojoin: shouldAutojoin, + occupantCount: occupantCount, + invitedBy: invitedBy + ) + } + + static func message( + id: String = UUID().uuidString, + conversationID: String = "alice@example.org", + senderJID: String = "alice@example.org", + body: String = "Привет! Как дела?", + timestamp: Date = Date(), + direction: ChatMessage.Direction = .incoming, + delivery: ChatMessage.Delivery = .delivered, + security: ChatMessage.Security = .omemo, + kind: ChatMessage.Kind = .text, + remoteAttachmentURL: String? = nil, + localFilename: String? = nil, + mimeType: String? = nil, + duration: TimeInterval? = nil, + byteCount: Int? = nil, + senderDisplayName: String? = nil, + isGroupMessage: Bool = false + ) -> ChatMessage { + ChatMessage( + id: id, + conversationID: conversationID, + senderJID: senderJID, + body: body, + timestamp: timestamp, + direction: direction, + delivery: delivery, + security: security, + kind: kind, + remoteAttachmentURL: remoteAttachmentURL, + localFilename: localFilename, + mimeType: mimeType, + duration: duration, + byteCount: byteCount, + senderDisplayName: senderDisplayName, + isGroupMessage: isGroupMessage + ) + } + + static func photoMessage( + body: String = "Фото с прогулки", + filename: String = "IMG_1234.jpg", + byteCount: Int = 2_420_112 + ) -> ChatMessage { + message( + body: body, + kind: .photo, + localFilename: filename, + mimeType: "image/jpeg", + byteCount: byteCount + ) + } + + static func videoMessage( + body: String = "Видео с концерта", + filename: String = "IMG_5678.mov", + duration: TimeInterval = 42, + byteCount: Int = 18_330_240 + ) -> ChatMessage { + message( + body: body, + kind: .video, + localFilename: filename, + mimeType: "video/quicktime", + duration: duration, + byteCount: byteCount + ) + } + + static func videoNoteMessage( + body: String = "", + duration: TimeInterval = 12 + ) -> ChatMessage { + message( + body: body, + kind: .videoNote, + mimeType: "video/mp4", + duration: duration, + byteCount: 1_840_000 + ) + } + + static func voiceMessage( + body: String = "", + duration: TimeInterval = 9 + ) -> ChatMessage { + message( + body: body, + kind: .voice, + mimeType: "audio/m4a", + duration: duration, + byteCount: 180_000 + ) + } + + static func callSnapshot( + peerJID: String = "alice@example.org", + direction: CallDirection = .incoming, + media: Set = [.video], + phase: CallPhase = .ringing, + isMuted: Bool = false, + isCameraEnabled: Bool = true, + isSpeakerEnabled: Bool = true + ) -> CallSnapshot { + CallSnapshot( + id: UUID(), + peerJID: peerJID, + direction: direction, + media: media, + phase: phase, + connectedAt: phase == .connected ? Date().addingTimeInterval(-75) : nil, + isMuted: isMuted, + isCameraEnabled: isCameraEnabled, + isSpeakerEnabled: isSpeakerEnabled, + hasLocalVideo: media.contains(.video), + hasRemoteVideo: media.contains(.video) + ) + } + + static func drafts() -> [AttachmentDraft] { + [ + AttachmentDraft( + url: URL(fileURLWithPath: "/tmp/preview-photo.jpg"), + filename: "IMG_1234.jpg", + mimeType: "image/jpeg", + kind: .photo, + byteCount: 2_420_112 + ), + AttachmentDraft( + url: URL(fileURLWithPath: "/tmp/preview-video.mov"), + filename: "IMG_5678.mov", + mimeType: "video/quicktime", + kind: .video, + duration: 21, + byteCount: 18_330_240 + ), + ] + } + + // MARK: - Composed scenes + + /// Populated chat screen: a conversation plus the seeded Alice thread so + /// the `@Query`-driven timeline renders bubbles instead of an empty + /// state. + @MainActor + static func chatPreview() -> some View { + let conversation = conversation( + jid: "alice@example.org", + displayName: "Алиса", + lastMessage: "Договорились 👌", + lastActivity: Date().addingTimeInterval(-60), + isOnline: true + ) + return ChatView(model: model, conversation: conversation) + .environment(\.modelContext, previewContext) + } + + /// Main screen backed by the seeded in-memory context so the chat list + /// shows conversations on both platforms. + @MainActor + static func mainPreview() -> some View { + MainTabView(model: model) + .environment(\.modelContext, previewContext) + } +} diff --git a/Sources/Shared/UI/RootView.swift b/Sources/Shared/UI/RootView.swift index 269b465..e5b96fa 100644 --- a/Sources/Shared/UI/RootView.swift +++ b/Sources/Shared/UI/RootView.swift @@ -1,40 +1,87 @@ import QuickLook +import SwiftData import SwiftUI struct RootView: View { @ObservedObject var model: AppModel + @State private var toastDismissTask: Task? var body: some View { ZStack { - Group { - if model.account == nil { - LoginView(model: model) - } else { - MainChatView(model: model) + if model.isAppLocked { + // The lock covers every layer: chat list, media viewer and + // calls stay hidden until the passcode or biometrics succeed. + AppLockView(model: model) + } else { + Group { + if model.account == nil { + LoginView(model: model) + } else { + MainTabView(model: model) + .environment(\.modelContext, model.modelContext) + } } - } - if let item = model.mediaViewerItem { - MediaViewer(item: item, onClose: model.closeMediaViewer) - .transition(.opacity.combined(with: .scale(scale: 0.98))) - .zIndex(100) - } + 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) + if let call = model.activeCall { + CallView(model: model, call: call) + .transition(.opacity.combined(with: .scale(scale: 1.015))) + .zIndex(200) + } + + if let info = model.informationalMessage { + VStack { + toast(info) + Spacer() + } + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(300) + } } } .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) + .animation(.spring(response: 0.3, dampingFraction: 0.85), value: model.informationalMessage) .alert("Luma", isPresented: errorBinding) { Button("OK") { model.clearError() } } message: { Text(model.errorMessage ?? "Неизвестная ошибка") } .quickLookPreview($model.previewURL) + .onChange(of: model.informationalMessage) { _, newValue in + guard newValue != nil else { return } + toastDismissTask?.cancel() + toastDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_500_000_000) + guard !Task.isCancelled else { return } + model.clearInformationalMessage() + } + } + } + + /// Compact Telegram-style confirmation toast shown for a couple of + /// seconds above every layer (lists, media viewer, calls). + private func toast(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + Text(message) + .font(.subheadline.weight(.medium)) + .lineLimit(2) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.06))) + .shadow(color: .black.opacity(0.18), radius: 12, y: 4) + .padding(.top, 8) + .accessibilityIdentifier("info-toast") } private var errorBinding: Binding { @@ -44,3 +91,7 @@ struct RootView: View { ) } } + +#Preview { + RootView(model: PreviewSupport.model) +} diff --git a/Sources/Shared/UI/ServerInfoView.swift b/Sources/Shared/UI/ServerInfoView.swift new file mode 100644 index 0000000..5d4e670 --- /dev/null +++ b/Sources/Shared/UI/ServerInfoView.swift @@ -0,0 +1,404 @@ +import SwiftUI + +/// "Server information" screen shown from Settings, mirroring Monal's +/// `ServerDetails`: a grouped list of headline + caption entries whose row +/// background is tinted green/orange/red according to the detected status. +struct ServerInfoView: View { + @ObservedObject var model: AppModel + @State private var isLoading = false + + var body: some View { + #if os(iOS) + Form { + infocontent() + } + .navigationTitle(model.account?.domain ?? "Сервер") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await load() } + } label: { + Label("Обновить", systemImage: "arrow.clockwise") + } + .disabled(isLoading) + } + } + .task { await load() } + .listStyle(.grouped) + #else + List { + infocontent() + } + .navigationTitle(model.account?.domain ?? "Сервер") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await load() } + } label: { + Label("Обновить", systemImage: "arrow.clockwise") + } + .disabled(isLoading) + } + } + .task { await load() } + .listStyle(.inset) + .frame(minWidth: 480, minHeight: 420) + #endif + } + + @ViewBuilder + private func infocontent() -> some View { + if let info = model.serverInformation { + Section("Это статистика вашего соединения.") { + statisticsEntries(info) + } + Section("Это программное обеспечение, работающее на вашем сервере.") { + softwareEntry(info) + } + Section { + connectionRows(info) + } header: { + Text("Параметры подключения к вашему серверу.") + } footer: { + Text( + "Luma автоматически выбирает самый безопасный вариант: при TLS договаривается о максимальной версии (1.3 > 1.2), а при входе — о самом стойком SASL-механизме, который поддерживает сервер." + ) + } + Section("Современные возможности XMPP, обнаруженные на вашем сервере после входа.") { + ForEach(info.capabilities) { capability in + entry( + title: capability.title, + detail: capability.detail, + status: capability.status + ) + } + } + Section("MUC-серверы, обнаруженные на вашем сервере.") { + mucEntries(info) + } + Section("STUN и TURN сервисы, объявленные вашим сервером.") { + stunTurnEntries(info) + } + Section("Методы аутентификации SASL, которые поддерживает ваш сервер.") { + saslEntries(info) + } + Section("Методы аутентификации SASL2 (RFC 9050), которые поддерживает ваш сервер.") { + sasl2Entries(info) + } + Section { + channelBindingEntries(info) + } header: { + Text("Привязка канала (channel binding), которую поддерживает ваш сервер.") + } footer: { + Text( + "Channel binding связывает SASL-обмен с TLS-каналом: MITM-ретрансляция не сможет подменить соединение. Зелёным отмечен тип, использованный в текущем соединении, оранжевым — типы, которые Luma не поддерживает." + ) + } + } else if isLoading { + Section { + HStack(spacing: 12) { + ProgressView() + Text("Загрузка информации о сервере…") + .foregroundStyle(.secondary) + } + } + } else { + Section { + ContentUnavailableView( + "Нет данных о сервере", + systemImage: "server.rack", + description: Text( + model.connectionStatus == .connected + ? "Сервер не ответил на запрос. Попробуйте обновить." + : "Подключитесь к серверу, чтобы увидеть его возможности." + ) + ) + } + } + } + + // MARK: - Sections + + private func softwareEntry(_ info: ServerInformation) -> some View { + let name = info.software.name ?? "Неизвестный сервер" + let version = info.software.version ?? "неизвестная версия" + let platform = info.software.os.map { ", работает на \($0)" } ?? "" + return VStack(alignment: .leading, spacing: 4) { + Text(name) + .font(.headline) + Text("версия \(version)\(platform)") + .font(.caption) + .foregroundStyle(.secondary) + Link( + "Рекомендации для администраторов серверов", + destination: URL( + string: "https://github.com/monal-im/Monal/wiki/Considerations-for-XMPP-server-admins" + )! + ) + .font(.caption) + } + .frame(maxWidth: .infinity, alignment: .leading) + .listRowBackground(Color.gray.opacity(0.16)) + } + + private func connectionRows(_ info: ServerInformation) -> some View { + Group { + if let account = model.account { + if let host = account.manualHost { + let port = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222) + LabeledContent("Хост", value: "\(host):\(port)") + LabeledContent( + "Шифрование", + value: account.usesDirectTLS ? "Direct TLS" : "STARTTLS" + ) + } else { + LabeledContent("Хост", value: "DNS SRV") + } + LabeledContent("Ресурс", value: account.effectiveResource) + } + if let tlsVersion = info.tlsVersion { + LabeledContent( + "Версия TLS", + value: info.tlsCipher.map { "\(tlsVersion) · \($0)" } ?? tlsVersion + ) + } else { + LabeledContent("Версия TLS", value: "Неизвестно") + } + if let saslMechanism = info.saslMechanism { + LabeledContent("SASL-механизм", value: saslMechanism) + } else { + LabeledContent("SASL-механизм", value: "Неизвестно") + } + } + } + + @ViewBuilder + private func mucEntries(_ info: ServerInformation) -> some View { + if info.conferenceServers.isEmpty { + entry( + title: "Нет", + detail: "Этот сервер не предоставляет MUC-серверы.", + status: .error + ) + } else { + ForEach(info.conferenceServers) { server in + entry( + title: "Сервер: \(server.jid)", + detail: "\(server.name ?? "<неизвестное имя>") (тип «\(server.type)», категория «\(server.category)»)", + status: server.type == "text" ? .success : .normal + ) + } + } + } + + @ViewBuilder + private func stunTurnEntries(_ info: ServerInformation) -> some View { + if info.externalServices.isEmpty { + entry( + title: "Нет", + detail: "Этот сервер не предоставляет STUN / TURN сервисы.", + status: .error + ) + } else { + ForEach(info.externalServices) { service in + let endpoint = "\(service.host):\(service.port.map(String.init) ?? "-")" + entry( + title: service.type.uppercased(), + detail: endpoint, + status: ["stun", "turn", "stuns", "turns"].contains(service.type.lowercased()) + ? .success + : .error + ) + } + } + } + + @ViewBuilder + private func statisticsEntries(_ info: ServerInformation) -> some View { + let stats = info.connectionStats + entry( + title: "Последний вход", + detail: formatDate(stats.lastLogin), + status: .normal + ) + entry( + title: "Сессия Stream Management установлена", + detail: formatDate(stats.smacksSessionEstablished), + status: .normal + ) + entry( + title: "Станз отправлено / не подтверждено / получено в этой сессии", + detail: "\(stats.sent) / \(stats.unacknowledged) / \(stats.received)", + status: .normal + ) + } + + @ViewBuilder + private func saslEntries(_ info: ServerInformation) -> some View { + if info.saslMethods.isEmpty { + entry( + title: "Нет", + detail: "Сервер не объявил методы SASL-аутентификации.", + status: .error + ) + } else { + ForEach(info.saslMethods, id: \.self) { method in + entry( + title: "Метод: \(method)", + detail: method == info.usedSASLMechanism + ? "Используется в текущем соединении. " + saslDescription(method) + : saslDescription(method), + status: method == info.usedSASLMechanism + ? .success + : (SASLMechanismPreference.weakMechanisms.contains(method) + ? .warning + : .normal) + ) + } + } + } + + @ViewBuilder + private func sasl2Entries(_ info: ServerInformation) -> some View { + if info.sasl2Methods.isEmpty { + entry( + title: "Нет", + detail: "Сервер не объявил методы аутентификации SASL2 (RFC 9050).", + status: .normal + ) + } else { + ForEach(info.sasl2Methods, id: \.self) { method in + entry( + title: "Метод: \(method)", + detail: method == info.usedSASLMechanism + ? "Используется в текущем соединении. " + sasl2Description(method) + : sasl2Description(method), + status: method == info.usedSASLMechanism + ? .success + : (SASLMechanismPreference.weakMechanisms.contains(method) + ? .warning + : .normal) + ) + } + } + } + + private func sasl2Description(_ method: String) -> String { + switch method { + case "PLAIN": + return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно." + case "OAUTHBEARER": + return "OAuth 2.0 bearer-токен: пароль не передаётся, но сам токен — bearer-секрет, не очень безопасно." + case "EXTERNAL": + return "Использует TLS-клиентские сертификаты для аутентификации." + case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"): + return "Salted Challenge Response Authentication Mechanism с channel-binding." + case let method where method.hasPrefix("SCRAM-"): + return "Salted Challenge Response Authentication Mechanism на указанном хэше." + default: + return "Неизвестный метод аутентификации." + } + } + + /// Channel-binding types Luma can actually produce binding data for. + private static let supportedChannelBindingTypes: Set = [ + "tls-exporter", "tls-server-end-point", + ] + + @ViewBuilder + private func channelBindingEntries(_ info: ServerInformation) -> some View { + if info.channelBindingTypes.isEmpty { + entry( + title: "Нет", + detail: "Сервер не рекламирует channel-binding типы (XEP-0440): SASL-обмен не детектирует MITM-атаку на TLS-слой.", + status: .warning + ) + } else { + ForEach(info.channelBindingTypes, id: \.self) { type in + let used = info.usedChannelBindingType == type + let supported = Self.supportedChannelBindingTypes.contains(type) + entry( + title: "Тип: \(type)", + detail: channelBindingDescription(type, used: used, supported: supported), + status: used ? .success : (supported ? .normal : .warning) + ) + } + } + } + + private func channelBindingDescription( + _ type: String, + used: Bool, + supported: Bool + ) -> String { + if used { + return "Используется в текущем соединении." + } + if supported { + return "Luma поддерживает этот тип привязки." + } + return "Luma не поддерживает этот тип привязки." + } + + private func saslDescription(_ method: String) -> String { + switch method { + case "PLAIN": + return "Отправляет пароль открытым текстом (шифруется только TLS), не очень безопасно." + case "EXTERNAL": + return "Использует TLS-клиентские сертификаты для аутентификации." + case let method where method.hasPrefix("SCRAM-") && method.hasSuffix("-PLUS"): + return "Salted Challenge Response Authentication Mechanism с channel-binding." + case let method where method.hasPrefix("SCRAM-"): + return "Salted Challenge Response Authentication Mechanism на указанном хэше." + default: + return "Неизвестный метод аутентификации." + } + } + + private func formatDate(_ date: Date?) -> String { + guard let date else { return "<неизвестно>" } + return DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short) + } + + // MARK: - Entry + + private func entry( + title: String, + detail: String, + status: ServerInformation.CapabilityStatus + ) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } +// .frame(maxWidth: .infinity, alignment: .leading) + .listRowBackground(status.color) + } + + private func load() async { + guard model.connectionStatus == .connected else { return } + isLoading = true + await model.refreshServerInformation() + isLoading = false + } +} + +private extension ServerInformation.CapabilityStatus { + var color: Color { + switch self { + case .success: return Color.green.opacity(0.16) + case .normal: return Color.gray.opacity(0.16) + case .warning: return Color.orange.opacity(0.16) + case .error: return Color.red.opacity(0.16) + } + } +} + +#Preview { + NavigationStack { + ServerInfoView(model: PreviewSupport.model) + } +} diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift index 8ef85a5..cbb1c0f 100644 --- a/Sources/Shared/UI/SettingsView.swift +++ b/Sources/Shared/UI/SettingsView.swift @@ -1,146 +1,112 @@ +import LocalAuthentication import PhotosUI import SwiftUI struct SettingsView: View { @ObservedObject var model: AppModel + /// When embedded as the «Настройки» tab there is no sheet to dismiss, + /// so the confirmation toolbar button is hidden. + var presentedAsTab: Bool = false @Environment(\.dismiss) private var dismiss @State private var showingSignOutConfirmation = false @State private var forgetHistory = false @State private var avatarItem: PhotosPickerItem? + @State private var passcodeSheetMode: AppLockPasscodeSheet.Mode? + @State private var showingAddAccount = false 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) + #if os(iOS) + NavigationStack { + Form { + formcontent() + } + .sheet(isPresented: $showingAddAccount) { LoginView(model: model) } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Настройки") + .font(.headline) + } + if !presentedAsTab { + ToolbarItem(placement: .confirmationAction) { + Button("Готово") { dismiss() } + } + } + } + } + #else + formcontent().sheet(isPresented: $showingAddAccount) { LoginView(model: model) } + #endif + } + + @ViewBuilder + private func formcontent() -> some View { + if let account = model.account { + Section("Аккаунты") { + ForEach(model.accounts) { configured in + Button { + Task { await model.switchAccount(to: configured) } + } label: { + HStack { + Image(systemName: configured.id == account.id ? "checkmark.circle.fill" : "person.circle") + .foregroundStyle(configured.id == account.id ? .green : .secondary) + VStack(alignment: .leading) { + Text(configured.displayName.isEmpty ? configured.normalizedJID : configured.displayName) + if !configured.displayName.isEmpty { Text(configured.normalizedJID).font(.caption).foregroundStyle(.secondary) } } 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") } } + .buttonStyle(.plain) } - - 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") - } + Button { + showingAddAccount = true + } label: { + Label("Добавить аккаунт", systemImage: "person.badge.plus") } } - .navigationTitle("Настройки") - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Готово") { dismiss() } - } - } - .confirmationDialog( - "Выйти из Luma?", - isPresented: $showingSignOutConfirmation, - titleVisibility: .visible - ) { - Button("Выйти", role: .destructive) { - Task { - await model.signOut(forgetHistory: forgetHistory) - dismiss() + 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() } } - 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 } + guard + let data = try await item.loadTransferable( + type: Data.self + ) + else { return } await model.updateOwnAvatar(from: data) avatarItem = nil } catch { @@ -148,10 +114,187 @@ struct SettingsView: View { } } } + + 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 ? "Синхронизация" : "Готово" + ) + NavigationLink { + ServerInfoView(model: model) + } label: { + Label("Информация о сервере", systemImage: "server.rack") + } + } + + Section("Шифрование") { + Toggle( + "OMEMO по умолчанию", + isOn: Binding( + get: { model.globalEncryptionEnabled }, + set: { model.setGlobalEncryptionEnabled($0) } + ) + ) + .toggleStyle(.switch) + 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.appLockIsEnabled }, + set: { enabled in + if enabled { + passcodeSheetMode = .setup + } else { + passcodeSheetMode = .verify(.disableLock) + } + } + ) + ) + .toggleStyle(.switch) + .sheet(item: $passcodeSheetMode) { mode in + AppLockPasscodeSheet(model: model, mode: mode) + } + if model.appLockIsEnabled { + Toggle( + "Вход по \(model.biometricUnlockName)", + isOn: Binding( + get: { model.appLockBiometricIsEnabled }, + set: { enabled in + passcodeSheetMode = .verify( + enabled ? .enableBiometrics : .disableBiometrics + ) + } + ) + ) + .toggleStyle(.switch) + .disabled(!model.biometricUnlockAvailable) + Button("Сменить пароль") { + passcodeSheetMode = .change + } + } + Text( + "При включённой блокировке Luma запрашивает пароль при запуске и после сворачивания. Пароль хранится в Keychain." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Приватность чата") { + Toggle( + "Показывать статус набора", + isOn: Binding( + get: { model.typingIndicatorsEnabled }, + set: { model.setTypingIndicatorsEnabled($0) } + ) + ) + .toggleStyle(.switch) + Text( + "Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section { + Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory) + .toggleStyle(.switch) + + Button(role: .destructive) { + showingSignOutConfirmation = true + } label: { + Label( + "Выйти из аккаунта", + systemImage: "rectangle.portrait.and.arrow.right" + ) + } + #if os(macOS) + .buttonStyle(.glassProminent) + .controlSize(.large) + #endif + } + .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 не изменится." + ) } -#if os(macOS) - .frame(minWidth: 500, minHeight: 560) -#endif } private var connectionTitle: String { @@ -179,16 +322,21 @@ struct SettingsView: View { } } -private extension String { - func chunked(every size: Int) -> [String] { +extension String { + fileprivate 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 + let end = + index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex result.append(String(self[cursor.. Void let onMedia: (Result) -> Void let onCancel: () -> Void @@ -20,7 +28,11 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(onMedia: onMedia, onCancel: onCancel) + Coordinator( + onDismissRequest: onDismissRequest, + onMedia: onMedia, + onCancel: onCancel + ) } func makeUIViewController(context: Context) -> UIImagePickerController { @@ -42,14 +54,17 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { ) {} final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { + private let onDismissRequest: () -> Void private let onMedia: (Result) -> Void private let onCancel: () -> Void private var completed = false init( + onDismissRequest: @escaping () -> Void, onMedia: @escaping (Result) -> Void, onCancel: @escaping () -> Void ) { + self.onDismissRequest = onDismissRequest self.onMedia = onMedia self.onCancel = onCancel } @@ -60,6 +75,7 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { ) { guard !completed else { return } completed = true + onDismissRequest() let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) } if mediaType?.conforms(to: .movie) == true, @@ -100,29 +116,34 @@ struct SystemPhotoCameraView: UIViewControllerRepresentable { } } + /// Claims the picker-owned temporary movie synchronously, while the + /// picker is still alive: UIImagePickerController can delete its + /// temporary file as soon as it is dismissed, so a background copy + /// races that cleanup and fails with an unreadable source. The move + /// is a same-volume rename and does not block the main thread. 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) + let logger = Logger(subsystem: "Luma", category: "video-preview") + do { + let sourceSize = (try? sourceURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? -1 + logger.info("picker movie ready: source=\(sourceURL.lastPathComponent) size=\(sourceSize)") + let fileExtension = sourceURL.pathExtension.isEmpty + ? "mov" + : sourceURL.pathExtension + let destination = try Self.destinationURL(extension: fileExtension) + try FileManager.default.moveItem(at: sourceURL, to: destination) + let values = try destination.resourceValues( + forKeys: [.fileSizeKey, .isRegularFileKey] + ) + let movedSize = values.fileSize ?? 0 + logger.info("picker movie moved: size=\(movedSize) regular=\(values.isRegularFile == true)") + guard values.isRegularFile == true, movedSize > 0 else { + try? FileManager.default.removeItem(at: destination) + throw SystemCameraError.emptyMovie } - DispatchQueue.main.async { completion(result) } + onMedia(.success(CapturedCameraMedia(url: destination, kind: .video))) + } catch { + logger.error("picker movie preparation failed: \(error.localizedDescription)") + onMedia(.failure(error)) } } @@ -156,4 +177,12 @@ private enum SystemCameraError: LocalizedError { } } } + +#Preview { + SystemPhotoCameraView( + onDismissRequest: {}, + onMedia: { _ in }, + onCancel: {} + ) +} #endif diff --git a/Sources/Shared/UI/VideoNoteCaptureView.swift b/Sources/Shared/UI/VideoNoteCaptureView.swift index 7a2f047..9f8b3f0 100644 --- a/Sources/Shared/UI/VideoNoteCaptureView.swift +++ b/Sources/Shared/UI/VideoNoteCaptureView.swift @@ -13,6 +13,7 @@ struct VideoNoteCaptureView: View { @StateObject private var recorder = VideoNoteRecorder() @State private var errorMessage: String? @State private var isFinalizing = false + @State private var isSwitchingCamera = false let onComplete: (VideoNoteRecorder.Recording) -> Void @@ -32,6 +33,26 @@ struct VideoNoteCaptureView: View { .frame(maxWidth: 420) .aspectRatio(1, contentMode: .fit) .overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1)) + #if os(iOS) + .overlay(alignment: .bottomTrailing) { + if recorder.hasAlternateCamera, recorder.isPrepared { + Button(action: flipCamera) { + Image( + systemName: + "arrow.triangle.2.circlepath.camera.fill" + ) + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(.white) + .padding(10) + .background(.ultraThinMaterial, in: Circle()) + } + .buttonStyle(.plain) + .padding(14) + .disabled(isSwitchingCamera || isFinalizing) + .accessibilityLabel("Переключить камеру") + } + } + #endif Text(recordingStatus) .font(.system(.title3, design: .monospaced).weight(.semibold)) @@ -120,6 +141,19 @@ struct VideoNoteCaptureView: View { #endif } + private func flipCamera() { + guard !isSwitchingCamera, !isFinalizing else { return } + isSwitchingCamera = true + Task { @MainActor in + do { + try await recorder.switchCamera() + } catch { + errorMessage = error.localizedDescription + } + isSwitchingCamera = false + } + } + private func formatted(_ duration: TimeInterval) -> String { let seconds = max(0, Int(duration.rounded(.down))) return String(format: "%d:%02d", seconds / 60, seconds % 60) @@ -190,3 +224,7 @@ struct CameraPreview: NSViewRepresentable { } } #endif + +#Preview { + VideoNoteCaptureView { _ in } +} diff --git a/Sources/Shared/XMPP/CallHistorySync.swift b/Sources/Shared/XMPP/CallHistorySync.swift new file mode 100644 index 0000000..c445501 --- /dev/null +++ b/Sources/Shared/XMPP/CallHistorySync.swift @@ -0,0 +1,65 @@ +import Foundation +import Martin + +/// Service messages that sync call-history cards between the user's own +/// devices. After a call ends the originating device sends a message to its +/// own bare JID carrying a `` +/// payload; Carbons deliver it to the other devices and MAM archives it. +/// Receivers parse the payload and upsert the same card, deduplicating by +/// the origin-id that matches the local card's clientID. +enum CallHistorySync { + static let namespace = "https://luma.chat/call-history" + + struct Envelope: Equatable, Sendable { + let id: String + let peerJID: String + let direction: CallDirection + let isVideo: Bool + let startedAt: Date + let duration: TimeInterval? + let outcome: CallHistoryOutcome + } + + static func envelope(from message: Message) -> Envelope? { + guard let element = message.element.findChild( + name: "call-history", + xmlns: namespace + ) else { + return nil + } + let value = { (name: String) -> String? in + element.findChild(name: name)?.value + } + guard let rawPeer = value("with"), !rawPeer.isEmpty else { return nil } + let durationSeconds = value("duration").flatMap { TimeInterval($0) } + return Envelope( + id: message.originId ?? message.id ?? UUID().uuidString, + peerJID: rawPeer.lowercased(), + direction: value("direction") == "outgoing" ? .outgoing : .incoming, + isVideo: value("video") == "true", + startedAt: value("start").flatMap { startFormatter.date(from: $0) } ?? Date(), + duration: (durationSeconds ?? 0) > 0 ? durationSeconds : nil, + outcome: value("status").flatMap(CallHistoryOutcome.init(rawValue:)) ?? .failed + ) + } + + static func payloadElement(entry: CallHistoryEntry) -> Element { + let element = Element(name: "call-history", xmlns: namespace) + element.addChild(Element(name: "direction", cdata: entry.direction.rawValue)) + element.addChild(Element(name: "status", cdata: entry.outcome.rawValue)) + element.addChild( + Element(name: "duration", cdata: String(Int((entry.duration ?? 0).rounded()))) + ) + element.addChild(Element(name: "start", cdata: startFormatter.string(from: entry.startedAt))) + element.addChild(Element(name: "with", cdata: entry.peerJID.lowercased())) + element.addChild(Element(name: "video", cdata: entry.isVideo ? "true" : "false")) + return element + } + + private static let startFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} + diff --git a/Sources/Shared/XMPP/LumaCallEngine.swift b/Sources/Shared/XMPP/LumaCallEngine.swift index d1ffff0..1c57327 100644 --- a/Sources/Shared/XMPP/LumaCallEngine.swift +++ b/Sources/Shared/XMPP/LumaCallEngine.swift @@ -4,15 +4,15 @@ import Martin import WebRTC #if os(iOS) -import UIKit + import UIKit #elseif os(macOS) -import AppKit + 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 { +final class LumaCallEngine: NSObject, @unchecked Sendable { var snapshotHandler: (@MainActor (CallSnapshot?) -> Void)? var historyHandler: (@MainActor (CallHistoryEntry) -> Void)? var errorHandler: (@MainActor (String) -> Void)? @@ -76,15 +76,16 @@ final class LumaCallEngine: NSObject { 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 + #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 + let initiationType: JingleSessionInitiationType = + destination.usesMessageInitiation ? .message : .iq let session = self.makeSession( @@ -108,14 +109,16 @@ final class LumaCallEngine: NSObject { 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 - ) - }) + 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 { @@ -150,8 +153,9 @@ final class LumaCallEngine: NSObject { func answerCall(completion: @escaping (Result) -> Void) { queue.async { guard let call = self.activeCall, - call.direction == .incoming, - call.phase == .ringing else { + call.direction == .incoming, + call.phase == .ringing + else { completion(.failure(LumaCallError.noIncomingCall)) return } @@ -324,8 +328,9 @@ final class LumaCallEngine: NSObject { 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 { + 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) @@ -375,9 +380,9 @@ final class LumaCallEngine: NSObject { 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 + #if DEBUG + print("Luma Call: timeout during \(stage) after \(Int(delay)) seconds.") + #endif self.fail(self.activeCall, error: LumaCallError.timedOut) } timeoutWorkItem = item @@ -387,9 +392,9 @@ final class LumaCallEngine: NSObject { 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 + #if DEBUG + print("Luma Call: failure: \(text)") + #endif report(error) let cause: CallTerminationCause if let callError = error as? LumaCallError, case .timedOut = callError { @@ -424,14 +429,14 @@ final class LumaCallEngine: NSObject { 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 + #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, @@ -515,20 +520,22 @@ extension LumaCallEngine: JingleSessionManager { 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 + 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 + #if DEBUG + print("Luma Jingle: proceed from \(jid.stringValue) for session \(id).") + #endif scheduleTimeout( for: call.id, after: 90, @@ -551,7 +558,8 @@ extension LumaCallEngine: JingleSessionManager { case .retract(let id): guard let session = sessions[id], - session.matchesPeerEndpoint(jid) else { return } + session.matchesPeerEndpoint(jid) + else { return } finishActiveCall( cause: .remoteCancelled, sendTermination: false, @@ -560,16 +568,17 @@ extension LumaCallEngine: JingleSessionManager { case .reject(let id): guard let session = sessions[id], - let call = activeCall, - call.session === session, - session.matchesPeerEndpoint(jid) else { return } + 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 + #if DEBUG + print("Luma Jingle: ignored late reject after proceed for session \(id).") + #endif return } finishActiveCall( @@ -599,7 +608,8 @@ extension LumaCallEngine: JingleSessionManager { bundle: [String]? ) throws { try performSync { - let media = Set(contents.compactMap { CallMedia(rawValue: $0.description?.media ?? "") }) + 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] { @@ -616,7 +626,9 @@ extension LumaCallEngine: JingleSessionManager { } return } - guard activeCall == nil else { throw XMPPError.resource_constraint("Другой звонок уже активен") } + guard activeCall == nil else { + throw XMPPError.resource_constraint("Другой звонок уже активен") + } let session = makeSession( context: context, @@ -648,21 +660,22 @@ extension LumaCallEngine: JingleSessionManager { ) throws { try performSync { guard let session = sessions[sid], - session.pinPeerResource(jid) else { + 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 + #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 @@ -679,7 +692,8 @@ extension LumaCallEngine: JingleSessionManager { func sessionTerminated(for context: Context, with jid: JID, sid: String) throws { try performSync { guard let session = sessions[sid], - session.matchesPeerEndpoint(jid) else { + session.matchesPeerEndpoint(jid) + else { throw XMPPError.item_not_found } if activeCall?.session.sid == sid { @@ -703,7 +717,8 @@ extension LumaCallEngine: JingleSessionManager { ) throws { try performSync { guard let session = sessions[sid], - session.pinPeerResource(jid) else { + session.pinPeerResource(jid) + else { throw XMPPError.item_not_found } // Also learn the endpoint from an early candidate sent by the @@ -728,7 +743,8 @@ extension LumaCallEngine: JingleSessionManager { ) throws { try performSync { guard let session = sessions[sid], - session.matchesPeerEndpoint(jid) else { + session.matchesPeerEndpoint(jid) + else { throw XMPPError.item_not_found } session.contentModified(action: action, contents: contents, bundle: bundle) @@ -743,7 +759,8 @@ extension LumaCallEngine: JingleSessionManager { ) throws { try performSync { guard let session = sessions[sid], - session.matchesPeerEndpoint(jid) else { + session.matchesPeerEndpoint(jid) + else { throw XMPPError.item_not_found } session.sessionInfoReceived(info: info) @@ -789,7 +806,8 @@ private final class LumaJingleSession: JingleSession { terminate(reason: reason) return } - let action: Jingle.MessageInitiationAction = role == .initiator + let action: Jingle.MessageInitiationAction = + role == .initiator ? .retract(id: sid) : .reject(id: sid) owningContext?.module(.jingle).sendMessageInitiation(action: action, to: jid) @@ -806,9 +824,9 @@ private final class LumaJingleSession: JingleSession { if jid.resource != nil { return peer == jid } -#if DEBUG - print("Luma Jingle: pinned session \(sid) to resource \(peer.stringValue).") -#endif + #if DEBUG + print("Luma Jingle: pinned session \(sid) to resource \(peer.stringValue).") + #endif accepted(by: peer) return true } @@ -871,7 +889,7 @@ private final class LumaJingleSession: JingleSession { // MARK: - Live WebRTC call -private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { +private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate, @unchecked Sendable { let id = UUID() let startedAt = Date() unowned let engine: LumaCallEngine @@ -901,7 +919,7 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { private var connectionFailureWorkItem: DispatchWorkItem? private var isPreparingPeerConnection = false private var prepareCallbacks: [(Result) -> Void] = [] - private let webRTCSID = String(UInt64.random(in: UInt64.min ... UInt64.max)) + private let webRTCSID = String(UInt64.random(in: UInt64.min...UInt64.max)) init( engine: LumaCallEngine, @@ -1001,22 +1019,25 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { } 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 + #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() + 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 } + 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)) } @@ -1032,12 +1053,12 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { localAudioTrack = nil localVideoTrack = nil remoteVideoTrack = nil -#if os(iOS) - try? AVAudioSession.sharedInstance().setActive( - false, - options: .notifyOthersOnDeactivation - ) -#endif + #if os(iOS) + try? AVAudioSession.sharedInstance().setActive( + false, + options: .notifyOthersOnDeactivation + ) + #endif } private func createPeerConnection(iceServers: [RTCIceServer]) throws { @@ -1071,19 +1092,19 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { delegate: self ) { createdPeerConnection = candidate -#if DEBUG - if index > 0 { - print("Luma WebRTC: RTCConfiguration recovered with \(attempt.name).") - } -#endif + #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 + #if DEBUG + print( + "Luma WebRTC: RTCConfiguration rejected " + + "\(attempt.name) (\(attempt.servers.count) ICE server(s))." + ) + #endif } guard let peerConnection = createdPeerConnection else { @@ -1091,22 +1112,22 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { } 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 + #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, @@ -1192,9 +1213,11 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { guard let self else { return } self.engine.performOnQueue { if let error { -#if DEBUG - print("Luma WebRTC: setRemoteDescription failed: \(error.localizedDescription)") -#endif + #if DEBUG + print( + "Luma WebRTC: setRemoteDescription failed: \(error.localizedDescription)" + ) + #endif self.engine.callDidFail(self, error: error) return } @@ -1209,7 +1232,8 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { // 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 + self.generateDescription(type: .answer, peerConnection: peerConnection) { + result in switch result { case .success(let local): Task { @@ -1266,11 +1290,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { let martinSDP = CallSDPNormalization.martinParseInput( description.sdp ) - guard let (sdp, _) = SDP.parse( - sdpString: martinSDP, - creatorProvider: self.session.contentCreator(of:), - localRole: self.session.role - ) else { + guard + let (sdp, _) = SDP.parse( + sdpString: martinSDP, + creatorProvider: self.session.contentCreator(of:), + localRole: self.session.role + ) + else { completion(.failure(LumaCallError.sdpGenerationFailed)) return } @@ -1280,14 +1306,14 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { 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 + #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)) } @@ -1307,30 +1333,33 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { // 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 } + 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 + #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 } + 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( @@ -1356,11 +1385,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { sdpMid: contentName ), completionHandler: { error in -#if DEBUG - if let error { - print("Luma WebRTC: remote ICE candidate rejected: \(error.localizedDescription)") - } -#endif + #if DEBUG + if let error { + print( + "Luma WebRTC: remote ICE candidate rejected: \(error.localizedDescription)" + ) + } + #endif } ) } @@ -1372,17 +1403,20 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { let expectedNames = localSDP.contents.map(\.name) let receivedNames = sdp.contents.map(\.name) - guard let indices = CallSDPOrdering.answerIndices( - localOrder: expectedNames, - remoteOrder: receivedNames - ) else { + 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 { + bundleNames.isSubset(of: Set(receivedNames)) + else { throw LumaCallError.incompatibleRemoteDescription } normalizedBundle = expectedNames.filter { bundleNames.contains($0) } @@ -1394,9 +1428,9 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { } let orderedContents = indices.map { sdp.contents[$0] } -#if DEBUG - print("Luma WebRTC: reordered session-accept contents to match the local offer.") -#endif + #if DEBUG + print("Luma WebRTC: reordered session-accept contents to match the local offer.") + #endif return SDP(contents: orderedContents, bundle: normalizedBundle) } @@ -1438,7 +1472,9 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { // MARK: RTCPeerConnectionDelegate - func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} + func peerConnection( + _ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState + ) {} func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { guard let track = stream.videoTracks.first else { return } engine.performOnQueue { @@ -1449,11 +1485,13 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {} func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {} - func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + func peerConnection( + _ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState + ) { engine.performOnQueue { -#if DEBUG - print("Luma WebRTC: ICE state changed to \(String(describing: newState)).") -#endif + #if DEBUG + print("Luma WebRTC: ICE state changed to \(String(describing: newState)).") + #endif switch newState { case .connected, .completed: self.cancelScheduledConnectionFailure() @@ -1475,16 +1513,21 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { } } - func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {} + func peerConnection( + _ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState + ) {} - func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { + 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, didRemove candidates: [RTCIceCandidate] + ) {} func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} func peerConnection( @@ -1499,7 +1542,8 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { } } - func peerConnection(_ peerConnection: RTCPeerConnection, didRemove rtpReceiver: RTCRtpReceiver) { + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove rtpReceiver: RTCRtpReceiver) + { guard let track = rtpReceiver.track as? RTCVideoTrack else { return } engine.performOnQueue { guard self.remoteVideoTrack === track else { return } @@ -1511,40 +1555,43 @@ private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate { // MARK: - Queue helpers kept fileprivate for the live call object -private extension LumaCallEngine { - func performOnQueue(_ action: @escaping () -> Void) { +extension LumaCallEngine { + fileprivate func performOnQueue(_ action: @escaping () -> Void) { queue.async(execute: action) } - func performOnQueue(after delay: TimeInterval, execute item: DispatchWorkItem) { + fileprivate func performOnQueue(after delay: TimeInterval, execute item: DispatchWorkItem) { queue.asyncAfter(deadline: .now() + delay, execute: item) } - func performAttachedClient() -> XMPPClient? { + fileprivate func performAttachedClient() -> XMPPClient? { performSync { client } } } -private extension ExternalServiceDiscoveryModule.Service { - var rtcIceServer: RTCIceServer? { +extension ExternalServiceDiscoveryModule.Service { + fileprivate 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 } + 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 } + let password, !password.isEmpty + else { return nil } } if let expires, expires <= Date() { return nil } - let escapedHost = normalizedHost.contains(":") && !normalizedHost.hasPrefix("[") + let escapedHost = + normalizedHost.contains(":") && !normalizedHost.hasPrefix("[") ? "[\(normalizedHost)]" : normalizedHost var url = "\(normalizedType):\(escapedHost)" @@ -1567,9 +1614,10 @@ private extension ExternalServiceDiscoveryModule.Service { 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 } + 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) } @@ -1583,17 +1631,19 @@ enum CallSDPOrdering { } 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 + let lfNormalized = + webRTCSDP .replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") - while normalized.hasSuffix("\n") { - normalized.removeLast() + + var lines = lfNormalized.components(separatedBy: "\n") + + while lines.last?.isEmpty == true { + lines.removeLast() } - return normalized.replacingOccurrences(of: "\n", with: "\r\n") + "\r\n" + + return lines.joined(separator: "\r\n") + "\r\n" } } @@ -1632,9 +1682,11 @@ enum CallSDPCompatibility { 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 } + guard + let value = source.parameters.first(where: { + $0.key.lowercased() == "msid" + })?.value, !value.isEmpty + else { continue } currentMSIDs[source.ssrc] = value } let desiredMSIDs = canonicalFIDMSIDs( @@ -1647,19 +1699,21 @@ enum CallSDPCompatibility { 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 { + 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 - ) - ] + 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 @@ -1756,11 +1810,14 @@ private enum LumaCallError: LocalizedError { case .unsupportedByContact: return "Устройства контакта не объявили поддержку совместимых Jingle-звонков." case .mediaEngineUnavailable: - return "WebRTC не смог создать соединение даже без STUN/TURN. Очистите сборку и переустановите приложение." + return + "WebRTC не смог создать соединение даже без STUN/TURN. Очистите сборку и переустановите приложение." case .incompatibleLocalDescription: - return "WebRTC сформировал несовместимое описание видеопотока. Повторите звонок после перезапуска приложения." + return + "WebRTC сформировал несовместимое описание видеопотока. Повторите звонок после перезапуска приложения." case .incompatibleRemoteDescription: - return "Ответ на видеозвонок содержит другой набор аудио/видео потоков. Обновите клиент собеседника или повторите аудиозвонок." + return + "Ответ на видеозвонок содержит другой набор аудио/видео потоков. Обновите клиент собеседника или повторите аудиозвонок." case .sdpGenerationFailed: return "Не удалось согласовать параметры аудио/видео." case .connectionLost: diff --git a/Sources/Shared/XMPP/LumaConnectionStatsModule.swift b/Sources/Shared/XMPP/LumaConnectionStatsModule.swift new file mode 100644 index 0000000..c3630cd --- /dev/null +++ b/Sources/Shared/XMPP/LumaConnectionStatsModule.swift @@ -0,0 +1,68 @@ +import Foundation +import Martin + +/// Counts XEP-0198 stanza statistics (sent / acknowledged / received) at the +/// stream level so the server-details screen can mirror Monal's connection +/// statistics. Registered as an `XmppStanzaFilter` *before* stream management +/// so it can also observe the server's `` acknowledgements. +final class LumaConnectionStatsModule: XmppModuleBase, XmppModule, XmppStanzaFilter { + static let ID = "lumaConnectionStats" + static let smNamespace = "urn:xmpp:sm:3" + + let criteria = Criteria.empty() + let features: [String] = [] + + private let lock = NSLock() + private var sent: UInt32 = 0 + private var received: UInt32 = 0 + private var acknowledged: UInt32 = 0 + + struct Snapshot: Equatable, Sendable { + let sent: UInt32 + let acknowledged: UInt32 + let received: UInt32 + + var unacknowledged: UInt32 { + sent > acknowledged ? sent - acknowledged : 0 + } + } + + var snapshot: Snapshot { + lock.lock() + defer { lock.unlock() } + return Snapshot(sent: sent, acknowledged: acknowledged, received: received) + } + + func processIncoming(stanza: Stanza) -> Bool { + if stanza.xmlns == Self.smNamespace, stanza.name == "a" { + if let raw = stanza.attribute("h"), let value = UInt32(raw) { + lock.lock() + acknowledged = value + lock.unlock() + } + // Let StreamManagementModule consume the acknowledgement too. + return false + } + if Self.isCountable(stanza) { + lock.lock() + received += 1 + lock.unlock() + } + return false + } + + func processOutgoing(stanza: Stanza) { + guard Self.isCountable(stanza) else { return } + lock.lock() + sent += 1 + lock.unlock() + } + + func process(stanza: Stanza) throws { + // Criteria is empty, so this module is never selected by the dispatcher. + } + + private static func isCountable(_ stanza: Stanza) -> Bool { + stanza.name == "iq" || stanza.name == "message" || stanza.name == "presence" + } +} diff --git a/Sources/Shared/XMPP/LumaOMEMO2Module.swift b/Sources/Shared/XMPP/LumaOMEMO2Module.swift new file mode 100644 index 0000000..509f0fb --- /dev/null +++ b/Sources/Shared/XMPP/LumaOMEMO2Module.swift @@ -0,0 +1,872 @@ +import Foundation +import Combine +import CommonCrypto +import CryptoKit +import os +@preconcurrency import Martin +import MartinOMEMO + +extension XmppModuleIdentifier { + static var omemo2: XmppModuleIdentifier { + LumaOMEMO2Module.IDENTIFIER + } +} + +/// OMEMO 2 (XEP-0384 0.8.3, urn:xmpp:omemo:2) support built on the +/// MartinOMEMO signal stack. Wire format implemented here: +/// - payload: AES-256-CBC + HMAC-SHA-256 keyed via HKDF ("OMEMO Payload"), +/// plaintext is a Stanza Content Encryption `` (XEP-0420); +/// - header: `` groups with `` elements; +/// - device list node `urn:xmpp:omemo:2:devices` (`` element); +/// - bundle node `urn:xmpp:omemo:2:bundles` (one item per device id, +/// `` with `///`). +/// The Double Ratchet sessions are shared with the legacy OMEMO module +/// through the same SignalStorage. +final class LumaOMEMO2Module: AbstractPEPModule, XmppModule, @unchecked Sendable { + public static let ID = "omemo2" + public static let IDENTIFIER = XmppModuleIdentifier() + public static let XMLNS = "urn:xmpp:omemo:2" + public static let DEVICES_LIST_NODE = "urn:xmpp:omemo:2:devices" + public static let BUNDLES_NODE = "urn:xmpp:omemo:2:bundles" + public static let SCE_XMLNS = "urn:xmpp:sce:0" + public static let HKDF_INFO = "OMEMO Payload" + + public let id: String = ID + public let criteria = Criteria.empty() + public let features: [String] = [LumaOMEMO2Module.DEVICES_LIST_NODE + "+notify"] + + public let signalContext: SignalContext + public let storage: SignalStorage + private let devicesQueue = DispatchQueue(label: "app.luma.omemo2.devices") + private var devices: [BareJID: [Int32]] = [:] + private var devicesFetchError: [BareJID: [Int32]] = [:] + + /// True once our device id has been published to the OMEMO 2 device + /// list and our bundle is on the server. Drives the UI readiness flag. + @Published public private(set) var isReady: Bool = false + + public override var isPepAvailable: Bool { + didSet { + if isPepAvailable { + publishBundleIfNeeded { [weak self] in + self?.publishDeviceListIfNeeded() + } + } + } + } + + public init(signalContext: SignalContext, signalStorage: SignalStorage) { + self.signalContext = signalContext + self.storage = signalStorage + super.init() + } + + public func process(stanza: Stanza) throws { + // Decryption is driven explicitly by XMPPService decode calls; + // PEP notifications arrive through onItemNotification. + } + + // MARK: - Device list + + public func devices(for jid: BareJID) -> [Int32]? { + let known = devicesQueue.sync { devices[jid] } + guard let known else { return nil } + guard let failed = devicesFetchError[jid] else { return known } + return known.filter { !failed.contains($0) } + } + + /// Returns the cached OMEMO 2 device list for `jid`, fetching it from + /// PEP on demand when `fetching` is true and nothing is cached yet. Used + /// by the send path so the first message to an OMEMO 2-only contact does + /// not fall back to the legacy protocol. + public func deviceIDs(for jid: BareJID, fetching: Bool) async -> [Int32]? { + if let known = devices(for: jid) { return known } + guard fetching, let context else { return nil } + return await withCheckedContinuation { continuation in + context.module(.pubsub).retrieveItems( + from: jid, + for: Self.DEVICES_LIST_NODE, + limit: .lastItems(1) + ) { [weak self] result in + guard let self, + case .success(let items) = result, + let listEl = items.items.first?.payload, + listEl.name == "devices", + listEl.xmlns == Self.XMLNS + else { + continuation.resume(returning: nil) + return + } + let known = listEl.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) }) + let jidString = jid.stringValue + self.devicesQueue.async { self.devices[BareJID(jidString)] = known } + continuation.resume(returning: known) + } + } + } + + public func isAvailable(for jid: BareJID) -> Bool { + !(devicesQueue.sync { devices[jid] }?.isEmpty ?? true) + || !storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true).isEmpty + } + + private func publishDeviceListIfNeeded() { + guard isPepAvailable, let context else { return } + let pepJid = context.userBareJid + context.module(.pubsub).retrieveItems( + from: pepJid, + for: Self.DEVICES_LIST_NODE, + limit: .lastItems(1) + ) { [weak self] result in + switch result { + case .success(let items): + self?.processDeviceList(jid: pepJid, payload: items.items.first?.payload) + case .failure(let error): + guard error.error == .item_not_found || error.error == .internal_server_error() else { return } + self?.processDeviceList(jid: pepJid, payload: nil) + } + } + } + + private func processDeviceList(jid: BareJID, payload input: Element?) { + guard let context else { return } + var list = input + if list?.name != "devices" || list?.xmlns != Self.XMLNS { + list = Element(name: "devices", xmlns: Self.XMLNS) + } + guard let list else { return } + + let isOwn = jid == context.userBareJid + let ourID = String(storage.identityKeyStore.localRegistrationId()) + var changed = false + if isOwn, + list.findChild(where: { $0.name == "device" && $0.getAttribute("id") == ourID }) == nil + { + list.addChild(Element(name: "device", attributes: ["id": ourID, "label": "Luma"])) + changed = true + } + + if isOwn, changed { + let options = PubSubNodeConfig() + options.accessModel = .open + context.module(.pubsub).publishItem( + at: jid, + to: Self.DEVICES_LIST_NODE, + itemId: "current", + payload: list, + publishOptions: options + ) { [weak self] result in + switch result { + case .success: + Logger(subsystem: "Luma", category: "omemo2") + .info("device list published jid=\(jid.stringValue)") + self?.isReady = true + case .failure(let error): + Logger(subsystem: "Luma", category: "omemo2") + .warning("device list publish failed: \(error.error)") + guard error.error == .conflict() else { return } + context.module(.pubsub).retrieveNodeConfiguration( + from: jid, + node: Self.DEVICES_LIST_NODE + ) { result in + guard case .success(let form) = result else { return } + form.accessModel = .open + context.module(.pubsub).configureNode( + at: jid, + node: Self.DEVICES_LIST_NODE, + with: form + ) { _ in + context.module(.pubsub).publishItem( + at: jid, + to: Self.DEVICES_LIST_NODE, + itemId: "current", + payload: list, + publishOptions: options + ) { result in + if case .success = result { self?.isReady = true } + } + } + } + } + } + } else if isOwn { + isReady = true + } + + // Track known devices and align identity states with the list. + let known = list.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) }) + devicesQueue.async { [weak self] in self?.devices[jid] = known } + // Record which device IDs are OMEMO 2 so the encryption screen can + // badge them; legacy-only identities stay unmarked. + (storage as? LumaOMEMOStore)?.setOMEMO2DeviceIDs(Set(known), for: jid.stringValue) + let active = storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true) + active.filter { !known.contains($0) }.forEach { + _ = storage.identityKeyStore.setStatus(active: false, forIdentity: SignalAddress(name: jid.stringValue, deviceId: $0)) + } + known.filter { !active.contains($0) }.forEach { + _ = storage.identityKeyStore.setStatus(active: true, forIdentity: SignalAddress(name: jid.stringValue, deviceId: $0)) + } + } + + override func onItemNotification(notification: PubSubModule.ItemNotification) { + guard notification.node == Self.DEVICES_LIST_NODE, let context else { return } + switch notification.action { + case .published(let item): + let from = notification.message.from?.bareJid ?? context.userBareJid + processDeviceList(jid: from, payload: item.payload) + default: + break + } + } + + // MARK: - Bundle + + func publishBundleIfNeeded(completionHandler: (() -> Void)?) { + guard isPepAvailable, let context else { + completionHandler?() + return + } + let deviceID = String(storage.identityKeyStore.localRegistrationId()) + context.module(.pubsub).retrieveItems( + from: context.userBareJid, + for: Self.BUNDLES_NODE, + limit: .items(withIds: [deviceID]) + ) { [weak self] result in + switch result { + case .success(let items): + self?.publishBundle(current: items.items.first?.payload, completionHandler: completionHandler) + case .failure(let error): + guard error.error == .item_not_found || error.error == .internal_server_error() else { + completionHandler?() + return + } + self?.publishBundle(current: nil, completionHandler: completionHandler) + } + } + } + + private func signedPreKey(regenerate: Bool = false) -> SignalSignedPreKey? { + let signedPreKeyId = storage.signedPreKeyStore.countSignedPreKeys() + var signedPreKey: SignalSignedPreKey? + if !regenerate, signedPreKeyId != 0, + let data = signalContext.storage.signedPreKeyStore.loadSignedPreKey(withId: UInt32(signedPreKeyId)) + { + signedPreKey = SignalSignedPreKey(fromSerializedData: data) + } + if signedPreKey == nil { + guard let identityKeyPair = storage.identityKeyStore.keyPair() else { return nil } + signedPreKey = signalContext.generateSignedPreKey( + withIdentity: identityKeyPair, + signedPreKeyId: UInt32(signedPreKeyId + 1) + ) + guard let signedPreKey, let serialized = signedPreKey.serializedData else { return nil } + guard signalContext.storage.signedPreKeyStore.storeSignedPreKey(serialized, withId: signedPreKey.preKeyId) else { + return nil + } + } + return signedPreKey + } + + private func publishBundle(current input: Element?, completionHandler: (() -> Void)?) { + guard let identityKeyPair = storage.identityKeyStore.keyPair(), + let identityPublicKey = identityKeyPair.publicKey + else { + completionHandler?() + return + } + + var flush = input == nil + if !flush { + flush = identityPublicKey.base64EncodedString() != input?.findChild(name: "ik")?.value + } + + guard let signedPreKey = signedPreKey(regenerate: flush) else { + completionHandler?() + return + } + let signedPublicBase64 = signedPreKey.publicKeyData?.base64EncodedString() ?? "" + let signatureBase64 = signedPreKey.signature.base64EncodedString() + var changed = flush + || signedPublicBase64 != input?.findChild(name: "spk")?.value + || signatureBase64 != input?.findChild(name: "spks")?.value + + let currentIDs = input?.findChild(name: "prekeys")?.mapChildren(transform: { + $0.getAttribute("id").flatMap(UInt32.init) + }) ?? [] + var validKeys = currentIDs.compactMap { id -> SignalPreKey? in + guard let data = storage.preKeyStore.loadPreKey(withId: id) else { return nil } + return SignalPreKey(fromSerializedData: data) + } + let needKeys = 100 - validKeys.count + if needKeys > 0 { + changed = true + let start = storage.preKeyStore.currentPreKeyId() + 1 + let newKeys = signalContext.generatePreKeys(withStartingPreKeyId: start, count: UInt32(needKeys)) + validKeys += newKeys.filter { key in + guard let serialized = key.serializedData else { return false } + return storage.preKeyStore.storePreKey(serialized, withId: key.preKeyId) + } + } + + if changed { + publishBundle( + signedPreKey: signedPreKey, + identityKey: identityPublicKey, + preKeys: validKeys, + completionHandler: completionHandler + ) + } else { + completionHandler?() + } + } + + private func publishBundle( + signedPreKey: SignalSignedPreKey, + identityKey: Data, + preKeys: [SignalPreKey], + completionHandler: (() -> Void)? + ) { + guard let context, let signedPreKeyPublic = signedPreKey.publicKeyData else { + completionHandler?() + return + } + + let bundleEl = Element(name: "bundle", xmlns: Self.XMLNS) + bundleEl.addChild(Element( + name: "spk", + cdata: signedPreKeyPublic.base64EncodedString(), + attributes: ["id": String(signedPreKey.preKeyId)] + )) + bundleEl.addChild(Element(name: "spks", cdata: signedPreKey.signature.base64EncodedString())) + bundleEl.addChild(Element(name: "ik", cdata: identityKey.base64EncodedString())) + let pkElements = preKeys.map { preKey -> Element in + let publicData = preKey.serializedPublicKey?.base64EncodedString() ?? "" + return Element(name: "pk", cdata: publicData, attributes: ["id": String(preKey.preKeyId)]) + } + bundleEl.addChild(Element(name: "prekeys", children: pkElements)) + + let options = PubSubNodeConfig() + options.accessModel = .open + options.maxItems = .max + + let deviceID = String(storage.identityKeyStore.localRegistrationId()) + context.module(.pubsub).publishItem( + at: nil, + to: Self.BUNDLES_NODE, + itemId: deviceID, + payload: bundleEl, + publishOptions: options + ) { result in + switch result { + case .success: + Logger(subsystem: "Luma", category: "omemo2") + .info("bundle published deviceID=\(deviceID)") + case .failure(let error): + Logger(subsystem: "Luma", category: "omemo2") + .warning("bundle publish failed: \(error.error)") + } + completionHandler?() + } + } + + // MARK: - Session building + + func buildSession(forAddress address: SignalAddress, completionHandler: (() -> Void)? = nil) { + guard let context else { + completionHandler?() + return + } + let pepJid = BareJID(address.name) + context.module(.pubsub).retrieveItems( + from: pepJid, + for: Self.BUNDLES_NODE, + limit: .items(withIds: [String(address.deviceId)]) + ) { [weak self] result in + defer { completionHandler?() } + guard let self, case .success(let items) = result, + let bundle = OMEMO2Bundle(from: items.items.first?.payload) + else { + self?.markDeviceAsFailed(for: pepJid, andDeviceId: address.deviceId) + return + } + guard let preKey = bundle.preKeys.randomElement(), + let preKeyBundle = SignalPreKeyBundle( + registrationId: 0, + deviceId: address.deviceId, + preKeyId: preKey.id, + preKeyPublic: preKey.data, + signedPreKeyId: bundle.signedPreKeyId, + signedPreKeyPublic: bundle.signedPreKeyPublic, + signedPreKeySignature: bundle.signedPreKeySignature, + identityKey: bundle.identityKey + ), + let builder = SignalSessionBuilder(withAddress: address, andContext: self.signalContext) + else { + self.markDeviceAsFailed(for: pepJid, andDeviceId: address.deviceId) + return + } + _ = builder.processPreKeyBundle(bundle: preKeyBundle) + } + } + + private func markDeviceAsFailed(for jid: BareJID, andDeviceId deviceId: Int32) { + var failed = devicesFetchError[jid] ?? [] + if !failed.contains(deviceId) { + failed.append(deviceId) + devicesFetchError[jid] = failed + } + } + + // MARK: - Decode + + public func decode(message: Message, from: BareJID, serverMsgId: String? = nil) -> DecryptionResult { + guard context != nil else { return .failure(.unknown) } + guard let encryptedEl = message.firstChild(name: "encrypted", xmlns: Self.XMLNS), + let headerEl = encryptedEl.findChild(name: "header"), + let sid = UInt32(headerEl.getAttribute("sid") ?? "") + else { + return .failure(.notEncrypted) + } + + let localDeviceID = String(storage.identityKeyStore.localRegistrationId()) + let ownJID = context!.userBareJid.stringValue.lowercased() + + // Collect candidate keys for our device: prefer the 0.8.3 + // `` grouping, fall back to direct `` children. + var keyElements: [Element] = [] + for keysEl in headerEl.getChildren(where: { $0.name == "keys" }) { + guard keysEl.getAttribute("jid")?.lowercased() == ownJID else { continue } + keyElements.append(contentsOf: keysEl.getChildren(where: { $0.name == "key" })) + } + if keyElements.isEmpty { + keyElements = headerEl.getChildren(where: { $0.name == "key" }) + } + let ours = keyElements.filter { $0.getAttribute("rid") == localDeviceID } + guard !ours.isEmpty else { + Logger(subsystem: "Luma", category: "omemo2") + .warning("message not encrypted for us: from=\(from.stringValue) sid=\(sid) ourRid=\(localDeviceID) keyCount=\(keyElements.count) hasPayload=\(encryptedEl.findChild(name: "payload") != nil)") + guard context!.userBareJid != from || sid != storage.identityKeyStore.localRegistrationId() else { + return .failure(.duplicateMessage) + } + guard encryptedEl.findChild(name: "payload") != nil else { + return .failure(.duplicateMessage) + } + return .failure(.invalidMessage) + } + + let address = SignalAddress(name: from.stringValue, deviceId: Int32(bitPattern: sid)) + var lastError: SignalError = .unknown + for keyEl in ours { + guard let base64 = keyEl.value, let keyData = Data(base64Encoded: base64), + let cipher = SignalSessionCipher(withAddress: address, andContext: signalContext) + else { + Logger(subsystem: "Luma", category: "omemo2") + .warning("unusable key element from=\(from.stringValue) sid=\(sid)") + continue + } + let kexRaw = keyEl.getAttribute("kex") ?? "" + let isKex = kexRaw == "true" || kexRaw == "1" + || keyEl.getAttribute("prekey") == "true" || keyEl.getAttribute("prekey") == "1" + // Trust the kex attribute first; some clients mislabel the key + // element, so retry with the opposite interpretation before + // giving up on the message. + var elementError: SignalError = .unknown + for prekey in [isKex, !isKex] { + let result = cipher.decrypt(key: SignalSessionCipher.Key( + key: keyData, + deviceId: Int32(bitPattern: sid), + prekey: prekey + )) + switch result { + case .success(let combined): + Logger(subsystem: "Luma", category: "omemo2") + .info("key decrypted from=\(from.stringValue) sid=\(sid) prekey=\(prekey)") + return finishDecode( + message: message, + encryptedEl: encryptedEl, + combined: combined, + address: address, + isKex: prekey + ) + case .failure(let error): + elementError = error + } + } + lastError = elementError + Logger(subsystem: "Luma", category: "omemo2") + .warning("key decrypt failed: error=\(elementError) from=\(from.stringValue) sid=\(sid) kexAttr=\(kexRaw)") + } + + Logger(subsystem: "Luma", category: "omemo2") + .warning("decode failed: error=\(lastError) from=\(from.stringValue) sid=\(sid)") + if (lastError == .noSession || lastError == .invalidMessage), serverMsgId != nil { + buildSession(forAddress: address) + } + return .failure(lastError) + } + + private func finishDecode( + message: Message, + encryptedEl: Element, + combined: Data, + address: SignalAddress, + isKex: Bool + ) -> DecryptionResult { + message.removeChild(encryptedEl) + + // A consumed prekey must leave our published bundle. + if isKex, storage.preKeyStore.flushDeletedPreKeys() { + publishBundleIfNeeded(completionHandler: nil) + } + + guard let payloadValue = encryptedEl.findChild(name: "payload")?.value, + let ciphertext = Data(base64Encoded: payloadValue) + else { + // Empty message: session management, nothing to display. + return .successTransportKey(combined, iv: Data()) + } + + // combined = key (32) || truncated HMAC (16) + guard combined.count >= 48 else { return .failure(.invalidMac) } + let key = combined.subdata(in: 0..<32) + let expectedMAC = combined.subdata(in: 32..<48) + guard let derived = Self.derivePayloadKeys(from: key) else { return .failure(.invalidMac) } + let computedMAC = Data(HMAC.authenticationCode( + for: ciphertext, + using: SymmetricKey(data: derived.authKey) + ).prefix(16)) + guard Self.constantTimeEquals(computedMAC, expectedMAC) else { + Logger(subsystem: "Luma", category: "omemo2") + .warning("payload HMAC mismatch from=\(address.name) device=\(address.deviceId)") + return .failure(.invalidMac) + } + + guard let plaintext = Self.aes256CBCDecrypt(ciphertext, key: derived.encryptionKey, iv: derived.iv), + let plaintextString = String(data: plaintext, encoding: .utf8), + let envelope = Element.from(string: plaintextString), + envelope.name == "envelope" + else { + Logger(subsystem: "Luma", category: "omemo2") + .warning("payload envelope parse failed from=\(address.name) device=\(address.deviceId)") + return .failure(.invalidMessage) + } + + if let content = envelope.findChild(name: "content"), + let bodyEl = content.findChild(name: "body"), + let body = bodyEl.value, !body.isEmpty + { + message.body = body + } + + Logger(subsystem: "Luma", category: "omemo2") + .info("decoded message from=\(address.name) device=\(address.deviceId) bodyLen=\(message.body?.count ?? 0)") + _ = storage.identityKeyStore.setStatus(active: true, forIdentity: address) + return .successMessage( + message, + fingerprint: storage.identityKeyStore.identityFingerprint(forAddress: address) + ) + } + + // MARK: - Encode + + public func encode( + message: Message, + withStoreHint: Bool = true, + completionHandler: @escaping (EncryptionResult) -> Void + ) { + guard let jid = message.to?.bareJid else { + completionHandler(.failure(.noDestination)) + return + } + encode(message: message, for: [jid], withStoreHint: withStoreHint, completionHandler: completionHandler) + } + + public func addresses( + for jids: [BareJID], + completionHandler: @escaping (Result<[SignalAddress], SignalError>) -> Void + ) { + guard let pubsub = context?.module(.pubsub) else { + completionHandler(.failure(.unknown)) + return + } + let group = DispatchGroup() + var addresses: [SignalAddress] = [] + for jid in jids { + if let known = devices(for: jid) { + let knownAddresses = known.map { SignalAddress(name: jid.stringValue, deviceId: $0) } + addresses.append(contentsOf: knownAddresses) + for address in knownAddresses where !storage.sessionStore.containsSessionRecord(forAddress: address) { + group.enter() + buildSession(forAddress: address) { group.leave() } + } + } else { + group.enter() + pubsub.retrieveItems(from: jid, for: Self.DEVICES_LIST_NODE, limit: .lastItems(1)) { [weak self] result in + defer { group.leave() } + guard let self, case .success(let items) = result, + let listEl = items.items.first?.payload, + listEl.name == "devices", listEl.xmlns == Self.XMLNS + else { return } + let known = listEl.mapChildren(transform: { $0.getAttribute("id").flatMap(Int32.init) }) + self.devicesQueue.async { self.devices[jid] = known } + let knownAddresses = known.map { SignalAddress(name: jid.stringValue, deviceId: $0) } + addresses.append(contentsOf: knownAddresses) + for address in knownAddresses where !self.storage.sessionStore.containsSessionRecord(forAddress: address) { + group.enter() + self.buildSession(forAddress: address) { group.leave() } + } + } + } + } + group.notify(queue: .main) { + completionHandler(.success(addresses)) + } + } + + public func encode( + message: Message, + for jids: [BareJID], + withStoreHint: Bool = true, + completionHandler: @escaping (EncryptionResult) -> Void + ) { + addresses(for: jids) { result in + switch result { + case .failure(let error): + completionHandler(.failure(error)) + case .success(let addresses): + if addresses.isEmpty { + completionHandler(.failure(.noSession)) + } else { + self.encode(message: message, forAddresses: addresses, withStoreHint: withStoreHint, completionHandler: completionHandler) + } + } + } + } + + public func encode( + message: Message, + forAddresses addresses: [SignalAddress], + roomJID: BareJID? = nil, + withStoreHint: Bool = true, + completionHandler: @escaping (EncryptionResult) -> Void + ) { + let result = encodeMessage(message: message, for: addresses, roomJID: roomJID) + switch result { + case .successMessage(let encoded, _): + if withStoreHint { + encoded.addChild(Element(name: "store", xmlns: "urn:xmpp:hints")) + } + default: + break + } + completionHandler(result) + } + + private func encodeMessage( + message: Message, + for remoteAddresses: [SignalAddress], + roomJID: BareJID? + ) -> EncryptionResult { + guard let context else { return .failure(.unknown) } + + let localAddresses = storage.sessionStore + .allDevices(for: context.userBareJid.stringValue, activeAndTrusted: true) + .map { SignalAddress(name: context.userBareJid.stringValue, deviceId: $0) } + let destinations = Set(remoteAddresses + localAddresses) + + // Stanza Content Encryption envelope (XEP-0420) as the plaintext. + let envelopeXML = Self.envelopeXML( + body: message.body, + from: context.userBareJid.stringValue, + to: roomJID?.stringValue + ) + guard let plaintext = envelopeXML.data(using: .utf8) else { return .failure(.unknown) } + + var key = Data(count: 32) + key.withUnsafeMutableBytes { bytes in + _ = SecRandomCopyBytes(kSecRandomDefault, 32, bytes.baseAddress!) + } + guard let derived = Self.derivePayloadKeys(from: key) else { return .failure(.unknown) } + guard let ciphertext = Self.aes256CBCEncrypt(plaintext, key: derived.encryptionKey, iv: derived.iv) else { + return .failure(.unknown) + } + let mac = Data(HMAC.authenticationCode( + for: ciphertext, + using: SymmetricKey(data: derived.authKey) + ).prefix(16)) + var combinedKey = key + combinedKey.append(mac) + + let encryptedEl = Element(name: "encrypted", xmlns: Self.XMLNS) + let header = Element(name: "header") + header.setAttribute("sid", value: String(storage.identityKeyStore.localRegistrationId())) + encryptedEl.addChild(header) + + var keysByJID: [String: [Element]] = [:] + for address in destinations { + guard let cipher = SignalSessionCipher(withAddress: address, andContext: signalContext) else { continue } + switch cipher.encrypt(data: combinedKey) { + case .success(let output): + let keyEl = Element(name: "key", cdata: output.key.base64EncodedString()) + keyEl.setAttribute("rid", value: String(output.deviceId)) + if output.prekey { keyEl.setAttribute("kex", value: "true") } + keysByJID[address.name.lowercased(), default: []].append(keyEl) + case .failure: + break + } + } + for (jid, keys) in keysByJID { + let keysEl = Element(name: "keys") + keysEl.setAttribute("jid", value: jid) + keysEl.addChildren(keys) + header.addChild(keysEl) + } + + encryptedEl.addChild(Element(name: "payload", cdata: ciphertext.base64EncodedString())) + message.body = nil + message.addChild(encryptedEl) + + let fingerprint = storage.identityKeyStore.identityFingerprint( + forAddress: SignalAddress( + name: context.userBareJid.stringValue, + deviceId: Int32(bitPattern: storage.identityKeyStore.localRegistrationId()) + ) + ) + return .successMessage(message, fingerprint: fingerprint) + } + + // MARK: - Crypto helpers (internal for unit tests) + + struct PayloadKeys: Equatable { + let encryptionKey: Data + let authKey: Data + let iv: Data + } + + static func derivePayloadKeys(from key: Data) -> PayloadKeys? { + guard key.count == 32 else { return nil } + let salt = Data(count: 32) // 256 zero bits + let material = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: key), + salt: salt, + info: Data(HKDF_INFO.utf8), + outputByteCount: 80 + ) + let bytes = material.withUnsafeBytes { Data($0) } + return PayloadKeys( + encryptionKey: bytes.subdata(in: 0..<32), + authKey: bytes.subdata(in: 32..<64), + iv: bytes.subdata(in: 64..<80) + ) + } + + static func aes256CBCEncrypt(_ plaintext: Data, key: Data, iv: Data) -> Data? { + crypt(operation: CCOperation(kCCEncrypt), data: plaintext, key: key, iv: iv) + } + + static func aes256CBCDecrypt(_ ciphertext: Data, key: Data, iv: Data) -> Data? { + crypt(operation: CCOperation(kCCDecrypt), data: ciphertext, key: key, iv: iv) + } + + private static func crypt(operation: CCOperation, data: Data, key: Data, iv: Data) -> Data? { + guard key.count == kCCKeySizeAES256, iv.count == kCCBlockSizeAES128 else { return nil } + let bufferSize = data.count + kCCBlockSizeAES128 + var buffer = Data(count: bufferSize) + var moved = 0 + let status = key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + data.withUnsafeBytes { dataBytes in + buffer.withUnsafeMutableBytes { outBytes in + CCCrypt( + operation, + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, key.count, + ivBytes.baseAddress, + dataBytes.baseAddress, data.count, + outBytes.baseAddress, bufferSize, + &moved + ) + } + } + } + } + guard status == kCCSuccess else { return nil } + buffer.removeSubrange(moved.. Bool { + guard lhs.count == rhs.count else { return false } + var difference: UInt8 = 0 + for index in 0.. String { + var content = "" + if let body, !body.isEmpty { + content += "" + escapeXML(body) + "" + } + let alphabet = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + let rpadLength = Int.random(in: 12...96) + let rpad = String((0.." + content + "" + result += "" + rpad + "" + result += "" + if let toJID { + result += "" + } + result += "" + return result + } + + static func escapeXML(_ value: String) -> String { + value + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } +} + +/// Parsed `urn:xmpp:omemo:2` bundle (`///`). +struct OMEMO2Bundle { + let signedPreKeyId: UInt32 + let signedPreKeyPublic: Data + let signedPreKeySignature: Data + let identityKey: Data + let preKeys: [(id: UInt32, data: Data)] + + init?(from element: Element?) { + guard let element, + element.name == "bundle", element.xmlns == LumaOMEMO2Module.XMLNS, + let spk = element.findChild(name: "spk"), + let spkID = spk.getAttribute("id").flatMap(UInt32.init), + let spkValue = spk.value, + let spkData = Data(base64Encoded: spkValue), + let spks = element.findChild(name: "spks")?.value, + let spksData = Data(base64Encoded: spks), + let ik = element.findChild(name: "ik")?.value, + let ikData = Data(base64Encoded: ik), + let preKeysEl = element.findChild(name: "prekeys") + else { return nil } + + let parsedPreKeys = preKeysEl.mapChildren(transform: { pk -> (UInt32, Data)? in + guard pk.name == "pk", + let id = pk.getAttribute("id").flatMap(UInt32.init), + let value = pk.value, + let data = Data(base64Encoded: value) + else { return nil } + return (id, data) + }) + guard !parsedPreKeys.isEmpty else { return nil } + + signedPreKeyId = spkID + signedPreKeyPublic = spkData + signedPreKeySignature = spksData + identityKey = ikData + preKeys = parsedPreKeys + } +} diff --git a/Sources/Shared/XMPP/LumaOMEMOStore.swift b/Sources/Shared/XMPP/LumaOMEMOStore.swift index 155309d..b073f35 100644 --- a/Sources/Shared/XMPP/LumaOMEMOStore.swift +++ b/Sources/Shared/XMPP/LumaOMEMOStore.swift @@ -86,23 +86,71 @@ final class LumaOMEMOStore: SignalStorage { } func devices(for jid: String) -> [OMEMODevice] { - identityAdapter.identities(forName: jid.lowercased()).map { identity in + let key = jid.lowercased() + let published: Set = Set(repository.read { $0.omemo2DeviceIDs?[key] ?? [] }) + return identityAdapter.identities(forName: key).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 + isOwn: identity.own, + isOMEMO2: published.contains(identity.address.deviceId) ) } .sorted { lhs, rhs in lhs.deviceID < rhs.deviceID } } + /// Records the set of device IDs a JID currently publishes under + /// `urn:xmpp:omemo:2`. Drives the per-device protocol badge on the + /// encryption screen and persists so the label survives a relaunch. + func setOMEMO2DeviceIDs(_ deviceIDs: Set, for jid: String) { + let key = jid.lowercased() + repository.mutate { state in + var all = state.omemo2DeviceIDs ?? [:] + if deviceIDs.isEmpty { + all.removeValue(forKey: key) + } else { + all[key] = deviceIDs.sorted() + } + state.omemo2DeviceIDs = all + } + } + func hasSession(for address: SignalAddress) -> Bool { sessionAdapter.containsSessionRecord(forAddress: address) } + var accountJID: String { repository.accountJID } + + var localRegistrationID: UInt32 { identityAdapter.localRegistrationId() } + + /// Removes a session record with our own current device. A poisoned + /// self-session (created by a buggy older build) makes encrypt-to-self + /// fail with "Bad MAC". Deleting it is safe, but the session is then + /// legitimately rebuilt from our own bundle and must never be purged + /// again: archived self-copies depend on its ratchet continuity. + func removeSessionWithOwnDevice() { + let registrationID = identityAdapter.localRegistrationId() + guard registrationID != 0 else { return } + let address = SignalAddress( + name: repository.accountJID, + deviceId: Int32(bitPattern: registrationID) + ) + _ = sessionAdapter.deleteSessionRecord(forAddress: address) + } + + /// One-time migration purge of the poisoned self-session, keyed per + /// account. Running it on every connect destroys the self-session ratchet + /// and turns every archived encrypt-to-self copy into "could not decrypt". + func removeSessionWithOwnDeviceOnce() { + let flagKey = "app.luma.chat.selfSessionPurged." + repository.accountJID + guard !UserDefaults.standard.bool(forKey: flagKey) else { return } + UserDefaults.standard.set(true, forKey: flagKey) + removeSessionWithOwnDevice() + } + func flushPendingPersistence() { repository.flush() } @@ -544,6 +592,9 @@ private struct StoredOMEMOState: Codable { var signedPreKeys: [String: Data] var sessions: [String: Data] var senderKeys: [String: Data] + // Lowercased JID -> device IDs currently published under urn:xmpp:omemo:2. + // Optional for backward compatibility with state written before OMEMO 2. + var omemo2DeviceIDs: [String: [Int32]]? static let empty = StoredOMEMOState( registrationID: 0, @@ -552,7 +603,8 @@ private struct StoredOMEMOState: Codable { preKeys: [:], signedPreKeys: [:], sessions: [:], - senderKeys: [:] + senderKeys: [:], + omemo2DeviceIDs: nil ) } diff --git a/Sources/Shared/XMPP/LumaRoomStore.swift b/Sources/Shared/XMPP/LumaRoomStore.swift index 16218c1..73dc6d6 100644 --- a/Sources/Shared/XMPP/LumaRoomStore.swift +++ b/Sources/Shared/XMPP/LumaRoomStore.swift @@ -1,8 +1,6 @@ import Foundation import Martin -/// Martin 3.2.4 does not expose `DefaultRoomStore.init()` outside its module. -/// This mirrors its in-memory behavior so the app can register XEP-0045 MUC. final class LumaRoomStore: RoomStore { typealias Room = RoomBase diff --git a/Sources/Shared/XMPP/LumaSaslChallengeModule.swift b/Sources/Shared/XMPP/LumaSaslChallengeModule.swift new file mode 100644 index 0000000..772ef25 --- /dev/null +++ b/Sources/Shared/XMPP/LumaSaslChallengeModule.swift @@ -0,0 +1,26 @@ +import Foundation +import Martin + +/// Observes the raw SASL `` stanzas before `SaslModule` consumes +/// them, so Luma can detect the XEP-0474 downgrade-protection hash (`h=` +/// attribute in the SCRAM server-first message) regardless of which SCRAM +/// mechanism implementation actually runs the exchange. Registered before +/// `SaslModule` in the module list. +final class LumaSaslChallengeModule: XmppModuleBase, XmppModule { + static let ID = "lumaSaslChallenge" + static let saslXMLNS = "urn:ietf:params:xml:ns:xmpp-sasl" + + let criteria = Criteria.name("challenge", xmlns: LumaSaslChallengeModule.saslXMLNS) + let features: [String] = [] + + private let onChallenge: (String) -> Void + + init(onChallenge: @escaping (String) -> Void) { + self.onChallenge = onChallenge + super.init() + } + + func process(stanza: Stanza) throws { + onChallenge(stanza.element.value ?? "") + } +} diff --git a/Sources/Shared/XMPP/LumaSaslFailureModule.swift b/Sources/Shared/XMPP/LumaSaslFailureModule.swift new file mode 100644 index 0000000..a04d855 --- /dev/null +++ b/Sources/Shared/XMPP/LumaSaslFailureModule.swift @@ -0,0 +1,37 @@ +import Foundation +import Martin + +/// Observes the raw SASL `` stanza before `SaslModule` collapses +/// it into its `SaslError` enum, so the UI can show the real RFC 6120 +/// condition (`account-disabled`, `credentials-expired`, …) and the +/// optional server `` instead of a catch-all "authentication +/// failure". Registered before `SaslModule` in the module list. +final class LumaSaslFailureModule: XmppModuleBase, XmppModule { + static let ID = "lumaSaslFailure" + static let saslXMLNS = "urn:ietf:params:xml:ns:xmpp-sasl" + + struct Failure: Equatable, Sendable { + let condition: String? + let text: String? + } + + let criteria = Criteria.name("failure", xmlns: LumaSaslFailureModule.saslXMLNS) + let features: [String] = [] + + private let lock = NSLock() + private var stored: Failure? + + var lastFailure: Failure? { + lock.lock() + defer { lock.unlock() } + return stored + } + + func process(stanza: Stanza) throws { + let condition = stanza.firstChild(where: { _ in true })?.name + let text = stanza.firstChild(name: "text")?.value + lock.lock() + stored = Failure(condition: condition, text: text) + lock.unlock() + } +} diff --git a/Sources/Shared/XMPP/LumaScramPlusMechanism.swift b/Sources/Shared/XMPP/LumaScramPlusMechanism.swift new file mode 100644 index 0000000..6b494c7 --- /dev/null +++ b/Sources/Shared/XMPP/LumaScramPlusMechanism.swift @@ -0,0 +1,370 @@ +import CommonCrypto +import Foundation +import Martin + +/// Digest-parameterized SCRAM math (RFC 5802) for the channel-binding +/// PLUS variants. The existing SCRAMSHA512 helper stays SHA-512-only for +/// its reference-vector tests; this core serves SHA-1/256/512 PLUS. +enum SCRAMHash { + case sha1 + case sha256 + case sha512 + + var mechanismName: String { + switch self { + case .sha1: return "SCRAM-SHA-1" + case .sha256: return "SCRAM-SHA-256" + case .sha512: return "SCRAM-SHA-512" + } + } + + var digestLength: Int { + switch self { + case .sha1: return Int(CC_SHA1_DIGEST_LENGTH) + case .sha256: return Int(CC_SHA256_DIGEST_LENGTH) + case .sha512: return Int(CC_SHA512_DIGEST_LENGTH) + } + } + + private var hmacAlgorithm: CCHmacAlgorithm { + switch self { + case .sha1: return CCHmacAlgorithm(kCCHmacAlgSHA1) + case .sha256: return CCHmacAlgorithm(kCCHmacAlgSHA256) + case .sha512: return CCHmacAlgorithm(kCCHmacAlgSHA512) + } + } + + private var pbkdfAlgorithm: CCPseudoRandomAlgorithm { + switch self { + case .sha1: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1) + case .sha256: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256) + case .sha512: return CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512) + } + } + + func hmac(key: Data, data: Data) -> Data { + var output = [UInt8](repeating: 0, count: digestLength) + key.withUnsafeBytes { keyBytes in + data.withUnsafeBytes { dataBytes in + CCHmac( + hmacAlgorithm, + keyBytes.baseAddress, + keyBytes.count, + dataBytes.baseAddress, + dataBytes.count, + &output + ) + } + } + return Data(output) + } + + func hash(data: Data) -> Data { + var output = [UInt8](repeating: 0, count: digestLength) + _ = data.withUnsafeBytes { bytes in + switch self { + case .sha1: + CC_SHA1(bytes.baseAddress, CC_LONG(bytes.count), &output) + case .sha256: + CC_SHA256(bytes.baseAddress, CC_LONG(bytes.count), &output) + case .sha512: + CC_SHA512(bytes.baseAddress, CC_LONG(bytes.count), &output) + } + } + return Data(output) + } + + /// PBKDF2 (RFC 5802 Hi function). + func saltedPassword(password: String, salt: Data, iterations: Int) -> Data { + let passwordBytes = Array(password.utf8) + var output = [UInt8](repeating: 0, count: digestLength) + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes, + passwordBytes.count, + [UInt8](salt), + salt.count, + pbkdfAlgorithm, + UInt32(iterations), + &output, + output.count + ) + return Data(output) + } +} + +/// XEP-0474 SASL SCRAM Downgrade Protection: the hash both sides include in +/// the SCRAM exchange (server `h` / client `x` attributes). Per §5.1 the +/// input is the server-advertised SASL mechanisms SORTED with i;octet +/// collation and joined with 0x1E; when the server also advertised +/// channel-binding types, a 0x1F delimiter followed by the sorted +/// channel-binding types joined with 0x1E. The digest is the SCRAM hash of +/// the negotiated mechanism. A mismatch means a MITM tampered with the +/// advertised lists. +enum SCRAMDowngradeProtection { + static func hash( + mechanisms: [String], + channelBindingTypes: [String], + using hash: SCRAMHash + ) -> Data { + // "i;octet" collation = byte-wise comparison; for these pure-ASCII + // identifiers plain lexicographic sorting matches it. + let sortedMechanisms = mechanisms.sorted() + let sortedBindings = channelBindingTypes.sorted() + var input = Data() + for (index, mechanism) in sortedMechanisms.enumerated() { + if index > 0 { input.append(0x1E) } + input.append(contentsOf: mechanism.utf8) + } + if !sortedBindings.isEmpty { + input.append(0x1F) + for (index, type) in sortedBindings.enumerated() { + if index > 0 { input.append(0x1E) } + input.append(contentsOf: type.utf8) + } + } + return hash.hash(data: input) + } +} + +/// Pure SCRAM channel-binding exchange math (RFC 5802 + RFC 9266): given +/// the fixed inputs of one exchange it produces the wire messages and the +/// expected server signature. Kept independent of Martin's SaslMechanism +/// so unit tests can check it against reference vectors. +struct SCRAMPlusExchange { + let hash: SCRAMHash + let username: String + let password: String + let channelBindingType: String + let channelBindingData: Data + let clientNonce: String + + var gs2Header: String { "p=\(channelBindingType),," } + + var clientFirstMessageBare: String { "n=\(username),r=\(clientNonce)" } + + var clientFirstMessage: String { gs2Header + clientFirstMessageBare } + + /// gs2-header || channel-binding-data (RFC 5802 §7). + private var channelBindingInput: Data { + (gs2Header.data(using: .utf8) ?? Data()) + channelBindingData + } + + func clientFinalMessage( + serverFirst: String, + downgradeProtectionHashBase64: String? = nil + ) throws -> String { + let parsed = try SCRAMSHA512.parseServerFirst( + serverFirst, + expectedNoncePrefix: clientNonce + ) + let saltedPassword = hash.saltedPassword( + password: password, + salt: parsed.salt, + iterations: parsed.iterations + ) + let finalWithoutProof = finalWithoutProofString( + parsed: parsed, + downgradeProtectionHashBase64: downgradeProtectionHashBase64 + ) + let authMessage = authMessageString( + serverFirst: serverFirst, + parsed: parsed, + downgradeProtectionHashBase64: downgradeProtectionHashBase64 + ) + let clientKey = hash.hmac(key: saltedPassword, data: Data("Client Key".utf8)) + let storedKey = hash.hash(data: clientKey) + let signature = hash.hmac(key: storedKey, data: Data(authMessage.utf8)) + let proof = Data(zip(clientKey, signature).map { $0 ^ $1 }) + return finalWithoutProof + ",p=" + proof.base64EncodedString() + } + + func expectedServerSignature( + serverFirst: String, + downgradeProtectionHashBase64: String? = nil + ) throws -> Data { + let parsed = try SCRAMSHA512.parseServerFirst( + serverFirst, + expectedNoncePrefix: clientNonce + ) + let saltedPassword = hash.saltedPassword( + password: password, + salt: parsed.salt, + iterations: parsed.iterations + ) + let authMessage = authMessageString( + serverFirst: serverFirst, + parsed: parsed, + downgradeProtectionHashBase64: downgradeProtectionHashBase64 + ) + let serverKey = hash.hmac(key: saltedPassword, data: Data("Server Key".utf8)) + return hash.hmac(key: serverKey, data: Data(authMessage.utf8)) + } + + private func finalWithoutProofString( + parsed: SCRAMSHA512.ServerFirst, + downgradeProtectionHashBase64: String? + ) -> String { + var result = + "c=\(channelBindingInput.base64EncodedString()),r=\(parsed.nonce)" + if let downgradeProtectionHashBase64 { + result += ",x=\(downgradeProtectionHashBase64)" + } + return result + } + + private func authMessageString( + serverFirst: String, + parsed: SCRAMSHA512.ServerFirst, + downgradeProtectionHashBase64: String? + ) -> String { + let finalWithoutProof = finalWithoutProofString( + parsed: parsed, + downgradeProtectionHashBase64: downgradeProtectionHashBase64 + ) + return clientFirstMessageBare + "," + serverFirst + "," + finalWithoutProof + } + + /// The base64 value of the server's final `v=` attribute, or nil when + /// the message is an `e=` error instead. + static func verifierValue(in finalMessage: String) -> String? { + guard finalMessage.hasPrefix("v=") else { return nil } + let value = finalMessage.dropFirst(2).split(separator: ",", maxSplits: 1).first ?? Substring() + guard !value.isEmpty else { return nil } + return String(value) + } +} + +/// SCRAM with channel binding (RFC 5802 / RFC 9266, XEP-0474): the +/// tls-exporter or tls-server-end-point binding proves the SASL exchange +/// runs over the same TLS channel the client sees, defeating MITM relays. +/// Registered ahead of the plain SCRAM variants when binding data exists. +final class LumaScramPlusMechanism: SaslMechanism { + let name: String + private(set) var status: SaslMechanismStatus = .new + + private let hash: SCRAMHash + private let channelBindingStore: LumaChannelBindingStore + + private var stage = 0 + private var exchange: SCRAMPlusExchange? + private var serverFirst: String? + /// XEP-0474: our own downgrade-protection hash sent in the `x` + /// attribute once the server has presented its `h` attribute. + private var downgradeHashBase64: String? + + init(hash: SCRAMHash, channelBindingStore: LumaChannelBindingStore) { + self.hash = hash + self.channelBindingStore = channelBindingStore + name = hash.mechanismName + "-PLUS" + } + + func reset(scopes: Set) { + guard scopes.contains(.stream) else { return } + status = .new + stage = 0 + exchange = nil + serverFirst = nil + downgradeHashBase64 = nil + } + + func isAllowedToUse(_ context: Context) -> Bool { + guard case .password(_, _, _) = context.connectionConfiguration.credentials else { + return false + } + return channelBindingStore.canUseChannelBinding + } + + func evaluateChallenge(_ input: String?, context: Context) throws -> String? { + guard status != .completed else { + guard input == nil else { + throw ClientSaslException.genericError(msg: "Already authorized") + } + return nil + } + switch stage { + case 0: + guard case .password(let password, _, _) = context.connectionConfiguration.credentials, + let bindingType = channelBindingStore.preferredChannelBindingType, + let bindingData = channelBindingStore.channelBindingData(for: bindingType) + else { + throw ClientSaslException.genericError( + msg: "Channel binding unavailable") + } + exchange = SCRAMPlusExchange( + hash: hash, + username: context.userBareJid.localPart ?? "", + password: password, + channelBindingType: bindingType, + channelBindingData: bindingData, + clientNonce: SCRAMSHA512.makeNonce() + ) + stage = 1 + status = .completedExpected + return exchange?.clientFirstMessage.data(using: .utf8)?.base64EncodedString() + + case 1: + guard let exchange, + let input, + let data = Data(base64Encoded: input), + let decodedServerFirst = String(data: data, encoding: .utf8) + else { + throw ClientSaslException.badChallenge(msg: "Invalid challenge") + } + serverFirst = decodedServerFirst + // XEP-0474: verify the server's `h` downgrade-protection hash over + // the advertised mechanism and channel-binding lists, and answer + // with our own hash in `x`. A mismatch means a MITM tampered with + // the advertised lists. + var downgradeHash: String? + if let serverHash = try SCRAMSHA512.parseServerFirst( + decodedServerFirst, + expectedNoncePrefix: exchange.clientNonce + ).downgradeProtectionHash { + channelBindingStore.markDowngradeProtectionDetected() + let expected = SCRAMDowngradeProtection.hash( + mechanisms: channelBindingStore.advertisedSASLMechanismsOrdered, + channelBindingTypes: channelBindingStore.advertisedChannelBindingTypesOrdered, + using: hash + ) + let expectedBase64 = expected.base64EncodedString() + guard expectedBase64 == serverHash else { + throw ClientSaslException.badChallenge( + msg: "SCRAM downgrade protection hash mismatch (possible MITM)" + ) + } + downgradeHash = expectedBase64 + } + downgradeHashBase64 = downgradeHash + let final = try exchange.clientFinalMessage( + serverFirst: decodedServerFirst, + downgradeProtectionHashBase64: downgradeHash + ) + stage = 2 + return final.data(using: .utf8)?.base64EncodedString() + + case 2: + guard let exchange, let serverFirst, + let input, + let data = Data(base64Encoded: input), + let finalMessage = String(data: data, encoding: .utf8), + let verifier = SCRAMPlusExchange.verifierValue(in: finalMessage), + let value = Data(base64Encoded: verifier) + else { + throw ClientSaslException.badChallenge(msg: "Invalid final challenge") + } + let expected = try exchange.expectedServerSignature( + serverFirst: serverFirst, + downgradeProtectionHashBase64: downgradeHashBase64 + ) + guard value == expected else { + throw ClientSaslException.invalidServerSignature + } + status = .completed + return nil + + default: + throw ClientSaslException.genericError(msg: "Invalid SCRAM stage") + } + } +} diff --git a/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift b/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift new file mode 100644 index 0000000..0207de7 --- /dev/null +++ b/Sources/Shared/XMPP/LumaScramSha512Mechanism.swift @@ -0,0 +1,251 @@ +import CommonCrypto +import CryptoKit +import Foundation +import Martin + +/// Pure SCRAM-SHA-512 math per RFC 5802 (SHA-512 / HMAC-SHA-512), kept +/// separate from the Martin mechanism so the proof computation can be +/// unit-tested against reference vectors. +enum SCRAMSHA512 { + struct ServerFirst { + let nonce: String + let salt: Data + let iterations: Int + /// XEP-0474: base64 downgrade-protection hash in the optional `h=` + /// SCRAM attribute, when the server supports the extension. + let downgradeProtectionHash: String? + } + + private static let nonceAlphabet = Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + ) + + static func makeNonce(length: Int = 24) -> String { + String((0.. ServerFirst { + let pattern = #"^(?:m=[^\000=]+,)?r=([\x21-\x2B\x2D-\x7E]+),s=([a-zA-Z0-9/+=]+),i=(\d+)(?:,h=([a-zA-Z0-9/+=]+))?(?:,.*)?$"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { + throw ClientSaslException.badChallenge(msg: "Failed to parse challenge") + } + let fullRange = NSRange(message.startIndex..., in: message) + guard let match = regex.firstMatch(in: message, range: fullRange), + match.numberOfRanges >= 4, + let nonceRange = Range(match.range(at: 1), in: message), + let saltRange = Range(match.range(at: 2), in: message), + let iterationsRange = Range(match.range(at: 3), in: message) else { + throw ClientSaslException.badChallenge(msg: "Failed to parse challenge") + } + let nonce = String(message[nonceRange]) + let iterations = Int(message[iterationsRange]) ?? 0 + let downgradeProtectionHash: String? + if let hashRange = Range(match.range(at: 4), in: message) { + downgradeProtectionHash = String(message[hashRange]) + } else { + downgradeProtectionHash = nil + } + guard nonce.hasPrefix(expectedNoncePrefix), + let salt = Data(base64Encoded: String(message[saltRange])), + iterations > 0 else { + throw ClientSaslException.badChallenge(msg: "Invalid challenge") + } + return ServerFirst( + nonce: nonce, + salt: salt, + iterations: iterations, + downgradeProtectionHash: downgradeProtectionHash + ) + } + + /// PBKDF2-HMAC-SHA-512 (RFC 5802 \"Hi\" function). + static func saltedPassword(password: String, salt: Data, iterations: Int) -> [UInt8] { + let passwordBytes = Array(password.utf8) + let saltBytes = [UInt8](salt) + var output = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passwordBytes, passwordBytes.count, + saltBytes, saltBytes.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512), + UInt32(iterations), + &output, output.count + ) + return output + } + + static func clientProof(saltedPassword: [UInt8], authMessage: String) -> [UInt8] { + let clientKey = hmac(saltedPassword, Array("Client Key".utf8)) + let storedKey = digest(clientKey) + let clientSignature = hmac(storedKey, Array(authMessage.utf8)) + return zip(clientKey, clientSignature).map(^) + } + + static func serverSignature(saltedPassword: [UInt8], authMessage: String) -> [UInt8] { + let serverKey = hmac(saltedPassword, Array("Server Key".utf8)) + return hmac(serverKey, Array(authMessage.utf8)) + } + + static func verifyServerSignature( + saltedPassword: [UInt8], + authMessage: String, + finalMessage: String + ) -> Bool { + let pattern = #"^(?:e=([^,]+)|v=([a-zA-Z0-9/+=]+)(?:,.*)?)$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch( + in: finalMessage, + range: NSRange(finalMessage.startIndex..., in: finalMessage) + ), + match.numberOfRanges >= 3, + let vRange = Range(match.range(at: 2), in: finalMessage), + let value = Data(base64Encoded: String(finalMessage[vRange])) else { + return false + } + return value == Data(serverSignature(saltedPassword: saltedPassword, authMessage: authMessage)) + } + + private static func hmac(_ key: [UInt8], _ data: [UInt8]) -> [UInt8] { + let code = HMAC.authenticationCode( + for: Data(data), + using: SymmetricKey(data: Data(key)) + ) + return Array(code) + } + + private static func digest(_ data: [UInt8]) -> [UInt8] { + Array(SHA512.hash(data: Data(data))) + } +} + +/// SCRAM-SHA-512 SASL mechanism. Martin ships only SCRAM-SHA-1 and +/// SCRAM-SHA-256; modern servers prefer SHA-512, so Luma registers this +/// mechanism ahead of Martin's ones. +final class LumaScramSha512Mechanism: SaslMechanism { + let name = "SCRAM-SHA-512" + private(set) var status: SaslMechanismStatus = .new + + /// Optional channel-binding store: when present the mechanism verifies + /// the XEP-0474 downgrade-protection hash from the server and answers + /// with its own hash. + private let channelBindingStore: LumaChannelBindingStore? + + private var stage = 0 + private var clientNonce = "" + private var clientFirstMessageBare = "" + private var authMessage = "" + private var saltedPassword: [UInt8] = [] + + init(channelBindingStore: LumaChannelBindingStore? = nil) { + self.channelBindingStore = channelBindingStore + } + + func reset(scopes: Set) { + guard scopes.contains(.stream) else { return } + status = .new + stage = 0 + clientNonce = "" + clientFirstMessageBare = "" + authMessage = "" + saltedPassword = [] + } + + func isAllowedToUse(_ context: Context) -> Bool { + if case .password(_, _, _) = context.connectionConfiguration.credentials { + return true + } + return false + } + + func evaluateChallenge(_ input: String?, context: Context) throws -> String? { + guard status != .completed else { + guard input == nil else { + throw ClientSaslException.genericError(msg: "Already authorized") + } + return nil + } + switch stage { + case 0: + guard case .password(_, _, _) = context.connectionConfiguration.credentials else { + throw ClientSaslException.genericError(msg: "Invalid credentials type") + } + clientNonce = SCRAMSHA512.makeNonce() + clientFirstMessageBare = + "n=\(context.userBareJid.localPart ?? ""),r=\(clientNonce)" + stage = 1 + status = .completedExpected + let first = "n,," + clientFirstMessageBare + return first.data(using: .utf8)?.base64EncodedString() + + case 1: + guard case .password(let password, _, _) = context.connectionConfiguration.credentials, + let input, + let data = Data(base64Encoded: input), + let serverFirst = String(data: data, encoding: .utf8) else { + throw ClientSaslException.badChallenge(msg: "Invalid challenge") + } + let parsed = try SCRAMSHA512.parseServerFirst( + serverFirst, + expectedNoncePrefix: clientNonce + ) + // XEP-0474: the optional `h` attribute carries the server's + // downgrade-protection hash over the advertised mechanism and + // channel-binding lists. Verify it (a mismatch means a MITM + // tampered with the lists) and answer with our own hash in `x`. + var downgradeHashBase64: String? + if let serverHash = parsed.downgradeProtectionHash { + channelBindingStore?.markDowngradeProtectionDetected() + let mechanisms = channelBindingStore?.advertisedSASLMechanismsOrdered ?? [] + let bindingTypes = channelBindingStore?.advertisedChannelBindingTypesOrdered ?? [] + let expected = SCRAMDowngradeProtection.hash( + mechanisms: mechanisms, + channelBindingTypes: bindingTypes, + using: .sha512 + ) + let expectedBase64 = expected.base64EncodedString() + guard expectedBase64 == serverHash else { + throw ClientSaslException.badChallenge( + msg: "SCRAM downgrade protection hash mismatch (possible MITM)" + ) + } + downgradeHashBase64 = expectedBase64 + } + let clientFinalWithoutProof = "c=biws,r=\(parsed.nonce)" + + (downgradeHashBase64.map { ",x=\($0)" } ?? "") + authMessage = clientFirstMessageBare + "," + serverFirst + "," + clientFinalWithoutProof + saltedPassword = SCRAMSHA512.saltedPassword( + password: password, + salt: parsed.salt, + iterations: parsed.iterations + ) + let proof = SCRAMSHA512.clientProof( + saltedPassword: saltedPassword, + authMessage: authMessage + ) + stage = 2 + let final = clientFinalWithoutProof + ",p=" + Data(proof).base64EncodedString() + return final.data(using: .utf8)?.base64EncodedString() + + case 2: + guard let input, + let data = Data(base64Encoded: input), + let finalMessage = String(data: data, encoding: .utf8), + SCRAMSHA512.verifyServerSignature( + saltedPassword: saltedPassword, + authMessage: authMessage, + finalMessage: finalMessage + ) else { + throw ClientSaslException.invalidServerSignature + } + status = .completed + return nil + + default: + throw ClientSaslException.genericError(msg: "Illegal state") + } + } +} + diff --git a/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift b/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift new file mode 100644 index 0000000..ba959ce --- /dev/null +++ b/Sources/Shared/XMPP/LumaTLSNetworkProcessor.swift @@ -0,0 +1,492 @@ +import CryptoKit +import Foundation +import Martin +import OpenSSL +import Security + +/// Negotiated TLS parameters of the live connection, captured by the TLS +/// processor and shared with the SASL layer (SCRAM channel binding) and the +/// server-information screen. +struct LumaTLSState: Sendable { + /// "TLSv1.3" / "TLSv1.2" — OpenSSL's SSL_get_version string. + let version: String + /// OpenSSL cipher-suite name, e.g. TLS_AES_256_GCM_SHA384. + let cipher: String + /// DER of the leaf certificate. + let leafCertificateDER: Data + /// RFC 9266 tls-exporter output (32 bytes), when the handshake version + /// supports exporters (TLS 1.3). + let exporter: Data? +} + +/// Thread-safe holder of the negotiated TLS state plus the channel-binding +/// types the server advertised in its stream features. +final class LumaChannelBindingStore: @unchecked Sendable { + private let lock = NSLock() + private var tlsState: LumaTLSState? + private var advertisedTypes: Set = [] + /// Ordered copies of the advertised SASL mechanism and channel-binding + /// lists — XEP-0474 hashes them in advertisement order. + private var mechanismsOrdered: [String] = [] + private var channelBindingTypesOrdered: [String] = [] + private var downgradeProtectionDetected = false + + func setTLSState(_ state: LumaTLSState) { + lock.lock() + tlsState = state + lock.unlock() + } + + func setAdvertisedChannelBindingTypes(_ types: Set) { + lock.lock() + advertisedTypes = types + lock.unlock() + } + + /// Records the advertised lists for the XEP-0474 downgrade-protection + /// hash and the server-information screen. + func setSASLContext( + mechanisms: [String], + channelBindingTypes: [String] + ) { + lock.lock() + mechanismsOrdered = mechanisms + channelBindingTypesOrdered = channelBindingTypes + lock.unlock() + } + + func markDowngradeProtectionDetected() { + lock.lock() + downgradeProtectionDetected = true + lock.unlock() + } + + var isDowngradeProtectionDetected: Bool { + lock.lock() + defer { lock.unlock() } + return downgradeProtectionDetected + } + + var advertisedSASLMechanismsOrdered: [String] { + lock.lock() + defer { lock.unlock() } + return mechanismsOrdered + } + + var advertisedChannelBindingTypesOrdered: [String] { + lock.lock() + defer { lock.unlock() } + return channelBindingTypesOrdered + } + + func reset() { + lock.lock() + tlsState = nil + advertisedTypes = [] + mechanismsOrdered = [] + channelBindingTypesOrdered = [] + downgradeProtectionDetected = false + lock.unlock() + } + + var negotiatedState: LumaTLSState? { + lock.lock() + defer { lock.unlock() } + return tlsState + } + + /// The strongest channel-binding type both sides support: RFC 9266 + /// tls-exporter first, then tls-server-end-point. + var preferredChannelBindingType: String? { + lock.lock() + defer { lock.unlock() } + guard let tlsState else { return nil } + if advertisedTypes.contains("tls-exporter"), tlsState.exporter != nil { + return "tls-exporter" + } + if advertisedTypes.contains("tls-server-end-point") { + return "tls-server-end-point" + } + return nil + } + + /// Channel-binding data for a concrete type: RFC 9266 §4.2 for + /// tls-exporter and §4.3 for tls-server-end-point (SHA-256 of the leaf + /// certificate; RFC 5929's upgrade rules make this correct for the + /// SHA-256-signed certificates used in practice on TLS 1.2 as well). + func channelBindingData(for type: String) -> Data? { + lock.lock() + defer { lock.unlock() } + guard let tlsState else { return nil } + switch type { + case "tls-exporter": + return tlsState.exporter + case "tls-server-end-point": + return Data(SHA256.hash(data: tlsState.leafCertificateDER)) + default: + return nil + } + } + + var canUseChannelBinding: Bool { + preferredChannelBindingType != nil + } + + /// Channel-binding types the server advertised in its stream features. + var advertisedChannelBindingTypes: Set { + lock.lock() + defer { lock.unlock() } + return advertisedTypes + } +} + +/// Supplies Luma's OpenSSL-based TLS processor to Martin's legacy +/// SocketConnector. SecureTransport caps stream TLS at 1.2 and has no keying +/// exporter, so Luma runs its own TLS 1.3 stack (like Monal) while Martin +/// keeps owning the socket and the XMPP framing. +final class LumaTLSNetworkProcessorProvider: NetworkProcessorProvider { + var providedFeatures: [ConnectorFeature] = [.TLS] + + private let channelBindingStore: LumaChannelBindingStore + + init(channelBindingStore: LumaChannelBindingStore) { + self.channelBindingStore = channelBindingStore + } + + func supply() -> SocketConnector.NetworkProcessor { + LumaTLSNetworkProcessor(channelBindingStore: channelBindingStore) + } +} + +/// OpenSSL TLS 1.3 client processor driven by Martin's byte stream: +/// encrypted socket bytes go in through read(data:), plaintext goes out to +/// the connector; plaintext from the connector is encrypted and handed to +/// the socket through the write delegate. +final class LumaTLSNetworkProcessor: SocketConnector.NetworkProcessor, SSLNetworkProcessor { + var serverName: String? + var certificateValidation: SSLCertificateValidation = .default + var certificateValidationFailed: ((SecTrust?) -> Void)? + + private let channelBindingStore: LumaChannelBindingStore + private var alpnProtocols: [String] = [] + + private var sslContext: OpaquePointer? + private var ssl: OpaquePointer? + /// C-string copy of the SNI hostname; OpenSSL keeps the pointer, so the + /// storage must outlive the SSL object. + private var storedServerName: [CChar]? + private var readBIO: OpaquePointer? + private var writeBIO: OpaquePointer? + private var handshakeCompleted = false + private var handshakeFailed = false + private var pendingPlaintext: [UInt8] = [] + + init(channelBindingStore: LumaChannelBindingStore) { + self.channelBindingStore = channelBindingStore + super.init() + } + + deinit { + SSL_free(ssl) + SSL_CTX_free(sslContext) + } + + func setALPNProtocols(_ protocols: [String]) { + alpnProtocols = protocols + } + + // MARK: - Byte stream + + /// Encrypted bytes from the socket. + override func read(data: Data) { + guard !handshakeFailed else { return } + ensureTLS() + guard ssl != nil, let readBIO else { return } + data.withUnsafeBytes { raw in + _ = BIO_write(readBIO, raw.baseAddress, Int32(raw.count)) + } + drive() + } + + /// Plaintext to encrypt and send to the socket. + override func write(data: Data, completion: WriteCompletion) { + guard !handshakeFailed else { + completion.completed(result: .failure(LumaTLSProcessorError.handshakeFailed)) + return + } + pendingPlaintext.append(contentsOf: data) + ensureTLS() + drive() + completion.completed(result: .success(())) + } + + // MARK: - TLS lifecycle + + private func ensureTLS() { + guard ssl == nil, !handshakeFailed else { return } + guard let context = SSL_CTX_new(TLS_client_method()) else { + handshakeFailed = true + return + } + sslContext = context + // SSL_CTX_set_min/max_proto_version are C macros, so the Swift + // importer drops them. Drive the underlying control instead; the + // values come from (TLS1_2_VERSION 0x0303, + // TLS1_3_VERSION 0x0304) and + // (SSL_CTRL_SET_MIN_PROTO_VERSION 123, SSL_CTRL_SET_MAX_PROTO_VERSION + // 124). This pins the connection to TLS 1.2–1.3. + _ = SSL_CTX_ctrl(context, 123, 0x0303, nil) + _ = SSL_CTX_ctrl(context, 124, 0x0304, nil) + // The peer must present a certificate, but OpenSSL's own chain + // verification is disabled: it has no CA store and would reject the + // server mid-handshake with an "unknown ca" alert. Actual trust + // evaluation is performed by Luma after the handshake (SecTrust + + // configured validation), mirroring Martin's custom-validator flow. + SSL_CTX_set_verify(context, SSL_VERIFY_PEER, { _, _ in 1 }) + guard let connection = SSL_new(context) else { + handshakeFailed = true + return + } + // This OpenSSL build does not pre-select the connection role in + // SSL_new; without it SSL_do_handshake fails with + // "connection type not set". + SSL_set_connect_state(connection) + ssl = connection + if let serverName { + // SSL_set_tlsext_host_name is a C macro; use SSL_ctrl with + // SSL_CTRL_SET_TLSEXT_HOSTNAME (55) and TLSEXT_NAMETYPE_host_name + // (0) instead. OpenSSL keeps the pointer, so the C string must + // stay alive for the lifetime of the handshake. + storedServerName = Array(serverName.utf8CString) + storedServerName?.withUnsafeMutableBufferPointer { buffer in + _ = SSL_ctrl(connection, 55, 0, buffer.baseAddress) + } + } + if !alpnProtocols.isEmpty { + var wire: [UInt8] = [] + for proto in alpnProtocols { + let bytes = Array(proto.utf8) + wire.append(UInt8(bytes.count)) + wire.append(contentsOf: bytes) + } + _ = SSL_set_alpn_protos(connection, wire, UInt32(wire.count)) + } + let incoming = BIO_new(BIO_s_mem()) + let outgoing = BIO_new(BIO_s_mem()) + guard let incoming, let outgoing else { + handshakeFailed = true + return + } + readBIO = incoming + writeBIO = outgoing + SSL_set_bio(connection, incoming, outgoing) + } + + private func drive() { + guard let ssl else { return } + if !handshakeCompleted, !handshakeFailed { + driveHandshake() + } + if handshakeCompleted, !pendingPlaintext.isEmpty { + // Copy the pending plaintext before the C call: the array cannot + // be mutated while it is borrowed by withUnsafeBufferPointer. + let buffer = pendingPlaintext + let written = buffer.withUnsafeBufferPointer { raw -> Int32 in + guard let base = raw.baseAddress, !raw.isEmpty else { return 0 } + return SSL_write(ssl, base, Int32(raw.count)) + } + if written > 0 { + pendingPlaintext.removeFirst(Int(written)) + } + } + flushEncryptedOutput() + if handshakeCompleted { + emitDecrypted() + flushEncryptedOutput() + } + } + + private func driveHandshake() { + guard let ssl else { return } + let result = SSL_do_handshake(ssl) + if result == 1 { + handshakeCompleted = true + completeHandshake() + return + } + switch SSL_get_error(ssl, result) { + case SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE: + // Progress made; more bytes needed from either side. + break + default: + handshakeFailed = true + certificateValidationFailed?(nil) + } + } + + private func flushEncryptedOutput() { + guard let writeBIO else { return } + var buffer = [UInt8](repeating: 0, count: 16_384) + while true { + let count = BIO_read(writeBIO, &buffer, Int32(buffer.count)) + guard count > 0 else { break } + super.write( + data: Data(buffer[0.. 0 { + super.read(data: Data(buffer[0.. Data? { + // Encode into a fixed scratch buffer: X.509 certificates are far + // smaller than 16 KiB in practice, and this avoids manual OpenSSL + // memory management in Swift. + let length = i2d_X509(certificate, nil) + guard length > 0, length <= 16_384 else { return nil } + var buffer = [UInt8](repeating: 0, count: Int(length)) + var copied: Int32 = 0 + buffer.withUnsafeMutableBytes { raw in + var cursor = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) + copied = i2d_X509(certificate, &cursor) + } + guard copied == length else { return nil } + return Data(buffer) + } + + private static func matchesFingerprint(_ der: Data, fingerprint: String) -> Bool { + let expected = fingerprint + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: " ", with: "") + .lowercased() + let candidates = [ + Insecure.SHA1.hash(data: der).map { String(format: "%02x", $0) }.joined(), + SHA256.hash(data: der).map { String(format: "%02x", $0) }.joined(), + ] + return candidates.contains(expected) + } +} + +enum LumaTLSProcessorError: Error { + case handshakeFailed +} diff --git a/Sources/Shared/XMPP/ReadStateSync.swift b/Sources/Shared/XMPP/ReadStateSync.swift new file mode 100644 index 0000000..791d63d --- /dev/null +++ b/Sources/Shared/XMPP/ReadStateSync.swift @@ -0,0 +1,74 @@ +import Foundation +import Martin + +/// Service messages that sync read receipts between the user's own devices. +/// When a device marks an outgoing message as read (the peer's XEP-0333 +/// marker arrived there) it sends a message to its own bare JID +/// carrying a `` payload; +/// Carbons deliver it to the other devices and MAM archives it, so a fresh +/// install and every later archive replay converge on the same read state. +/// Receivers upsert the state on the matching outgoing message, deduplicating +/// by the marker id. +/// +/// MUC read state never leaks into the room archive: the marker is a plain +/// self-addressed message whose payload references the room JID and the +/// archived stanza-id of the room message. Group conversations are matched +/// through the MUC-MAM stanza-id, 1:1 chats through the origin-id. +enum ReadStateSync { + static let namespace = "https://luma.chat/read-marker" + + struct Envelope: Equatable, Sendable { + /// Unique marker id; matches the message origin-id so Carbons and MAM + /// copies deduplicate into a single state application. + let id: String + /// Bare JID of the conversation holding the read message: a peer or + /// a MUC room. + let conversationJID: String + /// Origin-id (clientID) of the read message on the sending device. + let messageID: String + /// Archived stanza-id of the read message (1:1 archive or MUC-MAM); + /// group messages are matched through it on the receiving devices. + let stanzaID: String? + let timestamp: Date + } + + static func envelope(from message: Message) -> Envelope? { + guard let element = message.element.findChild( + name: "read-marker", + xmlns: namespace + ) else { + return nil + } + let value = { (name: String) -> String? in + element.findChild(name: name)?.value + } + guard let rawConversation = value("conversation"), !rawConversation.isEmpty, + let rawMessage = value("id"), !rawMessage.isEmpty + else { return nil } + let rawStanza = value("stanza") + return Envelope( + id: message.originId ?? message.id ?? UUID().uuidString, + conversationJID: rawConversation.lowercased(), + messageID: rawMessage, + stanzaID: (rawStanza?.isEmpty == false) ? rawStanza : nil, + timestamp: value("when").flatMap { timestampFormatter.date(from: $0) } ?? Date() + ) + } + + static func payloadElement(marker: Envelope) -> Element { + let element = Element(name: "read-marker", xmlns: namespace) + element.addChild(Element(name: "conversation", cdata: marker.conversationJID.lowercased())) + element.addChild(Element(name: "id", cdata: marker.messageID)) + if let stanzaID = marker.stanzaID, !stanzaID.isEmpty { + element.addChild(Element(name: "stanza", cdata: stanzaID)) + } + element.addChild(Element(name: "when", cdata: timestampFormatter.string(from: marker.timestamp))) + return element + } + + private static let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} diff --git a/Sources/Shared/XMPP/SASLprep.swift b/Sources/Shared/XMPP/SASLprep.swift new file mode 100644 index 0000000..036901d --- /dev/null +++ b/Sources/Shared/XMPP/SASLprep.swift @@ -0,0 +1,101 @@ +import Foundation + +/// RFC 4013 (SASLprep) profile of stringprep for XMPP passwords. +/// +/// Martin's SCRAM implementation feeds the password into the PBKDF as raw +/// UTF-8 (ScramMechanism.normalize), while SASLprep-compliant servers +/// (Prosody, ejabberd) normalize it first. Passwords containing characters +/// the profile changes (non-ASCII spaces, soft hyphen, zero-width marks, +/// fullwidth forms, decomposed accents, ...) therefore fail with +/// 'not-authorized' even when the password is correct. Luma prepares the +/// password itself before handing it to SCRAM so both sides agree. +/// +/// The profile is: mapping (RFC 3454 B.1 + RFC 4013 C.1.2), NFKC +/// normalization, prohibition checks. The RFC 3454 section 6 bidi rules are +/// intentionally not enforced: they only affect pathological passwords and +/// the server enforces them on its side anyway. +enum SASLprep { + enum PreparationError: Error, Equatable { + case prohibitedCharacter(Character) + } + + /// RFC 4013 C.1.2: non-ASCII space characters mapped to U+0020 SPACE. + private static let mappedToSpace: Set = [ + "\u{00A0}", "\u{1680}", "\u{2000}", "\u{2001}", "\u{2002}", "\u{2003}", + "\u{2004}", "\u{2005}", "\u{2006}", "\u{2007}", "\u{2008}", "\u{2009}", + "\u{200A}", "\u{200B}", "\u{202F}", "\u{205F}", "\u{3000}", + ] + + /// RFC 3454 B.1: characters mapped to nothing. U+200B is deliberately + /// absent: RFC 4013 C.1.2 maps it to SPACE instead. + private static func isMappedToNothing(_ scalar: Unicode.Scalar) -> Bool { + let value = scalar.value + if value == 0x00AD || value == 0x034F || value == 0x1806 { return true } + if value >= 0x180B && value <= 0x180D { return true } + if value == 0x200C || value == 0x200D { return true } + if value == 0x2060 || value == 0xFEFF { return true } + if value >= 0xFE00 && value <= 0xFE0F { return true } + return false + } + + /// RFC 3454 C.2.1, C.2.2, C.2.3, C.3, C.4 and C.8: prohibited output. + private static func isProhibited(_ scalar: Unicode.Scalar) -> Bool { + let value = scalar.value + // C.2.1 ASCII control characters. + if value <= 0x1F || value == 0x7F { return true } + // C.2.2 non-ASCII control characters. + if value >= 0x80 && value <= 0x9F { return true } + // C.2.3 private use. + if value >= 0xE000 && value <= 0xF8FF { return true } + if value >= 0xF0000 && value <= 0xFFFFD { return true } + if value >= 0x100000 && value <= 0x10FFFD { return true } + // C.3 non-character code points. + if value >= 0xFDD0 && value <= 0xFDEF { return true } + if value & 0xFFFF == 0xFFFE || value & 0xFFFF == 0xFFFF { return true } + // C.4 surrogates cannot appear in a Swift string. + // C.8 change display properties / deprecated. + if value == 0x0340 || value == 0x0341 { return true } + if value == 0x200E || value == 0x200F { return true } + if value == 0x202A || value == 0x202B || value == 0x202C || value == 0x202D + || value == 0x202E + { + return true + } + if value == 0x206A || value == 0x206B || value == 0x206C || value == 0x206D + || value == 0x206E || value == 0x206F + { + return true + } + return false + } + + /// Returns the prepared password, or throws PreparationError when the + /// input contains characters RFC 4013 prohibits. + static func prepare(_ input: String) throws -> String { + // 1. Map. + var mapped = String.UnicodeScalarView() + for scalar in input.unicodeScalars { + let character = Character(String(scalar)) + if isMappedToNothing(scalar) { continue } + if mappedToSpace.contains(character) { + mapped.append(contentsOf: " ".unicodeScalars) + continue + } + mapped.append(scalar) + } + + // 2. Normalize (NFKC also composes decomposed accents). + let normalized = String(mapped).applyingTransform( + StringTransform("NFKC"), + reverse: false + ) ?? String(mapped).precomposedStringWithCanonicalMapping + + // 3. Prohibit. + for scalar in normalized.unicodeScalars where isProhibited(scalar) { + throw PreparationError.prohibitedCharacter(Character(String(scalar))) + } + + return normalized + } +} + diff --git a/Sources/Shared/XMPP/SaslFailureMessage.swift b/Sources/Shared/XMPP/SaslFailureMessage.swift new file mode 100644 index 0000000..a185fbf --- /dev/null +++ b/Sources/Shared/XMPP/SaslFailureMessage.swift @@ -0,0 +1,56 @@ +import Foundation +import Martin + +/// Turns a SASL authentication failure into an actionable Russian message. +/// The raw `` condition (captured by `LumaSaslFailureModule`) +/// wins over Martin's `SaslError`, because Martin maps every unknown RFC +/// 6120 condition (e.g. `account-disabled`) to `not_authorized`. +enum SaslFailureMessage { + static func describe(error: Error, condition: String?, serverText: String?) -> String { + if let serverText, !serverText.isEmpty { + // Prosody's SCRAM proof mismatch: the password the client used + // differs from the one stored on the server. Translate it into an + // actionable message (most often this is a typo or password + // autofill substituting another account's password). + if serverText.localizedCaseInsensitiveContains( + "doesn't match the one we calculated" + ) { + return "Пароль не совпадает с сохранённым на сервере. Проверьте пароль (например, через значок глаза) и не используйте автозаполнение другого аккаунта." + } + return "Сервер отклонил вход: \(serverText)" + } + switch condition { + case "account-disabled": + return "Аккаунт отключён на сервере. Обратитесь к администратору сервера." + case "credentials-expired": + return "Срок действия пароля истёк. Обновите пароль на сервере." + case "encryption-required": + return "Сервер требует шифрование канала для входа." + case "malformed-request": + return "Сервер не смог обработать запрос входа. Попробуйте ещё раз." + case "restricted-connection": + return "Сервер ограничил подключения с этого адреса." + default: + break + } + if let saslError = error as? SaslError { + switch saslError { + case .not_authorized: + return "Сервер отклонил имя пользователя или пароль. Проверьте JID и пароль." + case .temporary_auth_failure: + return "Сервер временно не может проверить учётные данные. Попробуйте через минуту." + case .server_not_trusted: + return "Сервер не прошёл проверку подписи SCRAM." + case .incorrect_encoding: + return "Сервер не принял кодировку учётных данных." + case .invalid_authzid: + return "Сервер отклонил идентификатор авторизации." + case .invalid_mechanism, .mechanism_too_weak: + return "Сервер не поддерживает доступные способы входа." + case .aborted: + return "Вход прерван." + } + } + return error.localizedDescription + } +} diff --git a/Sources/Shared/XMPP/TLSVersionProbe.swift b/Sources/Shared/XMPP/TLSVersionProbe.swift new file mode 100644 index 0000000..89121b1 --- /dev/null +++ b/Sources/Shared/XMPP/TLSVersionProbe.swift @@ -0,0 +1,304 @@ +import Foundation +import Security + +/// Result of probing the negotiated TLS parameters of the XMPP server: +/// the protocol version and the cipher suite actually agreed upon. +struct TLSProbeResult: Sendable { + let version: String + let cipher: String +} + +/// Standalone, best-effort probe that negotiates TLS against the same host +/// and port the XMPP connection uses, and reports the negotiated TLS version +/// and cipher suite. It performs the STARTTLS stream handshake (stream +/// header + ) when the account does not use Direct TLS, mirroring +/// what Martin's SocketConnector does, then reads the result from +/// SecureTransport. The app's real connection negotiates TLS the same way, +/// so the probe accurately reflects what the server screen displays. +final class TLSVersionProbe: NSObject, StreamDelegate { + private let host: String + private let port: UInt32 + private let directTLS: Bool + private let expectedDomain: String + private let completion: (TLSProbeResult?) -> Void + + private enum Phase { + case starting + case expectingFeatures + case tlsNegotiating + case finished + } + + private var phase: Phase = .starting + private var inputStream: InputStream? + private var outputStream: OutputStream? + private var receivedData = Data() + private var sentStreamHeader = false + private var sentStartTLS = false + private var sawStartTLSOffer = false + private var sslContext: SSLContext? + private var runLoop: RunLoop? + private var finished = false + + private static let activeLock = NSLock() + private static var activeProbes: Set = [] + + static func run( + host: String, + port: UInt32, + directTLS: Bool, + expectedDomain: String, + completion: @escaping (TLSProbeResult?) -> Void + ) { + let probe = TLSVersionProbe( + host: host, + port: port, + directTLS: directTLS, + expectedDomain: expectedDomain, + completion: completion + ) + activeLock.lock() + activeProbes.insert(probe) + activeLock.unlock() + probe.start() + } + + private init( + host: String, + port: UInt32, + directTLS: Bool, + expectedDomain: String, + completion: @escaping (TLSProbeResult?) -> Void + ) { + self.host = host + self.port = port + self.directTLS = directTLS + self.expectedDomain = expectedDomain + self.completion = completion + super.init() + } + + private func start() { + let thread = Thread { [weak self] in + guard let self else { return } + self.runLoop = RunLoop.current + var readStream: Unmanaged? + var writeStream: Unmanaged? + CFStreamCreatePairWithSocketToHost( + kCFAllocatorDefault, + self.host as CFString, + self.port, + &readStream, + &writeStream + ) + guard let readStream, let writeStream else { + self.finish(nil) + return + } + self.inputStream = readStream.takeRetainedValue() + self.outputStream = writeStream.takeRetainedValue() + self.inputStream?.delegate = self + self.outputStream?.delegate = self + self.inputStream?.schedule(in: .current, forMode: .default) + self.outputStream?.schedule(in: .current, forMode: .default) + self.inputStream?.open() + self.outputStream?.open() + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 12) { [weak self] in + guard let self, !self.finished else { return } + self.finish(nil) + } + RunLoop.current.run() + } + thread.name = "app.luma.tlsProbe" + thread.start() + } + + // MARK: - StreamDelegate + + func stream(_ aStream: Stream, handle eventCode: Stream.Event) { + switch eventCode { + case .openCompleted: + if phase == .tlsNegotiating { + reportTLSResult() + } else if directTLS, phase == .starting { + configureTLS() + } else if !sentStreamHeader { + writeStreamHeader() + } + case .hasSpaceAvailable: + if directTLS { return } + if !sentStreamHeader { + writeStreamHeader() + } else if sawStartTLSOffer, !sentStartTLS { + writeStartTLS() + } + case .hasBytesAvailable: + readAvailableBytes() + case .errorOccurred, .endEncountered: + if phase == .tlsNegotiating { + reportTLSResult() + } else if phase != .finished { + finish(nil) + } + default: + break + } + } + + // MARK: - Steps + + private func writeStreamHeader() { + guard let outputStream else { return } + let header = + "" + + "" + let data = Data(header.utf8) + sentStreamHeader = data.count == outputStream.write( + [UInt8](data), + maxLength: data.count + ) + if sentStreamHeader, !directTLS { + phase = .expectingFeatures + } + } + + private func writeStartTLS() { + guard let outputStream else { return } + let startTLS = "" + let data = Data(startTLS.utf8) + sentStartTLS = data.count == outputStream.write( + [UInt8](data), + maxLength: data.count + ) + } + + private func readAvailableBytes() { + guard let inputStream else { return } + var chunk = [UInt8](repeating: 0, count: 4096) + while inputStream.hasBytesAvailable { + let count = inputStream.read(&chunk, maxLength: chunk.count) + guard count > 0 else { break } + receivedData.append(contentsOf: chunk[0.. String? { + switch protocolVersion.rawValue { + case 10: return "TLS 1.3" + case 8: return "TLS 1.2" + case 7: return "TLS 1.1" + case 4: return "TLS 1.0" + case 2: return "SSL 3.0" + default: return nil + } + } + + static func cipherDescription(_ cipher: SSLCipherSuite) -> String { + switch cipher { + case 0x1301: return "TLS_AES_128_GCM_SHA256" + case 0x1302: return "TLS_AES_256_GCM_SHA384" + case 0x1303: return "TLS_CHACHA20_POLY1305_SHA256" + case 0xC02F: return "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" + case 0xC02B: return "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" + case 0xC030: return "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" + case 0xC02C: return "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" + case 0xCCA8: return "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256" + case 0xCCA9: return "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" + case 0xCCAA: return "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256" + case 0x009E: return "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256" + case 0x009F: return "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384" + case 0x009C: return "TLS_RSA_WITH_AES_128_GCM_SHA256" + case 0x009D: return "TLS_RSA_WITH_AES_256_GCM_SHA384" + case 0xC013: return "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA" + case 0xC014: return "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA" + case 0x003C: return "TLS_RSA_WITH_AES_128_CBC_SHA256" + case 0x002F: return "TLS_RSA_WITH_AES_128_CBC_SHA" + case 0x0035: return "TLS_RSA_WITH_AES_256_CBC_SHA" + default: return String(format: "0x%04X", cipher) + } + } +} diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift index bd034c2..37803c1 100644 --- a/Sources/Shared/XMPP/XMPPService.swift +++ b/Sources/Shared/XMPP/XMPPService.swift @@ -1,8 +1,9 @@ import Combine import Foundation -import Martin -import MartinOMEMO +@preconcurrency import Martin +@preconcurrency import MartinOMEMO import WebRTC +import os private struct BufferedArchiveStanza { let queryID: String @@ -17,18 +18,31 @@ private struct BufferedArchivePage { let rejectedSource: Bool } +private enum BufferedLiveDelivery { + case direct(message: Message, timestamp: Date) + case group(message: Message, room: RoomProtocol) +} + /// 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 struct State { + let allowedSources: Set + var stanzas: [BufferedArchiveStanza] = [] + var overflowed = false + var rejectedSource = false + } + 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 + // private var activeQueryID: String? + // private var allowedSources: Set = [] + // private var stanzas: [BufferedArchiveStanza] = [] + // private var overflowed = false + // private var rejectedSource = false + private var states: [String: State] = [:] init(maximumCount: Int) { self.maximumCount = maximumCount @@ -37,11 +51,14 @@ private final class ArchiveStanzaInbox: @unchecked Sendable { 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 + // activeQueryID = queryID + // self.allowedSources = Set(allowedSources.map { $0.lowercased() }) + // stanzas.removeAll(keepingCapacity: true) + // overflowed = false + // rejectedSource = false + states[queryID] = State( + allowedSources: Set(allowedSources.map { $0.lowercased() }) + ) } func append( @@ -53,51 +70,73 @@ private final class ArchiveStanzaInbox: @unchecked Sendable { ) { lock.lock() defer { lock.unlock() } - guard activeQueryID == queryID else { return } - guard allowedSources.contains(source.stringValue.lowercased()) else { - rejectedSource = true + // guard activeQueryID == queryID else { return } + // guard allowedSources.contains(source.stringValue.lowercased()) else { + // rejectedSource = true + guard var state = states[queryID] else { return } + guard state.allowedSources.contains(source.stringValue.lowercased()) else { + state.rejectedSource = true + states[queryID] = state return } - guard stanzas.count < maximumCount else { - overflowed = true + // guard stanzas.count < maximumCount else { + // overflowed = true + guard state.stanzas.count < maximumCount else { + state.overflowed = true + states[queryID] = state return } - stanzas.append(BufferedArchiveStanza( - queryID: queryID, - message: message, - timestamp: timestamp, - archiveID: archiveID - )) + // stanzas.append( + state.stanzas.append( + BufferedArchiveStanza( + queryID: queryID, + message: message, + timestamp: timestamp, + archiveID: archiveID + )) + // print("MAM source: \(source), allowed: \(allowedSources)") + states[queryID] = state } func take(queryID: String) -> BufferedArchivePage { lock.lock() defer { lock.unlock() } - guard activeQueryID == queryID else { + // guard activeQueryID == queryID else { + guard let state = states.removeValue(forKey: queryID) else { return BufferedArchivePage(stanzas: [], overflowed: false, rejectedSource: false) } - let page = BufferedArchivePage( - stanzas: stanzas, - overflowed: overflowed, - rejectedSource: rejectedSource + // 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 + return BufferedArchivePage( + stanzas: state.stanzas, + overflowed: state.overflowed, + rejectedSource: state.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 + // if let queryID, activeQueryID != queryID { return } + // activeQueryID = nil + // allowedSources.removeAll(keepingCapacity: true) + // stanzas.removeAll(keepingCapacity: true) + // overflowed = false + // rejectedSource = false + if let queryID { + states.removeValue(forKey: queryID) + } else { + states.removeAll(keepingCapacity: true) + } } } @@ -128,6 +167,7 @@ final class XMPPService { let replyToID: String? let replyToJID: String? let replyPreview: String? + let originID: String? let stanzaID: String? let senderDisplayName: String? let isGroupMessage: Bool @@ -202,17 +242,22 @@ final class XMPPService { case retraction(RetractionEnvelope) case reaction(ReactionEnvelope) case chatState(ChatStateEnvelope) - case groupMessageEcho(roomJID: String, messageID: String, stanzaID: String, senderJID: String) + 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 read(conversationJID: String, messageID: String) case omemo(ready: Bool, ownFingerprint: String?) case archiveBatch([ArchiveMutation]) case archiveSyncing(Bool) case archiveSyncCompleted(ArchiveSyncCheckpoint) + case mucArchiveSyncCompleted(archive: MAMArchiveKey, checkpoint: MAMArchiveCheckpoint) case call(CallSnapshot?) case callHistory(CallHistoryEntry) + case callHistorySync(CallHistorySync.Envelope) + case readState(ReadStateSync.Envelope) case callError(String) case recoverableError(String) } @@ -222,6 +267,24 @@ final class XMPPService { private let callEngine: LumaCallEngine private var client: XMPPClient? private var omemoStorage: LumaOMEMOStore? + private var connectionStatsModule: LumaConnectionStatsModule? + private var saslFailureModule: LumaSaslFailureModule? + private var activePassword: String? + private var lastLoginDate: Date? + private var smacksSessionEstablishedDate: Date? + private(set) var negotiatedSASLMechanism: String? + private(set) var negotiatedTLSVersion: String? + private(set) var negotiatedTLSCipher: String? + private var tlsProbeTask: Task? + /// TLS parameters and channel-binding data of the live connection, + /// captured by Luma's OpenSSL TLS processor and shared with SCRAM-PLUS. + private let channelBindingStore = LumaChannelBindingStore() + /// Mechanisms advertised in the stream features (classic SASL + SASL2), + /// shown on the server-information screen. + private var advertisedSASLMechanisms: [String] = [] + /// SASL2 (RFC 9050) mechanisms from the `` stream + /// feature, shown as a dedicated list on the server-information screen. + private var advertisedSASL2Mechanisms: [String] = [] private var cancellables: Set = [] private var account: AccountConfiguration? private var archiveSyncStarted = false @@ -233,6 +296,8 @@ final class XMPPService { private var archiveRetryTask: Task? private var archiveQueryTimeoutTask: Task? private var archiveQueryCompletionTask: Task? + private var archiveSyncRetryTask: Task? + private var archiveAutoRetryCount = 0 private var archiveActiveQueryID: String? private var archiveResumeAfter: String? private var archiveWorkBudget = ArchiveSyncWorkBudget() @@ -248,6 +313,30 @@ final class XMPPService { private var archiveStanzaBuffer: [BufferedArchiveStanza] = [] private var archiveBufferOverflowed = false private var archiveRejectedSource = false + private var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:] + private var pendingMUCCatchups: [MAMArchiveKey] = [] + private var activeMUCCatchup: MAMArchiveKey? + private var mucCatchupHighWatermark: Date? + private var mucCatchupLastCursor: String? + private var mucCursorFallbackArchives: Set = [] + private var delayedLiveByArchive: [MAMArchiveKey: [BufferedLiveDelivery]] = [:] + private var olderHistoryQueryID: String? + private var olderHistoryCompletion: ((Result) -> Void)? + private var olderHistoryTimeoutTask: Task? + /// When non-nil, archived mutations produced while applying an interactive + /// backward-history page are collected here instead of the catch-up + /// accumulator, so the two never interleave their mutations. + private var interactiveHistoryMutations: [ArchiveMutation]? + private let olderHistoryQueryTimeoutNanoseconds: UInt64 = 15_000_000_000 + /// Serial background queue for OMEMO decryption. `decode` performs the + /// expensive Signal work (session decrypt + AES-GCM) and previously ran on + /// the main actor for every MAM stanza, stalling the UI during large + /// archive catch-ups. All decodes stay serialized on this single queue so + /// Signal's per-session state is never mutated concurrently. + private let omemoDecodeQueue = DispatchQueue( + label: "app.luma.omemo.decode", + qos: .userInitiated + ) private let archiveStanzaInbox = ArchiveStanzaInbox( maximumCount: ArchiveMessageBatchPolicy.maximumBufferedStanzas ) @@ -291,20 +380,22 @@ final class XMPPService { case .connected: return .connected case .disconnected(let reason): - return .disconnected(reason: Self.reasonText(reason)) + return .disconnected(reason: reasonText(reason)) } } func connect( account: AccountConfiguration, password: String, - archiveCheckpoint: ArchiveSyncCheckpoint? = nil + archiveCheckpoint: ArchiveSyncCheckpoint? = nil, + mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:] ) async throws { await disconnect() cancellables.removeAll() archiveSyncStarted = false archiveSyncCompletedForConnection = false archiveSyncCheckpoint = archiveCheckpoint + self.mamCheckpoints = mamCheckpoints archiveSyncQueryStartedAt = nil archivePagination = ArchiveSyncPagination() archiveRetryTask?.cancel() @@ -313,6 +404,9 @@ final class XMPPService { archiveQueryTimeoutTask = nil archiveQueryCompletionTask?.cancel() archiveQueryCompletionTask = nil + archiveSyncRetryTask?.cancel() + archiveSyncRetryTask = nil + archiveAutoRetryCount = 0 archiveActiveQueryID = nil archiveResumeAfter = nil archiveWorkBudget = ArchiveSyncWorkBudget() @@ -327,6 +421,11 @@ final class XMPPService { archiveBufferOverflowed = false archiveRejectedSource = false archiveStanzaInbox.cancel() + pendingMUCCatchups.removeAll() + activeMUCCatchup = nil + mucCursorFallbackArchives.removeAll() + mucCatchupHighWatermark = nil + mucCatchupLastCursor = nil setArchiveSyncIndicator(false) avatarRequests.removeAll() observedRoomJIDs.removeAll() @@ -334,6 +433,14 @@ final class XMPPService { roomRealJIDByNickname.removeAll() omemoConfiguredRoomJIDs.removeAll() knownChatStatePeers.removeAll() + negotiatedSASLMechanism = nil + negotiatedTLSVersion = nil + negotiatedTLSCipher = nil + tlsProbeTask?.cancel() + tlsProbeTask = nil + channelBindingStore.reset() + advertisedSASLMechanisms = [] + advertisedSASL2Mechanisms = [] let account = try account.validated() self.account = account @@ -343,11 +450,20 @@ final class XMPPService { guard let signalContext = SignalContext(withStorage: omemoStorage) else { throw LumaXMPPError.omemoInitializationFailed } + // Clean up an invalid self-session left behind by a previous buggy + // build (one-time migration per account). + omemoStorage.removeSessionWithOwnDeviceOnce() configureModules(client: client, signalContext: signalContext, omemoStorage: omemoStorage) configureConnection(client: client, account: account, password: password) + activePassword = password subscribe(to: client, omemoStorage: omemoStorage) + // The self-session must be rebuilt after the modules exist: the + // ensureSelfSession lookup goes through the modules manager and + // crashes on a not-yet-registered OMEMO module. + ensureSelfSession(client: client, omemoStorage: omemoStorage) + self.client = client self.omemoStorage = omemoStorage callEngine.attach(client: client) @@ -355,24 +471,35 @@ final class XMPPService { do { try await client.loginAndWait() + let now = Date() + lastLoginDate = now + let streamManagement = client.module(.streamManagement) + if streamManagement.ackEnabled || streamManagement.resumptionEnabled { + smacksSessionEstablishedDate = now + } eventHandler?(.connection(.connected)) fetchAvatar(for: account.normalizedJID) startArchiveSyncIfAvailable() } catch { callEngine.detach() - eventHandler?(.connection(.disconnected(reason: error.localizedDescription))) - throw LumaXMPPError.connection(error.localizedDescription) + let message = connectionFailureMessage(for: error) + eventHandler?(.connection(.disconnected(reason: message))) + throw LumaXMPPError.connection(message) } } func disconnect() async { callEngine.detach() + finishOlderHistory(result: .failure(LumaXMPPError.notConnected)) archiveRetryTask?.cancel() archiveRetryTask = nil archiveQueryTimeoutTask?.cancel() archiveQueryTimeoutTask = nil archiveQueryCompletionTask?.cancel() archiveQueryCompletionTask = nil + archiveSyncRetryTask?.cancel() + archiveSyncRetryTask = nil + archiveAutoRetryCount = 0 archiveActiveQueryID = nil archiveResumeAfter = nil archiveWorkBudget = ArchiveSyncWorkBudget() @@ -409,6 +536,15 @@ final class XMPPService { roomRealJIDByNickname.removeAll() omemoConfiguredRoomJIDs.removeAll() knownChatStatePeers.removeAll() + connectionStatsModule = nil + saslFailureModule = nil + lastLoginDate = nil + smacksSessionEstablishedDate = nil + negotiatedSASLMechanism = nil + negotiatedTLSVersion = nil + negotiatedTLSCipher = nil + tlsProbeTask?.cancel() + tlsProbeTask = nil eventHandler?(.connection(.disconnected(reason: nil))) } @@ -430,6 +566,9 @@ final class XMPPService { _ = client.module(.csi).setState(active) if active { archiveRetrySuppressedUntilActivation = false + archiveAutoRetryCount = 0 + archiveSyncRetryTask?.cancel() + archiveSyncRetryTask = nil refreshArchiveIfNeeded(client: client) } else { // iOS may freeze network callbacks and timeout tasks while the @@ -464,18 +603,21 @@ final class XMPPService { self.archiveResumeAfterMediaWorkTask = nil self.archiveSyncSuspended = false guard let client = self.client, - client.state == .connected() else { return } + client.state == .connected() + else { return } self.startArchiveSyncIfAvailable() } } - func reconnectIfNeeded(password: String) async throws { + func reconnectIfNeeded(password: String, archiveCheckpoint: ArchiveSyncCheckpoint? = nil) + async throws + { guard connectionStatus != .connected, let account else { return } + if let archiveCheckpoint { + self.archiveSyncCheckpoint = archiveCheckpoint + } try await connect( - account: account, - password: password, - archiveCheckpoint: archiveSyncCheckpoint - ) + account: account, password: password, archiveCheckpoint: archiveSyncCheckpoint) } func startCall(to peerJID: String, withVideo: Bool) async throws { @@ -571,30 +713,38 @@ final class XMPPService { room = joinedRoom } observe(room: room) - eventHandler?(.roomState(RoomStateEnvelope( - roomJID: room.jid.stringValue, - nickname: room.nickname, - joined: room.state == .joined, - occupantCount: 0 - ))) + enqueueMUCCatchup(roomJID: room.jid) + + 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 } + 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 - ))) + eventHandler?( + .roomState( + RoomStateEnvelope( + roomJID: normalized, + nickname: room.nickname, + joined: false, + occupantCount: 0 + ))) } func inviteMembers(_ jids: [String], to roomJID: String) async throws { @@ -604,7 +754,8 @@ final class XMPPService { let normalized = roomJID.lowercased() let muc = client.module(.muc) guard let room = muc.roomManager.room(for: client, with: BareJID(normalized)), - room.state == .joined else { + room.state == .joined + else { throw LumaXMPPError.roomNotJoined } let invitees = jids.map { @@ -624,14 +775,17 @@ final class XMPPService { ) if let owners, let admins { let privilegedJIDs = Set((owners + admins).map { $0.jid.bareJid }) - let memberships = invitees + 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 + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + muc.setRoomAffiliations(to: room, changedAffiliations: memberships) { + result in continuation.resume(with: result) } } @@ -647,6 +801,83 @@ final class XMPPService { } } + /// Sends a `` service message to the user's own bare JID + /// so Carbons deliver it to the other devices and MAM archives it. The + /// message is plaintext and never reaches the call counterpart. + func syncCallHistory(_ entry: CallHistoryEntry) { + guard let client, client.state == .connected(), let account else { return } + let message = Message() + message.to = JID(account.normalizedJID) + message.type = .chat + message.id = entry.id + message.body = Self.callHistoryBody(entry) + addOriginID(entry.id, to: message) + message.addChild(CallHistorySync.payloadElement(entry: entry)) + client.context.writer.write(message, writeCompleted: nil) + Logger(subsystem: "Luma", category: "call-sync") + .info( + "sent call-history id=\(entry.id) peer=\(entry.peerJID) status=\(entry.outcome.rawValue)" + ) + } + + /// Sends a `` service message to the user's own bare JID + /// so Carbons deliver it to the other devices and MAM archives it. The + /// message is plaintext and never reaches the chat counterpart; MUC read + /// state rides the same self-addressed envelope and never enters the room + /// archive. + func syncReadState(_ marker: ReadStateSync.Envelope) { + guard let client, client.state == .connected(), let account else { return } + let message = Message() + message.to = JID(account.normalizedJID) + message.type = .chat + message.id = marker.id + // No : this is a service stanza for the user's own devices only. + // A plaintext body would leak into the self-chat and be rendered as a + // normal message by clients that do not know our read-marker namespace + // (Conversations, Monal), so the marker rides solely on its payload. + addOriginID(marker.id, to: message) + message.addChild(ReadStateSync.payloadElement(marker: marker)) + client.context.writer.write(message, writeCompleted: nil) + Logger(subsystem: "Luma", category: "read-sync") + .info( + "sent read-marker id=\(marker.id) message=\(marker.messageID) conversation=\(marker.conversationJID)" + ) + } + + /// Sends an XEP-0333 `` chat marker so the peer's client can + /// show the message as read. Markers are never sent to MUC rooms + /// (XEP-0333 forbids them there); the read state of outgoing MUC messages + /// only changes when a member's client sends a marker anyway or when + /// another of the user's devices broadcasts a read-marker sync message. + func sendDisplayedMarker(messageID: String, to peerJID: String) { + guard let client, client.state == .connected() else { return } + let message = Message() + message.to = JID(peerJID) + message.type = .chat + message.chatMarkers = .displayed(id: messageID) + client.context.writer.write(message, writeCompleted: nil) + } + + /// Detects OMEMO 2 (`urn:xmpp:omemo:2`) payloads, which the pinned + /// MartinOMEMO version cannot decrypt (it only speaks the legacy + /// `eu.siacs.conversations.axolotl` namespace). + private static func isOMEMO2Payload(_ message: Message) -> Bool { + message.element.findChild(name: "encrypted", xmlns: "urn:xmpp:omemo:2") != nil + } + + private static func callHistoryBody(_ entry: CallHistoryEntry) -> String { + let kind = entry.isVideo ? "Видеозвонок" : "Аудиозвонок" + switch entry.outcome { + case .completed: return "\(kind) завершён" + case .missed: return "Пропущенный \(kind.lowercased())" + case .declined: return "Отклонённый \(kind.lowercased())" + case .cancelled: return "Отменённый \(kind.lowercased())" + case .unanswered: return "\(kind) без ответа" + case .failed: return "Неудачный \(kind.lowercased())" + case .answeredElsewhere: return "\(kind) принят на другом устройстве" + } + } + func sendText( _ text: String, to recipient: String, @@ -679,23 +910,43 @@ final class XMPPService { let roomJID = BareJID(recipient.lowercased()) let muc = client.module(.muc) guard let room = muc.roomManager.room(for: client, with: roomJID), - room.state == .joined else { + room.state == .joined + else { throw LumaXMPPError.roomNotJoined } let message = room.createMessage(text: wireBody, id: messageID) + addOriginID(messageID, to: message) 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 ) + // OMEMO 2 only when every member publishes OMEMO 2 devices; + // otherwise the legacy protocol keeps legacy members readable. + // Device lists are fetched on demand so a first message to a + // room never falls back to legacy just because the lists are + // not cached yet. + if let omemo2 = client.moduleOrNil(.omemo2), + omemo2.isReady, + await Self.allRecipientsPublishOMEMO2(recipients, module: omemo2) + { + let encryptedMessage = try await encrypt( + message, + forGroupRecipients: recipients, + using: omemo2, + roomJID: roomJID + ) + try await room.send(message: encryptedMessage.message) + return encryptedMessage.fingerprint + } + guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else { + throw LumaXMPPError.omemoNotReady + } let encryptedMessage = try await encrypt( message, forGroupRecipients: recipients, @@ -711,11 +962,22 @@ final class XMPPService { 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 { + 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) + addOriginID(messageID, to: message) + // XEP-0333: peers only answer with `` when the outgoing + // stanza is marked ``. Martin adds `` (receipts) + // on its own but never markable, so 1:1 read state stayed stuck on + // "delivered". The element stays outside the OMEMO payload, which is + // exactly where Monal/Conversations look for it. MUC is exempt — + // XEP-0333 forbids markers in groupchats. + message.isMarkable = true message.lastMessageCorrectionId = replacingMessageID if chatStatesEnabled { message.chatState = .active } addReply(replyTo, fallback: replyFallback, to: message) @@ -723,6 +985,19 @@ final class XMPPService { message.oob = outOfBandURL } if encrypted { + // Prefer OMEMO 2 when the peer publishes OMEMO 2 devices; legacy + // OMEMO stays as the fallback for legacy-only contacts. The + // peer's device list is fetched on demand, so the very first + // message to an OMEMO 2-only contact no longer falls back to + // legacy. + if let omemo2 = client.moduleOrNil(.omemo2), + omemo2.isReady, + await !(omemo2.deviceIDs(for: peer, fetching: true) ?? []).isEmpty + { + let encryptedMessage = try await encrypt(message, using: omemo2) + try await chat.send(message: encryptedMessage.message) + return encryptedMessage.fingerprint + } guard let omemo = client.moduleOrNil(.omemo), omemo.isReady else { throw LumaXMPPError.omemoNotReady } @@ -735,6 +1010,254 @@ final class XMPPService { } } + /// OpenSSL version strings to their user-facing form ("TLSv1.3" → + /// "TLS 1.3"). + private static func displayTLSVersion(_ version: String) -> String { + switch version { + case "TLSv1.3": return "TLS 1.3" + case "TLSv1.2": return "TLS 1.2" + case "TLSv1.1": return "TLS 1.1" + case "TLSv1": return "TLS 1.0" + default: return version + } + } + + /// True when every MUC recipient has a non-empty OMEMO 2 device list. + /// Fetches the lists from PEP on demand for recipients that have not been + /// cached yet. + private static func allRecipientsPublishOMEMO2( + _ recipients: [BareJID], + module: LumaOMEMO2Module + ) async -> Bool { + for recipient in recipients { + let devices = await module.deviceIDs(for: recipient, fetching: true) ?? [] + if devices.isEmpty { return false } + } + return true + } + + /// Loads one older MAM page for a single conversation. `before` is the + /// oldest server/MAM id currently known by the UI. For direct chats the + /// query is scoped with XEP-0313 `with`; for MUC the IQ is addressed to the + /// room archive itself. The completion carries the cursor for the next, + /// older page (RSM of the returned page). + func loadOlderHistory( + conversationJID: String, + isGroup: Bool, + before: String?, + completion: @escaping (Result) -> Void + ) { + guard let client, client.state == .connected() else { + completion(.failure(LumaXMPPError.notConnected)) + return + } + // Interactive backward-history is fully independent from catch-up: it + // owns a query-ID-scoped inbox entry and its own mutation collector, so + // it must never wait for an account/MUC catch-up pass to settle. + performOlderHistoryRequest( + conversationJID: conversationJID, + isGroup: isGroup, + before: before, + completion: completion + ) + } + + private func performOlderHistoryRequest( + conversationJID: String, + isGroup: Bool, + before: String?, + completion: @escaping (Result) -> Void + ) { + guard let client, client.state == .connected() else { + completion(.failure(LumaXMPPError.notConnected)) + return + } + guard let version = client.module(.mam).availableVersions.first else { + completion(.failure(LumaXMPPError.connection("MAM недоступен"))) + return + } + + let archive = + isGroup + ? MAMArchiveKey.muc(conversationJID) + : MAMArchiveKey.account(client.userBareJid.stringValue) + // Only one interactive backward-history query is allowed at a time. + // This is independent from the normal account/MUC catch-up query. + if let previous = olderHistoryCompletion { + // AppModel normally serializes requests, but never leak the + // previous completion if a request does slip through. + previous(.failure(LumaXMPPError.connection("Запрос истории заменён более новым."))) + } + olderHistoryTimeoutTask?.cancel() + olderHistoryTimeoutTask = nil + interactiveHistoryMutations = nil + let queryID = UUID().uuidString + olderHistoryQueryID = queryID + olderHistoryCompletion = completion + archiveStanzaInbox.begin( + queryID: queryID, + allowedSources: allowedMAMSources(for: archive, client: client) + ) + scheduleOlderHistoryTimeout( + client: client, + queryID: queryID, + conversationJID: conversationJID + ) + + // Martin 3.2.4's convenience overload accidentally clears `with`. + // Construct MAMQueryForm explicitly so direct-chat history is truly + // scoped to this peer. + let form = MAMQueryForm(version: version) + if !isGroup { + form.with = JID(conversationJID.lowercased()) + } + let componentJID: JID? = isGroup ? JID(conversationJID.lowercased()) : nil + let rsm: RSM.Query = + before.map { + RSM.Query(before: $0, max: archivePageSize) + } ?? RSM.Query(lastItems: archivePageSize) + + client.module(.mam).queryItems( + version: version, + componentJid: componentJID, + query: form, + queryId: queryID, + rsm: rsm + ) { [weak self, weak client] result in + DispatchQueue.main.async { + guard let self else { + completion(.failure(LumaXMPPError.notConnected)) + return + } + guard let client, self.client === client else { + self.finishOlderHistory(result: .failure(LumaXMPPError.notConnected)) + return + } + guard self.olderHistoryQueryID == queryID else { + // A newer request, a timeout, or a disconnect already owns + // the slot and has released this completion. + return + } + self.olderHistoryTimeoutTask?.cancel() + self.olderHistoryTimeoutTask = nil + let page = self.archiveStanzaInbox.take(queryID: queryID) + guard !page.overflowed, !page.rejectedSource else { + self.finishOlderHistory( + result: .failure(LumaXMPPError.connection("Некорректная MAM history page")) + ) + return + } + + Task { @MainActor [weak self, weak client] in + guard let self else { return } + guard let client, self.client === client else { + self.finishOlderHistory(result: .failure(LumaXMPPError.notConnected)) + return + } + // Collect this page's mutations in a dedicated buffer so a + // concurrently-running catch-up pass never interleaves its + // mutations with the interactive page. + self.interactiveHistoryMutations = [] + for stanza in page.stanzas { + if stanza.message.type == .groupchat, + let roomJID = stanza.message.from?.bareJid + { + await self.handle( + groupMessage: stanza.message, + archivedRoomJID: roomJID, + archivedTimestamp: stanza.timestamp, + archiveID: stanza.archiveID, + isArchived: true + ) + } else { + await self.handle( + message: stanza.message, + timestamp: stanza.timestamp, + archiveID: stanza.archiveID, + isArchived: true, + archivedPeerJID: isGroup + ? nil : BareJID(conversationJID.lowercased()) + ) + } + } + let historyMutations = self.interactiveHistoryMutations ?? [] + self.interactiveHistoryMutations = nil + if !historyMutations.isEmpty { + self.eventHandler?(.archiveBatch(historyMutations)) + } + switch result { + case .success(let response): + // The next page's anchor is the server's RSM + // of this page. Pages that insert nothing (reactions, + // retractions, duplicates) still advance the cursor, + // so the next scroll requests genuinely older history + // instead of the same page again. + self.finishOlderHistory( + result: .success( + OlderHistoryScrollPolicy.page( + complete: response.complete, + pageFirstID: response.rsm?.first + ) + )) + case .failure(let error): + // Do not leave the UI in the loading state. A failed + // interactive query is recoverable by the next scroll. + self.finishOlderHistory(result: .failure(error)) + } + } + } + } + } + + private func scheduleOlderHistoryTimeout( + client: XMPPClient, + queryID: String, + conversationJID: String + ) { + olderHistoryTimeoutTask?.cancel() + olderHistoryTimeoutTask = Task { @MainActor [weak self, weak client] in + try? await Task.sleep( + nanoseconds: self?.olderHistoryQueryTimeoutNanoseconds ?? 15_000_000_000) + guard !Task.isCancelled, + let self, + let client, + self.client === client, + self.olderHistoryQueryID == queryID + else { + return + } + + self.archiveStanzaInbox.cancel(queryID: queryID) + self.eventHandler?( + .recoverableError( + "Не удалось загрузить более старую историю чата \(conversationJID): MAM не ответил за 15 секунд." + ) + ) + self.finishOlderHistory( + result: .failure( + LumaXMPPError.connection( + "MAM не ответил вовремя. Повторите прокрутку вверх." + ) + ) + ) + } + } + + /// Releases the in-flight interactive backward-history request exactly once. + /// Every terminal path (success, failure, timeout, disconnect, background) + /// funnels through here so the UI's "loading older history" spinner can + /// never be left stuck. + private func finishOlderHistory(result: Result) { + guard olderHistoryCompletion != nil else { return } + let completion = olderHistoryCompletion + olderHistoryCompletion = nil + olderHistoryQueryID = nil + olderHistoryTimeoutTask?.cancel() + olderHistoryTimeoutTask = nil + interactiveHistoryMutations = nil + completion?(result) + } + func sendRetraction( to recipient: String, targetID: String, @@ -747,11 +1270,15 @@ final class XMPPService { 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 { + guard + let chat = manager.chat(for: client, with: peer) + ?? manager.createChat(for: client, with: peer) + else { throw LumaXMPPError.cannotCreateChat } - let fallbackText = "/me удалил(а) сообщение. Обновите клиент, если оно всё ещё отображается." + let fallbackText = + "/me удалил(а) сообщение. Обновите клиент, если оно всё ещё отображается." let message = chat.createMessage(text: fallbackText, id: retractionID) let retract = Element(name: "retract", xmlns: Self.retractionNamespace) retract.setAttribute("id", value: targetID) @@ -796,7 +1323,8 @@ final class XMPPService { let roomJID = BareJID(recipient.lowercased()) let muc = client.module(.muc) guard let room = muc.roomManager.room(for: client, with: roomJID), - room.state == .joined else { + room.state == .joined + else { throw LumaXMPPError.roomNotJoined } let message = room.createMessage(id: reactionMessageID, type: .groupchat) @@ -808,8 +1336,10 @@ final class XMPPService { 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 { + 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) @@ -824,15 +1354,17 @@ final class XMPPService { isGroup: Bool ) async throws { guard chatStatesEnabled, - let client, - client.state == .connected() else { return } + 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 } + room.state == .joined + else { return } let message = room.createMessage(id: UUID().uuidString, type: .groupchat) message.chatState = martinState try await room.send(message: message) @@ -841,13 +1373,16 @@ final class XMPPService { let peer = BareJID(recipient.lowercased()) let chatStateModule = client.module(.chatStateNotifications) - let hasSupport = knownChatStatePeers.contains(peer.stringValue.lowercased()) + 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 } + 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) @@ -906,9 +1441,12 @@ final class XMPPService { } catch { throw LumaXMPPError.uploadDiscoveryFailed(error.localizedDescription) } - guard let component = components - .filter({ $0.maxSize >= uploadData.count }) - .max(by: { $0.maxSize < $1.maxSize }) else { + guard + let component = + components + .filter({ $0.maxSize >= uploadData.count }) + .max(by: { $0.maxSize < $1.maxSize }) + else { throw LumaXMPPError.uploadUnavailable(size: uploadData.count) } @@ -924,7 +1462,8 @@ final class XMPPService { throw LumaXMPPError.uploadSlotFailed(error.localizedDescription) } guard slot.putUri.scheme?.lowercased() == "https", - slot.getUri.scheme?.lowercased() == "https" else { + slot.getUri.scheme?.lowercased() == "https" + else { throw LumaXMPPError.insecureUploadURL } @@ -943,7 +1482,8 @@ final class XMPPService { let uploadSession = URLSession(configuration: uploadConfiguration) defer { uploadSession.finishTasksAndInvalidate() } do { - let (_, receivedResponse) = try await uploadSession.upload(for: request, from: uploadData) + let (_, receivedResponse) = try await uploadSession.upload( + for: request, from: uploadData) response = receivedResponse } catch { throw LumaXMPPError.uploadTransportFailed(error.localizedDescription) @@ -955,7 +1495,8 @@ final class XMPPService { throw LumaXMPPError.uploadFailed(statusCode: http.statusCode) } - guard var urlComponents = URLComponents(url: slot.getUri, resolvingAgainstBaseURL: false) else { + guard var urlComponents = URLComponents(url: slot.getUri, resolvingAgainstBaseURL: false) + else { throw LumaXMPPError.invalidUploadURL } if let decryptionFragment { @@ -1014,13 +1555,270 @@ final class XMPPService { } 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)) + guard omemoStorage?.setVerified(verified, jid: jid, deviceID: deviceID) == true else { + return + } + eventHandler?( + .omemo( + ready: client?.moduleOrNil(.omemo)?.isReady ?? false, + ownFingerprint: omemoStorage?.ownFingerprint)) + } + + /// Negotiated TLS version of the current connection, probing the + /// server with a parallel handshake when the underlying library does not + /// expose it. Single-flight: the first caller starts the probe, later + /// callers (and later screen refreshes) reuse the stored result. + private func currentTLSVersion() async -> String? { + // Prefer the negotiated parameters of the live connection, captured + // by the OpenSSL TLS processor. + if let state = channelBindingStore.negotiatedState { + negotiatedTLSVersion = Self.displayTLSVersion(state.version) + negotiatedTLSCipher = state.cipher + return negotiatedTLSVersion + } + if let existing = tlsProbeTask { + return await existing.value?.version + } + guard let client, client.state == .connected() else { + return negotiatedTLSVersion + } + let endpointHost: String + let endpointPort: Int + if let endpoint = client.connector?.currentEndpoint as? SocketConnector.Endpoint { + endpointHost = endpoint.host + endpointPort = endpoint.port + } else if let account { + endpointHost = account.manualHost ?? account.domain ?? account.normalizedJID + endpointPort = account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222) + } else { + return negotiatedTLSVersion + } + let directTLS = account?.usesDirectTLS ?? (endpointPort == 5223) + let domain = account?.domain ?? client.userBareJid.domain + let task = Task.detached(priority: .utility) { () -> TLSProbeResult? in + await withCheckedContinuation { continuation in + TLSVersionProbe.run( + host: endpointHost, + port: UInt32(endpointPort), + directTLS: directTLS, + expectedDomain: domain + ) { result in + continuation.resume(returning: result) + } + } + } + tlsProbeTask = task + let result = await task.value + if let result { + negotiatedTLSVersion = result.version + negotiatedTLSCipher = result.cipher + } + return negotiatedTLSVersion + } + + /// Queries the server's capabilities to populate the "server information" + /// settings screen: XEP-0030 disco#info (server + account), XEP-0092 + /// software version, conference (MUC) services via disco#items, and + /// STUN/TURN services via XEP-0215. + func fetchServerInformation() async throws -> ServerInformation { + guard let client, client.state == .connected(), let account else { + throw LumaXMPPError.notConnected + } + let disco = client.module(.disco) + let versionModule = client.module(.softwareVersion) + let serverDomain = account.domain ?? client.userBareJid.domain + + // Discover features, software version, server components and external + // services in parallel. The version/components/services queries are + // best-effort: not every server implements those XEPs, so they degrade + // to nil instead of failing the whole screen. + async let serverInfo = disco.serverFeatures() + async let accountInfo = disco.accountFeatures() + async let software = try? versionModule.checkSoftwareVersion( + for: JID(serverDomain) + ) + async let components = try? disco.serverComponents() + async let services = try? client.module(.externalServiceDiscovery) + .discover(from: nil, type: nil) + + let server = try await serverInfo + let accountDisco = try await accountInfo + let version = await software + let componentItems = await components + let extServices = await services + + // Resolve each server component to its disco identity. Keep conference + // (MUC) services, and also detect HTTP File Upload when it is announced + // on a component (e.g. upload.) rather than the main domain. + var conferenceServers: [ServerInformation.ConferenceServer] = [] + var uploadOnComponent = false + if let componentItems { + for item in componentItems.items { + guard let info = try? await disco.info(for: item.jid) else { continue } + if info.features.contains("urn:xmpp:http:upload:0") + || info.features.contains("http://jabber.org/protocol/httpupload") + { + uploadOnComponent = true + } + for identity in info.identities where identity.category == "conference" { + conferenceServers.append( + ServerInformation.ConferenceServer( + jid: item.jid.stringValue, + name: identity.name, + type: identity.type, + category: identity.category + ) + ) + } + } + } + + let externalServices = (extServices ?? []).filter { + ["stun", "turn", "stuns", "turns"].contains($0.type.lowercased()) + }.map { + ServerInformation.ExternalService( + type: $0.type, + host: $0.host, + port: $0.port, + transport: $0.transport?.rawValue + ) + } + + // SASL mechanisms and Client State Indication are advertised in the raw + // stream , not in disco#info, so read them from there. + let streamFeaturesElement = client.module(.streamFeatures).streamFeatures.element + let saslMechanisms: [String] + if !advertisedSASLMechanisms.isEmpty { + // Includes both classic SASL and SASL2 mechanisms when the server + // advertises them in separate stream-feature elements. + saslMechanisms = advertisedSASLMechanisms + } else { + saslMechanisms = + streamFeaturesElement? + .findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")? + .children + .filter { $0.name == "mechanism" } + .compactMap { $0.value } ?? [] + } + // Same fallback pattern for SASL2 mechanisms, channel-binding types + // and XEP-0474: prefer the values captured from the pre-auth stream + // features, and only fall back to the live features element when the + // capture never ran (e.g. the session was resumed without a fresh + // login). + let sasl2ForDisplay: [String] + if !advertisedSASL2Mechanisms.isEmpty { + sasl2ForDisplay = advertisedSASL2Mechanisms + } else { + sasl2ForDisplay = + streamFeaturesElement? + .findChild(name: "authentication", xmlns: "urn:xmpp:sasl:2")? + .children + .filter { $0.name == "mechanism" } + .compactMap { $0.value } ?? [] + } + let channelBindingTypesForDisplay: [String] + if !channelBindingStore.advertisedChannelBindingTypes.isEmpty { + channelBindingTypesForDisplay = Array( + channelBindingStore.advertisedChannelBindingTypes + ).sorted() + } else { + channelBindingTypesForDisplay = + streamFeaturesElement? + .findChild(name: "sasl-channel-binding", xmlns: "urn:xmpp:sasl-cb:0")? + .children + .filter { $0.name == "channel-binding" } + .compactMap { $0.getAttribute("type") } ?? [] + } + // XEP-0474 support is detected during the SCRAM exchange: the server + // includes its downgrade-protection hash in the `h` attribute of the + // server-first message. + let downgradeProtectionForDisplay = channelBindingStore.isDowngradeProtectionDetected + + let supportsCSI = + streamFeaturesElement? + .findChild(name: "csi", xmlns: "urn:xmpp:csi:0") != nil + let supportsRosterVersioning = + streamFeaturesElement? + .findChild(name: "ver", xmlns: "urn:xmpp:features:rosterver") != nil + let supportsRosterPreApproval = + streamFeaturesElement? + .findChild(name: "sub", xmlns: "urn:xmpp:features:pre-approval") != nil + + let uploadOnServer = + server.features.contains("urn:xmpp:http:upload:0") + || server.features.contains("http://jabber.org/protocol/httpupload") + let supportsHTTPUpload = uploadOnServer || uploadOnComponent + + let stats = + connectionStatsModule?.snapshot + ?? LumaConnectionStatsModule.Snapshot(sent: 0, acknowledged: 0, received: 0) + + let tlsVersion = await currentTLSVersion() + // Show the channel-binding type alongside a PLUS mechanism, so the + // screen reflects how the exchange was actually bound to the TLS + // channel (tls-exporter preferred, tls-server-end-point fallback). + let displaySASLMechanism: String? + if let negotiatedSASLMechanism { + if negotiatedSASLMechanism.hasSuffix("-PLUS"), + let bindingType = channelBindingStore.preferredChannelBindingType + { + displaySASLMechanism = negotiatedSASLMechanism + " · " + bindingType + } else { + displaySASLMechanism = negotiatedSASLMechanism + } + } else { + displaySASLMechanism = nil + } + + return ServerInformation( + serverDomain: serverDomain, + software: ServerInformation.Software( + name: version?.name, + version: version?.version, + os: version?.os + ), + identities: server.identities.map { + ServerInformation.Identity( + category: $0.category, + type: $0.type, + name: $0.name + ) + }, + serverFeatures: server.features, + accountFeatures: accountDisco.features, + conferenceServers: conferenceServers, + externalServices: externalServices, + connectionStats: ServerInformation.ConnectionStats( + lastLogin: lastLoginDate, + smacksSessionEstablished: smacksSessionEstablishedDate, + sent: stats.sent, + acknowledged: stats.acknowledged, + received: stats.received + ), + saslMethods: saslMechanisms, + sasl2Methods: sasl2ForDisplay, + channelBindingTypes: channelBindingTypesForDisplay, + usedChannelBindingType: (negotiatedSASLMechanism?.hasSuffix("-PLUS") == true) + ? channelBindingStore.preferredChannelBindingType + : nil, + supportsSCRAMDowngradeProtection: downgradeProtectionForDisplay, + usedSASLMechanism: negotiatedSASLMechanism, + tlsVersion: tlsVersion, + tlsCipher: negotiatedTLSCipher, + saslMechanism: displaySASLMechanism, + supportsStreamManagement: client.module(.streamManagement).isAvailable, + supportsCarbons: client.module(.messageCarbons).isAvailable, + supportsClientState: supportsCSI, + supportsHTTPUpload: supportsHTTPUpload, + supportsRosterVersioning: supportsRosterVersioning, + supportsRosterPreApproval: supportsRosterPreApproval + ) } 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(.roster).addItem( + jid: JID(jid), name: name, groups: [], completionHandler: nil) client.module(.presence).subscribe(to: JID(jid)) fetchAvatar(for: jid) } @@ -1070,6 +1868,22 @@ final class XMPPService { } } + /// Rebuilds the self-session from our own published bundle when it is + /// missing, so encrypt-to-self keeps working after the migration purge. + private func ensureSelfSession( + client: XMPPClient, + omemoStorage: LumaOMEMOStore + ) { + let registrationID = omemoStorage.localRegistrationID + guard registrationID != 0 else { return } + let address = SignalAddress( + name: omemoStorage.accountJID, + deviceId: Int32(bitPattern: registrationID) + ) + guard !omemoStorage.hasSession(for: address) else { return } + client.module(.omemo).buildSession(forAddress: address) + } + private func configureModules( client: XMPPClient, signalContext: SignalContext, @@ -1077,19 +1891,65 @@ final class XMPPService { ) { _ = client.modulesManager.register(AuthModule()) _ = client.modulesManager.register(StreamFeaturesModule()) + // Registered before StreamManagementModule so it can observe the + // server's `` acknowledgements for the stanza statistics. + connectionStatsModule = client.modulesManager.register( + LumaConnectionStatsModule() + ) _ = client.modulesManager.register(StreamManagementModule()) - _ = client.modulesManager.register(SaslModule()) + // Registered before SaslModule so the raw RFC 6120 failure condition + // is captured before Martin collapses it into SaslError. + saslFailureModule = client.modulesManager.register(LumaSaslFailureModule()) + // Watches the raw SASL for the XEP-0474 `h=` attribute so + // the server screen can report downgrade protection regardless of the + // SCRAM mechanism implementation in use. + _ = client.modulesManager.register( + LumaSaslChallengeModule { [weak self] challenge in + guard let data = Data(base64Encoded: challenge), + let text = String(data: data, encoding: .utf8), + let parsed = try? SCRAMSHA512.parseServerFirst( + text, + expectedNoncePrefix: "" + ), + parsed.downgradeProtectionHash != nil + else { return } + self?.channelBindingStore.markDowngradeProtectionDetected() + } + ) + // Channel-binding SCRAM first (tls-exporter / tls-server-end-point + // over the live TLS 1.3 connection), then SCRAM-SHA-512: Martin only + // ships SHA-1/SHA-256, while modern servers prefer SHA-512. A + // mechanism is only eligible when the server advertises it, and the + // PLUS variants additionally gate on available binding data. + let sasl = client.modulesManager.register(SaslModule()) + sasl.addMechanism( + LumaScramPlusMechanism(hash: .sha1, channelBindingStore: channelBindingStore), + first: true + ) + sasl.addMechanism( + LumaScramPlusMechanism(hash: .sha256, channelBindingStore: channelBindingStore), + first: true + ) + sasl.addMechanism( + LumaScramPlusMechanism(hash: .sha512, channelBindingStore: channelBindingStore), + first: true + ) + sasl.addMechanism( + LumaScramSha512Mechanism(channelBindingStore: channelBindingStore), + first: true + ) _ = 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)) + SoftwareVersionModule( + version: .init(name: "Luma", version: "20261013.0", os: Self.platformName)) ) _ = client.modulesManager.register(PingModule()) _ = client.modulesManager.register(ClientStateIndicationModule()) -// _ = client.modulesManager.register(ClientStateIndicationModule()) + // _ = client.modulesManager.register(ClientStateIndicationModule()) _ = client.modulesManager.register( RosterModule(rosterManager: RosterManagerBase(store: LumaRosterStore())) ) @@ -1102,7 +1962,7 @@ final class XMPPService { .init(rawValue: Self.retractionNamespace), .init(rawValue: Self.reactionsNamespace), .init(rawValue: ChatStateNotificationsModule.XMLNS), - .init(rawValue: Self.stanzaIDNamespace) + .init(rawValue: Self.stanzaIDNamespace), ]) ) _ = client.modulesManager.register(PubSubModule()) @@ -1126,7 +1986,7 @@ final class XMPPService { transport: Jingle.Transport.ICEUDPTransport.self, features: [ Jingle.Transport.ICEUDPTransport.XMLNS, - "urn:xmpp:jingle:apps:dtls:0" + "urn:xmpp:jingle:apps:dtls:0", ] ) jingle.register( @@ -1134,7 +1994,7 @@ final class XMPPService { features: [ "urn:xmpp:jingle:apps:rtp:1", "urn:xmpp:jingle:apps:rtp:audio", - "urn:xmpp:jingle:apps:rtp:video" + "urn:xmpp:jingle:apps:rtp:video", ] ) jingle.supportsMessageInitiation = true @@ -1146,6 +2006,12 @@ final class XMPPService { signalStorage: omemoStorage ) ) + // OMEMO 2 (urn:xmpp:omemo:2) shares the signal storage with the + // legacy module: Double Ratchet sessions are per-device and + // namespace-agnostic. + _ = client.modulesManager.register( + LumaOMEMO2Module(signalContext: signalContext, signalStorage: omemoStorage) + ) } private func configureConnection( @@ -1155,14 +2021,16 @@ final class XMPPService { ) { client.connectionConfiguration.userJid = BareJID(account.normalizedJID) client.connectionConfiguration.resource = account.effectiveResource - client.connectionConfiguration.nickname = account.displayName.isEmpty ? nil : account.displayName + 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 + client.connectionConfiguration.modifyConnectorOptions(type: SocketConnector.Options.self) { + options in options.conntectionTimeout = 20 options.sslCertificateValidation = .customValidator { trust in CertificateTrustEvaluator.evaluateServerTrust( @@ -1170,6 +2038,13 @@ final class XMPPService { expectedDomain: expectedDomain ) } + // SecureTransport caps stream TLS at 1.2 and has no keying + // exporter, so Luma supplies its own OpenSSL TLS 1.3 processor + // (TLS 1.3 + STARTTLS + channel binding). Certificate trust is + // still evaluated through the custom validator above. + options.networkProcessorProviders = [ + LumaTLSNetworkProcessorProvider(channelBindingStore: channelBindingStore) + ] if let host = account.manualHost { let defaultPort = account.usesDirectTLS ? 5223 : 5222 options.dnsResolver = StaticDNSSrvResolver( @@ -1189,6 +2064,20 @@ final class XMPPService { } .store(in: &cancellables) + // When the server advertises SCRAM, swap the password in the + // connection configuration for its RFC 4013 (SASLprep) form before + // the challenge response is computed. Martin's SCRAM hashes the raw + // UTF-8 password, which mismatches SASLprep-compliant servers for + // passwords with non-ASCII spaces, soft hyphens, fullwidth forms, … + client.module(.streamFeatures).$streamFeatures + .compactMap { $0.element } + .receive(on: DispatchQueue.main) + .sink { [weak self, weak client] features in + guard let self, let client else { return } + self.applySASLprepIfNeeded(client: client, features: features) + } + .store(in: &cancellables) + client.module(.roster).events .receive(on: DispatchQueue.main) .sink { [weak self] action in @@ -1213,43 +2102,52 @@ final class XMPPService { 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 - )) + 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) + Task { @MainActor [weak self] in + await self?.deliverOrDelayDirect(incoming.message, timestamp: Date()) + } } .store(in: &cancellables) client.module(.muc).messagesPublisher .receive(on: DispatchQueue.main) .sink { [weak self] incoming in - self?.handle(groupMessage: incoming.message, room: incoming.room) + Task { @MainActor [weak self] in + await self?.deliverOrDelayGroup(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 - ))) + 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) + Task { @MainActor [weak self] in + await self?.deliverOrDelayDirect(carbon.message, timestamp: Date()) + } } .store(in: &cancellables) @@ -1269,6 +2167,17 @@ final class XMPPService { } .store(in: &cancellables) + // XEP-0333 chat markers. Martin republishes markers found on live + // stanzas, Carbons copies and archived messages through one publisher, + // so `` markers the server carbon-copies to the user's + // other devices reach this handler there as well. + client.module(.chatMarkers).markersPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] received in + self?.handleChatMarker(received) + } + .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 @@ -1295,13 +2204,124 @@ final class XMPPService { } .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)) + // The UI is ready for encrypted messaging once either protocol + // has published its keys. + Publishers.CombineLatest( + client.module(.omemo).$isReady, + client.module(.omemo2).$isReady + ) + .map { legacyReady, omemo2Ready in legacyReady || omemo2Ready } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] ready in + self?.eventHandler?( + .omemo(ready: ready, ownFingerprint: omemoStorage.ownFingerprint)) + } + .store(in: &cancellables) + } + + private func deliverOrDelayDirect(_ message: Message, timestamp: Date) async { + guard let client else { return } + let archive = MAMArchiveKey.account(client.userBareJid.stringValue) + if archiveSyncStarted { + delayedLiveByArchive[archive, default: []].append( + .direct(message: message, timestamp: timestamp) + ) + return + } + await handle(message: message, timestamp: timestamp, archiveID: nil) + } + + private func deliverOrDelayGroup(_ message: Message, room: RoomProtocol) async { + let archive = MAMArchiveKey.muc(room.jid.stringValue) + if activeMUCCatchup == archive { + delayedLiveByArchive[archive, default: []].append( + .group(message: message, room: room) + ) + return + } + await handle(groupMessage: message, room: room) + } + + private func replayDelayedLive(for archive: MAMArchiveKey) async { + let delayed = delayedLiveByArchive.removeValue(forKey: archive) ?? [] + for item in delayed { + switch item { + case .direct(let message, let timestamp): + await handle(message: message, timestamp: timestamp, archiveID: nil) + case .group(let message, let room): + await handle(groupMessage: message, room: room) } - .store(in: &cancellables) + } + } + + /// XEP-0333 chat markers: `` marks an outgoing message as read, + /// `` as delivered. MUC markers are best effort — few clients + /// send them, but the room stanza-id maps them to the right outgoing + /// message. + private func handleChatMarker(_ received: ChatMarkersModule.ChatMarkerReceived) { + let message = received.message + guard message.type != .error else { return } + switch received.marker { + case .displayed(let id): + guard let conversationJID = markerConversationJID(for: message) else { return } + eventHandler?(.read(conversationJID: conversationJID, messageID: id)) + case .received(let id): + eventHandler?(.delivered(messageID: id)) + case .acknowledged: + break + } + } + + /// Bare JID of the conversation a marker references. Peer markers arrive + /// with the peer in `from`; our own displayed markers carbon-copied from + /// another device carry our JID in `from` and the peer in `to`. Groupchat + /// markers reference the room. + private func markerConversationJID(for message: Message) -> String? { + if message.type == .groupchat { + return message.from?.bareJid.stringValue + } + if let from = message.from?.bareJid { + if let client, from == client.userBareJid { + return message.to?.bareJid.stringValue ?? from.stringValue + } + return from.stringValue + } + return message.to?.bareJid.stringValue + } + + /// Runs OMEMO decryption off the main actor and resumes with the raw + /// result. Kept as a thin wrapper so `handle` keeps its existing switch + /// logic and only the expensive `decode` call leaves the main thread. + private func decodeOmemoOffMain( + _ message: Message, + from sender: BareJID, + serverMsgId: String?, + module: OMEMOModule + ) async -> DecryptionResult { + let queue = omemoDecodeQueue + return await withCheckedContinuation { continuation in + queue.async { + let result = module.decode(message: message, from: sender, serverMsgId: serverMsgId) + continuation.resume(returning: result) + } + } + } + + /// OMEMO 2 twin of `decodeOmemoOffMain`. + private func decodeOmemo2OffMain( + _ message: Message, + from sender: BareJID, + serverMsgId: String?, + module: LumaOMEMO2Module + ) async -> DecryptionResult { + let queue = omemoDecodeQueue + return await withCheckedContinuation { continuation in + queue.async { + let result = module.decode(message: message, from: sender, serverMsgId: serverMsgId) + continuation.resume(returning: result) + } + } } private func handle(state: XMPPClient.State) { @@ -1313,7 +2333,7 @@ final class XMPPService { startArchiveSyncIfAvailable() case .disconnected(let reason): suspendArchiveSyncForDisconnect() - eventHandler?(.connection(.disconnected(reason: Self.reasonText(reason)))) + eventHandler?(.connection(.disconnected(reason: reasonText(reason)))) } } @@ -1321,21 +2341,70 @@ final class XMPPService { message: Message, timestamp: Date, archiveID: String?, - isArchived: Bool = false - ) { + isArchived: Bool = false, + archivedPeerJID: BareJID? = nil + ) async { + // Call-history service messages sync call cards between the user's + // devices; they never become ordinary chat messages. + if let sync = CallHistorySync.envelope(from: message) { + Logger(subsystem: "Luma", category: "call-sync") + .info( + "received call-history id=\(sync.id) peer=\(sync.peerJID) status=\(sync.outcome.rawValue)" + ) + eventHandler?(.callHistorySync(sync)) + return + } + // Read-marker service messages sync read receipts between the user's + // devices the same way: Carbons deliver them live, MAM replays them + // after reinstalls. They never become ordinary chat messages. + if let marker = ReadStateSync.envelope(from: message) { + Logger(subsystem: "Luma", category: "read-sync") + .info( + "received read-marker id=\(marker.id) message=\(marker.messageID) conversation=\(marker.conversationJID)" + ) + eventHandler?(.readState(marker)) + return + } guard message.type != .error, - message.type != .groupchat, - let client, - let sender = message.from?.bareJid else { return } + 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 } + // guard let peer = outgoing ? message.to?.bareJid : message.from?.bareJid else { return } + // let peer: BareJID? + // if outgoing { + let peer: BareJID? + if isArchived, let archivedPeerJID { + peer = archivedPeerJID + } else if outgoing { + peer = message.to?.bareJid ?? message.from?.bareJid + } else { + peer = message.from?.bareJid + } + guard let peer else { return } let security: ChatMessage.Security let fingerprint: String? let contentMessage: Message? - switch client.module(.omemo).decode(message: message, from: sender, serverMsgId: archiveID) { + let decryptionResult = + await + (Self.isOMEMO2Payload(message) + ? decodeOmemo2OffMain( + message, + from: sender, + serverMsgId: archiveID, + module: client.module(.omemo2) + ) + : decodeOmemoOffMain( + message, + from: sender, + serverMsgId: archiveID, + module: client.module(.omemo) + )) + switch decryptionResult { case .successMessage(let decodedMessage, let value): security = .omemo fingerprint = value @@ -1349,33 +2418,63 @@ final class XMPPService { fingerprint = nil contentMessage = message case .duplicateMessage: + // The Double Ratchet message key is single-use: `.duplicateMessage` + // means this exact stanza was already decrypted on an earlier + // delivery (carbon, SMACK resend or a previous MAM pass), and its + // content was handled then. Emitting another bubble here turned the + // archived echoes of our own receipts, chat markers and resent + // copies into phantom "Не удалось расшифровать сообщение" + // placeholders that do not exist on the server. Mirrors the group + // message path. return default: - security = .decryptionFailed - fingerprint = nil - contentMessage = nil + // Some clients include a plaintext fallback alongside the + // OMEMO payload. When decryption fails, prefer that + // fallback so a readable message is never shown as undecryptable. + if message.body?.isEmpty == false { + security = .plaintext + fingerprint = nil + contentMessage = message + } else { + // An undecryptable outgoing stanza is still part of the + // user's own history — e.g. messages written on another + // device whose keys this device never had. Dropping them + // silently made such conversations look empty even though + // the archive has history, so surface them as a + // decryption-failed placeholder instead. Echoes of + // messages sent from this device carry the same origin-id + // as the local optimistic copy and are merged there, so + // they never produce a second bubble. + security = .decryptionFailed + fingerprint = nil + contentMessage = nil + } } } let payload = contentMessage ?? message if !isArchived, - let state = Self.chatTypingState(payload.chatState ?? message.chatState) { + 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 - ))) + 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 { + let targetID = reactions.getAttribute("id"), + !targetID.isEmpty + { emitReaction( ReactionEnvelope( peerJID: peer.stringValue, @@ -1396,8 +2495,9 @@ final class XMPPService { if let retract = payload.firstChild(name: "retract", xmlns: Self.retractionNamespace) ?? message.firstChild(name: "retract", xmlns: Self.retractionNamespace), - let targetID = retract.getAttribute("id"), - !targetID.isEmpty { + let targetID = retract.getAttribute("id"), + !targetID.isEmpty + { emitRetraction( peer: peer, sender: sender, @@ -1411,10 +2511,12 @@ final class XMPPService { } if payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace) != nil - || message.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) + let tombstone = + payload.firstChild(name: "retracted", xmlns: Self.retractionNamespace) + ?? message.firstChild(name: "retracted", xmlns: Self.retractionNamespace) emitRetraction( peer: peer, sender: sender, @@ -1427,21 +2529,25 @@ final class XMPPService { return } - let rawBody = contentMessage?.body + 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 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, @@ -1450,7 +2556,8 @@ final class XMPPService { ) let visibleBody = parsedReply.body - let isEncryptedAttachment = visibleBody.hasPrefix("aesgcm://") + let isEncryptedAttachment = + visibleBody.hasPrefix("aesgcm://") && visibleBody.rangeOfCharacter(from: .whitespacesAndNewlines) == nil && URLComponents(string: visibleBody)?.scheme?.lowercased() == "aesgcm" let oob = payload.oob ?? message.oob @@ -1466,54 +2573,82 @@ final class XMPPService { } let inferred = transportFilename.map(MediaMetadata.infer(from:)) let kind = inferred?.kind ?? (GeoLocation(uri: visibleBody) == nil ? .text : .location) - let displayBody = attachmentURL == nil + let displayBody = + attachmentURL == nil ? visibleBody : Self.displayBody(kind: kind, filename: transportFilename ?? "Вложение") + // Live 1:1 messages carry their server archive UID in a + // element (by = own bare JID). Surface it so the interactive MAM + // "before" cursor and dedup match the IDs the archive later returns. + let stanzaID = archiveID ?? Self.accountStanzaID(in: message, accountJID: ownJID) 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) + 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, + originID: message.originId, + stanzaID: stanzaID, + senderDisplayName: nil, + isGroupMessage: false, + isArchived: isArchived + ), isArchived: isArchived) } - private func handle(groupMessage message: Message, room: RoomProtocol) { + private func handle( + groupMessage message: Message, + room: RoomProtocol? = nil, + archivedRoomJID: BareJID? = nil, + archivedTimestamp: Date? = nil, + archiveID: String? = nil, + isArchived: Bool = false + ) async { guard message.type != .error, - let client, - let from = message.from else { return } + let client, + let from = message.from, + let roomBareJID = room?.jid ?? archivedRoomJID ?? message.from?.bareJid + else { return } let nickname = from.resource ?? "Участник" - let outgoing = nickname == room.nickname - let roomJID = room.jid.stringValue.lowercased() + // let outgoing = nickname == room.nickname + // let roomJID = room.jid.stringValue.lowercased() + let disclosedSender = + Self.originalSenderJID(in: message) + ?? XMucUserElement.extract(from: message)?.jid?.bareJid + let outgoing = + room.map { nickname == $0.nickname } + ?? (disclosedSender == client.userBareJid) + let roomJID = roomBareJID.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 + // let stanzaID = Self.roomStanzaID(in: message, roomJID: room.jid) + let stanzaID = Self.roomStanzaID(in: message, roomJID: roomBareJID) ?? archiveID + 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 encrypted = + message.firstChild(name: "encrypted", xmlns: OMEMOModule.XMLNS) != nil + || message.firstChild(name: "encrypted", xmlns: LumaOMEMO2Module.XMLNS) != nil let realSender: BareJID? if outgoing { realSender = client.userBareJid @@ -1523,7 +2658,8 @@ final class XMPPService { } 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 { + // } else if let disclosed = room.occupant(nickname: nickname)?.jid?.bareJid { + } else if let disclosed = room?.occupant(nickname: nickname)?.jid?.bareJid { realSender = disclosed roomRealJIDByNickname[roomJID, default: [:]][nickname] = disclosed } else { @@ -1534,11 +2670,22 @@ final class XMPPService { let fingerprint: String? let contentMessage: Message? if let realSender { - switch client.module(.omemo).decode( - message: message, - from: realSender, - serverMsgId: stanzaID - ) { + let decryptionResult = + await + (Self.isOMEMO2Payload(message) + ? decodeOmemo2OffMain( + message, + from: realSender, + serverMsgId: stanzaID, + module: client.module(.omemo2) + ) + : decodeOmemoOffMain( + message, + from: realSender, + serverMsgId: stanzaID, + module: client.module(.omemo) + )) + switch decryptionResult { case .successMessage(let decodedMessage, let value): security = .omemo fingerprint = value @@ -1561,17 +2708,27 @@ final class XMPPService { return default: if outgoing { + // Register the room-assigned stanza-id for future + // replies even when this device cannot decrypt the + // payload. Fall through to the placeholder path: the + // message is part of the room's history and must not + // vanish from the timeline. emitGroupEchoIfPossible( roomJID: roomJID, messageID: id, stanzaID: stanzaID, senderJID: senderJID ) - return } - security = .decryptionFailed - fingerprint = nil - contentMessage = nil + if message.body?.isEmpty == false { + security = .plaintext + fingerprint = nil + contentMessage = message + } else { + security = .decryptionFailed + fingerprint = nil + contentMessage = nil + } } } } else if encrypted { @@ -1585,24 +2742,29 @@ final class XMPPService { } let payload = contentMessage ?? message - if let state = Self.chatTypingState(payload.chatState ?? message.chatState) { + // if let state = Self.chatTypingState(payload.chatState ?? message.chatState) { + if !isArchived, 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 - ))) + 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 + let targetID = reactions.getAttribute("id"), + !targetID.isEmpty + { + let reactionSender = + outgoing ? client.userBareJid.stringValue : (realSender?.stringValue ?? senderJID) emitReaction( @@ -1614,29 +2776,35 @@ final class XMPPService { guard child.name == "reaction" else { return nil } return child.value }, - timestamp: message.delay?.stamp ?? Date(), + // timestamp: message.delay?.stamp ?? Date(), + timestamp: archivedTimestamp ?? message.delay?.stamp ?? Date(), isOutgoing: outgoing, isGroupMessage: true ), - isArchived: false + // isArchived: false + isArchived: isArchived ) return } - let rawBody = contentMessage?.body + 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 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, @@ -1645,7 +2813,8 @@ final class XMPPService { ) let visibleBody = parsedReply.body - let isEncryptedAttachment = visibleBody.hasPrefix("aesgcm://") + let isEncryptedAttachment = + visibleBody.hasPrefix("aesgcm://") && visibleBody.rangeOfCharacter(from: .whitespacesAndNewlines) == nil && URLComponents(string: visibleBody)?.scheme?.lowercased() == "aesgcm" let oob = payload.oob ?? message.oob @@ -1661,33 +2830,43 @@ final class XMPPService { } let inferred = transportFilename.map(MediaMetadata.infer(from:)) let kind = inferred?.kind ?? (GeoLocation(uri: visibleBody) == nil ? .text : .location) - let displayBody = attachmentURL == nil + 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 - ))) + // eventHandler?( + emitMessage( + // .message( + MessageEnvelope( + id: id, + // peerJID: room.jid.stringValue, + peerJID: roomBareJID.stringValue, + senderJID: senderJID, + body: displayBody, + // timestamp: message.delay?.stamp ?? Date(), + timestamp: archivedTimestamp ?? 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, + originID: message.originId, + stanzaID: stanzaID, + senderDisplayName: nickname, + isGroupMessage: true, + // isArchived: false + // ))) + isArchived: isArchived + ), isArchived: isArchived) } private func observe(room: RoomProtocol) { @@ -1698,12 +2877,14 @@ final class XMPPService { .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 - ))) + self?.eventHandler?( + .roomState( + RoomStateEnvelope( + roomJID: roomJID, + nickname: room.nickname, + joined: state == .joined, + occupantCount: 0 + ))) } .store(in: &cancellables) @@ -1719,12 +2900,14 @@ final class XMPPService { } } self.roomRealJIDByNickname[roomJID] = knownAuthors - self.eventHandler?(.roomState(RoomStateEnvelope( - roomJID: roomJID, - nickname: room.nickname, - joined: room.state == .joined, - occupantCount: occupants.count - ))) + self.eventHandler?( + .roomState( + RoomStateEnvelope( + roomJID: roomJID, + nickname: room.nickname, + joined: room.state == .joined, + occupantCount: occupants.count + ))) } .store(in: &cancellables) } @@ -1744,46 +2927,43 @@ final class XMPPService { 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) - } + let roomConfig: RoomConfig = try await withCheckedThrowingContinuation { continuation in + muc.roomConfiguration(roomJid: roomJID) { result in + continuation.resume(with: result) } } var supportsNonAnonymousRooms = false - if let field: ListSingleField = configuration.getField(named: "muc#roomconfig_whois") { - field.value = "anyone" + + if roomConfig.hasField(for: "muc#roomconfig_whois") { + roomConfig.whois = .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 roomConfig.hasField(for: "muc#roomconfig_getmemberlist") { + let current = roomConfig.getMemberList ?? [] + roomConfig.getMemberList = Array(Set(current + ["moderator", "participant"])).sorted() + } + + if makeMembersOnly, roomConfig.hasField(for: "muc#roomconfig_membersonly") { + roomConfig.membersOnly = true + } + + if makeMembersOnly, roomConfig.hasField(for: "muc#roomconfig_persistentroom") { + roomConfig.persistentRoom = true + } + + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + muc.setRoomConfiguration(roomJid: roomJID, configuration: roomConfig) { result in + continuation.resume(with: result) } } + if supportsNonAnonymousRooms { omemoConfiguredRoomJIDs.insert(room.jid.stringValue.lowercased()) } + return supportsNonAnonymousRooms } @@ -1875,9 +3055,10 @@ final class XMPPService { guard configured else { throw LumaXMPPError.groupEncryptionConfigurationUnsupported } - eventHandler?(.recoverableError( - "Комната переведена в неанонимный режим, необходимый для группового OMEMO." - )) + eventHandler?( + .recoverableError( + "Комната переведена в неанонимный режим, необходимый для группового OMEMO." + )) return } @@ -1918,24 +3099,45 @@ final class XMPPService { message.addChild(fallback) } + private func addOriginID(_ id: String, to message: Message) { + guard message.originId == nil else { return } + let origin = Element(name: "origin-id", xmlns: Self.stanzaIDNamespace) + origin.setAttribute("id", value: id) + message.addChild(origin) + } + 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 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 accountStanzaID(in message: Message, accountJID: 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 == accountJID.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 } + 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 } @@ -1967,12 +3169,13 @@ final class XMPPService { senderJID: String ) { guard let stanzaID, !stanzaID.isEmpty else { return } - eventHandler?(.groupMessageEcho( - roomJID: roomJID, - messageID: messageID, - stanzaID: stanzaID, - senderJID: senderJID - )) + eventHandler?( + .groupMessageEcho( + roomJID: roomJID, + messageID: messageID, + stanzaID: stanzaID, + senderJID: senderJID + )) } private func emitRetraction( @@ -1993,7 +3196,7 @@ final class XMPPService { isOutgoing: outgoing ) if isArchived { - archivePassMutations.append(.retraction(envelope)) + appendArchiveMutation(.retraction(envelope)) } else { eventHandler?(.retraction(envelope)) } @@ -2004,7 +3207,7 @@ final class XMPPService { isArchived: Bool ) { if isArchived { - archivePassMutations.append(.reaction(envelope)) + appendArchiveMutation(.reaction(envelope)) } else { eventHandler?(.reaction(envelope)) } @@ -2012,12 +3215,24 @@ final class XMPPService { private func emitMessage(_ envelope: MessageEnvelope, isArchived: Bool) { if isArchived { - archivePassMutations.append(.message(envelope)) + appendArchiveMutation(.message(envelope)) } else { eventHandler?(.message(envelope)) } } + /// Archived mutations normally belong to the active account/MUC catch-up + /// pass. While an interactive backward-history page is being applied, its + /// mutations are collected in a dedicated buffer so the two flows never + /// interleave and never steal each other's mutations. + private func appendArchiveMutation(_ mutation: ArchiveMutation) { + if interactiveHistoryMutations != nil { + interactiveHistoryMutations?.append(mutation) + } else { + archivePassMutations.append(mutation) + } + } + private func retrievePEPAvatar(for jid: String, client: XMPPClient) async -> Data? { do { let avatarModule = client.module(.pepUserAvatar) @@ -2026,7 +3241,8 @@ final class XMPPService { 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) + 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 @@ -2037,8 +3253,9 @@ final class XMPPService { 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 } + let data = Data(base64Encoded: encoded), + data.count <= 2 * 1_024 * 1_024 + else { return nil } return data } catch { return nil @@ -2064,38 +3281,66 @@ final class XMPPService { } } - private func encrypt(_ message: Message, using omemo: OMEMOModule) async throws -> (message: Message, fingerprint: String?) { + 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)) + continuation.resume( + throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) } } } } + /// OMEMO 2 twin of `encrypt(_:using:)`. + private func encrypt( + _ message: Message, + using omemo: LumaOMEMO2Module + ) 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)) + } + } + } + } + + /// OMEMO 2 twin of `encrypt(_:forGroupRecipients:using:)`. Keys are + /// nested in `` groups, so the per-device validation walks the + /// header's groups. private func encrypt( _ message: Message, forGroupRecipients recipients: [BareJID], - using omemo: OMEMOModule + using omemo: LumaOMEMO2Module, + roomJID: BareJID ) async throws -> (message: Message, fingerprint: String?) { - let fetchedAddresses: [SignalAddress] = try await withCheckedThrowingContinuation { continuation in + 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)) + continuation.resume( + throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) } } } let addresses = Array(Set(fetchedAddresses)) let availableJIDs = Set(addresses.map { $0.name.lowercased() }) - let missingJIDs = recipients + let missingJIDs = + recipients .map(\.stringValue) .filter { !availableJIDs.contains($0.lowercased()) } guard missingJIDs.isEmpty else { @@ -2113,16 +3358,104 @@ final class XMPPService { ) } - let encryptedMessage: (message: Message, fingerprint: String?) = try await withCheckedThrowingContinuation { continuation in - omemo.encode(message: message, forAddresses: addresses, withStoreHint: true) { result in + let encryptedMessage: (message: Message, fingerprint: String?) = + try await withCheckedThrowingContinuation { continuation in + omemo.encode( + message: message, + forAddresses: addresses, + roomJID: roomJID, + 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] = [:] + if let encrypted = encryptedMessage.message.firstChild( + name: "encrypted", xmlns: LumaOMEMO2Module.XMLNS + ), let header = encrypted.findChild(name: "header") { + var keyElements: [Element] = [] + for keysEl in header.getChildren(where: { $0.name == "keys" }) { + keyElements.append(contentsOf: keysEl.getChildren(where: { $0.name == "key" })) + } + keyElements + .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 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 .successMessage(let message, let fingerprint): - continuation.resume(returning: (message, fingerprint)) + case .success(let addresses): + continuation.resume(returning: addresses) case .failure(let error): - continuation.resume(throwing: LumaXMPPError.omemoEncryptionFailed(error.rawValue)) + 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 @@ -2148,21 +3481,66 @@ final class XMPPService { return encryptedMessage } + /// Single Martin-specific MAM entry point. Martin 3.2.4 supports + /// `componentJid:` and writes it to IQ `to`, which is required for MUC MAM. + private func queryMAM( + client: XMPPClient, + archive: MAMArchiveKey, + start: Date?, + queryID: String, + rsm: RSM.Query?, + completion: + @escaping (Result) -> Void + ) { + let componentJID: JID? + switch archive.kind { + case .account: + componentJID = nil + case .muc: + componentJID = JID(archive.jid) + } + + client.module(.mam).queryItems( + componentJid: componentJID, + start: start, + queryId: queryID, + rsm: rsm, + completionHandler: completion + ) + } + + private func allowedMAMSources( + for archive: MAMArchiveKey, + client: XMPPClient + ) -> Set { + switch archive.kind { + case .account: + return [ + client.userBareJid.stringValue.lowercased(), + client.userBareJid.domain.lowercased(), + ] + case .muc: + return [archive.jid.lowercased()] + } + } + private func startArchiveSyncIfAvailable() { guard let client, - client.state == .connected(), - !archiveSyncSuspended, - !archiveRetrySuppressedUntilActivation, - !client.module(.mam).availableVersions.isEmpty else { return } + 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 } + !archiveSyncCompletedForConnection, + !archiveSyncSuspended, + !archiveRetrySuppressedUntilActivation, + self.client === client + else { return } archiveSyncStarted = true if archiveSyncQueryStartedAt == nil { archiveSyncQueryStartedAt = Date() @@ -2181,12 +3559,20 @@ final class XMPPService { archiveQueryTimeoutTask = nil archiveQueryCompletionTask?.cancel() archiveQueryCompletionTask = nil + archiveSyncRetryTask?.cancel() + archiveSyncRetryTask = nil archiveActiveQueryID = nil archiveStanzaBuffer.removeAll(keepingCapacity: true) archiveBufferOverflowed = false archiveRejectedSource = false - archiveStanzaInbox.cancel() - setArchiveSyncIndicator(true) + // Do NOT blanket-cancel the inbox here: an interactive backward-history + // query may be in flight and must keep its own query-ID-scoped buffer. + // Show the visible "Синхронизация истории…" banner only for the + // one-page bootstrap window. Incremental catch-up continues silently so + // a large backlog never leaves the spinner visible for the whole pass. + if archiveIsBootstrapQuery { + setArchiveSyncIndicator(true) + } client.module(.omemo).mamSyncStarted(for: nil) let initialPosition = ArchiveSyncCursorPolicy.requestPosition( checkpoint: archiveSyncCheckpoint, @@ -2200,10 +3586,169 @@ final class XMPPService { ) } + private func enqueueMUCCatchup(roomJID: BareJID) { + let archive = MAMArchiveKey.muc(roomJID.stringValue) + guard activeMUCCatchup != archive, + !pendingMUCCatchups.contains(archive) + else { return } + pendingMUCCatchups.append(archive) + startNextMUCCatchupIfPossible() + } + + /// Keep this first version serialized with the account pass. It prevents + /// archive mutations from different namespaces sharing the legacy batch. + /// Patch 0007 makes live delivery archive-aware while catch-up runs. + private func startNextMUCCatchupIfPossible() { + guard activeMUCCatchup == nil, + !archiveSyncStarted, + let client, + client.state == .connected(), + !client.module(.mam).availableVersions.isEmpty, + !pendingMUCCatchups.isEmpty + else { return } + + let archive = pendingMUCCatchups.removeFirst() + activeMUCCatchup = archive + mucCatchupHighWatermark = nil + mucCatchupLastCursor = mamCheckpoints[archive]?.cursor + queryMUCArchivePage( + archive: archive, + after: mamCheckpoints[archive]?.cursor, + client: client + ) + } + + private func queryMUCArchivePage( + archive: MAMArchiveKey, + after: String?, + client: XMPPClient + ) { + guard activeMUCCatchup == archive else { return } + let queryID = UUID().uuidString + archiveStanzaInbox.begin( + queryID: queryID, + allowedSources: allowedMAMSources(for: archive, client: client) + ) + + let checkpoint = mamCheckpoints[archive] + let start = + after == nil + ? checkpoint?.timestamp.addingTimeInterval( + -ArchiveSyncRecoveryPolicy.incrementalOverlap) + : nil + let rsm = + checkpoint == nil + ? RSM.Query(lastItems: archiveBootstrapMessageLimit) + : RSM.Query(after: after, max: archivePageSize) + + queryMAM( + client: client, + archive: archive, + start: start, + queryID: queryID, + rsm: rsm + ) { [weak self, weak client] result in + DispatchQueue.main.async { + guard let self, let client, + self.activeMUCCatchup == archive + else { return } + let page = self.archiveStanzaInbox.take(queryID: queryID) + guard !page.overflowed, !page.rejectedSource else { + self.finishMUCCatchup(archive: archive, succeeded: false) + return + } + Task { @MainActor [weak self, weak client] in + guard let self, let client, + self.activeMUCCatchup == archive + else { return } + for stanza in page.stanzas { + self.mucCatchupHighWatermark = max( + self.mucCatchupHighWatermark ?? .distantPast, + stanza.timestamp + ) + guard stanza.message.type == .groupchat, + let roomJID = stanza.message.from?.bareJid + else { continue } + await self.handle( + groupMessage: stanza.message, + archivedRoomJID: roomJID, + archivedTimestamp: stanza.timestamp, + archiveID: stanza.archiveID, + isArchived: true + ) + } + + switch result { + case .failure: + // Same recovery principle as account MAM: a retained + // local UID may have expired on the server. Retry once + // from the durable timestamp overlap, never loop + // cursor<->time. + if self.mamCheckpoints[archive]?.cursor != nil, + self.mucCursorFallbackArchives.insert(archive).inserted, + let old = self.mamCheckpoints[archive] + { + self.mamCheckpoints[archive] = MAMArchiveCheckpoint( + timestamp: old.timestamp, + cursor: nil + ) + self.queryMUCArchivePage( + archive: archive, + after: nil, + client: client + ) + } else { + self.finishMUCCatchup(archive: archive, succeeded: false) + } + case .success(let response): + self.mucCatchupLastCursor = + response.rsm?.last ?? self.mucCatchupLastCursor + if !response.complete, let next = response.rsm?.last, next != after { + self.queryMUCArchivePage(archive: archive, after: next, client: client) + } else { + self.finishMUCCatchup(archive: archive, succeeded: true) + } + } + } + } + } + } + + private func finishMUCCatchup(archive: MAMArchiveKey, succeeded: Bool) { + defer { + activeMUCCatchup = nil + mucCatchupHighWatermark = nil + mucCatchupLastCursor = nil + Task { @MainActor [weak self] in + await self?.replayDelayedLive(for: archive) + } + startNextMUCCatchupIfPossible() + } + // MUC catch-up decodes into archivePassMutations just like the account + // pass. Publish them once the room catch-up settles, otherwise group + // history fetched via MAM is decoded and then silently dropped. + if succeeded, !archivePassMutations.isEmpty { + let mutations = archivePassMutations + archivePassMutations.removeAll(keepingCapacity: true) + eventHandler?(.archiveBatch(mutations)) + } else { + archivePassMutations.removeAll(keepingCapacity: true) + } + guard succeeded else { return } + let checkpoint = MAMArchiveCheckpoint( + timestamp: max(mucCatchupHighWatermark ?? .distantPast, Date()), + cursor: mucCatchupLastCursor + ) + mamCheckpoints[archive] = checkpoint + mucCursorFallbackArchives.remove(archive) + eventHandler?(.mucArchiveSyncCompleted(archive: archive, checkpoint: checkpoint)) + } + private func queryArchive(client: XMPPClient, after: String?, retry: Int) { guard archiveSyncStarted, - self.client === client, - client.state == .connected() else { + self.client === client, + client.state == .connected() + else { if archiveSyncStarted, self.client === client { finishArchiveSync(client: client, succeeded: false) } else { @@ -2216,25 +3761,33 @@ final class XMPPService { archiveStanzaBuffer.removeAll(keepingCapacity: true) archiveBufferOverflowed = false archiveRejectedSource = false + let accountArchive = MAMArchiveKey.account(client.userBareJid.stringValue) archiveStanzaInbox.begin( queryID: queryID, - allowedSources: [ - client.userBareJid.stringValue, - client.userBareJid.domain - ] + // allowedSources: [ + // client.userBareJid.stringValue, + // client.userBareJid.domain, + // ] + allowedSources: allowedMAMSources(for: accountArchive, client: client) ) scheduleArchiveQueryTimeout(client: client, queryID: queryID) - let start = archiveIsBootstrapQuery + let start = + archiveIsBootstrapQuery ? nil - : (after == nil ? archiveSyncCheckpoint?.timestamp.addingTimeInterval( - -ArchiveSyncRecoveryPolicy.incrementalOverlap - ) : nil) - let resultSet = archiveIsBootstrapQuery + : (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( + // client.module(.mam).queryItems( + queryMAM( + client: client, + archive: accountArchive, start: start, - queryId: queryID, + queryID: queryID, rsm: resultSet ) { [weak self, weak client] result in // XEP-0313 guarantees that the final IQ follows every result. The @@ -2242,10 +3795,11 @@ final class XMPPService { // actor hand-off runs. DispatchQueue.main.async { guard let self, - let client, - self.archiveSyncStarted, - self.archiveActiveQueryID == queryID, - self.client === client else { return } + 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) @@ -2254,8 +3808,9 @@ final class XMPPService { self.archiveRejectedSource = page.rejectedSource guard case .success = result, - !page.overflowed, - !page.rejectedSource else { + !page.overflowed, + !page.rejectedSource + else { self.archiveActiveQueryID = nil self.completeArchivePage( result, @@ -2272,10 +3827,11 @@ final class XMPPService { 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 } + applied, + self.archiveSyncStarted, + self.archiveActiveQueryID == queryID, + self.client === client + else { return } self.archiveQueryTimeoutTask?.cancel() self.archiveQueryTimeoutTask = nil self.archiveQueryCompletionTask = nil @@ -2293,8 +3849,9 @@ final class XMPPService { private func drainArchiveStanzas(queryID: String) async -> Bool { while archiveSyncStarted, - archiveActiveQueryID == queryID, - !Task.isCancelled { + archiveActiveQueryID == queryID, + !Task.isCancelled + { guard !archiveStanzaBuffer.isEmpty else { return true } let count = min(archiveApplyBatchSize, archiveStanzaBuffer.count) let batch = Array(archiveStanzaBuffer.prefix(count)) @@ -2306,12 +3863,36 @@ final class XMPPService { } else { archiveHighWatermark = stanza.timestamp } - handle( - message: stanza.message, - timestamp: stanza.timestamp, - archiveID: stanza.archiveID, - isArchived: true - ) + // handle( + // message: stanza.message, + // timestamp: stanza.timestamp, + // archiveID: stanza.archiveID, + // isArchived: true + // ) + if stanza.message.type == .groupchat, + // let roomJID = stanza.message.from?.bareJid, + // let room = client?.module(.muc).roomManager.room(for: client!, with: roomJID) + let roomJID = stanza.message.from?.bareJid + { + // Some servers include MUC messages in the account MAM. + // Route them through the same group handler instead of + // dropping them in the direct-message handler. + // handle(groupMessage: stanza.message, room: room) + await handle( + groupMessage: stanza.message, + archivedRoomJID: roomJID, + archivedTimestamp: stanza.timestamp, + archiveID: stanza.archiveID, + isArchived: true + ) + } else { + await handle( + message: stanza.message, + timestamp: stanza.timestamp, + archiveID: stanza.archiveID, + isArchived: true + ) + } } // Give SwiftUI, UIScrollView and AVFoundation delegate tasks a @@ -2334,9 +3915,10 @@ final class XMPPService { archiveBufferOverflowed = false archiveStanzaBuffer.removeAll(keepingCapacity: true) archivePassMutations.removeAll(keepingCapacity: true) - eventHandler?(.recoverableError( - "Сервер вернул слишком большую страницу MAM; синхронизация остановлена без потери контрольной точки." - )) + eventHandler?( + .recoverableError( + "Сервер вернул слишком большую страницу MAM; синхронизация остановлена без потери контрольной точки." + )) finishArchiveSync(client: client, succeeded: false) return } @@ -2344,9 +3926,10 @@ final class XMPPService { archiveRejectedSource = false archiveStanzaBuffer.removeAll(keepingCapacity: true) archivePassMutations.removeAll(keepingCapacity: true) - eventHandler?(.recoverableError( - "MAM вернул результат от неожиданного отправителя; страница отклонена." - )) + eventHandler?( + .recoverableError( + "MAM вернул результат от неожиданного отправителя; страница отклонена." + )) finishArchiveSync(client: client, succeeded: false) return } @@ -2394,19 +3977,28 @@ final class XMPPService { ) archiveLastCompletedCursor = checkpoint.cursor if workBudgetReached { - if checkpoint.advances(over: archiveSyncCheckpoint) { - finishArchiveSync( - client: client, - succeeded: true, - checkpoint: checkpoint - ) - } else { + guard checkpoint.advances(over: archiveSyncCheckpoint) else { archivePassMutations.removeAll(keepingCapacity: true) - eventHandler?(.recoverableError( - "MAM не продвинул контрольную точку; синхронизация приостановлена." - )) + eventHandler?( + .recoverableError( + "MAM не продвинул контрольную точку; синхронизация приостановлена." + )) finishArchiveSync(client: client, succeeded: false) + return } + + // Flush the accumulated mutations so the UI advances + // incrementally, then keep catching up in the background. + // The visible sync banner is only shown for the bootstrap + // window, so a large backlog never leaves a spinner hanging. + if !archivePassMutations.isEmpty { + let mutations = archivePassMutations + archivePassMutations.removeAll(keepingCapacity: true) + eventHandler?(.archiveBatch(mutations)) + } + archiveSyncCheckpoint = checkpoint + archiveWorkBudget = ArchiveSyncWorkBudget() + scheduleArchiveNextPage(client: client, after: cursor) } else { scheduleArchiveNextPage(client: client, after: cursor) } @@ -2415,9 +4007,10 @@ final class XMPPService { // atomically. Drop its decoded mutations so the next attempt // can safely request the same page again. archivePassMutations.removeAll(keepingCapacity: true) - eventHandler?(.recoverableError( - "MAM вернул некорректный курсор; страница отклонена без изменения истории." - )) + eventHandler?( + .recoverableError( + "MAM вернул некорректный курсор; страница отклонена без изменения истории." + )) finishArchiveSync(client: client, succeeded: false) } case .failure(let error): @@ -2443,9 +4036,10 @@ final class XMPPService { archivePassMutations.removeAll(keepingCapacity: true) queryArchive(client: client, after: nil, retry: 0) } else { - eventHandler?(.recoverableError( - "MAM: \(error.localizedDescription). Продолжим после следующего открытия приложения." - )) + eventHandler?( + .recoverableError( + "MAM: \(error.localizedDescription). Продолжим после следующего открытия приложения." + )) finishArchiveSync(client: client, succeeded: false) } } @@ -2474,10 +4068,11 @@ final class XMPPService { 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 } + let self, + let client, + self.archiveSyncStarted, + self.client === client + else { return } self.queryArchive(client: client, after: after, retry: retry) } } @@ -2492,10 +4087,11 @@ final class XMPPService { nanoseconds: ArchiveSyncRecoveryPolicy.interPageDelayNanoseconds ) guard !Task.isCancelled, - let self, - let client, - self.archiveSyncStarted, - self.client === client else { return } + let self, + let client, + self.archiveSyncStarted, + self.client === client + else { return } self.queryArchive(client: client, after: after, retry: 0) } } @@ -2507,11 +4103,12 @@ final class XMPPService { nanoseconds: ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds ) guard !Task.isCancelled, - let self, - let client, - self.archiveSyncStarted, - self.archiveActiveQueryID == queryID, - self.client === client else { return } + 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) @@ -2521,9 +4118,10 @@ final class XMPPService { self.archiveBufferOverflowed = false self.archiveRejectedSource = false self.archivePassMutations.removeAll(keepingCapacity: true) - self.eventHandler?(.recoverableError( - "MAM не ответил вовремя; синхронизация остановлена до следующего открытия приложения." - )) + self.eventHandler?( + .recoverableError( + "MAM не ответил вовремя; синхронизация остановлена до следующего открытия приложения." + )) self.finishArchiveSync(client: client, succeeded: false) } } @@ -2535,11 +4133,12 @@ final class XMPPService { nanoseconds: ArchiveSyncRecoveryPolicy.pageApplyTimeoutNanoseconds ) guard !Task.isCancelled, - let self, - let client, - self.archiveSyncStarted, - self.archiveActiveQueryID == queryID, - self.client === client else { return } + 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) @@ -2549,9 +4148,10 @@ final class XMPPService { self.archiveBufferOverflowed = false self.archiveRejectedSource = false self.archivePassMutations.removeAll(keepingCapacity: true) - self.eventHandler?(.recoverableError( - "Обработка MAM заняла слишком много времени; синхронизация приостановлена." - )) + self.eventHandler?( + .recoverableError( + "Обработка MAM заняла слишком много времени; синхронизация приостановлена." + )) self.finishArchiveSync(client: client, succeeded: false) } } @@ -2573,7 +4173,8 @@ final class XMPPService { archiveQueryTimeoutTask = nil archiveQueryCompletionTask?.cancel() archiveQueryCompletionTask = nil - archiveStanzaInbox.cancel() + // Do NOT blanket-cancel the inbox here: an interactive backward-history + // query may be in flight and must keep its own query-ID-scoped buffer. archiveActiveQueryID = nil archiveStanzaBuffer.removeAll(keepingCapacity: true) archiveBufferOverflowed = false @@ -2595,17 +4196,25 @@ final class XMPPService { archiveCursorFallbackUsed = false if succeeded { - let resolvedCheckpoint = checkpoint ?? ArchiveSyncCheckpoint( - timestamp: archiveSyncQueryStartedAt ?? Date(), - cursor: archiveLastCompletedCursor ?? archiveSyncCheckpoint?.cursor - ) + let resolvedCheckpoint = + checkpoint + ?? ArchiveSyncCheckpoint( + timestamp: archiveSyncQueryStartedAt ?? Date(), + cursor: archiveLastCompletedCursor ?? archiveSyncCheckpoint?.cursor + ) archiveSyncCheckpoint = resolvedCheckpoint archiveLastCompletedCursor = resolvedCheckpoint.cursor archiveSyncQueryStartedAt = nil archiveSyncCompletedForConnection = true archiveResumeAfter = nil archiveRetrySuppressedUntilActivation = false + archiveAutoRetryCount = 0 eventHandler?(.archiveSyncCompleted(resolvedCheckpoint)) + Task { @MainActor [weak self, weak client] in + guard let self, let client else { return } + await self.replayDelayedLive(for: .account(client.userBareJid.stringValue)) + } + startNextMUCCatchupIfPossible() return } @@ -2613,23 +4222,55 @@ final class XMPPService { archiveSyncCompletedForConnection = false archiveResumeAfter = archiveSyncCheckpoint?.cursor archiveLastCompletedCursor = archiveSyncCheckpoint?.cursor - archiveRetrySuppressedUntilActivation = true - eventHandler?(.recoverableError( - "Синхронизация истории приостановлена. Luma продолжит после возврата в приложение." - )) + if archiveAutoRetryCount < ArchiveSyncRecoveryPolicy.maximumAutomaticRetries { + // A single slow/timed-out page must not leave history unloaded + // until the next app activation. Retry automatically a bounded + // number of times while still connected and foregrounded. + archiveAutoRetryCount += 1 + archiveRetrySuppressedUntilActivation = false + scheduleArchiveSyncRetry(client: client) + } else { + archiveAutoRetryCount = 0 + archiveRetrySuppressedUntilActivation = true + eventHandler?( + .recoverableError( + "Синхронизация истории приостановлена. Luma продолжит после возврата в приложение." + )) + } + } + + private func scheduleArchiveSyncRetry(client: XMPPClient) { + archiveSyncRetryTask?.cancel() + archiveSyncRetryTask = Task { @MainActor [weak self, weak client] in + try? await Task.sleep( + nanoseconds: ArchiveSyncRecoveryPolicy.retryAfterFailureDelayNanoseconds + ) + guard !Task.isCancelled, + let self, + let client, + self.client === client, + client.state == .connected(), + !self.archiveSyncSuspended + else { return } + self.archiveSyncRetryTask = nil + self.refreshArchiveIfNeeded(client: client) + } } private func refreshArchiveIfNeeded(client: XMPPClient) { guard !archiveSyncStarted, - !archiveSyncSuspended, - self.client === client else { return } + !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 } + guard Date().timeIntervalSince(lastCheckpoint) >= archiveForegroundRefreshInterval else { + return + } archiveSyncCompletedForConnection = false archiveResumeAfter = nil archiveSyncQueryStartedAt = nil @@ -2638,9 +4279,11 @@ final class XMPPService { private func suspendArchiveSyncForDisconnect() { setArchiveSyncIndicator(false) + finishOlderHistory(result: .failure(LumaXMPPError.notConnected)) + archiveStanzaInbox.cancel() guard let client else { archiveSyncStarted = false - archiveStanzaInbox.cancel() + // archiveStanzaInbox.cancel() archiveActiveQueryID = nil archiveStanzaBuffer.removeAll(keepingCapacity: false) archiveBufferOverflowed = false @@ -2654,6 +4297,8 @@ final class XMPPService { archiveQueryTimeoutTask = nil archiveQueryCompletionTask?.cancel() archiveQueryCompletionTask = nil + archiveSyncRetryTask?.cancel() + archiveSyncRetryTask = nil if archiveSyncStarted { archiveResumeAfter = archiveSyncCheckpoint?.cursor client.module(.omemo).mamSyncFinished(for: nil) @@ -2678,6 +4323,10 @@ final class XMPPService { private func pauseArchiveSync(client: XMPPClient) { setArchiveSyncIndicator(false) + // An in-flight interactive history request must not survive into the + // background with a dangling spinner; finish it before the catch-up + // guard below, which may early-return when no catch-up is active. + finishOlderHistory(result: .failure(LumaXMPPError.notConnected)) guard archiveSyncStarted, self.client === client else { return } archiveRetryTask?.cancel() archiveRetryTask = nil @@ -2685,6 +4334,8 @@ final class XMPPService { archiveQueryTimeoutTask = nil archiveQueryCompletionTask?.cancel() archiveQueryCompletionTask = nil + archiveSyncRetryTask?.cancel() + archiveSyncRetryTask = nil archiveStanzaInbox.cancel() archiveResumeAfter = archiveSyncCheckpoint?.cursor archiveActiveQueryID = nil @@ -2712,18 +4363,105 @@ final class XMPPService { } private static var platformName: String { -#if os(macOS) - return "macOS" -#else - return "iOS/iPadOS" -#endif + #if os(macOS) + return "macOS" + #else + return "iOS/iPadOS" + #endif } - private static func reasonText(_ reason: XMPPClient.State.DisconnectionReason) -> String? { - if case .none = reason { + private func reasonText(_ reason: XMPPClient.State.DisconnectionReason) -> String? { + switch reason { + case .none: return nil + case .authenticationFailure(let error): + return SaslFailureMessage.describe( + error: error, + condition: saslFailureModule?.lastFailure?.condition, + serverText: saslFailureModule?.lastFailure?.text + ) + case .sslCertError: + return "Сертификат сервера не прошёл проверку доверия." + case .timeout: + return "Сервер не ответил вовремя." + case .noRouteToServer: + return "Сервер недоступен по сети." + default: + return reason.localizedDescription } - return reason.localizedDescription + } + + private func connectionFailureMessage(for error: Error) -> String { + if let reason = error as? XMPPClient.State.DisconnectionReason { + return reasonText(reason) ?? error.localizedDescription + } + return error.localizedDescription + } + + private func applySASLprepIfNeeded(client: XMPPClient, features: Element) { + // Classic RFC 6120 mechanisms, SASL2 (RFC 9050) mechanisms and the + // XEP-0440 channel-binding types are three separate stream-feature + // elements; collect all three so the selection and the server screen + // see the complete picture. + let mechanisms = + features + .findChild(name: "mechanisms", xmlns: "urn:ietf:params:xml:ns:xmpp-sasl")? + .children + .filter { $0.name == "mechanism" } + .compactMap { $0.value } ?? [] + let sasl2Mechanisms = + features + .findChild(name: "authentication", xmlns: "urn:xmpp:sasl:2")? + .children + .filter { $0.name == "mechanism" } + .compactMap { $0.value } ?? [] + let channelBindingTypesOrdered = + features + .findChild(name: "sasl-channel-binding", xmlns: "urn:xmpp:sasl-cb:0")? + .children + .filter { $0.name == "channel-binding" } + .compactMap { $0.getAttribute("type") } ?? [] + let channelBindingTypes = Set(channelBindingTypesOrdered) + // Post-auth stream features (SMACK resumption) carry no SASL elements; + // refreshing the captured state from them would wipe the screen to + // "no methods / no channel binding". Only pre-auth features (those + // carrying any SASL-related element) update the state. + let hasSASLElements = + !mechanisms.isEmpty + || !sasl2Mechanisms.isEmpty + || !channelBindingTypes.isEmpty + if hasSASLElements { + channelBindingStore.setAdvertisedChannelBindingTypes(channelBindingTypes) + channelBindingStore.setSASLContext( + mechanisms: mechanisms, + channelBindingTypes: channelBindingTypesOrdered + ) + advertisedSASLMechanisms = Array(Set(mechanisms + sasl2Mechanisms)) + advertisedSASL2Mechanisms = sasl2Mechanisms + } + // Record the mechanism the SASL layer picks for this connection: the + // strongest password-based one the server advertises + // (SCRAM-*-PLUS > SCRAM-SHA-512 > … > PLAIN). Keep the previous value + // when the server sends features without mechanisms (e.g. stream + // resumption after SMACK), so the screen does not flip to unknown. + if let selected = SASLMechanismPreference.selected( + among: mechanisms, + allowsChannelBinding: channelBindingStore.canUseChannelBinding + ) { + negotiatedSASLMechanism = selected + } + guard let activePassword, !activePassword.isEmpty else { return } + guard mechanisms.contains(where: { $0.hasPrefix("SCRAM-") }) else { return } + guard + case .password(let currentPassword, _, _) = client.connectionConfiguration.credentials, + let prepared = try? SASLprep.prepare(activePassword), + prepared != currentPassword + else { return } + client.connectionConfiguration.credentials = .password( + password: prepared, + authenticationName: nil, + cache: nil + ) } } @@ -2778,17 +4516,23 @@ enum LumaXMPPError: LocalizedError { case .roomNotJoined: return "Сначала подключитесь к групповой комнате." case .groupEncryptionRequiresNonAnonymousRoom: - return "Групповое OMEMO требует неанонимную MUC-комнату. Попросите владельца включить показ реальных JID (muc#roomconfig_whois = anyone)." + return + "Групповое OMEMO требует неанонимную MUC-комнату. Попросите владельца включить показ реальных JID (muc#roomconfig_whois = anyone)." case .groupEncryptionConfigurationUnsupported: - return "Сервер не предлагает настройку неанонимной MUC-комнаты, необходимую для группового OMEMO." + return + "Сервер не предлагает настройку неанонимной MUC-комнаты, необходимую для группового OMEMO." case .groupEncryptionParticipantsHidden(let nicknames): - return "Нельзя безопасно зашифровать сообщение: комната скрывает JID участников \(nicknames.joined(separator: ", ")). Переподключитесь после включения неанонимного режима." + return + "Нельзя безопасно зашифровать сообщение: комната скрывает JID участников \(nicknames.joined(separator: ", ")). Переподключитесь после включения неанонимного режима." case .groupEncryptionMemberListUnavailable(let message): - return "Сообщение не отправлено: сервер не отдал полный список участников комнаты для OMEMO (\(message))." + return + "Сообщение не отправлено: сервер не отдал полный список участников комнаты для OMEMO (\(message))." case .groupOMEMODevicesUnavailable(let jids): - return "Сообщение не отправлено: OMEMO-устройства отсутствуют у \(jids.joined(separator: ", "))." + return + "Сообщение не отправлено: OMEMO-устройства отсутствуют у \(jids.joined(separator: ", "))." case .groupOMEMOSessionsUnavailable(let devices): - return "Сообщение не отправлено: не удалось создать OMEMO-сессию для \(devices.joined(separator: ", "))." + return + "Сообщение не отправлено: не удалось создать OMEMO-сессию для \(devices.joined(separator: ", "))." case .fileEncryptionFailed: return "Не удалось зашифровать вложение." case .fileDecryptionFailed: @@ -2796,7 +4540,8 @@ enum LumaXMPPError: LocalizedError { case .uploadDiscoveryFailed(let message): return "Не удалось найти XEP-0363 HTTP Upload на сервере: \(message)" case .uploadUnavailable(let size): - return "Сервер не предлагает HTTP Upload для файла размером \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))." + return + "Сервер не предлагает HTTP Upload для файла размером \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))." case .uploadSlotFailed(let message): return "Prosody не выдал слот для загрузки: \(message)" case .uploadTransportFailed(let message): diff --git a/Sources/Watch/WatchSessionModel.swift b/Sources/Watch/WatchSessionModel.swift index 5ee9ab7..703ddca 100644 --- a/Sources/Watch/WatchSessionModel.swift +++ b/Sources/Watch/WatchSessionModel.swift @@ -48,7 +48,7 @@ final class WatchSessionModel: NSObject, ObservableObject { let payload: [String: Any] = [ "action": "reply", "jid": jid, - "text": text + "text": text, ] if session.isReachable { @@ -70,12 +70,15 @@ final class WatchSessionModel: NSObject, ObservableObject { errorMessage = "Соединение с iPhone ещё не готово. Повторите через несколько секунд." return false } - guard session.isCompanionAppInstalled else { - errorMessage = "Установите Luma на связанный iPhone." - return false - } + #if os(watchOS) + guard session.isCompanionAppInstalled else { + errorMessage = "Установите Luma на связанный iPhone." + return false + } + #endif guard duration.isFinite, duration >= 0.4, - FileManager.default.fileExists(atPath: fileURL.path) else { + FileManager.default.fileExists(atPath: fileURL.path) + else { errorMessage = "Запись голосового сообщения недоступна." return false } @@ -87,7 +90,7 @@ final class WatchSessionModel: NSObject, ObservableObject { "transferID": transferID, "jid": normalizedJID, "filename": fileURL.lastPathComponent, - "duration": duration + "duration": duration, ] outgoingVoiceTransfers[transferID] = OutgoingVoiceTransfer( jid: normalizedJID, @@ -119,8 +122,9 @@ final class WatchSessionModel: NSObject, ObservableObject { 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 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() @@ -134,7 +138,8 @@ final class WatchSessionModel: NSObject, ObservableObject { voiceStatusByJID[jid] = "Голосовое отправлено" } else { voiceStatusByJID[jid] = "Голосовое не отправлено" - errorMessage = (payload["error"] as? String) ?? "iPhone не смог отправить голосовое сообщение." + errorMessage = + (payload["error"] as? String) ?? "iPhone не смог отправить голосовое сообщение." } } @@ -144,7 +149,8 @@ final class WatchSessionModel: NSObject, ObservableObject { errorDescription: String? ) { guard let transferID, - let transfer = outgoingVoiceTransfers[transferID] else { + let transfer = outgoingVoiceTransfers[transferID] + else { try? FileManager.default.removeItem(at: fallbackURL) return } @@ -165,10 +171,11 @@ final class WatchSessionModel: NSObject, ObservableObject { private func loadOutgoingVoiceTransfers() { guard let data = UserDefaults.standard.data(forKey: voiceTransferCacheKey), - let stored = try? JSONDecoder().decode( + let stored = try? JSONDecoder().decode( [String: OutgoingVoiceTransfer].self, from: data - ) else { return } + ) + else { return } let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60) outgoingVoiceTransfers = stored.filter { $0.value.createdAt >= expiration } for transfer in outgoingVoiceTransfers.values { @@ -204,11 +211,19 @@ extension WatchSessionModel: WCSessionDelegate { } } + // nonisolated func sessionDidDeactivate(_ session: WCSession) { + // Task { @MainActor in + // phoneReachable = false + // } + // } + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { Task { @MainActor in phoneReachable = session.isReachable } } - nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + nonisolated func session( + _ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any] + ) { guard let data = applicationContext["snapshot"] as? Data else { return } Task { @MainActor in apply(data: data) } } diff --git a/Sources/Watch/WatchViews.swift b/Sources/Watch/WatchViews.swift index fc91993..0efd364 100644 --- a/Sources/Watch/WatchViews.swift +++ b/Sources/Watch/WatchViews.swift @@ -226,3 +226,9 @@ private struct WatchChatView: View { return String(format: "%d:%02d", seconds / 60, seconds % 60) } } + +#Preview { + MainActor.assumeIsolated { + WatchChatListView(model: WatchSessionModel()) + } +} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 2811ef0..e78e8c5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -5,6 +5,9 @@ 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. +- OpenSSL 3.x binary XCFramework package by krzyzanowskim — Apache-2.0. Used for + the TLS layer (TLS 1.3, STARTTLS) and SCRAM channel binding + (tls-exporter / tls-server-end-point). - tigase-logging.swift, pulled transitively by Martin — AGPL-3.0. - Tigase's libsignal Swift package, pulled by MartinOMEMO — GPL-3.0. diff --git a/Tests/AccountConfigurationTests.swift b/Tests/AccountConfigurationTests.swift index 42a7487..af3f951 100644 --- a/Tests/AccountConfigurationTests.swift +++ b/Tests/AccountConfigurationTests.swift @@ -14,10 +14,31 @@ final class AccountConfigurationTests: XCTestCase { XCTAssertEqual(validated.normalizedJID, "alice@example.org") XCTAssertEqual(validated.domain, "example.org") - XCTAssertEqual(validated.effectiveResource, "Luma") + XCTAssertTrue(validated.effectiveResource.hasPrefix("Luma-")) + XCTAssertNotEqual(validated.effectiveResource, DeviceResource.legacyDefault) + XCTAssertEqual(validated.effectiveResource, DeviceResource.default) XCTAssertEqual(validated.manualHost, "xmpp.example.org") } + func testLegacyDefaultResourceIsMigratedToAUniqueDeviceResource() throws { + let account = try AccountConfiguration( + jid: "alice@example.org", + resource: "Luma" + ).validated() + + XCTAssertTrue(account.effectiveResource.hasPrefix("Luma-")) + XCTAssertNotEqual(account.effectiveResource, "Luma") + } + + func testExplicitResourceIsPreserved() throws { + let account = try AccountConfiguration( + jid: "alice@example.org", + resource: " MyDevice " + ).validated() + + XCTAssertEqual(account.effectiveResource, "MyDevice") + } + func testInvalidJIDIsRejected() { XCTAssertThrowsError(try AccountConfiguration(jid: "not-a-jid").validated()) XCTAssertThrowsError(try AccountConfiguration(jid: "@example.org").validated()) diff --git a/Tests/AppLockPolicyTests.swift b/Tests/AppLockPolicyTests.swift new file mode 100644 index 0000000..39d7ed3 --- /dev/null +++ b/Tests/AppLockPolicyTests.swift @@ -0,0 +1,21 @@ +import XCTest +@testable import Luma + +final class AppLockPolicyTests: XCTestCase { + func testMinimumLengthIsFour() { + XCTAssertEqual(AppLockPolicy.minimumLength, 4) + } + + func testValidationRejectsShortPasscodes() { + XCTAssertFalse(AppLockPolicy.isValid("")) + XCTAssertFalse(AppLockPolicy.isValid("1")) + XCTAssertFalse(AppLockPolicy.isValid("123")) + } + + func testValidationAcceptsFourOrMoreCharacters() { + XCTAssertTrue(AppLockPolicy.isValid("1234")) + XCTAssertTrue(AppLockPolicy.isValid("пароль")) + XCTAssertTrue(AppLockPolicy.isValid("long-passcode")) + } +} + diff --git a/Tests/ArchiveMessageBatchPolicyTests.swift b/Tests/ArchiveMessageBatchPolicyTests.swift index 8f28a9b..e400bd6 100644 --- a/Tests/ArchiveMessageBatchPolicyTests.swift +++ b/Tests/ArchiveMessageBatchPolicyTests.swift @@ -2,13 +2,13 @@ import XCTest @testable import Luma final class ArchiveMessageBatchPolicyTests: XCTestCase { - func testMamPagesStaySmallEnoughForInteractiveUI() { - XCTAssertLessThanOrEqual(ArchiveMessageBatchPolicy.pageSize, 32) - XCTAssertGreaterThan(ArchiveMessageBatchPolicy.pageSize, 0) + func testInteractivePageSize() { + XCTAssertEqual(ArchiveMessageBatchPolicy.pageSize, 40) } - func testOnlyOneStanzaIsDecodedPerMainActorSlice() { - XCTAssertEqual(ArchiveMessageBatchPolicy.decodeSliceSize, 1) + func testStanzasAreDecodedInSmallBatchesPerMainActorSlice() { + XCTAssertGreaterThan(ArchiveMessageBatchPolicy.decodeSliceSize, 0) + XCTAssertLessThanOrEqual(ArchiveMessageBatchPolicy.decodeSliceSize, 16) XCTAssertGreaterThan(ArchiveMessageBatchPolicy.interSliceDelayNanoseconds, 0) } diff --git a/Tests/ArchiveStoreTests.swift b/Tests/ArchiveStoreTests.swift new file mode 100644 index 0000000..3f9241e --- /dev/null +++ b/Tests/ArchiveStoreTests.swift @@ -0,0 +1,203 @@ +import SwiftData +import XCTest +@testable import Luma + +@MainActor +final class ArchiveStoreTests: XCTestCase { + func testStoreRoundTrip() async throws { + let jid = "test-\(UUID().uuidString)@example.org" + let store = try ArchiveStore(accountJID: jid) + defer { try? store.erase() } + + let conversation = Conversation(jid: "bob@example.org", displayName: "Bob") + let message = ChatMessage( + conversationID: conversation.jid, + senderJID: jid, + body: "Encrypted hello", + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + direction: .outgoing, + delivery: .sent, + security: .omemo + ) + store.context.insert(conversation) + store.context.insert(message) + + try store.save( + locallyDeletedMessageIDs: ["deleted-message-id"], + rosterContactJIDs: [conversation.jid], + lastSuccessfulMAMSync: Date(timeIntervalSince1970: 1_700_000_000), + lastSuccessfulMAMCursor: "mam-cursor-42", + mamCheckpoints: [:] + ) + + let loaded = store.load() + XCTAssertEqual(loaded.conversations.map(\.jid), [conversation.jid]) + XCTAssertEqual(loaded.messages.map(\.clientID), [message.clientID]) + XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set(["deleted-message-id"])) + XCTAssertEqual(loaded.rosterContactJIDs, Set([conversation.jid])) + XCTAssertEqual( + loaded.lastSuccessfulMAMSync, + Date(timeIntervalSince1970: 1_700_000_000) + ) + XCTAssertEqual(loaded.lastSuccessfulMAMCursor, "mam-cursor-42") + } + + func testLegacyImportMapsConversationAndMessage() throws { + let jid = "test-\(UUID().uuidString)@example.org" + let legacy = LegacyArchiveImporter.Snapshot( + conversations: [ + LegacyArchiveImporter.LegacyConversation( + id: "bob@example.org", + jid: "bob@example.org", + displayName: "Bob", + lastMessage: "", + lastActivity: .distantPast, + unreadCount: 0, + isOnline: false, + isPinned: false, + encryptionPreference: .inheritGlobal, + kind: .direct, + groupNickname: nil, + isGroupJoined: false, + shouldAutojoin: false, + occupantCount: 0, + invitedBy: nil + ) + ], + messages: [ + LegacyArchiveImporter.LegacyMessage( + id: "msg-1", + conversationID: "bob@example.org", + senderJID: jid, + body: "Hello", + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + direction: .outgoing, + delivery: .sent, + security: .omemo, + kind: .text + ) + ] + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + let data = try encoder.encode(legacy) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try data.write(to: url) + + let imported = try LegacyArchiveImporter.importIfNeeded(from: url) + XCTAssertNotNil(imported) + XCTAssertEqual(imported?.conversations.map(\.jid), ["bob@example.org"]) + XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"]) + XCTAssertEqual(imported?.messages.first?.conversation?.jid, "bob@example.org") + } + + func testLegacyImportToleratesMissingOptionalFields() throws { + // Snapshots written by older schema versions lack fields that were + // added later (reactions, isGroupMessage, rosterContactJIDs, ...). + // The importer must decode them with defaults, exactly like the + // removed ChatArchive did. + let raw: [String: Any] = [ + "schemaVersion": 2, + "conversations": [ + [ + "id": "bob@example.org", + "jid": "bob@example.org", + "displayName": "Bob", + "lastMessage": "", + "lastActivity": 1_700_000_000_000.0, + "unreadCount": 0, + "isOnline": false, + "isPinned": false, + ] + ], + "messages": [ + [ + "id": "msg-1", + "conversationID": "bob@example.org", + "senderJID": "alice@example.org", + "body": "Hello", + "timestamp": 1_700_000_000_000.0, + "direction": "outgoing", + "delivery": "sent", + "security": "omemo", + "kind": "text", + ] + ], + ] + let data = try JSONSerialization.data(withJSONObject: raw) + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("legacy-old-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + try data.write(to: url) + + let imported = try LegacyArchiveImporter.importIfNeeded(from: url) + XCTAssertNotNil(imported) + XCTAssertEqual(imported?.conversations.map(\.jid), ["bob@example.org"]) + XCTAssertEqual(imported?.conversations.first?.kind, .direct) + XCTAssertEqual(imported?.conversations.first?.isGroupJoined, false) + XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"]) + XCTAssertEqual(imported?.messages.first?.isGroupMessage, false) + XCTAssertEqual(imported?.messages.first?.reactions ?? [], []) + XCTAssertEqual(imported?.rosterContactJIDs ?? [], []) + XCTAssertEqual(imported?.mamCheckpoints ?? [], []) + XCTAssertNil(imported?.lastSuccessfulMAMSync) + } + + func testLocallyDeletedRowsArePurgedFromTheStore() throws { + // @Query views read the store directly, so messages that were only + // marked as locally deleted must be physically removed on open. + let jid = "test-\(UUID().uuidString)@example.org" + let store = try ArchiveStore(accountJID: jid) + defer { try? store.erase() } + + let conversation = Conversation(jid: "bob@example.org", displayName: "Bob") + let message = ChatMessage( + conversationID: conversation.jid, + senderJID: jid, + body: "Will be deleted", + direction: .outgoing, + delivery: .sent, + security: .omemo + ) + store.context.insert(conversation) + store.context.insert(message) + let deletionKey = ArchiveStore.deletionKey( + messageID: message.clientID, + conversationID: conversation.jid + ) + try store.save( + locallyDeletedMessageIDs: [deletionKey], + rosterContactJIDs: [], + lastSuccessfulMAMSync: nil, + lastSuccessfulMAMCursor: nil, + mamCheckpoints: [:] + ) + + store.purgeLocallyDeletedMessages() + let loaded = store.load() + XCTAssertTrue(loaded.messages.isEmpty) + XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set([deletionKey])) + } + + func testCorruptLegacyJSONIsKeptForRetry() throws { + let jid = "test-\(UUID().uuidString)@example.org" + let directory = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("Luma/Accounts", isDirectory: true) + let legacyURL = directory.appendingPathComponent("\(ArchiveStore.stableHash(jid)).json") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data("not-json".utf8).write(to: legacyURL) + defer { try? FileManager.default.removeItem(at: legacyURL) } + + let store = try ArchiveStore(accountJID: jid) + defer { try? store.erase() } + XCTAssertTrue(store.load().conversations.isEmpty) + // The unreadable snapshot must stay on disk instead of being deleted + // together with the user's history. + XCTAssertTrue(FileManager.default.fileExists(atPath: legacyURL.path)) + } +} diff --git a/Tests/ArchiveSyncRecoveryPolicyTests.swift b/Tests/ArchiveSyncRecoveryPolicyTests.swift index 40f0119..904d609 100644 --- a/Tests/ArchiveSyncRecoveryPolicyTests.swift +++ b/Tests/ArchiveSyncRecoveryPolicyTests.swift @@ -10,7 +10,7 @@ final class ArchiveSyncRecoveryPolicyTests: XCTestCase { XCTAssertGreaterThan(ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds, 0) XCTAssertLessThanOrEqual( ArchiveSyncRecoveryPolicy.queryTimeoutNanoseconds, - 15_000_000_000 + 20_000_000_000 ) } @@ -26,6 +26,15 @@ final class ArchiveSyncRecoveryPolicyTests: XCTestCase { XCTAssertEqual(ArchiveSyncRecoveryPolicy.pageRetryLimit, 1) } + func testCatchUpRetryDelayAndCapAreBounded() { + XCTAssertGreaterThan( + ArchiveSyncRecoveryPolicy.retryAfterFailureDelayNanoseconds, + 0 + ) + XCTAssertGreaterThan(ArchiveSyncRecoveryPolicy.maximumAutomaticRetries, 0) + XCTAssertLessThanOrEqual(ArchiveSyncRecoveryPolicy.maximumAutomaticRetries, 5) + } + func testCaptureResumeDelayLetsCameraReleaseResources() { XCTAssertGreaterThan( ArchiveSyncRecoveryPolicy.resumeAfterCaptureDelayNanoseconds, diff --git a/Tests/CallHistorySyncTests.swift b/Tests/CallHistorySyncTests.swift new file mode 100644 index 0000000..7d73c6b --- /dev/null +++ b/Tests/CallHistorySyncTests.swift @@ -0,0 +1,72 @@ +import Martin +import XCTest +@testable import Luma + +final class CallHistorySyncTests: XCTestCase { + private func makePayloadMessage(id: String = "call-42") -> Message { + let message = Message() + message.id = id + let payload = Element(name: "call-history", xmlns: CallHistorySync.namespace) + payload.addChild(Element(name: "direction", cdata: "incoming")) + payload.addChild(Element(name: "status", cdata: "missed")) + payload.addChild(Element(name: "duration", cdata: "0")) + payload.addChild(Element(name: "start", cdata: "2026-08-27T10:00:00Z")) + payload.addChild(Element(name: "with", cdata: "Bob@Example.org")) + payload.addChild(Element(name: "video", cdata: "false")) + message.addChild(payload) + return message + } + + func testEnvelopeParsesPayload() throws { + let envelope = try XCTUnwrap(CallHistorySync.envelope(from: makePayloadMessage())) + XCTAssertEqual(envelope.id, "call-42") + XCTAssertEqual(envelope.peerJID, "bob@example.org") + XCTAssertEqual(envelope.direction, .incoming) + XCTAssertEqual(envelope.outcome, .missed) + XCTAssertFalse(envelope.isVideo) + XCTAssertNil(envelope.duration) + XCTAssertEqual( + envelope.startedAt, + ISO8601DateFormatter().date(from: "2026-08-27T10:00:00Z") + ) + } + + func testEnvelopePrefersOriginID() throws { + let message = makePayloadMessage() + let origin = Element(name: "origin-id", xmlns: "urn:xmpp:sid:0") + origin.setAttribute("id", value: "call-uuid") + message.addChild(origin) + XCTAssertEqual(CallHistorySync.envelope(from: message)?.id, "call-uuid") + } + + func testEnvelopeIgnoresOrdinaryMessages() { + let message = Message() + message.body = "hello" + XCTAssertNil(CallHistorySync.envelope(from: message)) + } + + func testPayloadRoundTripPreservesCallDetails() throws { + let entry = CallHistoryEntry( + id: "call-1", + peerJID: "bob@example.org", + direction: .outgoing, + isVideo: true, + startedAt: Date(timeIntervalSince1970: 1_700_000_000), + endedAt: Date(timeIntervalSince1970: 1_700_000_042), + duration: 42, + outcome: .completed + ) + let message = Message() + message.id = entry.id + message.addChild(CallHistorySync.payloadElement(entry: entry)) + + let envelope = try XCTUnwrap(CallHistorySync.envelope(from: message)) + XCTAssertEqual(envelope.id, "call-1") + XCTAssertEqual(envelope.peerJID, "bob@example.org") + XCTAssertEqual(envelope.direction, .outgoing) + XCTAssertTrue(envelope.isVideo) + XCTAssertEqual(envelope.duration, 42) + XCTAssertEqual(envelope.outcome, .completed) + } +} + diff --git a/Tests/CallSDPOrderingTests.swift b/Tests/CallSDPOrderingTests.swift index 10211ec..95ee669 100644 --- a/Tests/CallSDPOrderingTests.swift +++ b/Tests/CallSDPOrderingTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import Luma final class CallSDPOrderingTests: XCTestCase { @@ -49,18 +50,27 @@ final class CallSDPOrderingTests: XCTestCase { } func testMartinParseInputPreservesFinalMSIDCharacter() { - let raw = [ - "v=0", - "m=video 9 UDP/TLS/RTP/SAVPF 96", - "a=ssrc-group:FID 2408145738 2025569816", - "a=ssrc:2408145738 msid:luma-stream luma-video-0435", - "a=ssrc:2025569816 msid:luma-stream luma-video-0435" - ].joined(separator: "\r\n") + "\n" + let raw = + [ + "v=0", + "m=video 9 UDP/TLS/RTP/SAVPF 96", + "a=ssrc-group:FID 2408145738 2025569816", + "a=ssrc:2408145738 msid:luma-stream luma-video-0435", + "a=ssrc:2025569816 msid:luma-stream luma-video-0435", + ].joined(separator: "\r\n") + "\n" - let parseInput = CallSDPNormalization.martinParseInput(raw) + let result = CallSDPNormalization.martinParseInput(raw) - XCTAssertTrue(parseInput.hasSuffix("luma-video-0435\r\n")) - XCTAssertTrue(String(parseInput.dropLast(2)).hasSuffix("luma-video-0435")) + let expected = + [ + "v=0", + "m=video 9 UDP/TLS/RTP/SAVPF 96", + "a=ssrc-group:FID 2408145738 2025569816", + "a=ssrc:2408145738 msid:luma-stream luma-video-0435", + "a=ssrc:2025569816 msid:luma-stream luma-video-0435", + ].joined(separator: "\r\n") + "\r\n" + + XCTAssertEqual(result, expected) } func testMartinParseInputUsesExactlyOneTrailingCRLF() { @@ -80,7 +90,7 @@ final class CallSDPOrderingTests: XCTestCase { ], msids: [ "primary": "luma-stream luma-video-9FBECB58-45C5-445B-9F4E-0598594F4F2D", - "rtx": "luma-stream luma-video-9FBECB58-45C5-445B-9F4E-0598594F4F2" + "rtx": "luma-stream luma-video-9FBECB58-45C5-445B-9F4E-0598594F4F2", ] ) @@ -92,12 +102,12 @@ final class CallSDPOrderingTests: XCTestCase { let repaired = CallSDPCompatibility.canonicalFIDMSIDs( groups: [ .init(semantics: "fid", sources: ["primary", "rtx"]), - .init(semantics: "SIM", sources: ["sim-a", "sim-b"]) + .init(semantics: "SIM", sources: ["sim-a", "sim-b"]), ], msids: [ "primary": "stream video-track", "sim-a": "stream camera-a", - "sim-b": "stream camera-b" + "sim-b": "stream camera-b", ] ) @@ -107,26 +117,29 @@ final class CallSDPOrderingTests: XCTestCase { } func testCandidateDispatchWaitsForRemoteAnswerAndFullJID() { - XCTAssertFalse(CallCandidateDispatchGate.canSend( - initialDescriptionWasSignaled: true, - hasLocalDescription: true, - hasRemoteDescription: false, - hasFullPeerJID: true - )) - XCTAssertFalse(CallCandidateDispatchGate.canSend( - initialDescriptionWasSignaled: true, - hasLocalDescription: true, - hasRemoteDescription: true, - hasFullPeerJID: false - )) + XCTAssertFalse( + CallCandidateDispatchGate.canSend( + initialDescriptionWasSignaled: true, + hasLocalDescription: true, + hasRemoteDescription: false, + hasFullPeerJID: true + )) + XCTAssertFalse( + CallCandidateDispatchGate.canSend( + initialDescriptionWasSignaled: true, + hasLocalDescription: true, + hasRemoteDescription: true, + hasFullPeerJID: false + )) } func testCandidateDispatchOpensAfterSessionAccept() { - XCTAssertTrue(CallCandidateDispatchGate.canSend( - initialDescriptionWasSignaled: true, - hasLocalDescription: true, - hasRemoteDescription: true, - hasFullPeerJID: true - )) + XCTAssertTrue( + CallCandidateDispatchGate.canSend( + initialDescriptionWasSignaled: true, + hasLocalDescription: true, + hasRemoteDescription: true, + hasFullPeerJID: true + )) } } diff --git a/Tests/ChatArchiveTests.swift b/Tests/ChatArchiveTests.swift deleted file mode 100644 index c0e8254..0000000 --- a/Tests/ChatArchiveTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import XCTest -@testable import Luma - -final class ChatArchiveTests: XCTestCase { - func testSnapshotRoundTrip() async throws { - let jid = "test-\(UUID().uuidString)@example.org" - let archive = ChatArchive(accountJID: jid) - let conversation = Conversation(jid: "bob@example.org", displayName: "Bob") - let message = ChatMessage( - conversationID: conversation.id, - senderJID: jid, - body: "Encrypted hello", - direction: .outgoing, - delivery: .sent, - security: .omemo - ) - - try await archive.save(.init( - conversations: [conversation], - messages: [message], - locallyDeletedMessageIDs: ["deleted-message-id"], - rosterContactJIDs: [conversation.jid], - lastSuccessfulMAMSync: Date(timeIntervalSince1970: 1_700_000_000), - lastSuccessfulMAMCursor: "mam-cursor-42" - )) - let loaded = await archive.load() - - XCTAssertEqual(loaded.conversations, [conversation]) - XCTAssertEqual(loaded.messages, [message]) - XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set(["deleted-message-id"])) - XCTAssertEqual(loaded.rosterContactJIDs, Set([conversation.jid])) - XCTAssertEqual( - loaded.lastSuccessfulMAMSync, - Date(timeIntervalSince1970: 1_700_000_000) - ) - XCTAssertEqual(loaded.lastSuccessfulMAMCursor, "mam-cursor-42") - XCTAssertEqual(loaded.schemaVersion, ChatArchive.Snapshot.currentSchemaVersion) - try await archive.erase() - } - - func testLegacySnapshotDefaultsToSchemaOne() throws { - let data = Data( - #"{"conversations":[],"messages":[],"locallyDeletedMessageIDs":[]}"#.utf8 - ) - - let snapshot = try JSONDecoder().decode(ChatArchive.Snapshot.self, from: data) - - XCTAssertEqual(snapshot.schemaVersion, 1) - XCTAssertTrue(snapshot.rosterContactJIDs.isEmpty) - XCTAssertNil(snapshot.lastSuccessfulMAMSync) - XCTAssertNil(snapshot.lastSuccessfulMAMCursor) - } -} diff --git a/Tests/ChatMessageDeliveryTests.swift b/Tests/ChatMessageDeliveryTests.swift new file mode 100644 index 0000000..740f482 --- /dev/null +++ b/Tests/ChatMessageDeliveryTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import Luma + +final class ChatMessageDeliveryTests: XCTestCase { + func testMergeKeepsTheStrongestState() { + XCTAssertEqual(ChatMessage.Delivery.sent.merged(with: .sending), .sent) + XCTAssertEqual(ChatMessage.Delivery.sending.merged(with: .sent), .sent) + XCTAssertEqual(ChatMessage.Delivery.sent.merged(with: .delivered), .delivered) + XCTAssertEqual(ChatMessage.Delivery.delivered.merged(with: .read), .read) + XCTAssertEqual(ChatMessage.Delivery.read.merged(with: .sent), .read) + XCTAssertEqual(ChatMessage.Delivery.read.merged(with: .delivered), .read) + } + + func testMAMReplayNeverDowngradesReadState() { + // The archived copy of an outgoing message carries `.sent`; the local + // copy that received the peer's displayed marker must stay `.read`. + XCTAssertEqual(ChatMessage.Delivery.read.merged(with: .sent), .read) + XCTAssertEqual(ChatMessage.Delivery.delivered.merged(with: .sent), .delivered) + } + + func testFailedIsSticky() { + XCTAssertEqual(ChatMessage.Delivery.failed.merged(with: .sent), .failed) + XCTAssertEqual(ChatMessage.Delivery.sent.merged(with: .failed), .failed) + } + + func testReadMessagesRemainEditableAndRetractable() { + let message = ChatMessage( + conversationID: "bob@example.org", + senderJID: "alice@example.org", + body: "привет", + direction: .outgoing, + delivery: .read, + security: .plaintext + ) + XCTAssertTrue(message.canBeEdited) + XCTAssertTrue(message.canBeRetracted) + } +} diff --git a/Tests/CryptoKitAESGCMEngineTests.swift b/Tests/CryptoKitAESGCMEngineTests.swift index c507882..1d1460c 100644 --- a/Tests/CryptoKitAESGCMEngineTests.swift +++ b/Tests/CryptoKitAESGCMEngineTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import Luma final class CryptoKitAESGCMEngineTests: XCTestCase { @@ -10,24 +11,56 @@ final class CryptoKitAESGCMEngineTests: XCTestCase { var ciphertext = Data() var tag = Data() - XCTAssertTrue(engine.encrypt(iv: iv, key: key, message: plaintext, output: &ciphertext, tag: &tag)) + XCTAssertTrue( + engine.encrypt(iv: iv, key: key, message: plaintext, output: &ciphertext, tag: &tag)) var decrypted = Data() - XCTAssertTrue(engine.decrypt(iv: iv, key: key, encoded: ciphertext, auth: tag, output: &decrypted)) + XCTAssertTrue( + engine.decrypt(iv: iv, key: key, encoded: ciphertext, auth: tag, output: &decrypted)) XCTAssertEqual(decrypted, plaintext) } func testModifiedTagFails() { let engine = CryptoKitAESGCMEngine() + let key = Data(repeating: 0x31, count: 16) let iv = Data(repeating: 0x22, count: 12) + var ciphertext = Data() var tag = Data() - XCTAssertTrue(engine.encrypt(iv: iv, key: key, message: Data("secret".utf8), output: &ciphertext, tag: &tag)) - tag[0] ^= 0x01 + + guard + engine.encrypt( + iv: iv, + key: key, + message: Data("secret".utf8), + output: &ciphertext, + tag: &tag + ) + else { + XCTFail("AES-GCM encryption unexpectedly failed") + return + } + + XCTAssertEqual(tag.count, 16) + + guard tag.count == 16 else { + return + } + + var modifiedTag = tag + modifiedTag[modifiedTag.startIndex] ^= 0x01 var decrypted = Data() - XCTAssertFalse(engine.decrypt(iv: iv, key: key, encoded: ciphertext, auth: tag, output: &decrypted)) + + XCTAssertFalse( + engine.decrypt( + iv: iv, + key: key, + encoded: ciphertext, + auth: modifiedTag, + output: &decrypted + ) + ) } } - diff --git a/Tests/EncryptionPreferenceTests.swift b/Tests/EncryptionPreferenceTests.swift index 5e98704..72b2138 100644 --- a/Tests/EncryptionPreferenceTests.swift +++ b/Tests/EncryptionPreferenceTests.swift @@ -10,19 +10,6 @@ final class EncryptionPreferenceTests: XCTestCase { XCTAssertFalse(EncryptionPreference.disabled.resolved(globalEnabled: true)) } - func testConversationWithoutEncryptionFieldStillDecodes() throws { - let conversation = Conversation(jid: "bob@example.org", displayName: "Bob") - let encoded = try JSONEncoder().encode(conversation) - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - object.removeValue(forKey: "encryptionPreference") - let legacyData = try JSONSerialization.data(withJSONObject: object) - - let decoded = try JSONDecoder().decode(Conversation.self, from: legacyData) - - XCTAssertEqual(decoded.encryptionPreference, .inheritGlobal) - XCTAssertEqual(decoded.jid, conversation.jid) - } - func testGlobalSettingIsStoredPerAccount() throws { let suite = "LumaTests.\(UUID().uuidString)" let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) diff --git a/Tests/GroupConversationTests.swift b/Tests/GroupConversationTests.swift index c1f464b..569d36b 100644 --- a/Tests/GroupConversationTests.swift +++ b/Tests/GroupConversationTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Luma final class GroupConversationTests: XCTestCase { - func testGroupConversationRoundTrips() throws { + func testGroupConversationProperties() { let conversation = Conversation( jid: "team@conference.example.org", displayName: "Команда", @@ -16,33 +16,11 @@ final class GroupConversationTests: XCTestCase { invitedBy: "bob@example.org" ) - let decoded = try JSONDecoder().decode( - Conversation.self, - from: JSONEncoder().encode(conversation) - ) - - XCTAssertTrue(decoded.isGroup) - XCTAssertEqual(decoded.groupNickname, "Alice") - XCTAssertEqual(decoded.occupantCount, 4) - XCTAssertEqual(decoded.invitedBy, "bob@example.org") - } - - func testLegacyConversationDefaultsToDirect() throws { - let conversation = Conversation(jid: "bob@example.org", displayName: "Bob") - let encoded = try JSONEncoder().encode(conversation) - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - for key in ["kind", "groupNickname", "isGroupJoined", "shouldAutojoin", "occupantCount", "invitedBy"] { - object.removeValue(forKey: key) - } - - let decoded = try JSONDecoder().decode( - Conversation.self, - from: JSONSerialization.data(withJSONObject: object) - ) - - XCTAssertFalse(decoded.isGroup) - XCTAssertFalse(decoded.isGroupJoined) - XCTAssertEqual(decoded.occupantCount, 0) + XCTAssertTrue(conversation.isGroup) + XCTAssertEqual(conversation.groupNickname, "Alice") + XCTAssertEqual(conversation.occupantCount, 4) + XCTAssertEqual(conversation.invitedBy, "bob@example.org") + XCTAssertEqual(conversation.encryptionPreference, .disabled) } func testNewGroupInheritsGlobalEncryptionByDefault() { @@ -52,5 +30,6 @@ final class GroupConversationTests: XCTestCase { ) XCTAssertEqual(conversation.encryptionPreference, .inheritGlobal) + XCTAssertTrue(conversation.isGroup) } } diff --git a/Tests/LumaOMEMO2Tests.swift b/Tests/LumaOMEMO2Tests.swift new file mode 100644 index 0000000..8e8146b --- /dev/null +++ b/Tests/LumaOMEMO2Tests.swift @@ -0,0 +1,376 @@ +import XCTest +import Foundation +import CryptoKit +@testable import Luma +import Martin +import MartinOMEMO + +final class LumaOMEMO2Tests: XCTestCase { + + // MARK: - Payload crypto (XEP-0384 0.8.3, section 4.4) + + func testDerivePayloadKeysHasStableStructure() throws { + let key = Data(repeating: 0x42, count: 32) + let derived = try XCTUnwrap(LumaOMEMO2Module.derivePayloadKeys(from: key)) + XCTAssertEqual(derived.encryptionKey.count, 32) + XCTAssertEqual(derived.authKey.count, 32) + XCTAssertEqual(derived.iv.count, 16) + + // Deterministic and key-dependent. + XCTAssertEqual(derived, LumaOMEMO2Module.derivePayloadKeys(from: key)) + let other = try XCTUnwrap(LumaOMEMO2Module.derivePayloadKeys(from: Data(repeating: 0x43, count: 32))) + XCTAssertNotEqual(derived, other) + + // Wrong input size is rejected. + XCTAssertNil(LumaOMEMO2Module.derivePayloadKeys(from: Data([1, 2, 3]))) + } + + func testCBCRoundTrip() throws { + let key = Data(repeating: 0x11, count: 32) + let iv = Data(repeating: 0x22, count: 16) + let plaintext = Data("OMEMO 2 payload".utf8) + + let ciphertext = try XCTUnwrap(LumaOMEMO2Module.aes256CBCEncrypt(plaintext, key: key, iv: iv)) + XCTAssertNotEqual(ciphertext, plaintext) + let recovered = try XCTUnwrap(LumaOMEMO2Module.aes256CBCDecrypt(ciphertext, key: key, iv: iv)) + XCTAssertEqual(recovered, plaintext) + + // A wrong key never recovers the plaintext: the padding check may + // either reject the result or yield garbage, but never the content. + // Integrity itself is guaranteed by the HMAC step in finishDecode. + let wrongKey = Data(repeating: 0x12, count: 32) + let wrong = LumaOMEMO2Module.aes256CBCDecrypt(ciphertext, key: wrongKey, iv: iv) + if let wrong { + XCTAssertNotEqual(wrong, plaintext) + } + + // PKCS#7 padding rounds up to a whole block. + XCTAssertEqual(ciphertext.count % 16, 0) + } + + func testHMACTruncationMatchesManualComputation() throws { + let authKey = Data(repeating: 0x33, count: 32) + let message = Data("authenticate me".utf8) + let full = Data(HMAC.authenticationCode(for: message, using: SymmetricKey(data: authKey))) + XCTAssertEqual(full.count, 32) + // The wire format keeps the first 16 bytes ("cutting off excess + // bytes from the end" in the spec). + let truncated = Data(full.prefix(16)) + XCTAssertEqual(truncated.count, 16) + XCTAssertEqual(LumaOMEMO2Module.constantTimeEquals(truncated, full.subdata(in: 0..<16)), true) + XCTAssertFalse(LumaOMEMO2Module.constantTimeEquals(truncated, Data(repeating: 0, count: 16))) + XCTAssertFalse(LumaOMEMO2Module.constantTimeEquals(truncated, Data([1]))) + } + + // MARK: - SCE envelope (XEP-0420) + + func testEnvelopeRoundTrip() throws { + let xml = LumaOMEMO2Module.envelopeXML( + body: "Привет & <тест>", + from: "user@example.org", + to: nil + ) + let envelope = try XCTUnwrap(Element.from(string: xml)) + XCTAssertEqual(envelope.name, "envelope") + XCTAssertEqual(envelope.xmlns, "urn:xmpp:sce:0") + + let content = try XCTUnwrap(envelope.findChild(name: "content")) + let body = try XCTUnwrap(content.findChild(name: "body")) + XCTAssertEqual(body.value, "Привет & <тест>") + XCTAssertEqual(body.xmlns, "jabber:client") + + // rpad must be present and non-empty. + let rpad = try XCTUnwrap(envelope.findChild(name: "rpad")?.value) + XCTAssertFalse(rpad.isEmpty) + + // from affix is mandatory. + XCTAssertEqual(envelope.findChild(name: "from")?.getAttribute("jid"), "user@example.org") + // No without a MUC. + XCTAssertNil(envelope.findChild(name: "to")) + } + + func testEnvelopeIncludesToAffixForMUC() throws { + let xml = LumaOMEMO2Module.envelopeXML( + body: "hi", + from: "user@example.org", + to: "room@conference.example.org" + ) + let envelope = try XCTUnwrap(Element.from(string: xml)) + XCTAssertEqual( + envelope.findChild(name: "to")?.getAttribute("jid"), + "room@conference.example.org" + ) + } + + func testSCEEnvelopeUsesSCE0Namespace() throws { + // XEP-0420 Stanza Content Encryption uses urn:xmpp:sce:0; OMEMO 2 + // relies on that exact namespace for the encrypted payload envelope. + XCTAssertEqual(LumaOMEMO2Module.SCE_XMLNS, "urn:xmpp:sce:0") + let xml = LumaOMEMO2Module.envelopeXML(body: "hi", from: "a@b.c", to: nil) + let envelope = try XCTUnwrap(Element.from(string: xml)) + XCTAssertEqual(envelope.name, "envelope") + XCTAssertEqual(envelope.xmlns, "urn:xmpp:sce:0") + } + + // MARK: - Bundle parsing + + func testBundleParsing() throws { + let bundle = Element(name: "bundle", xmlns: "urn:xmpp:omemo:2") + bundle.addChild(Element(name: "spk", cdata: Data([1, 2, 3]).base64EncodedString(), attributes: ["id": "7"])) + bundle.addChild(Element(name: "spks", cdata: Data([4, 5, 6]).base64EncodedString())) + bundle.addChild(Element(name: "ik", cdata: Data([7, 8, 9]).base64EncodedString())) + let prekeys = Element(name: "prekeys") + prekeys.addChild(Element(name: "pk", cdata: Data([10, 11]).base64EncodedString(), attributes: ["id": "11"])) + prekeys.addChild(Element(name: "pk", cdata: Data([12, 13]).base64EncodedString(), attributes: ["id": "12"])) + bundle.addChild(prekeys) + + let parsed = try XCTUnwrap(OMEMO2Bundle(from: bundle)) + XCTAssertEqual(parsed.signedPreKeyId, 7) + XCTAssertEqual(parsed.signedPreKeyPublic, Data([1, 2, 3])) + XCTAssertEqual(parsed.signedPreKeySignature, Data([4, 5, 6])) + XCTAssertEqual(parsed.identityKey, Data([7, 8, 9])) + XCTAssertEqual(parsed.preKeys.count, 2) + + // Wrong namespace is rejected. + bundle.xmlns = "urn:xmpp:omemo:0" + XCTAssertNil(OMEMO2Bundle(from: bundle)) + } + + // MARK: - OMEMODevice protocol label & store wiring + + func testOMEMODeviceProtocolLabel() { + let legacy = OMEMODevice( + jid: "bob@example.org", + deviceID: 5, + fingerprint: "aa", + trust: .trusted, + isActive: true, + isOwn: false, + isOMEMO2: false + ) + let omemo2 = OMEMODevice( + jid: "bob@example.org", + deviceID: 5, + fingerprint: "bb", + trust: .trusted, + isActive: true, + isOwn: false, + isOMEMO2: true + ) + + XCTAssertEqual(legacy.protocolName, "OMEMO") + XCTAssertEqual(omemo2.protocolName, "OMEMO 2") + // Same JID/deviceID across protocols must not collide as list rows. + XCTAssertEqual(legacy.id, "bob@example.org|5|legacy") + XCTAssertEqual(omemo2.id, "bob@example.org|5|omemo2") + XCTAssertNotEqual(legacy.id, omemo2.id) + } + + func testStoreMarksOMEMO2DeviceIDs() throws { + let store = LumaOMEMOStore(accountJID: "alice@example.org") + let context = try XCTUnwrap(SignalContext(withStorage: store)) + _ = context + let ownID = store.localRegistrationID + XCTAssertNotEqual(ownID, 0) + + store.setOMEMO2DeviceIDs([Int32(bitPattern: ownID)], for: "alice@example.org") + let devices = store.devices(for: "alice@example.org") + let own = try XCTUnwrap(devices.first { $0.isOwn }) + XCTAssertTrue(own.isOMEMO2) + + // Clearing the published set drops the badge. + store.setOMEMO2DeviceIDs([], for: "alice@example.org") + let cleared = store.devices(for: "alice@example.org") + let clearedOwn = try XCTUnwrap(cleared.first { $0.isOwn }) + XCTAssertFalse(clearedOwn.isOMEMO2) + } + + // MARK: - Double Ratchet round trip (shared with the legacy module) + + func testRatchetSessionRoundTrip() throws { + let alice = try makeStorage() + let bob = try makeStorage() + + let aliceContext = try XCTUnwrap(SignalContext(withStorage: alice)) + let bobContext = try XCTUnwrap(SignalContext(withStorage: bob)) + + let aliceDevice = Int32(bitPattern: alice.identities.localRegistrationId()) + let aliceAddress = SignalAddress(name: "alice@example.org", deviceId: aliceDevice) + let bobAddress = SignalAddress(name: "bob@example.org", deviceId: Int32(bitPattern: bob.identities.localRegistrationId())) + + // Bob builds a session with Alice from her published bundle. + let preKey = try XCTUnwrap(alice.preKeys.loadPreKey(withId: 1)) + let preKeyRecord = try XCTUnwrap(SignalPreKey(fromSerializedData: preKey)) + let signedPreKey = try XCTUnwrap(alice.signedPreKeys.loadSignedPreKey(withId: 1)) + let signedPreKeyRecord = try XCTUnwrap(SignalSignedPreKey(fromSerializedData: signedPreKey)) + let identityKey = try XCTUnwrap(alice.identities.keyPair()?.publicKey) + let bundle = try XCTUnwrap(SignalPreKeyBundle( + registrationId: 0, + deviceId: aliceDevice, + preKeyId: 1, + preKeyPublic: try XCTUnwrap(preKeyRecord.serializedPublicKey), + signedPreKeyId: 1, + signedPreKeyPublic: try XCTUnwrap(signedPreKeyRecord.publicKeyData), + signedPreKeySignature: signedPreKeyRecord.signature, + identityKey: identityKey + )) + let builder = try XCTUnwrap(SignalSessionBuilder(withAddress: aliceAddress, andContext: bobContext)) + XCTAssertTrue(builder.processPreKeyBundle(bundle: bundle)) + + // The 48-byte combined key (payload key + truncated HMAC) travels + // through the ratchet exactly like in LumaOMEMO2Module. + var combined = Data(repeating: 0x5A, count: 32) + combined.append(Data(repeating: 0x3C, count: 16)) + + let bobCipher = try XCTUnwrap(SignalSessionCipher(withAddress: aliceAddress, andContext: bobContext)) + let encryptedKey = try bobCipher.encrypt(data: combined).get() + XCTAssertTrue(encryptedKey.prekey) + + let aliceCipher = try XCTUnwrap(SignalSessionCipher(withAddress: bobAddress, andContext: aliceContext)) + let decrypted = try aliceCipher.decrypt(key: SignalSessionCipher.Key( + key: encryptedKey.key, + deviceId: Int32(bitPattern: bob.identities.localRegistrationId()), + prekey: true + )).get() + XCTAssertEqual(decrypted, combined) + } +} + +// MARK: - In-memory signal stores + +private final class TestOMEMOStorage: SignalStorage { + let sessions = InMemorySessionStore() + let preKeys = InMemoryPreKeyStore() + let signedPreKeys = InMemorySignedPreKeyStore() + let identities = InMemoryIdentityKeyStore() + let senderKeys = InMemorySenderKeyStore() + + init() { + super.init( + sessionStore: sessions, + preKeyStore: preKeys, + signedPreKeyStore: signedPreKeys, + identityKeyStore: identities, + senderKeyStore: senderKeys + ) + } + + override func setup(withContext context: SignalContext) { + identities.registrationID = context.generateRegistrationId() + if let pair = SignalIdentityKeyPair.generateKeyPair(context: context) { + identities.keyPairData = pair.serialized() + } + if let identityKeyPair = identities.keyPair(), + let signedPreKey = context.generateSignedPreKey(withIdentity: identityKeyPair, signedPreKeyId: 1), + let serialized = signedPreKey.serializedData { + _ = signedPreKeys.storeSignedPreKey(serialized, withId: 1) + } + let generated = context.generatePreKeys(withStartingPreKeyId: 1, count: 1) + for preKey in generated { + if let serialized = preKey.serializedData { + _ = preKeys.storePreKey(serialized, withId: preKey.preKeyId) + } + } + super.setup(withContext: context) + } +} + +private final class InMemorySessionStore: SignalSessionStoreProtocol { + private var records: [String: Data] = [:] + func sessionRecord(forAddress address: SignalAddress) -> Data? { + records["\(address.name)|\(address.deviceId)"] + } + func allDevices(for name: String, activeAndTrusted: Bool) -> [Int32] { [] } + func storeSessionRecord(_ data: Data, forAddress address: SignalAddress) -> Bool { + records["\(address.name)|\(address.deviceId)"] = data + return true + } + func containsSessionRecord(forAddress address: SignalAddress) -> Bool { + sessionRecord(forAddress: address) != nil + } + func deleteSessionRecord(forAddress address: SignalAddress) -> Bool { + records.removeValue(forKey: "\(address.name)|\(address.deviceId)") != nil + } + func deleteAllSessions(for name: String) -> Bool { + records.removeAll() + return true + } +} + +private final class InMemoryPreKeyStore: SignalPreKeyStoreProtocol { + private var keys: [UInt32: Data] = [:] + private var pendingDeletion: Set = [] + func currentPreKeyId() -> UInt32 { keys.keys.max() ?? 0 } + func loadPreKey(withId id: UInt32) -> Data? { keys[id] } + func storePreKey(_ data: Data, withId id: UInt32) -> Bool { + keys[id] = data + return true + } + func containsPreKey(withId id: UInt32) -> Bool { keys[id] != nil } + func deletePreKey(withId id: UInt32) -> Bool { + pendingDeletion.insert(id) + return true + } + func flushDeletedPreKeys() -> Bool { + let ids = pendingDeletion + pendingDeletion.removeAll() + ids.forEach { keys.removeValue(forKey: $0) } + return !ids.isEmpty + } +} + +private final class InMemorySignedPreKeyStore: SignalSignedPreKeyStoreProtocol { + private var keys: [UInt32: Data] = [:] + func countSignedPreKeys() -> Int { keys.count } + func loadSignedPreKey(withId id: UInt32) -> Data? { keys[id] } + func storeSignedPreKey(_ data: Data, withId id: UInt32) -> Bool { + keys[id] = data + return true + } + func containsSignedPreKey(withId id: UInt32) -> Bool { keys[id] != nil } + func deleteSignedPreKey(withId id: UInt32) -> Bool { + keys.removeValue(forKey: id) != nil + } +} + +private final class InMemoryIdentityKeyStore: SignalIdentityKeyStoreProtocol { + var registrationID: UInt32 = 0 + var keyPairData: Data? + private var identities: [String: Data] = [:] + func keyPair() -> SignalIdentityKeyPairProtocol? { + guard let keyPairData else { return nil } + return SignalIdentityKeyPair(fromKeyPairData: keyPairData) + } + func localRegistrationId() -> UInt32 { 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 } + identities["\(identity.name)|\(identity.deviceId)"] = publicKeyData + return true + } + func isTrusted(identity: SignalAddress, publicKeyData: Data?) -> Bool { true } + func setStatus(_ status: IdentityStatus, forIdentity identity: SignalAddress) -> Bool { true } + func setStatus(active: Bool, forIdentity identity: SignalAddress) -> Bool { true } + func identities(forName name: String) -> [Identity] { [] } + func identityFingerprint(forAddress address: SignalAddress) -> String? { nil } +} + +private final class InMemorySenderKeyStore: SignalSenderKeyStoreProtocol { + private var keys: [String: Data] = [:] + func storeSenderKey(_ key: Data, address: SignalAddress?, groupId: String?) -> Bool { + keys["\(address?.name ?? "-")|\(groupId ?? "-")"] = key + return true + } + func loadSenderKey(forAddress address: SignalAddress?, groupId: String?) -> Data? { + keys["\(address?.name ?? "-")|\(groupId ?? "-")"] + } +} + +private func makeStorage() throws -> TestOMEMOStorage { + TestOMEMOStorage() +} diff --git a/Tests/MediaMetadataTests.swift b/Tests/MediaMetadataTests.swift index 8741803..d7756cb 100644 --- a/Tests/MediaMetadataTests.swift +++ b/Tests/MediaMetadataTests.swift @@ -55,32 +55,4 @@ final class MediaMetadataTests: XCTestCase { XCTAssertFalse(photo.hasSuffix(".aesgcm")) XCTAssertFalse(voice.hasSuffix(".aesgcm")) } - - func testLegacyMessageWithoutMediaMetadataStillDecodes() throws { - let message = ChatMessage( - conversationID: "bob@example.org", - senderJID: "alice@example.org", - body: "old attachment", - direction: .incoming, - delivery: .delivered, - security: .omemo, - kind: .attachment, - remoteAttachmentURL: "aesgcm://upload.example.org/file.aesgcm#key", - localFilename: "file.pdf" - ) - let encoded = try JSONEncoder().encode(message) - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - object.removeValue(forKey: "mimeType") - object.removeValue(forKey: "duration") - object.removeValue(forKey: "byteCount") - - let decoded = try JSONDecoder().decode( - ChatMessage.self, - from: JSONSerialization.data(withJSONObject: object) - ) - - XCTAssertEqual(decoded.localFilename, "file.pdf") - XCTAssertNil(decoded.mimeType) - XCTAssertNil(decoded.duration) - } } diff --git a/Tests/MediaPreviewProcessorVideoTests.swift b/Tests/MediaPreviewProcessorVideoTests.swift new file mode 100644 index 0000000..9fae201 --- /dev/null +++ b/Tests/MediaPreviewProcessorVideoTests.swift @@ -0,0 +1,68 @@ +import AVFoundation +import XCTest +@testable import Luma + +final class MediaPreviewProcessorVideoTests: XCTestCase { + /// Reproduces the camera-video analysis on the simulator: the pipeline + /// must produce a duration and a JPEG thumbnail for a freshly written + /// HEVC QuickTime movie. + func testVideoAnalysisProducesDurationAndThumbnail() async throws { + let url = try await Self.makeTestMovie() + defer { try? FileManager.default.removeItem(at: url) } + + let processor = MediaPreviewProcessor() + let analysis = await processor.analyze( + url: url, + kind: .video, + mimeType: "video/quicktime" + ) + XCTAssertNotNil(analysis.duration) + if let duration = analysis.duration { + XCTAssertGreaterThan(duration, 0) + } + XCTAssertNotNil(analysis.thumbnailData) + XCTAssertFalse(analysis.thumbnailData?.isEmpty ?? true) + } + + private static func makeTestMovie() async throws -> URL { + let output = FileManager.default.temporaryDirectory + .appendingPathComponent("test-camera-\(UUID().uuidString).mov") + let writer = try AVAssetWriter(outputURL: output, fileType: .mov) + let input = AVAssetWriterInput( + mediaType: .video, + outputSettings: [ + AVVideoCodecKey: AVVideoCodecType.hevc, + AVVideoWidthKey: 640, + AVVideoHeightKey: 480, + ] + ) + input.expectsMediaDataInRealTime = true + let adaptor = AVAssetWriterInputPixelBufferAdaptor( + assetWriterInput: input, + sourcePixelBufferAttributes: [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + kCVPixelBufferWidthKey as String: 640, + kCVPixelBufferHeightKey as String: 480, + ] + ) + writer.add(input) + guard writer.startWriting() else { + throw writer.error ?? NSError(domain: "test", code: 1) + } + writer.startSession(atSourceTime: .zero) + for frame in 0..<60 { + while !input.isReadyForMoreMediaData { + try await Task.sleep(nanoseconds: 2_000_000) + } + var buffer: CVPixelBuffer? + CVPixelBufferCreate( + kCFAllocatorDefault, 640, 480, kCVPixelFormatType_32BGRA, nil, &buffer + ) + adaptor.append(buffer!, withPresentationTime: CMTime(value: Int64(frame), timescale: 30)) + } + input.markAsFinished() + await writer.finishWriting() + return output + } +} + diff --git a/Tests/MessageCorrectionTests.swift b/Tests/MessageCorrectionTests.swift index 81eae23..0c98ead 100644 --- a/Tests/MessageCorrectionTests.swift +++ b/Tests/MessageCorrectionTests.swift @@ -4,31 +4,32 @@ import XCTest final class MessageCorrectionTests: XCTestCase { func testOnlyDeliveredOrSentOutgoingTextCanBeEdited() { - let editable = ChatMessage( - conversationID: "bob@example.org", - senderJID: "alice@example.org", - body: "До исправления", - direction: .outgoing, - delivery: .sent, - security: .omemo - ) - var incoming = editable - incoming.direction = .incoming - var attachment = editable - attachment.kind = .attachment - var failed = editable - failed.delivery = .failed - var retracted = editable - retracted.retractedAt = Date() + func make( + direction: ChatMessage.Direction = .outgoing, + delivery: ChatMessage.Delivery = .sent, + kind: ChatMessage.Kind = .text, + retractedAt: Date? = nil + ) -> ChatMessage { + ChatMessage( + conversationID: "bob@example.org", + senderJID: "alice@example.org", + body: "До исправления", + direction: direction, + delivery: delivery, + security: .omemo, + kind: kind, + retractedAt: retractedAt + ) + } - XCTAssertTrue(editable.canBeEdited) - XCTAssertFalse(incoming.canBeEdited) - XCTAssertFalse(attachment.canBeEdited) - XCTAssertFalse(failed.canBeEdited) - XCTAssertFalse(retracted.canBeEdited) + XCTAssertTrue(make().canBeEdited) + XCTAssertFalse(make(direction: .incoming).canBeEdited) + XCTAssertFalse(make(kind: .attachment).canBeEdited) + XCTAssertFalse(make(delivery: .failed).canBeEdited) + XCTAssertFalse(make(retractedAt: Date()).canBeEdited) } - func testEditedTimestampRoundTrips() throws { + func testEditedTimestampIsStored() { let editedAt = Date(timeIntervalSince1970: 1_700_000_000) let message = ChatMessage( conversationID: "bob@example.org", @@ -40,30 +41,6 @@ final class MessageCorrectionTests: XCTestCase { editedAt: editedAt ) - let data = try JSONEncoder().encode(message) - let decoded = try JSONDecoder().decode(ChatMessage.self, from: data) - - XCTAssertEqual(decoded.editedAt, editedAt) - } - - func testLegacyMessageWithoutEditedTimestampStillDecodes() throws { - let message = ChatMessage( - conversationID: "bob@example.org", - senderJID: "alice@example.org", - body: "Старое сообщение", - direction: .incoming, - delivery: .delivered, - security: .omemo - ) - let encoded = try JSONEncoder().encode(message) - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - object.removeValue(forKey: "editedAt") - - let decoded = try JSONDecoder().decode( - ChatMessage.self, - from: JSONSerialization.data(withJSONObject: object) - ) - - XCTAssertNil(decoded.editedAt) + XCTAssertEqual(message.editedAt, editedAt) } } diff --git a/Tests/MessageInteractionTests.swift b/Tests/MessageInteractionTests.swift index de4fcbc..c271b99 100644 --- a/Tests/MessageInteractionTests.swift +++ b/Tests/MessageInteractionTests.swift @@ -3,7 +3,7 @@ import XCTest @testable import Luma final class MessageInteractionTests: XCTestCase { - func testReplyMetadataRoundTrips() throws { + func testReplyMetadataIsStored() { let message = ChatMessage( conversationID: "bob@example.org", senderJID: "alice@example.org", @@ -16,12 +16,9 @@ final class MessageInteractionTests: XCTestCase { replyPreview: "Исходный текст" ) - let data = try JSONEncoder().encode(message) - let decoded = try JSONDecoder().decode(ChatMessage.self, from: data) - - XCTAssertEqual(decoded.replyToID, "original-id") - XCTAssertEqual(decoded.replyToJID, "bob@example.org") - XCTAssertEqual(decoded.replyPreview, "Исходный текст") + XCTAssertEqual(message.replyToID, "original-id") + XCTAssertEqual(message.replyToJID, "bob@example.org") + XCTAssertEqual(message.replyPreview, "Исходный текст") } func testRetractedMessageCannotBeEditedRepliedOrForwarded() { @@ -42,35 +39,6 @@ final class MessageInteractionTests: XCTestCase { XCTAssertEqual(message.previewText, "🚫 Сообщение удалено") } - func testLegacyMessageWithoutInteractionFieldsStillDecodes() throws { - let original = ChatMessage( - conversationID: "bob@example.org", - senderJID: "alice@example.org", - body: "Старый архив", - direction: .incoming, - delivery: .delivered, - security: .omemo - ) - let encoded = try JSONEncoder().encode(original) - var object = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) - for key in [ - "replyToID", "replyToJID", "replyPreview", "forwardedFrom", "retractedAt", - "stanzaID", "senderDisplayName", "isGroupMessage", "callHistory" - ] { - object.removeValue(forKey: key) - } - - let decoded = try JSONDecoder().decode( - ChatMessage.self, - from: JSONSerialization.data(withJSONObject: object) - ) - - XCTAssertNil(decoded.replyToID) - XCTAssertNil(decoded.retractedAt) - XCTAssertEqual(decoded.body, "Старый архив") - XCTAssertFalse(decoded.isGroupMessage) - } - func testGroupReplyRequiresRoomAssignedStanzaID() { var message = ChatMessage( conversationID: "team@conference.example.org", @@ -92,7 +60,7 @@ final class MessageInteractionTests: XCTestCase { XCTAssertFalse(message.canBeRetracted) } - func testCompletedVideoCallHistoryRoundTripsWithDuration() throws { + func testCompletedVideoCallHistoryShowsMetadata() { let metadata = CallHistoryMetadata(isVideo: true, outcome: .completed) let message = ChatMessage( id: "call-alice-123", @@ -107,14 +75,11 @@ final class MessageInteractionTests: XCTestCase { callHistory: metadata ) - let data = try JSONEncoder().encode(message) - let decoded = try JSONDecoder().decode(ChatMessage.self, from: data) - - XCTAssertEqual(decoded.callHistory, metadata) - XCTAssertEqual(decoded.callTitle, "Исходящий видеозвонок") - XCTAssertEqual(decoded.callSubtitle, "Длительность 1:05") - XCTAssertEqual(decoded.previewText, "🎥 Исходящий видеозвонок") - XCTAssertFalse(decoded.canBeRepliedTo) - XCTAssertFalse(decoded.canBeForwarded) + XCTAssertEqual(message.callHistory, metadata) + XCTAssertEqual(message.callTitle, "Исходящий видеозвонок") + XCTAssertEqual(message.callSubtitle, "Длительность 1:05") + XCTAssertEqual(message.previewText, "🎥 Исходящий видеозвонок") + XCTAssertFalse(message.canBeRepliedTo) + XCTAssertFalse(message.canBeForwarded) } } diff --git a/Tests/MessageReactionTests.swift b/Tests/MessageReactionTests.swift index 4487de2..acd05f4 100644 --- a/Tests/MessageReactionTests.swift +++ b/Tests/MessageReactionTests.swift @@ -27,22 +27,4 @@ final class MessageReactionTests: XCTestCase { MessageReactionSummary(emoji: "❤️", count: 1, includesOwnReaction: true) ) } - - func testOlderArchivesDecodeWithoutReactionField() throws { - let message = ChatMessage( - conversationID: "peer@example.org", - senderJID: "peer@example.org", - body: "hello", - direction: .incoming, - delivery: .delivered, - security: .plaintext - ) - let encoded = try JSONEncoder().encode(message) - var object = try XCTUnwrap( - JSONSerialization.jsonObject(with: encoded) as? [String: Any] - ) - object.removeValue(forKey: "reactions") - let legacyData = try JSONSerialization.data(withJSONObject: object) - XCTAssertEqual(try JSONDecoder().decode(ChatMessage.self, from: legacyData).reactions, []) - } } diff --git a/Tests/MessageReplySwipeTests.swift b/Tests/MessageReplySwipeTests.swift index 516aa0a..57b0e9a 100644 --- a/Tests/MessageReplySwipeTests.swift +++ b/Tests/MessageReplySwipeTests.swift @@ -3,53 +3,172 @@ 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) - )) + func testRightToLeftSwipeReplies() { XCTAssertTrue(MessageReplySwipePolicy.shouldReply( + locked: false, translation: CGSize(width: -70, height: 8), predictedEndTranslation: CGSize(width: -72, height: 9) )) + XCTAssertLessThan( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -70, height: 8)), + 0 + ) + } + + func testLeftToRightSwipeReplies() { + XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10))) + XCTAssertGreaterThan( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 70, height: 8)), + 0 + ) + XCTAssertGreaterThan( + MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 8)), + 0 + ) + XCTAssertTrue(MessageReplySwipePolicy.shouldReply( + locked: false, + 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) + locked: false, + 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) + locked: false, + translation: CGSize(width: -54, height: 44), + predictedEndTranslation: CGSize(width: -76, height: 58) )) XCTAssertEqual( - MessageReplySwipePolicy.offset(for: CGSize(width: 54, height: 44)), + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -54, height: 44)), + 0 + ) + } + + func testDiagonalScrollAtOldThresholdDoesNotReply() { + // width/height ≈ 1.5 passed the old 1.4 dominance and moved bubbles + // during scrolling; the stricter policy must stay silent. + XCTAssertFalse(MessageReplySwipePolicy.shouldReply( + locked: false, + translation: CGSize(width: -60, height: 40), + predictedEndTranslation: CGSize(width: -60, height: 40) + )) + XCTAssertEqual( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -60, height: 40)), 0 ) } func testShortSwipeDoesNotReply() { XCTAssertFalse(MessageReplySwipePolicy.shouldReply( - translation: CGSize(width: 36, height: 4), - predictedEndTranslation: CGSize(width: 48, height: 5) + locked: false, + translation: CGSize(width: -36, height: 4), + predictedEndTranslation: CGSize(width: -48, height: 5) )) } func testFastHorizontalFlickUsesPredictedEnd() { XCTAssertTrue(MessageReplySwipePolicy.shouldReply( + locked: false, translation: CGSize(width: -35, height: 3), predictedEndTranslation: CGSize(width: -90, height: 7) )) } + func testIndicatorStaysHiddenUntilActivationDistance() { + XCTAssertEqual( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -20, height: 0)), + 0 + ) + XCTAssertLessThan( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -24, height: 0)), + 0 + ) + } + func testVisualOffsetIsCapped() { XCTAssertEqual( - MessageReplySwipePolicy.offset(for: CGSize(width: 500, height: 0)), + MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: -500, height: 0)), + -MessageReplySwipePolicy.maximumOffset + ) + } + + func testCanLockRequiresClearHorizontalStart() { + XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: -24, height: 10))) + XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10))) + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -30, height: 20))) + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -8, height: 5))) + XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 40, height: 4))) + } + + func testCanLockIsUnavailableOnceTheGestureTurnsVertical() { + // A scroll that arcs left after travelling down must never lock the + // swipe: the vertical travel already exceeds the lock budget. + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -30, height: 40))) + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -80, height: 60))) + } + + func testLockedSwipeToleratesVerticalDrift() { + // Once locked the finger may drift: a 1.17 ratio would be rejected + // by the 1.6 lock dominance but must still count as a reply swipe. + XCTAssertTrue(MessageReplySwipePolicy.shouldReply( + locked: true, + translation: CGSize(width: -70, height: 60), + predictedEndTranslation: CGSize(width: -70, height: 60) + )) + XCTAssertLessThan( + MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: -70, height: 60)), + 0 + ) + } + + func testRightwardDiagonalScrollDoesNotReply() { + XCTAssertFalse(MessageReplySwipePolicy.shouldReply( + locked: false, + translation: CGSize(width: 54, height: 44), + predictedEndTranslation: CGSize(width: 76, height: 58) + )) + XCTAssertEqual( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 54, height: 44)), + 0 + ) + } + + func testRightwardVisualOffsetIsCapped() { + XCTAssertEqual( + MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 500, height: 0)), MessageReplySwipePolicy.maximumOffset ) } + + func testRightwardIndicatorStaysHiddenUntilActivationDistance() { + XCTAssertEqual( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 20, height: 0)), + 0 + ) + XCTAssertGreaterThan( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 24, height: 0)), + 0 + ) + } + + func testRightwardLockedSwipeToleratesVerticalDrift() { + XCTAssertTrue(MessageReplySwipePolicy.shouldReply( + locked: true, + translation: CGSize(width: 70, height: 60), + predictedEndTranslation: CGSize(width: 70, height: 60) + )) + XCTAssertGreaterThan( + MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 60)), + 0 + ) + } } + diff --git a/Tests/OlderHistoryScrollPolicyTests.swift b/Tests/OlderHistoryScrollPolicyTests.swift new file mode 100644 index 0000000..3bd4d64 --- /dev/null +++ b/Tests/OlderHistoryScrollPolicyTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import Luma + +final class OlderHistoryScrollPolicyTests: XCTestCase { + func testIncompletePageWithFirstCursorContinues() { + let page = OlderHistoryScrollPolicy.page( + complete: false, + pageFirstID: " mam-17 " + ) + + XCTAssertTrue(page.hasMore) + XCTAssertEqual(page.nextBefore, "mam-17") + } + + func testCompletePageStopsEvenWithCursor() { + let page = OlderHistoryScrollPolicy.page( + complete: true, + pageFirstID: "mam-17" + ) + + XCTAssertFalse(page.hasMore) + XCTAssertEqual(page.nextBefore, "mam-17") + } + + func testEmptyPageNeverReportsMore() { + // A server that keeps answering without RSM must not leave + // the UI re-requesting the same page in a spinner loop. + let page = OlderHistoryScrollPolicy.page(complete: false, pageFirstID: nil) + + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextBefore) + } + + func testWhitespaceOnlyCursorIsDiscarded() { + let page = OlderHistoryScrollPolicy.page(complete: false, pageFirstID: " ") + + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextBefore) + } + + func testPageFirstIDMustNotBecomeAnEmptyCursor() { + let page = OlderHistoryScrollPolicy.page(complete: false, pageFirstID: "") + + XCTAssertFalse(page.hasMore) + XCTAssertNil(page.nextBefore) + } +} diff --git a/Tests/ReadStateSyncTests.swift b/Tests/ReadStateSyncTests.swift new file mode 100644 index 0000000..830bebc --- /dev/null +++ b/Tests/ReadStateSyncTests.swift @@ -0,0 +1,73 @@ +import Martin +import XCTest +@testable import Luma + +final class ReadStateSyncTests: XCTestCase { + private func makePayloadMessage(id: String = "marker-42") -> Message { + let message = Message() + message.id = id + let payload = Element(name: "read-marker", xmlns: ReadStateSync.namespace) + payload.addChild(Element(name: "conversation", cdata: "Bob@Example.org")) + payload.addChild(Element(name: "id", cdata: "msg-uuid")) + payload.addChild(Element(name: "stanza", cdata: "stanza-7")) + payload.addChild(Element(name: "when", cdata: "2026-09-14T12:00:00Z")) + message.addChild(payload) + return message + } + + func testEnvelopeParsesPayload() throws { + let envelope = try XCTUnwrap(ReadStateSync.envelope(from: makePayloadMessage())) + XCTAssertEqual(envelope.id, "marker-42") + XCTAssertEqual(envelope.conversationJID, "bob@example.org") + XCTAssertEqual(envelope.messageID, "msg-uuid") + XCTAssertEqual(envelope.stanzaID, "stanza-7") + XCTAssertEqual( + envelope.timestamp, + ISO8601DateFormatter().date(from: "2026-09-14T12:00:00Z") + ) + } + + func testEnvelopePrefersOriginID() throws { + let message = makePayloadMessage() + let origin = Element(name: "origin-id", xmlns: "urn:xmpp:sid:0") + origin.setAttribute("id", value: "marker-uuid") + message.addChild(origin) + XCTAssertEqual(ReadStateSync.envelope(from: message)?.id, "marker-uuid") + } + + func testEnvelopeIgnoresOrdinaryMessages() { + let message = Message() + message.body = "hello" + XCTAssertNil(ReadStateSync.envelope(from: message)) + } + + func testEnvelopeAllowsMissingStanzaID() throws { + let message = Message() + message.id = "marker-1" + let payload = Element(name: "read-marker", xmlns: ReadStateSync.namespace) + payload.addChild(Element(name: "conversation", cdata: "bob@example.org")) + payload.addChild(Element(name: "id", cdata: "msg-uuid")) + message.addChild(payload) + let envelope = try XCTUnwrap(ReadStateSync.envelope(from: message)) + XCTAssertNil(envelope.stanzaID) + } + + func testPayloadRoundTripPreservesReadDetails() throws { + let marker = ReadStateSync.Envelope( + id: "marker-1", + conversationJID: "Room@Conference.example.org", + messageID: "msg-uuid", + stanzaID: "stanza-7", + timestamp: Date(timeIntervalSince1970: 1_700_000_000) + ) + let message = Message() + message.id = marker.id + message.addChild(ReadStateSync.payloadElement(marker: marker)) + + let envelope = try XCTUnwrap(ReadStateSync.envelope(from: message)) + XCTAssertEqual(envelope.id, "marker-1") + XCTAssertEqual(envelope.conversationJID, "room@conference.example.org") + XCTAssertEqual(envelope.messageID, "msg-uuid") + XCTAssertEqual(envelope.stanzaID, "stanza-7") + } +} diff --git a/Tests/SASLMechanismPreferenceTests.swift b/Tests/SASLMechanismPreferenceTests.swift new file mode 100644 index 0000000..c42b553 --- /dev/null +++ b/Tests/SASLMechanismPreferenceTests.swift @@ -0,0 +1,112 @@ +import XCTest +@testable import Luma + +final class SASLMechanismPreferenceTests: XCTestCase { + func testPrefersScramSha512WhenAdvertised() { + XCTAssertEqual( + SASLMechanismPreference.selected(among: [ + "PLAIN", "SCRAM-SHA-1", "SCRAM-SHA-512", "SCRAM-SHA-256", + ]), + "SCRAM-SHA-512" + ) + } + + func testFallsBackToSha256WithoutSha512() { + XCTAssertEqual( + SASLMechanismPreference.selected(among: ["SCRAM-SHA-256", "PLAIN"]), + "SCRAM-SHA-256" + ) + } + + func testFallsBackToSha1AndPlain() { + XCTAssertEqual( + SASLMechanismPreference.selected(among: ["PLAIN", "SCRAM-SHA-1"]), + "SCRAM-SHA-1" + ) + XCTAssertEqual( + SASLMechanismPreference.selected(among: ["PLAIN"]), + "PLAIN" + ) + } + + func testReturnsNilWhenNothingKnownIsOffered() { + XCTAssertNil( + SASLMechanismPreference.selected(among: ["X-OAUTH2", "ANONYMOUS"]) + ) + XCTAssertNil(SASLMechanismPreference.selected(among: [])) + } + + func testStrongestAvailableRanking() { + XCTAssertTrue( + SASLMechanismPreference.isStrongestAvailable( + "SCRAM-SHA-512", + among: ["SCRAM-SHA-512", "SCRAM-SHA-256", "PLAIN"] + ) + ) + XCTAssertFalse( + SASLMechanismPreference.isStrongestAvailable( + "SCRAM-SHA-1", + among: ["SCRAM-SHA-512", "SCRAM-SHA-1"] + ) + ) + XCTAssertFalse( + SASLMechanismPreference.isStrongestAvailable( + "X-OAUTH2", + among: ["X-OAUTH2"] + ) + ) + } + + func testPLAINIsNeverChosenWhileSCRAMIsOffered() { + let offered = ["SCRAM-SHA-1", "PLAIN", "SCRAM-SHA-512"] + XCTAssertEqual(SASLMechanismPreference.selected(among: offered), "SCRAM-SHA-512") + XCTAssertNotEqual(SASLMechanismPreference.selected(among: offered), "PLAIN") + } + + func testPlusVariantsArePreferredOverPlainSCRAM() { + let offered = ["PLAIN", "SCRAM-SHA-1", "SCRAM-SHA-256-PLUS", "SCRAM-SHA-512"] + XCTAssertEqual( + SASLMechanismPreference.selected(among: offered), + "SCRAM-SHA-256-PLUS" + ) + XCTAssertEqual( + SASLMechanismPreference.selected(among: [ + "SCRAM-SHA-512", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-256-PLUS", + ]), + "SCRAM-SHA-256-PLUS" + ) + XCTAssertEqual( + SASLMechanismPreference.selected(among: [ + "SCRAM-SHA-512-PLUS", "SCRAM-SHA-256-PLUS", "SCRAM-SHA-512", + ]), + "SCRAM-SHA-512-PLUS" + ) + } + + func testWeakMechanismsAreFlagged() { + XCTAssertTrue(SASLMechanismPreference.weakMechanisms.contains("PLAIN")) + XCTAssertTrue(SASLMechanismPreference.weakMechanisms.contains("OAUTHBEARER")) + XCTAssertFalse(SASLMechanismPreference.weakMechanisms.contains("SCRAM-SHA-1")) + XCTAssertFalse(SASLMechanismPreference.weakMechanisms.contains("SCRAM-SHA-512-PLUS")) + XCTAssertFalse(SASLMechanismPreference.weakMechanisms.contains("EXTERNAL")) + } + + func testPlusVariantsSkippedWithoutChannelBinding() { + let offered = ["SCRAM-SHA-512-PLUS", "SCRAM-SHA-512", "PLAIN"] + XCTAssertEqual( + SASLMechanismPreference.selected( + among: offered, + allowsChannelBinding: false + ), + "SCRAM-SHA-512" + ) + // Without any plain SCRAM alternative, PLAIN is the only choice. + XCTAssertEqual( + SASLMechanismPreference.selected( + among: ["SCRAM-SHA-1-PLUS", "PLAIN"], + allowsChannelBinding: false + ), + "PLAIN" + ) + } +} diff --git a/Tests/SASLprepTests.swift b/Tests/SASLprepTests.swift new file mode 100644 index 0000000..97455ce --- /dev/null +++ b/Tests/SASLprepTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import Luma + +final class SASLprepTests: XCTestCase { + func testASCIIPasswordIsUnchanged() throws { + XCTAssertEqual(try SASLprep.prepare("password123"), "password123") + } + + func testCyrillicPasswordIsUnchanged() throws { + XCTAssertEqual(try SASLprep.prepare("пароль123"), "пароль123") + } + + func testNonASCIISpacesMapToSpace() throws { + XCTAssertEqual(try SASLprep.prepare("a\u{00A0}b"), "a b") + XCTAssertEqual(try SASLprep.prepare("a\u{202F}b"), "a b") + } + + func testMappedToNothingCharactersAreRemoved() throws { + XCTAssertEqual(try SASLprep.prepare("a\u{00AD}b"), "ab") + XCTAssertEqual(try SASLprep.prepare("a\u{FE00}b"), "ab") + } + + func testZeroWidthSpaceMapsToSpacePerRFC4013() throws { + // RFC 4013 C.1.2 maps U+200B to SPACE, overriding RFC 3454 B.1. + XCTAssertEqual(try SASLprep.prepare("a\u{200B}b"), "a b") + } + + func testFullwidthAndCompatibilityFormsAreNormalized() throws { + XCTAssertEqual(try SASLprep.prepare("ABC"), "ABC") + XCTAssertEqual(try SASLprep.prepare("①"), "1") + } + + func testDecomposedAccentIsComposed() throws { + XCTAssertEqual(try SASLprep.prepare("e\u{0301}"), "é") + } + + func testMixedPassword() throws { + XCTAssertEqual(try SASLprep.prepare("Пароль\u{00A0}①"), "Пароль 1") + } + + func testProhibitedCharactersThrow() { + XCTAssertThrowsError(try SASLprep.prepare("a\u{0007}b")) + XCTAssertThrowsError(try SASLprep.prepare("a\u{007F}b")) + XCTAssertThrowsError(try SASLprep.prepare("a\u{FFFE}b")) + XCTAssertThrowsError(try SASLprep.prepare("a\u{202E}b")) + } +} + diff --git a/Tests/SCRAMDowngradeProtectionTests.swift b/Tests/SCRAMDowngradeProtectionTests.swift new file mode 100644 index 0000000..bb63536 --- /dev/null +++ b/Tests/SCRAMDowngradeProtectionTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import Luma + +final class SCRAMDowngradeProtectionTests: XCTestCase { + /// Reference vector from XEP-0474 §6.3: the server's `h` attribute is + /// base64(SHA-1('SCRAM-SHA-1\u{1E}SCRAM-SHA-1-PLUS\u{1F}tls-exporter\u{1E}tls-server-end-point')). + func testHashMatchesXEP0474Example() { + let hash = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"], + channelBindingTypes: ["tls-exporter", "tls-server-end-point"], + using: .sha1 + ) + XCTAssertEqual(hash.base64EncodedString(), "G6k/rBLDqgOhRRaCuuatSDFkJ08=") + } + + func testHashSortsListsPerIOctetCollation() { + // XEP-0474 §5.1 requires i;octet-sorted lists, so advertisement order + // must not affect the hash. + let advertisedOrder = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1", "SCRAM-SHA-1-PLUS"], + channelBindingTypes: ["tls-exporter", "tls-server-end-point"], + using: .sha1 + ) + let reordered = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1-PLUS", "SCRAM-SHA-1"], + channelBindingTypes: ["tls-server-end-point", "tls-exporter"], + using: .sha1 + ) + XCTAssertEqual(advertisedOrder, reordered) + } + + func testHashOmitsBindingSectionWhenNoTypesAdvertised() { + let withBindings = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1"], + channelBindingTypes: ["tls-exporter"], + using: .sha1 + ) + let withoutBindings = SCRAMDowngradeProtection.hash( + mechanisms: ["SCRAM-SHA-1"], + channelBindingTypes: [], + using: .sha1 + ) + XCTAssertNotEqual(withBindings, withoutBindings) + // Without bindings the input is exactly the sorted mechanisms with no + // trailing 0x1F delimiter. + let expected = SCRAMHash.sha1.hash(data: Data("SCRAM-SHA-1".utf8)) + XCTAssertEqual(withoutBindings, expected) + } + + func testServerFirstParsesDowngradeProtectionHash() throws { + let message = "r=12C4CD5C-E38E-4A98-8F6D-15C38F51CCC6a09117a6-ac50-4f2f-93f1-93799c2bddf6,s=QSXCR+Q6sek8bf92,i=4096,h=G6k/rBLDqgOhRRaCuuatSDFkJ08=" + let parsed = try SCRAMSHA512.parseServerFirst( + message, + expectedNoncePrefix: "12C4CD5C-E38E-4A98-8F6D-15C38F51CCC6" + ) + XCTAssertEqual(parsed.downgradeProtectionHash, "G6k/rBLDqgOhRRaCuuatSDFkJ08=") + } + + func testServerFirstWithoutHashParsesCleanly() throws { + let message = "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + let parsed = try SCRAMSHA512.parseServerFirst( + message, + expectedNoncePrefix: "fyko+d2lbbFgONRv9qkxdawL" + ) + XCTAssertNil(parsed.downgradeProtectionHash) + } + + func testClientFinalIncludesXAttributeWhenHashPresent() throws { + let exchange = SCRAMPlusExchange( + hash: .sha1, + username: "user", + password: "pencil", + channelBindingType: "tls-server-end-point", + channelBindingData: Data(0..<32), + clientNonce: "fyko+d2lbbFgONRv9qkxdawL" + ) + let serverFirst = "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096,h=G6k/rBLDqgOhRRaCuuatSDFkJ08=" + let final = try exchange.clientFinalMessage( + serverFirst: serverFirst, + downgradeProtectionHashBase64: "G6k/rBLDqgOhRRaCuuatSDFkJ08=" + ) + XCTAssertTrue(final.contains(",x=G6k/rBLDqgOhRRaCuuatSDFkJ08=,")) + // The same x-attribute must be part of the expected signature's + // auth message (server reconstructs client-final-without-proof). + let expected = try exchange.expectedServerSignature( + serverFirst: serverFirst, + downgradeProtectionHashBase64: "G6k/rBLDqgOhRRaCuuatSDFkJ08=" + ) + XCTAssertFalse(expected.isEmpty) + } +} diff --git a/Tests/SCRAMPlusTests.swift b/Tests/SCRAMPlusTests.swift new file mode 100644 index 0000000..1691a86 --- /dev/null +++ b/Tests/SCRAMPlusTests.swift @@ -0,0 +1,106 @@ +import CryptoKit +import XCTest +@testable import Luma + +final class SCRAMPlusTests: XCTestCase { + /// Reference vectors computed with an independent Python + /// implementation of RFC 5802 SCRAM-SHA-1 with channel binding + /// (tls-server-end-point, deterministic 32-byte binding data). + private func referenceExchange() -> SCRAMPlusExchange { + SCRAMPlusExchange( + hash: .sha1, + username: "user", + password: "pencil", + channelBindingType: "tls-server-end-point", + channelBindingData: Data(0..<32), + clientNonce: "fyko+d2lbbFgONRv9qkxdawL" + ) + } + + private static let referenceServerFirst = + "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096" + + func testClientFirstMessageMatchesReference() { + let exchange = referenceExchange() + let first = exchange.clientFirstMessage.data(using: .utf8)!.base64EncodedString() + XCTAssertEqual( + first, + "cD10bHMtc2VydmVyLWVuZC1wb2ludCwsbj11c2VyLHI9ZnlrbytkMmxiYkZnT05Sdjlxa3hkYXdM" + ) + } + + func testClientFinalMessageMatchesReference() throws { + let exchange = referenceExchange() + let final = try exchange.clientFinalMessage(serverFirst: Self.referenceServerFirst) + let encoded = final.data(using: .utf8)!.base64EncodedString() + XCTAssertEqual( + encoded, + "Yz1jRDEwYkhNdGMyVnlkbVZ5TFdWdVpDMXdiMmx1ZEN3c0FBRUNBd1FGQmdjSUNRb0xEQTBPRHhBUkVoTVVGUllYR0JrYUd4d2RIaDg9LHI9ZnlrbytkMmxiYkZnT05Sdjlxa3hkYXdMM3JmY05IWUpZMVpWdldWczdqLHA9RkVLbDNvQU13UFErc0JsYnR6SU1oaGdKSFJjPQ==" + ) + } + + func testExpectedServerSignatureMatchesReference() throws { + let exchange = referenceExchange() + let signature = try exchange.expectedServerSignature(serverFirst: Self.referenceServerFirst) + XCTAssertEqual(signature.base64EncodedString(), "CCSN72nmVmqopGNXz27xIKr6CxA=") + } + + func testVerifierValueExtractsOnlyTheBase64Payload() { + XCTAssertEqual( + SCRAMPlusExchange.verifierValue(in: "v=DQNSo72nmVmqopGNXz27xIKr6CxA="), + "DQNSo72nmVmqopGNXz27xIKr6CxA=" + ) + XCTAssertEqual( + SCRAMPlusExchange.verifierValue(in: "v=abc123"), + "abc123" + ) + XCTAssertEqual( + SCRAMPlusExchange.verifierValue(in: "v=abc123,ext=value"), + "abc123" + ) + XCTAssertNil(SCRAMPlusExchange.verifierValue(in: "e=invalid-proof")) + XCTAssertNil(SCRAMPlusExchange.verifierValue(in: "")) + } + + func testChannelBindingStoreDerivesEndPointDigest() { + let store = LumaChannelBindingStore() + let leaf = Data("certificate".utf8) + store.setTLSState( + LumaTLSState( + version: "TLSv1.3", + cipher: "TLS_AES_256_GCM_SHA384", + leafCertificateDER: leaf, + exporter: Data(0..<32) + ) + ) + store.setAdvertisedChannelBindingTypes(["tls-exporter", "tls-server-end-point"]) + XCTAssertEqual(store.preferredChannelBindingType, "tls-exporter") + XCTAssertEqual( + store.channelBindingData(for: "tls-server-end-point"), + Data(SHA256.hash(data: leaf)) + ) + XCTAssertEqual( + store.channelBindingData(for: "tls-exporter"), + Data(0..<32), + ) + } + + func testChannelBindingStoreFallsBackToEndPoint() { + let store = LumaChannelBindingStore() + store.setTLSState( + LumaTLSState( + version: "TLSv1.3", + cipher: "TLS_AES_256_GCM_SHA384", + leafCertificateDER: Data("leaf".utf8), + exporter: nil, + ) + ) + // Only the certificate digest is available: pick tls-server-end-point. + store.setAdvertisedChannelBindingTypes(["tls-server-end-point"]) + XCTAssertEqual(store.preferredChannelBindingType, "tls-server-end-point") + // Nothing usable when the server does not advertise any known type. + store.setAdvertisedChannelBindingTypes([]) + XCTAssertNil(store.preferredChannelBindingType) + XCTAssertFalse(store.canUseChannelBinding) + } +} diff --git a/Tests/SCRAMSHA512Tests.swift b/Tests/SCRAMSHA512Tests.swift new file mode 100644 index 0000000..6bd61bc --- /dev/null +++ b/Tests/SCRAMSHA512Tests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import Luma + +final class SCRAMSHA512Tests: XCTestCase { + /// Reference values computed with Python hashlib/hmac for the RFC 5802 + /// vector inputs (password \"pencil\", salt QSXCR+Q6sek8bf92, 4096 rounds). + private let referenceAuthMessage = + "n=user,r=fyko+d2lbbFgONRv9qkxdawL" + + ",r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096" + + ",c=biws,r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j" + + func testSaltedPasswordMatchesReference() throws { + let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92")) + let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096) + let hex = salted.map { String(format: "%02x", $0) }.joined() + XCTAssertEqual( + hex, + "97382788b15cbe09512d2d20b7e0b8832f8dbab4b7388395440535cd9395e0ffaa1625453b6fde746412bbf903d4bc1d5f448d57f2ac3dd1d2c04979a914ee65" + ) + } + + func testClientProofMatchesReference() throws { + let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92")) + let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096) + let proof = SCRAMSHA512.clientProof( + saltedPassword: salted, + authMessage: referenceAuthMessage + ) + XCTAssertEqual( + Data(proof).base64EncodedString(), + "VdS8LkrURiej1tG6iX+fqCXQfUnBb//d9llXYaH+ylUbDwBUz9geyR9fC4TewskRUM2tlYSalhAT4Aay1Q5dTA==" + ) + } + + func testServerSignatureMatchesReference() throws { + let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92")) + let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096) + let signature = SCRAMSHA512.serverSignature( + saltedPassword: salted, + authMessage: referenceAuthMessage + ) + XCTAssertEqual( + Data(signature).base64EncodedString(), + "14PAAuavk9hxBEkgB0brDxUhvWu+N16meYk+qxVNFqchR8QPohM09Y4Z6WaTCuX4C6nqMB9KIJTDm6RpSM990g==" + ) + } + + func testVerifyServerSignatureAcceptsAndRejects() throws { + let salt = try XCTUnwrap(Data(base64Encoded: "QSXCR+Q6sek8bf92")) + let salted = SCRAMSHA512.saltedPassword(password: "pencil", salt: salt, iterations: 4096) + let good = "v=14PAAuavk9hxBEkgB0brDxUhvWu+N16meYk+qxVNFqchR8QPohM09Y4Z6WaTCuX4C6nqMB9KIJTDm6RpSM990g==" + XCTAssertTrue(SCRAMSHA512.verifyServerSignature( + saltedPassword: salted, + authMessage: referenceAuthMessage, + finalMessage: good + )) + XCTAssertFalse(SCRAMSHA512.verifyServerSignature( + saltedPassword: salted, + authMessage: referenceAuthMessage, + finalMessage: "v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + )) + XCTAssertFalse(SCRAMSHA512.verifyServerSignature( + saltedPassword: salted, + authMessage: referenceAuthMessage, + finalMessage: "e=server-error", + )) + } + + func testParseServerFirst() throws { + let parsed = try SCRAMSHA512.parseServerFirst( + "r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096", + expectedNoncePrefix: "fyko+d2lbbFgONRv9qkxdawL" + ) + XCTAssertEqual(parsed.nonce, "fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j") + XCTAssertEqual(parsed.salt, Data(base64Encoded: "QSXCR+Q6sek8bf92")) + XCTAssertEqual(parsed.iterations, 4096) + } + + func testParseServerFirstRejectsWrongNonce() { + XCTAssertThrowsError(try SCRAMSHA512.parseServerFirst( + "r=OTHER,s=QSXCR+Q6sek8bf92,i=4096", + expectedNoncePrefix: "fyko+d2lbbFgONRv9qkxdawL" + )) + } +} + diff --git a/Tests/SaslFailureMessageTests.swift b/Tests/SaslFailureMessageTests.swift new file mode 100644 index 0000000..e2bfb47 --- /dev/null +++ b/Tests/SaslFailureMessageTests.swift @@ -0,0 +1,58 @@ +import Martin +import XCTest +@testable import Luma + +final class SaslFailureMessageTests: XCTestCase { + func testNotAuthorizedProducesActionableMessage() { + let message = SaslFailureMessage.describe( + error: SaslError.not_authorized, condition: nil, serverText: nil) + XCTAssertEqual( + message, + "Сервер отклонил имя пользователя или пароль. Проверьте JID и пароль.") + } + + func testServerTextWinsOverCondition() { + let message = SaslFailureMessage.describe( + error: SaslError.not_authorized, + condition: "not-authorized", + serverText: "Bad password") + XCTAssertEqual(message, "Сервер отклонил вход: Bad password") + } + + func testAccountDisabledIsExplained() { + let message = SaslFailureMessage.describe( + error: SaslError.not_authorized, + condition: "account-disabled", + serverText: nil) + XCTAssertEqual( + message, + "Аккаунт отключён на сервере. Обратитесь к администратору сервера.") + } + + func testCredentialsExpiredIsExplained() { + let message = SaslFailureMessage.describe( + error: SaslError.not_authorized, + condition: "credentials-expired", + serverText: nil) + XCTAssertEqual(message, "Срок действия пароля истёк. Обновите пароль на сервере.") + } + + func testProsodyProofMismatchIsExplained() { + let message = SaslFailureMessage.describe( + error: SaslError.not_authorized, + condition: "not-authorized", + serverText: "The response provided by the client doesn't match the one we calculated") + XCTAssertEqual( + message, + "Пароль не совпадает с сохранённым на сервере. Проверьте пароль (например, через значок глаза) и не используйте автозаполнение другого аккаунта.") + } + + func testTemporaryAuthFailureIsExplained() { + let message = SaslFailureMessage.describe( + error: SaslError.temporary_auth_failure, condition: nil, serverText: nil) + XCTAssertEqual( + message, + "Сервер временно не может проверить учётные данные. Попробуйте через минуту.") + } +} + diff --git a/Tests/VideoNoteRotationPolicyTests.swift b/Tests/VideoNoteRotationPolicyTests.swift new file mode 100644 index 0000000..f924057 --- /dev/null +++ b/Tests/VideoNoteRotationPolicyTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import Luma + +final class VideoNoteRotationPolicyTests: XCTestCase { + func testPortraitNeedsClockwiseQuarterTurn() { + // The sensor is landscape-native; an upright portrait circle must be + // rotated 90 degrees clockwise in the QuickTime track matrix. + XCTAssertEqual( + VideoNoteRotationPolicy.angle(for: .portrait), + 90 + ) + } + + func testPortraitUpsideDownNeedsThreeQuarterTurn() { + XCTAssertEqual( + VideoNoteRotationPolicy.angle(for: .portraitUpsideDown), + 270 + ) + } + + func testLandscapeOrientationsMapToNativeAngles() { + XCTAssertEqual( + VideoNoteRotationPolicy.angle(for: .landscapeLeft), + 180 + ) + XCTAssertEqual( + VideoNoteRotationPolicy.angle(for: .landscapeRight), + 0 + ) + } + + func testAllAnglesAreSupportedCaptureAngles() { + let orientations: [VideoNoteRotationPolicy.InterfaceOrientation] = [ + .portrait, .portraitUpsideDown, .landscapeLeft, .landscapeRight, + ] + for orientation in orientations { + let angle = VideoNoteRotationPolicy.angle(for: orientation) + XCTAssertTrue( + [CGFloat(0), 90, 180, 270].contains(angle), + "unexpected angle \(angle) for \(orientation)" + ) + } + } +} diff --git a/ThirdParty/MartinOMEMO/.github/FUNDING.yml b/ThirdParty/MartinOMEMO/.github/FUNDING.yml new file mode 100644 index 0000000..69bde74 --- /dev/null +++ b/ThirdParty/MartinOMEMO/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [tigase] diff --git a/ThirdParty/MartinOMEMO/.gitignore b/ThirdParty/MartinOMEMO/.gitignore new file mode 100644 index 0000000..6cb6e6f --- /dev/null +++ b/ThirdParty/MartinOMEMO/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/.build +/.swiftpm +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/ThirdParty/MartinOMEMO/LICENSE b/ThirdParty/MartinOMEMO/LICENSE new file mode 100644 index 0000000..aa08301 --- /dev/null +++ b/ThirdParty/MartinOMEMO/LICENSE @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE +Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + +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. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + +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 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. Use with the GNU Affero General Public License. + +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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) +This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. +This is free software, and you are welcome to redistribute it +under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + +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 GPL, see +. + +The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/ThirdParty/MartinOMEMO/Package.swift b/ThirdParty/MartinOMEMO/Package.swift new file mode 100644 index 0000000..b81beeb --- /dev/null +++ b/ThirdParty/MartinOMEMO/Package.swift @@ -0,0 +1,36 @@ +// swift-tools-version:5.6 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MartinOMEMO", + platforms: [.iOS(.v13), .macOS(.v10_15)], + products: [ + // Products define the executables and libraries produced by a package, and make them visible to other packages. + .library( + name: "MartinOMEMO", + targets: ["MartinOMEMO"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/tigase/Martin", from: "3.2.4"), + .package(url: "https://github.com/tigase/libsignal", from: "1.0.0") + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages which this package depends on. + .target( + name: "MartinOMEMO", + dependencies: ["Martin", "libsignal"], + swiftSettings: [ + // Martin 3.2 still exposes deprecated event APIs that are + // required for compatibility with older clients. + .unsafeFlags(["-suppress-warnings"]) + ]), + .testTarget( + name: "MartinOMEMOTests", + dependencies: ["MartinOMEMO"]), + ] +) diff --git a/ThirdParty/MartinOMEMO/README.md b/ThirdParty/MartinOMEMO/README.md new file mode 100644 index 0000000..247de63 --- /dev/null +++ b/ThirdParty/MartinOMEMO/README.md @@ -0,0 +1,7 @@ +

+ Martin - OMEMO support +

+ +# What it is + +Martin is an [XMPP](https://xmpp.org) client library written in a [Swift](https://swift.org) programming language. This project is an extension for that library providing support for OMEMO extension providing multi-end message and object encryption. diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/Identity.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/Identity.swift new file mode 100644 index 0000000..6314ede --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/Identity.swift @@ -0,0 +1,40 @@ +// +// Identity.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation + +public class Identity { + + public let address: SignalAddress; + public let status: IdentityStatus; + public let fingerprint: String; + public let key: Data; + public let own: Bool; + + public init(address: SignalAddress, status: IdentityStatus, fingerprint: String, key: Data, own: Bool) { + self.address = address; + self.status = status; + self.fingerprint = fingerprint; + self.key = key; + self.own = own; + } + +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/IdentityStatus.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/IdentityStatus.swift new file mode 100644 index 0000000..7487684 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/IdentityStatus.swift @@ -0,0 +1,108 @@ +// +// IdentityStatus.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation + +public enum IdentityStatus: Int { + case compromisedActive = -2 + case compromisedInactive = -1 + case undecidedActive = 0 + case undecidedInactive = 1 + case trustedActive = 2 + case trustedInactive = 3 + case verifiedActive = 4 + case verifiedInactive = 5 + + public var trust: Trust { + switch self { + case .compromisedActive, .compromisedInactive: + return .compromised; + case .undecidedActive, .undecidedInactive: + return .undecided; + case .trustedActive, .trustedInactive: + return .trusted; + case .verifiedActive, .verifiedInactive: + return .verified; + } + } + + public var isActive: Bool { + switch self { + case .compromisedActive, .undecidedActive, .trustedActive, .verifiedActive: + return true; + case .compromisedInactive, .undecidedInactive, .trustedInactive, .verifiedInactive: + return false; + } + } + + public func toActive() -> IdentityStatus { + guard !self.isActive else { + return self; + } + + return make(active: true, trust: self.trust); + } + + public func toInactive() -> IdentityStatus { + guard self.isActive else { + return self; + } + + return make(active: false, trust: self.trust); + } + + public func toTrust(_ trust: Trust) -> IdentityStatus { + return make(active: isActive, trust: trust); + } + + public func make(active: Bool, trust: Trust) -> IdentityStatus { + if active { + switch trust { + case .compromised: + return .compromisedActive; + case .undecided: + return .undecidedActive; + case .trusted: + return .trustedActive; + case .verified: + return .verifiedActive; + } + } else { + switch trust { + case .compromised: + return .compromisedInactive; + case .undecided: + return .undecidedInactive; + case .trusted: + return .trustedInactive; + case .verified: + return .verifiedInactive; + } + } + } +} + +public enum Trust { + case compromised + case undecided + case trusted + case verified +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/OMEMOModule.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/OMEMOModule.swift new file mode 100644 index 0000000..1921289 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/OMEMOModule.swift @@ -0,0 +1,1101 @@ +// +// OMEMOModule.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import Martin +import libsignal +import Combine + +extension XmppModuleIdentifier { + public static var omemo: XmppModuleIdentifier { + return OMEMOModule.IDENTIFIER; + } +} + +struct KeyDecryptionResult { + let result: Result; + let address: SignalAddress; + let isPrekey: Bool; + + var isSuccess: Bool { + switch result { + case .success(_): + return true; + case .failure(_): + return false; + } + } +} + +open class OMEMOModule: AbstractPEPModule, XmppModule, Resetable { + + public static let ID = "omemo"; + public static let IDENTIFIER = XmppModuleIdentifier(); + public static let XMLNS = "eu.siacs.conversations.axolotl"; + public static let DEVICES_LIST_NODE = "eu.siacs.conversations.axolotl.devicelist"; + + public let id: String = ID; + + // Default body to set for OMEMO encrypted messages + open var defaultBody: String? = "I sent you an OMEMO encrypted message but your client doesn’t seem to support that."; + + public override var isPepAvailable: Bool { + didSet { + if isPepAvailable { + publishDeviceBundleIfNeeded() { + self.publishDeviceIdIfNeeded(); + if let context = self.context { + context.sessionObject.setProperty(OMEMOModule.XMLNS + ".bundle", value: true); + } + } + } + } + } + + public let criteria = Criteria.empty(); + + public let features: [String] = [OMEMOModule.DEVICES_LIST_NODE + "+notify"]; + + public let engine: AES_GCM_Engine; + public let signalContext: SignalContext; + public let storage: SignalStorage; + fileprivate let devicesQueue: DispatchQueue = DispatchQueue(label: "omemo_devices_dispatch_queue"); + fileprivate var devices: [BareJID: [Int32]] = [:]; + fileprivate var devicesFetchError: [BareJID: [Int32]] = [:]; + private var ownBrokenDevices: [Int32] = []; + + @Published + public private(set) var isReady: Bool = false; + + public let activeDevicesPublisher = PassthroughSubject(); + + public func isAvailable(for jid: BareJID) -> Bool { + return (!(self.devicesQueue.sync(execute: { self.devices[jid] })?.isEmpty ?? true)) || !self.storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true).isEmpty; + } + + public init(aesGCMEngine: AES_GCM_Engine, signalContext: SignalContext, signalStorage: SignalStorage) { + self.signalContext = signalContext; + self.storage = signalStorage; + self.engine = aesGCMEngine; + super.init(); + } + + public func reset(scopes: Set) { + if scopes.contains(.session) { + self.isReady = false; + self.devicesQueue.async { + self.devices.removeAll(); + } + } + } + + public func regenerateKeys(wipe: Bool = false) -> Bool { + let regenerated = self.storage.regenerateKeys(wipe: wipe); + if regenerated && isPepAvailable { + self.publishDeviceBundle(currentBundle: nil) { + self.publishDeviceIdIfNeeded(); + } + } + return regenerated; + } + + public func devices(for jid: BareJID) -> [Int32]? { + guard let devices = self.devicesQueue.sync(execute: { self.devices[jid] }) else { + return nil; + } + guard let failed = self.devicesFetchError[jid] else { + return devices; + } + return devices.filter({ (deviceId) -> Bool in + return !failed.contains(deviceId); + }); + } + + + public func process(stanza: Stanza) throws { + throw ErrorCondition.feature_not_implemented; + } + + public func decode(message: Message, serverMsgId: String? = nil) -> DecryptionResult { + guard let from = message.from?.bareJid else { + return .failure(.invalidArgument); + } + return self.decode(message: message, from: from, serverMsgId: serverMsgId); + } + + + public func decode(message: Message, from: BareJID, serverMsgId: String? = nil) -> DecryptionResult { + guard let context = context else { + return .failure(.unknown); + } + + guard let encryptedEl = message.findChild(name: "encrypted", xmlns: OMEMOModule.XMLNS) else { + return .failure(SignalError.notEncrypted); + } + + guard let headerEl = encryptedEl.findChild(name: "header"), let sid = UInt32(headerEl.getAttribute("sid") ?? "") else { + return .failure(.invalidArgument); + } + + let localDeviceIdStr = String(signalContext.storage.identityKeyStore.localRegistrationId()); + + guard headerEl.findChild(where: { (el) -> Bool in + return el.name == "key" && el.getAttribute("rid") == localDeviceIdStr; + }) != nil else { + guard context.userBareJid != from || sid != signalContext.storage.identityKeyStore.localRegistrationId() else { + return .failure(.duplicateMessage); + } + guard encryptedEl.findChild(name: "payload") != nil else { + return .failure(.duplicateMessage); + } + return .failure(.invalidMessage); + } + + let possibleKeys = headerEl.getChildren(where: { (el) -> Bool in + return el.name == "key" && el.getAttribute("rid") == localDeviceIdStr; + }).map({ (keyEl) -> KeyDecryptionResult in + let prekey = "true" == keyEl.getAttribute("prekey") || keyEl.getAttribute("prekey") == "1"; + let address = SignalAddress(name: from.stringValue, deviceId: Int32(bitPattern: sid)); + guard let keyElValue = keyEl.value, let key = Data(base64Encoded: keyElValue) else { + return .init(result: .failure(.invalidArgument), address: address, isPrekey: prekey); + } + guard let session = SignalSessionCipher(withAddress: address, andContext: self.signalContext) else { + return .init(result: .failure(SignalError.noMemory), address: address, isPrekey: prekey); + } + return .init(result: session.decrypt(key: SignalSessionCipher.Key(key: key,deviceId: Int32(bitPattern: sid), prekey: prekey)), address: address, isPrekey: prekey); + }); + + guard let possibleKey = possibleKeys.first(where: { $0.isSuccess }) else { + if let key = possibleKeys.first { + switch key.result { + case .failure(let error): + switch error { + case .noSession, .invalidMessage: + if serverMsgId != nil { + if !postponeHealing(for: message.type == .groupchat ? message.from?.bareJid : nil, address: key.address) { + self.buildSession(forAddress: key.address, completionHandler: { + self.completeSession(forAddress: key.address); + }); + } + } + default: + break; + } + return .failure(error); + case .success(_): + return .failure(.unknown); + } + } else { + guard encryptedEl.findChild(name: "payload") != nil else { + return .failure(.duplicateMessage); + } + return .failure(.invalidMessage); + } + } + + switch possibleKey.result { + case .failure(let error): + return .failure(error); + case .success(let data): + message.removeChild(encryptedEl); + + let address = possibleKey.address; + let prekey = possibleKey.isPrekey; + var decodedKey = data; + + if prekey { + // pre key was removed so we need to republish the bundle! + if !postponeSession(for: message.type == .groupchat ? message.from?.bareJid : nil, address: address) { + if self.storage.preKeyStore.flushDeletedPreKeys() { + self.publishDeviceBundleIfNeeded(completionHandler: nil); + } + } + } + + var auth: Data? = nil; + if decodedKey.count >= 32 { + auth = decodedKey.subdata(in: 16..)->Void) { + guard let jid = message.to?.bareJid else { + completionHandler(.failure(.noDestination)); + return; + } + + encode(message: message, for: [jid], withStoreHint: withStoreHint, completionHandler: completionHandler); + } + + public func addresses(for jids: [BareJID], completionHandler: @escaping(Result<[SignalAddress],SignalError>)->Void) { + guard let pubsubModule: PubSubModule = context?.module(.pubsub) else { + completionHandler(.failure(.unknown)); + return; + } + let group = DispatchGroup(); + var addresses: [SignalAddress] = []; + for jid in jids { + if let devices = self.devices(for: jid) { + addresses.append(contentsOf: devices.map({ SignalAddress(name: jid.stringValue, deviceId: $0) })); + for address in addresses.filter({ !self.storage.sessionStore.containsSessionRecord(forAddress: $0) }) { + group.enter(); + self.buildSession(forAddress: address, completionHandler: { + group.leave(); + }) + } + } else { + group.enter(); + pubsubModule.retrieveItems(from: jid, for: OMEMOModule.DEVICES_LIST_NODE, limit: .lastItems(1), completionHandler: { result in + switch result { + case .success(let items): + print("got published devices from:", jid, ", ", items.items.first as Any); + if let listEl = items.items.first?.payload, listEl.name == "list" && listEl.xmlns == "eu.siacs.conversations.axolotl" { + let knownActiveDevices: [Int32] = listEl.mapChildren(transform: { $0.getAttribute("id") }).compactMap({ Int32($0) }); + + + let allDevices = self.storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true); + allDevices.filter { (id) -> Bool in + return !knownActiveDevices.contains(id); + }.forEach { (deviceId) in + _ = self.storage.identityKeyStore.setStatus(active: false, forIdentity: SignalAddress(name: jid.stringValue, deviceId: deviceId)); + } + + knownActiveDevices.filter { (id) -> Bool in + return !allDevices.contains(id); + }.forEach { (deviceId) in + // TODO: we should enable this device key if we have its identity! + _ = self.storage.identityKeyStore.setStatus(active: true, forIdentity: SignalAddress(name: jid.stringValue, deviceId: deviceId)); + } + addresses.append(contentsOf: knownActiveDevices.map({ SignalAddress(name: jid.stringValue, deviceId: $0) })); + } + + // should we start fetching sessions here? without waiting for all JIDs to return? should improve performance + for address in addresses.filter({ !self.storage.sessionStore.containsSessionRecord(forAddress: $0) }) { + group.enter(); + self.buildSession(forAddress: address, completionHandler: { + group.leave(); + }) + } + case .failure(_): + break; + } + group.leave(); + }) + } + } + group.notify(queue: DispatchQueue.main, execute: { + // we finished address retrieval.. + completionHandler(.success(addresses)); + }) + } + + public func encode(message: Message, for jids: [BareJID], withStoreHint: Bool = true, completionHandler: @escaping (EncryptionResult)->Void) { + + addresses(for: jids, completionHandler: { result in + switch result { + case .failure(let error): + completionHandler(.failure(error)); + case .success(let addresses): + if addresses.isEmpty { + completionHandler(.failure(.noSession)); + } else { + self.encode(message: message, forAddresses: addresses, withStoreHint: withStoreHint, completionHandler: completionHandler); + } + } + + }) + } + + public func encode(message: Message, forAddresses addresses: [SignalAddress], withStoreHint: Bool = true, completionHandler: @escaping (EncryptionResult)->Void) { + + let result = self._encode(message: message, for: addresses); + + switch result { + case .successMessage(let encodedMessage, _): + encodedMessage.body = self.defaultBody; + if withStoreHint { + encodedMessage.addChild(Element(name: "store", xmlns: "urn:xmpp:hints")); + } + default: + break; + } + + completionHandler(result); + } + + public func decryptFile(url localUrl: URL, fragment: String) -> Result { + guard let data = try? Data(contentsOf: localUrl) else { + return .failure(ErrorCondition.item_not_found); + } + + return decryptFile(data: data, fragment: fragment); + } + + public func decryptFile(data inData: Data, fragment: String) -> Result { + guard fragment.count % 2 == 0 && fragment.count > 64 && inData.count > 32 else { + return .failure(.not_acceptable); + } + + let fragmentData = fragment.map { (c) -> UInt8 in + return UInt8(c.hexDigitValue ?? 0); + }; + + let ivLen = fragmentData.count - (32 * 2); + + var iv = Data(); + var key = Data(); + + for i in 0..<(ivLen/2) { + iv.append(fragmentData[i*2]*16 + fragmentData[i*2+1]); + } + for i in (ivLen/2)..<(fragmentData.count/2) { + key.append(fragmentData[i*2]*16 + fragmentData[i*2+1]); + } + + let tag = inData.subdata(in: inData.count-16.. Result<(Data, String),ErrorCondition> { + guard let data = try? Data(contentsOf: url) else { + return .failure(ErrorCondition.item_not_found); + } + + return encryptFile(data: data); + } + + public func encryptFile(data: Data) -> Result<(Data, String),ErrorCondition> { + var iv = Data(count: 12); + iv.withUnsafeMutableBytes { (bytes) -> Void in + _ = SecRandomCopyBytes(kSecRandomDefault, 12, bytes.baseAddress!); + } + + var key = Data(count: 32); + key.withUnsafeMutableBytes { (bytes) -> Void in + _ = SecRandomCopyBytes(kSecRandomDefault, 32, bytes.baseAddress!); + } + + var encryptedBody = Data(); + var tag = Data(); + + guard engine.encrypt(iv: iv, key: key, message: data, output: &encryptedBody, tag: &tag) else { + return .failure(.not_acceptable); + } + + let combinedKey = iv + key; + + return .success((encryptedBody + tag, combinedKey.map({ String(format: "%02x", $0) }).joined())); + } + + private func _encode(message: Message, for remoteAddresses: [SignalAddress], forSelf: Bool = true) -> EncryptionResult { + guard let context = self.context else { + return .failure(.unknown); + } + + var iv = Data(count: 12); + iv.withUnsafeMutableBytes { (bytes) -> Void in + _ = SecRandomCopyBytes(kSecRandomDefault, 12, bytes.baseAddress!); + } + + var key = Data(count: 16); + key.withUnsafeMutableBytes { (bytes) -> Void in + _ = SecRandomCopyBytes(kSecRandomDefault, 16, bytes.baseAddress!); + } + + var tag = Data(); + var combinedKey = key; + + let encryptedEl = Element(name: "encrypted", xmlns: OMEMOModule.XMLNS); + + if let data = message.body?.data(using: .utf8) { + var encryptedBody = Data(); + guard engine.encrypt(iv: iv, key: key, message: data, output: &encryptedBody, tag: &tag) else { + return .failure(.notEncrypted); + } + encryptedEl.addChild(Element(name: "payload", cdata: encryptedBody.base64EncodedString())); + } + + combinedKey.append(tag); + + let header = Element(name: "header"); + header.setAttribute("sid", value: String(signalContext.storage.identityKeyStore.localRegistrationId())); + encryptedEl.addChild(header); + + let localAddresses = forSelf ? storage.sessionStore.allDevices(for: context.userBareJid.stringValue, activeAndTrusted: true).map({ (deviceId) -> SignalAddress in + return SignalAddress(name: context.userBareJid.stringValue, deviceId: deviceId); + }) : []; + let destinations: Set = Set(remoteAddresses + localAddresses); + header.addChildren(destinations.map({ (addr) -> Result in + // TODO: maybe we should cache this session? + guard let session = SignalSessionCipher(withAddress: addr, andContext: self.signalContext) else { + return .failure(.noMemory); + } + return session.encrypt(data: combinedKey); + }).map({ (result) -> Element? in + switch result { + case .success(let key): + let keyEl = Element(name: "key", cdata: key.key.base64EncodedString()); + keyEl.setAttribute("rid", value: String(key.deviceId)); + if key.prekey { + keyEl.setAttribute("prekey", value: "true"); + } + return keyEl; + case .failure(_): + return nil; + } + }).filter({ (el) -> Bool in + return el != nil; + }).map({ el -> Element in + return el!; + })); + header.addChild(Element(name: "iv", cdata: iv.base64EncodedString())); + + message.body = nil; + message.addChild(Element(name: "store", xmlns: "urn:xmpp:hints")); + message.addChild(encryptedEl); + + let fingerprint = storage.identityKeyStore.identityFingerprint(forAddress: SignalAddress(name: context.userBareJid.stringValue, deviceId: Int32(bitPattern: storage.identityKeyStore.localRegistrationId()))); + + return .successMessage(message, fingerprint: fingerprint); + } + + private var mamSyncsInProgress: Set = []; + + open func mamSyncStarted(for jid: BareJID?) { + self.devicesQueue.sync { + self.mamSyncsInProgress.insert(jid); + self.postponedSessions[jid] = []; + } + } + + open func mamSyncFinished(for jid: BareJID?) { + self.devicesQueue.sync { + self.mamSyncsInProgress.remove(jid); + self.processPostponed(for: jid); + } + } + + open override func onItemNotification(notification: PubSubModule.ItemNotification) { + if notification.node == OMEMOModule.DEVICES_LIST_NODE, let context = self.context { + switch notification.action { + case .published(let item): + let from = notification.message.from?.bareJid ?? context.userBareJid; + checkAndPublishDevicesListIfNeeded(jid: from, list: item.payload) + default: + break; + } + } + } + + func publishDeviceIdIfNeeded(removeDevicesWithIds: [UInt32]? = nil) { + guard isPepAvailable, let context = context else { + return; + } + let pepJid = context.userBareJid; + context.module(.pubsub).retrieveItems(from: pepJid, for: OMEMOModule.DEVICES_LIST_NODE, limit: .lastItems(1), completionHandler: { result in + switch result { + case .success(let items): + print("got published devices:", items.items.first as Any); + self.checkAndPublishDevicesListIfNeeded(jid: pepJid, list: items.items.first?.payload, removeDevicesWithIds: removeDevicesWithIds); + case .failure(let pubsubError): + guard pubsubError.error == .item_not_found || pubsubError.error == .internal_server_error() else { + return; + } + self.checkAndPublishDevicesListIfNeeded(jid: pepJid, list: nil); + } + }); + } + + fileprivate func checkAndPublishDevicesListIfNeeded(jid: BareJID, list input: Element?, removeDevicesWithIds: [UInt32]? = nil) { + guard let context = context else { + return; + } + + let pubsubModule = context.module(.pubsub); + var listEl = input; + + if listEl?.name != "list" || listEl?.xmlns != "eu.siacs.conversations.axolotl" { + listEl = Element(name: "list", xmlns: "eu.siacs.conversations.axolotl"); + } + + + let me = context.userBareJid == jid; + + if me { + var changed = false; + + if removeDevicesWithIds != nil && listEl != nil { + let deviceIds = removeDevicesWithIds!.map { (deviceId) -> String in + return String(deviceId); + } + listEl!.removeChildren(where: { (el) -> Bool in + guard let id = el.getAttribute("id") else { + return false; + } + return deviceIds.contains(id); + }); + changed = true; + } + + let ourDeviceId = self.storage.identityKeyStore.localRegistrationId(); + let ourDeviceIdStr = String(ourDeviceId); + if listEl?.findChild(where: { (deviceEl) -> Bool in + deviceEl.getAttribute("id") == ourDeviceIdStr; + }) == nil { + listEl?.addChild(Element(name: "device", attributes: ["id": ourDeviceIdStr])); + changed = true; + } + + if changed { + let publishOptions = PubSubNodeConfig(); + publishOptions.accessModel = .open + pubsubModule.publishItem(at: jid, to: OMEMOModule.DEVICES_LIST_NODE, itemId: "current", payload: listEl!, publishOptions: publishOptions, completionHandler: { result in + switch result { + case .success(_): + self.isReady = true; + print("device id:", ourDeviceIdStr, " successfully registered!"); + case .failure(let pubsubError): + print("item registration failed!"); + if pubsubError.error == .conflict() { + pubsubModule.retrieveNodeConfiguration(from: jid, node: OMEMOModule.DEVICES_LIST_NODE, resultHandler: { result in + switch result { + case .success(let form): + form.accessModel = .open; + pubsubModule.configureNode(at: jid, node: OMEMOModule.DEVICES_LIST_NODE, with: form, completionHandler: { result in + switch result { + case .success(_): + pubsubModule.publishItem(at: jid, to: OMEMOModule.DEVICES_LIST_NODE, itemId: "current", payload: listEl!, publishOptions: publishOptions, completionHandler: { result in + switch result { + case .success(_): + self.isReady = true; + print("device id:", ourDeviceIdStr, " successfully registered 2!"); + case .failure(let error): + print("item registration failed 2! \(error)"); + } + }); + case .failure(let error): + print("node reconfiguration failed! \(error)"); + } + }); + case .failure(let error): + print("node configuration retrieval failed! \(error)"); + } + }); + } + } + }); + } else { + self.isReady = true; + context.sessionObject.setProperty(OMEMOModule.DEVICES_LIST_NODE, value: true); + } + } + + let knownActiveDevices = listEl!.mapChildren(transform: { (el) -> Int32? in + guard let id = el.getAttribute("id") else { + return nil; + } + return Int32(id); + }); + + let allDevices = storage.sessionStore.allDevices(for: jid.stringValue, activeAndTrusted: true); + allDevices.filter { (id) -> Bool in + return !knownActiveDevices.contains(id); + }.forEach { (deviceId) in + _ = storage.identityKeyStore.setStatus(active: false, forIdentity: SignalAddress(name: jid.stringValue, deviceId: deviceId)); + } + + knownActiveDevices.filter { (id) -> Bool in + return !allDevices.contains(id); + }.forEach { (deviceId) in + // TODO: we should enable this device key if we have its identity! + _ = storage.identityKeyStore.setStatus(active: true, forIdentity: SignalAddress(name: jid.stringValue, deviceId: deviceId)); + } + + if me { + let group = DispatchGroup(); + group.notify(queue: self.devicesQueue, execute: { + let brokenIds = self.ownBrokenDevices; + guard !brokenIds.isEmpty else { + return; + } + + self.ownBrokenDevices.removeAll(); + + print("removing own devices with ids \(brokenIds) as there are no bundles for them!") + self.removeDevices(withIds: brokenIds); + }) + + group.enter(); + knownActiveDevices.forEach { (deviceId) in + guard deviceId != self.storage.identityKeyStore.localRegistrationId() else { + return; + } + let address = SignalAddress(name: jid.stringValue, deviceId: deviceId); + if !self.storage.sessionStore.containsSessionRecord(forAddress: address) { + // we do not have a session, so we need to build one! + group.enter(); + self.buildSession(forAddress: address, completionHandler: { + group.leave(); + }); + } + } + group.leave(); + } + self.devicesQueue.async { + self.devices[jid] = knownActiveDevices; + self.activeDevicesPublisher.send(AvailabilityChanged(jid: jid, activeDevices: knownActiveDevices)); + self.fire(AvailabilityChangedEvent(sessionObject: context.sessionObject, jid: jid)); + } + } + + func publishDeviceBundleIfNeeded(completionHandler: (()->Void)?) { + guard let pepJid = context?.userBareJid, let pubsubModule = context?.module(.pubsub) else { + return; + } + + pubsubModule.retrieveItems(from: pepJid, for: bundleNode(for: storage.identityKeyStore.localRegistrationId()), limit: .items(withIds: ["current"]), completionHandler: { result in + switch result { + case .success(let items): + self.publishDeviceBundle(currentBundle: items.items.first?.payload, completionHandler: completionHandler); + case .failure(let pubsubError): + guard pubsubError.error == .item_not_found || pubsubError.error == .internal_server_error() else { + return; + } + self.publishDeviceBundle(currentBundle: nil, completionHandler: completionHandler); + } + }); + } + + public func removeDevices(withIds: [Int32]) { + guard let pepJid = context?.userBareJid, let pubsubModule = context?.module(.pubsub) else { + return; + } + + let ids = withIds.map { (deviceId) -> UInt32 in + return UInt32(bitPattern: deviceId); + } + + withIds.forEach { deviceId in + _ = self.storage.identityKeyStore.setStatus(active: false, forIdentity: SignalAddress(name: pepJid.stringValue, deviceId: deviceId)); + + pubsubModule.deleteNode(from: pepJid, node: bundleNode(for: UInt32(bitPattern: deviceId)), completionHandler: nil); + } + + self.publishDeviceIdIfNeeded(removeDevicesWithIds: ids); + } + + private var postponedSessions: [BareJID?:[SignalAddress]] = [:]; + private var postponedHealing: [BareJID?:[SignalAddress]] = [:]; + + private func postponeSession(for jid: BareJID?, address: SignalAddress) -> Bool { + return devicesQueue.sync { + if mamSyncsInProgress.contains(jid) { + if var tmp = postponedSessions[jid] { + tmp.append(address); + postponedSessions[jid] = tmp; + return true; + } + } + if mamSyncsInProgress.contains(nil) { + if var tmp = postponedSessions[nil] { + tmp.append(address); + postponedSessions[nil] = tmp; + return true; + } + } + return false; + } + } + + private func postponeHealing(for jid: BareJID?, address: SignalAddress) -> Bool { + return devicesQueue.sync { + if mamSyncsInProgress.contains(jid) { + if var tmp = postponedHealing[jid] { + tmp.append(address); + postponedHealing[jid] = tmp; + return true; + } + } + if mamSyncsInProgress.contains(nil) { + if var tmp = postponedHealing[nil] { + tmp.append(address); + postponedHealing[nil] = tmp; + return true; + } + } + return false; + } + } + + private func processPostponed(for jid: BareJID?) { + if let sessions = postponedSessions.removeValue(forKey: jid) { + if !sessions.isEmpty { + if self.storage.preKeyStore.flushDeletedPreKeys() { + self.publishDeviceBundleIfNeeded(completionHandler: nil); + } + } + + for address in sessions { + self.completeSession(forAddress: address); + } + } + if let healings = postponedHealing.removeValue(forKey: jid) { + for healing in healings { + self.buildSession(forAddress: healing, completionHandler: { + self.completeSession(forAddress: healing); + }) + } + } + } + + fileprivate func signedPreKey(regenerate: Bool = false) -> SignalSignedPreKey? { + let signedPreKeyId = storage.signedPreKeyStore.countSignedPreKeys(); + var signedPreKey: SignalSignedPreKey? = nil; + if (!regenerate) && (signedPreKeyId != 0) { + if let data = signalContext.storage.signedPreKeyStore.loadSignedPreKey(withId: UInt32(signedPreKeyId)) { + signedPreKey = SignalSignedPreKey(fromSerializedData: data); + } + } + + if signedPreKey == nil { + let identityKeyPair = storage.identityKeyStore.keyPair()!; + print("regenerating signed pre key!"); + signedPreKey = signalContext.generateSignedPreKey(withIdentity: identityKeyPair, signedPreKeyId: UInt32(signedPreKeyId + 1)) + guard signedPreKey != nil else { + return nil; + } + guard signalContext.storage.signedPreKeyStore.storeSignedPreKey(signedPreKey!.serializedData!, withId: signedPreKey!.preKeyId) else { + return nil; + } + } + return signedPreKey; + } + +// fileprivate func publishDeviceBundleNoKeys(regenerate: Bool = false, completionHandler: @escaping ()->Void) { +// if let signedPreKey = signedPreKey(regenerate: regenerate) { +// let preKeys = signalContext.generatePreKeys(withStartingPreKeyId: 0, count: 20); +// preKeys.forEach { (preKey) in +// _ = self.storage.preKeyStore.storePreKey(preKey.serializedData!, withId: preKey.preKeyId); +// } +// publishDeviceBundle(signedPreKey: signedPreKey, preKeys: preKeys, completionHandler: completionHandler); +// } +// } + + fileprivate func publishDeviceBundle(currentBundle: Element?, completionHandler: (()->Void)?) { + guard let identityPublicKey = storage.identityKeyStore.keyPair()?.publicKey?.base64EncodedString() else { + completionHandler?(); + return; + } + + var flush: Bool = currentBundle == nil; + if !flush { + flush = identityPublicKey != currentBundle?.findChild(name: "identityKey")?.value; + } + + if let signedPreKey = self.signedPreKey(regenerate: flush), let signedPreKeyBase64 = signedPreKey.publicKeyData?.base64EncodedString() { + let signatureBase64 = signedPreKey.signature.base64EncodedString(); + var changed = flush || signedPreKeyBase64 != currentBundle?.findChild(name: "signedPreKeyPublic")?.value || signatureBase64 != currentBundle?.findChild(name: "signedPreKeySignature")?.value; + + let currentKeys = currentBundle?.findChild(name: "prekeys")?.mapChildren(transform: { (preKeyEl) -> UInt32? in + guard let preKeyId = preKeyEl.getAttribute("preKeyId") else { + return nil; + } + return UInt32(preKeyId); + }); + + var validKeys = currentKeys?.map({ (preKeyId) -> SignalPreKey? in + guard let key = self.storage.preKeyStore.loadPreKey(withId: preKeyId) else { + return nil; + } + return SignalPreKey(fromSerializedData: key); + }).filter({ (preKey) -> Bool in + return preKey != nil; + }).map({ preKey -> SignalPreKey in + return preKey!; + }) ?? []; + let needKeys = 100 - validKeys.count; + if needKeys > 0 { + changed = true; + let currentPreKeyId = self.storage.preKeyStore.currentPreKeyId(); + var newKeys = self.signalContext.generatePreKeys(withStartingPreKeyId: currentPreKeyId + 1, count: UInt32(needKeys)); + newKeys = newKeys.filter { (key) in + self.storage.preKeyStore.storePreKey(key.serializedData!, withId: key.preKeyId); + }; + validKeys = validKeys + newKeys; + } + + if changed { + // something has changed, we need to publish new bundle! + publishDeviceBundle(signedPreKey: signedPreKey, preKeys: validKeys, completionHandler: completionHandler); + } else { + completionHandler?(); + } + } + } + + func publishDeviceBundle(signedPreKey: SignalSignedPreKey, preKeys: [SignalPreKey], completionHandler: (()->Void)?) { + let identityKeyPair = storage.identityKeyStore.keyPair()!; + + let bundleEl = Element(name: "bundle", xmlns: OMEMOModule.XMLNS); + bundleEl.addChild(Element(name: "signedPreKeyPublic", cdata: signedPreKey.publicKeyData!.base64EncodedString(), attributes: ["signedPreKeyId": String(signedPreKey.preKeyId)])); + bundleEl.addChild(Element(name: "signedPreKeySignature", cdata: signedPreKey.signature.base64EncodedString())); + bundleEl.addChild(Element(name: "identityKey", cdata: identityKeyPair.publicKey!.base64EncodedString())); + let preKeysElems = preKeys.map({ (preKey) -> Element in + return Element(name: "preKeyPublic", cdata: preKey.serializedPublicKey!.base64EncodedString(), attributes: ["preKeyId": String(preKey.preKeyId)]); + }); + bundleEl.addChild(Element(name: "prekeys", children: preKeysElems)); + + let publishOptions = PubSubNodeConfig(); + publishOptions.accessModel = .open; + + guard let pubsubModule = context?.module(.pubsub) else { + return; + } + pubsubModule.publishItem(at: nil, to: bundleNode(for: storage.identityKeyStore.localRegistrationId()), itemId: "current", payload: bundleEl, publishOptions: publishOptions, completionHandler: { result in + switch result { + case .success(_): + print("published public keys!"); + completionHandler?(); + case .failure(let pubsubError): + print("cound not publish keys:", pubsubError); + } + }); + } + + private func completeSession(forAddress address: SignalAddress) { + let message = Message() + message.type = .chat; + message.to = JID(address.name); + let result = self._encode(message: message, for: [address], forSelf: false); + switch result { + case .successMessage(let message, _): + message.hints = [.store]; + self.write(message); + case .failure(let error): + print("failed to complete session for address: \(address), error: \(error)"); + } + } + + open func buildSession(forAddress address: SignalAddress, completionHandler: (()->Void)? = nil) { + let pepJid = BareJID(address.name); + guard let pubsubModule: PubSubModule = context?.module(.pubsub) else { + return; + } + pubsubModule.retrieveItems(from: pepJid, for: bundleNode(for: UInt32(bitPattern: address.deviceId)), limit: .lastItems(1), completionHandler: { result in + switch result { + case .success(let items): + guard let bundle = OMEMOBundle(from: items.items.first?.payload) else { + print("could not create a bundle!"); + self.markDeviceAsFailed(for: pepJid, andDeviceId: address.deviceId); + completionHandler?(); + return; + } + + let preKey = bundle.preKeys[Int.random(in: 0.. String { + return "eu.siacs.conversations.axolotl.bundles:\(deviceId)"; + } + + open class OMEMOBundle { + + let preKeys: [OMEMOPreKey]; + + let signedPreKeyId: UInt32; + let signedPreKeyPublic: Data; + let identityKey: Data; + let signature: Data; + + init?(from: Element?) { + guard let el = from, el.name == "bundle" && el.xmlns == OMEMOModule.XMLNS else { + return nil; + } + + guard let preKeys = el.findChild(name: "prekeys")?.mapChildren(transform: { (el) -> OMEMOPreKey? in + return OMEMOPreKey(from: el); + }), !preKeys.isEmpty else { + return nil; + } + + guard let signedKeyPublic = el.findChild(name: "signedPreKeyPublic"), let signedPreKeyIdStr = signedKeyPublic.getAttribute("signedPreKeyId"), let signedPreKeyId = UInt32(signedPreKeyIdStr), let signedKeyPublicValue = signedKeyPublic.value, let signedKeyPublicData = Data(base64Encoded: signedKeyPublicValue, options: [.ignoreUnknownCharacters]) else { + return nil; + } + + guard let identityKeyValue = el.findChild(name: "identityKey")?.value, let identityKeyData = Data(base64Encoded: identityKeyValue, options: [.ignoreUnknownCharacters]) else { + return nil; + } + + guard let signatureValue = el.findChild(name: "signedPreKeySignature")?.value, let signatureData = Data(base64Encoded: signatureValue, options: [.ignoreUnknownCharacters]) else { + return nil; + } + + self.signedPreKeyId = signedPreKeyId; + self.signedPreKeyPublic = signedKeyPublicData; + self.signature = signatureData; + self.identityKey = identityKeyData; + self.preKeys = preKeys; + } + + } + + open class OMEMOPreKey { + + public let preKeyId: UInt32; + public let data: Data; + + public convenience init?(from: Element) { + guard from.name == "preKeyPublic", let value = from.value, let preKeyIdStr = from.getAttribute("preKeyId") else { + return nil; + } + guard let data = Data(base64Encoded: value, options: [.ignoreUnknownCharacters]), let preKeyId = UInt32(preKeyIdStr) else { + return nil; + } + + self.init(data: data, preKeyId: preKeyId); + } + + public init(data: Data, preKeyId: UInt32) { + self.data = data; + self.preKeyId = preKeyId; + } + + } + + public struct AvailabilityChanged { + + public let jid: BareJID; + public let activeDevices: [Int32]; + + } + + open class AvailabilityChangedEvent: Event { + + public static let TYPE = AvailabilityChangedEvent(); + + public let type = "OMEMOAvailabilityChangedEvent"; + + public let sessionObject: SessionObject!; + public var account: BareJID { + return sessionObject.context.userBareJid; + } + public let jid: BareJID!; + + init() { + self.sessionObject = nil; + self.jid = nil; + } + + public init(sessionObject: SessionObject, jid: BareJID) { + self.sessionObject = sessionObject; + self.jid = jid; + } + + } +} + +public protocol AES_GCM_Engine { + + func encrypt(iv: Data, key: Data, message: Data, output: UnsafeMutablePointer?, tag: UnsafeMutablePointer?) -> Bool; + + func decrypt(iv: Data, key: Data, encoded: Data, auth tag: Data?, output: UnsafeMutablePointer?) -> Bool; + +} + +public enum EncryptionResult { + case successMessage(_ success: Success, fingerprint: String?) + case failure(_ error: Failure) +} + +public enum DecryptionResult { + case successMessage(_ success: Success, fingerprint: String?) + case successTransportKey(_ key: Data, iv: Data) + case failure(_ error: Failure) +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalAddress.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalAddress.swift new file mode 100644 index 0000000..6cd78a1 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalAddress.swift @@ -0,0 +1,82 @@ +// +// SignalAddress.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import libsignal + +open class SignalAddress: Hashable, CustomStringConvertible { + + public static func == (lhs: SignalAddress, rhs: SignalAddress) -> Bool { + return lhs.name == rhs.name && lhs.deviceId == rhs.deviceId; + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name); + hasher.combine(deviceId); + } + + public let name: String; + public let deviceId: Int32; + + fileprivate let nameBytes: UnsafeMutablePointer; + public let address: UnsafeMutablePointer; + + public var description: String { + return "(name: \(name), deviceId: \(deviceId) or \(UInt32(bitPattern: deviceId))"; + } + + public init(name: String, deviceId: Int32) { + self.name = name; + self.deviceId = deviceId; + + let rawPointer = malloc(MemoryLayout.size)!.assumingMemoryBound(to: signal_protocol_address.self); + rawPointer.pointee.device_id = deviceId; + let tmp = self.name.utf8CString; + self.nameBytes = tmp.withUnsafeBytes { (inBuf) -> UnsafeMutablePointer in + let outBuf = malloc(tmp.count)!.assumingMemoryBound(to: Int8.self); + memcpy(outBuf, inBuf.baseAddress!, tmp.count); + return outBuf; + } + + rawPointer.pointee.name = UnsafePointer(nameBytes); + self.address = rawPointer; + } + + deinit { + free(address); + free(nameBytes); + } + + public convenience init?(from: UnsafePointer?) { + guard let namePtr = from?.pointee.name, let name = String(validatingUTF8: namePtr), let deviceId = from?.pointee.device_id else { + return nil; + } + + self.init(name: name, deviceId: deviceId); + } + + public convenience init?(from: UnsafePointer?) { + guard var addr = from?.pointee else { + return nil; + } + self.init(from: &addr); + } +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalContext.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalContext.swift new file mode 100644 index 0000000..6d1e588 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalContext.swift @@ -0,0 +1,238 @@ +// +// SignalContext.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import libsignal + +open class SignalContext { + + public fileprivate(set) var globalContext: OpaquePointer?; + fileprivate let provider: SignalCryptoProvider = SignalCryptoProvider(); + fileprivate let lock: NSRecursiveLock = NSRecursiveLock(); + public let storage: SignalStorage; + + public init?(withStorage: SignalStorage) { + self.storage = withStorage; + + guard signal_context_create(&globalContext, SignalContext.bridge(self)) == 0 else { + return nil; + } + + var provider = self.provider.provider; + signal_context_set_crypto_provider(globalContext, &provider) + + signal_context_set_locking_functions(globalContext, signalLock, signalUnlock) + signal_context_set_log_function(globalContext, signalLog); + + self.storage.setup(withContext: self); + } + + open func generateRegistrationId() -> UInt32 { + var regId: UInt32 = 0; + let res = signal_protocol_key_helper_generate_registration_id(®Id, 1, self.globalContext!); + guard res >= 0 else { + return 0; + } + return regId; + } + + open func generatePreKeys(withStartingPreKeyId preKeyId: UInt32, count: UInt32) -> [SignalPreKey] { + var head: OpaquePointer? = nil; + guard signal_protocol_key_helper_generate_pre_keys(&head, preKeyId, count, globalContext) >= 0 && head != nil else { + return []; + } + var keys: [SignalPreKey] = []; + while (head != nil) { + if let pre_key = signal_protocol_key_helper_key_list_element(head) { + keys.append(SignalPreKey(fromPreKey: pre_key)); + } + head = signal_protocol_key_helper_key_list_next(head); + } + return keys; + } + + open func generateSignedPreKey(withIdentity identity: SignalIdentityKeyPairProtocol, signedPreKeyId: UInt32, timestamp: Date = Date()) -> SignalSignedPreKey? { + guard let keyPair = identity.keyPairPointer else { + return nil; + } + + var signed_pre_key: OpaquePointer?; + guard signal_protocol_key_helper_generate_signed_pre_key(&signed_pre_key, keyPair, signedPreKeyId, UInt64(timestamp.timeIntervalSince1970) * 1000, globalContext) >= 0 && signed_pre_key != nil else { + return nil; + } + + return SignalSignedPreKey(fromSignedPreKey: signed_pre_key!); + } + + static func bridge(_ obj : T) -> UnsafeMutableRawPointer { + return Unmanaged.passUnretained(obj).toOpaque(); + } + + static func bridge(_ ptr : UnsafeMutableRawPointer) -> T { + return Unmanaged.fromOpaque(ptr).takeUnretainedValue(); + } + + static func bridge(fromPointer ptr : UnsafeMutableRawPointer) -> AnyObject { + return Unmanaged.fromOpaque(ptr).takeUnretainedValue(); + } + +} + +fileprivate func signalLock(_ userData: UnsafeMutableRawPointer?) { + let ctx: SignalContext = SignalContext.bridge(userData!); + ctx.lock.lock(); +} + +fileprivate func signalUnlock(_ userData: UnsafeMutableRawPointer?) { + let ctx: SignalContext = SignalContext.bridge(userData!); + ctx.lock.unlock(); +} + +fileprivate func signalLog(level: CInt, message: UnsafePointer?, len: Int, userData: UnsafeMutableRawPointer?) { + print("SignalProtocol:", level, String(validatingUTF8: message!) as Any); +} + +open class SignalSignedPreKey { + + fileprivate let signedPreKey: OpaquePointer; + + public var preKeyId: UInt32 { + return session_signed_pre_key_get_id(signedPreKey); + } + + public var timestamp: Date { + return Date(timeIntervalSince1970: TimeInterval(session_signed_pre_key_get_timestamp(signedPreKey) / 1000)); + } + + public var signature: Data { + let sigBytes = session_signed_pre_key_get_signature(signedPreKey); + let sigLen = session_signed_pre_key_get_signature_len(signedPreKey); + return Data(bytes: sigBytes!, count: sigLen); + } + + public var serializedData: Data? { + var buf: OpaquePointer?; + guard session_signed_pre_key_serialize(&buf, signedPreKey) == 0 && buf != nil else { + return nil; + } + + defer { + signal_buffer_free(buf); + } + return Data(bytes: signal_buffer_data(buf), count: signal_buffer_len(buf)); + } + + public var publicKeyData: Data? { + guard let keyPair = session_signed_pre_key_get_key_pair(signedPreKey) else { + return nil; + } + guard let publicKey = ec_key_pair_get_public(keyPair) else { + return nil; + } + var buf: OpaquePointer?; + guard ec_public_key_serialize(&buf, publicKey) >= 0 && buf != nil else { + return nil; + } + + defer { + signal_buffer_free(buf); + } + return Data(bytes: signal_buffer_data(buf), count: signal_buffer_len(buf)); + } + + public init(fromSignedPreKey: OpaquePointer) { + self.signedPreKey = fromSignedPreKey; + signal_type_ref(signedPreKey); + } + + public convenience init?(fromSerializedData: Data) { + guard let preKey: OpaquePointer = fromSerializedData.withUnsafeBytes({ (bytes) -> OpaquePointer? in + var tmp: OpaquePointer?; + guard session_signed_pre_key_deserialize(&tmp, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), fromSerializedData.count, nil) >= 0 else { + return nil; + } + return tmp; + }) else { + return nil; + } + self.init(fromSignedPreKey: preKey); + } + + deinit { + signal_type_unref(signedPreKey); + } +} + +open class SignalPreKey { + + fileprivate let preKey: OpaquePointer; + + public var preKeyId: UInt32 { + return session_pre_key_get_id(preKey); + } + + public var serializedData: Data? { + var buf: OpaquePointer?; + guard session_pre_key_serialize(&buf, preKey) == 0 && buf != nil else { + return nil; + } + + defer { + signal_buffer_free(buf); + } + return Data(bytes: signal_buffer_data(buf), count: signal_buffer_len(buf)); + } + + public var serializedPublicKey: Data? { + let keyPair = session_signed_pre_key_get_key_pair(preKey); + var buf: OpaquePointer?; + guard ec_public_key_serialize(&buf, ec_key_pair_get_public(keyPair)) >= 0 else { + return nil; + } + defer { + signal_buffer_free(buf); + } + return Data(bytes: signal_buffer_data(buf), count: signal_buffer_len(buf)); + } + + public init(fromPreKey: OpaquePointer) { + self.preKey = fromPreKey; + signal_type_ref(self.preKey); + } + + public convenience init?(fromSerializedData: Data) { + guard let preKey: OpaquePointer = fromSerializedData.withUnsafeBytes({ (bytes) -> OpaquePointer? in + var tmp: OpaquePointer?; + guard session_pre_key_deserialize(&tmp, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), fromSerializedData.count, nil) >= 0 else { + return nil; + } + return tmp; + }) else { + return nil; + } + self.init(fromPreKey: preKey); + } + + deinit { + signal_type_unref(preKey); + } + +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalCryptoProvider.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalCryptoProvider.swift new file mode 100644 index 0000000..e0bd765 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalCryptoProvider.swift @@ -0,0 +1,175 @@ +// +// SignalCryptoProvider.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import CommonCrypto +import libsignal + +public class SignalCryptoProvider { + + public fileprivate(set) var provider: signal_crypto_provider; + + init() { + provider = signal_crypto_provider(); + provider.random_func = random_func; + provider.hmac_sha256_init_func = hmac_sha256_init_func; + provider.hmac_sha256_update_func = hmac_sha256_update_func; + provider.hmac_sha256_final_func = hmac_sha256_final_func; + provider.hmac_sha256_cleanup_func = hmac_sha256_cleanup_func; + provider.sha512_digest_init_func = sha512_digest_init_func; + provider.sha512_digest_update_func = sha512_digest_update_func; + provider.sha512_digest_final_func = sha512_digest_final_func; + provider.sha512_digest_cleanup_func = sha512_digest_cleanup_func; + provider.encrypt_func = encrypt_func; + provider.decrypt_func = decrypt_func; + provider.user_data = SignalContext.bridge(self); + } +} + +fileprivate func random_func(data: UnsafeMutablePointer?, len: Int, ctx: UnsafeMutableRawPointer?) -> CInt { + guard CCRandomGenerateBytes(data, len) == kCCSuccess else { + return SG_ERR_INVAL; + } + return SG_SUCCESS; +} + +fileprivate func hmac_sha256_init_func(hmacCtx: UnsafeMutablePointer?, key: UnsafePointer?, keyLen: Int, ctx: UnsafeMutableRawPointer?) -> CInt { + guard hmacCtx != nil && key != nil else { + return SG_ERR_INVAL; + } + + guard let ctx: UnsafeMutablePointer = malloc(MemoryLayout.size)?.assumingMemoryBound(to: CCHmacContext.self) else { + return SG_ERR_NOMEM; + } + + CCHmacInit(ctx, CCHmacAlgorithm(kCCHmacAlgSHA256), key, keyLen); + hmacCtx!.initialize(to: ctx); + return SG_SUCCESS; +} + +fileprivate func hmac_sha256_update_func(hmacCtx: UnsafeMutableRawPointer?, data: UnsafePointer?, dataLen: Int, ctx: UnsafeMutableRawPointer?) -> CInt { + guard hmacCtx != nil && data != nil else { + return SG_ERR_INVAL; + } + CCHmacUpdate(hmacCtx?.assumingMemoryBound(to: CCHmacContext.self), data!, dataLen); + return SG_SUCCESS; +} + +fileprivate func hmac_sha256_final_func(hmacCtx: UnsafeMutableRawPointer?, output: UnsafeMutablePointer?, ctx: UnsafeMutableRawPointer?) -> CInt { + guard hmacCtx != nil && output != nil else { + return SG_ERR_INVAL; + } + + let length = Int(CC_SHA256_DIGEST_LENGTH); + var data = Data(capacity: length); + data.withUnsafeMutableBytes({ (ptr: UnsafeMutableRawBufferPointer) -> Void in + CCHmacFinal(hmacCtx?.assumingMemoryBound(to: CCHmacContext.self), ptr.baseAddress) + return; + }); + output!.initialize(to: data.withUnsafeBytes({ (ptr: UnsafeRawBufferPointer) -> OpaquePointer in + return signal_buffer_create(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self), length); + })); + return SG_SUCCESS; +} + +fileprivate func hmac_sha256_cleanup_func(hmacCtx: UnsafeMutableRawPointer?, ctx: UnsafeMutableRawPointer?) { + guard hmacCtx != nil else { + return; + } + free(hmacCtx!); +} + +fileprivate func sha512_digest_init_func(digestCtx: UnsafeMutablePointer?, ctx: UnsafeMutableRawPointer?) -> CInt { + guard digestCtx != nil else { + return SG_ERR_INVAL; + } + + guard let ctx: UnsafeMutablePointer = malloc(MemoryLayout.size)?.assumingMemoryBound(to: CC_SHA512_CTX.self) else { + return SG_ERR_NOMEM; + } + + CC_SHA512_Init(ctx); + digestCtx!.initialize(to: ctx); + return SG_SUCCESS; +} + +fileprivate func sha512_digest_update_func(digestCtx: UnsafeMutableRawPointer?, data: UnsafePointer?, dataLen: Int, ctx: UnsafeMutableRawPointer?) -> CInt { + guard digestCtx != nil && data != nil else { + return SG_ERR_INVAL; + } + CC_SHA512_Update(digestCtx!.assumingMemoryBound(to: CC_SHA512_CTX.self), data, UInt32(dataLen)); + return SG_SUCCESS; +} + +fileprivate func sha512_digest_final_func(digestCtx: UnsafeMutableRawPointer?, output: UnsafeMutablePointer?, ctx: UnsafeMutableRawPointer?) -> CInt { + guard digestCtx != nil && output != nil else { + return SG_ERR_INVAL; + } + + let length = Int(CC_SHA512_DIGEST_LENGTH); + var data = Data(capacity: length); + data.withUnsafeMutableBytes({ (ptr: UnsafeMutableRawBufferPointer) -> Void in + CC_SHA512_Final(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self), digestCtx?.assumingMemoryBound(to: CC_SHA512_CTX.self)) + return; + }); + output!.initialize(to: data.withUnsafeBytes({ (ptr: UnsafeRawBufferPointer) -> OpaquePointer in + return signal_buffer_create(ptr.baseAddress?.assumingMemoryBound(to: UInt8.self), length); + })); + return SG_SUCCESS; +} + +fileprivate func sha512_digest_cleanup_func(digestCtx: UnsafeMutableRawPointer?, ctx: UnsafeMutableRawPointer?) { + guard digestCtx != nil else { + return; + } + free(digestCtx!); +} + +fileprivate func encrypt_func(output: UnsafeMutablePointer?, cipher: CInt, key: UnsafePointer?, keyLen: Int, iv: UnsafePointer?, ivLen: Int, plaintext: UnsafePointer?, plaintextLen: Int, ctx: UnsafeMutableRawPointer?) -> CInt { + guard cipher == SG_CIPHER_AES_CBC_PKCS5 else { + return SG_ERR_INVAL; + } + + var outLen: Int = 0; + var bytes = Array(repeating: UInt8(0), count: kCCBlockSizeAES128 + plaintextLen); + let result = CCCrypt(CCOperation(kCCEncrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), key, keyLen, iv, plaintext, plaintextLen, &bytes, bytes.count, &outLen); + guard result == kCCSuccess else { + return SG_ERR_UNKNOWN; + } + + output?.initialize(to: signal_buffer_create(&bytes, outLen)); + return SG_SUCCESS; +} + +fileprivate func decrypt_func(output: UnsafeMutablePointer?, cipher: CInt, key: UnsafePointer?, keyLen: Int, iv: UnsafePointer?, ivLen: Int, ciphertext: UnsafePointer?, ciphertextLen: Int, ctx: UnsafeMutableRawPointer?) -> CInt { + guard cipher == SG_CIPHER_AES_CBC_PKCS5 else { + return SG_ERR_INVAL; + } + + var outLen: Int = 0; + var bytes = Array(repeating: UInt8(0), count: ciphertextLen + kCCBlockSizeAES128); + guard CCCrypt(CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), key, keyLen, iv, ciphertext, ciphertextLen, &bytes, bytes.count, &outLen) == kCCSuccess else { + return SG_ERR_UNKNOWN; + } + + output?.initialize(to: signal_buffer_create(&bytes, outLen)); + return SG_SUCCESS; +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalError.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalError.swift new file mode 100644 index 0000000..687fb42 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalError.swift @@ -0,0 +1,54 @@ +// +// SignalError.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation + +public enum SignalError: Int, Error { + + // custom error code, not from libsignal but required for internal use to mark that message was not encrypted + case notEncrypted = -100000; + case noDestination = -100001; + + case noMemory = -12; + case invalidArgument = -22; + case unknown = -1000; + case duplicateMessage = -1001; + case invalidKey = -1002; + case invalidKeyId = -1003; + case invalidMac = -1004; + case invalidMessage = -1005; + case invalidVersion = -1006; + case legacyMessage = -1007; + case noSession = -1008; + case staleKeyExchange = -1009; + case unstrustedIdentity = -1010; + case signatureVerificationFailed = -1011; + case invalidProtoBuf = -1100; + case fpInvalidVersion = -1200; + case fpIdentityMismatch = -1201; + + public static func from(code: Int32) -> SignalError? { + guard code < 0 else { + return nil; + } + return SignalError(rawValue: Int(code)) ?? .unknown; + } +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalIdentityKey.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalIdentityKey.swift new file mode 100644 index 0000000..069c168 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalIdentityKey.swift @@ -0,0 +1,75 @@ +// +// SignalIdentityKey.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import libsignal + +open class SignalIdentityKey: SignalIdentityKeyProtocol { + + public static func publicKey(from publicKey: Data) -> OpaquePointer? { + return publicKey.withUnsafeBytes({ (bytes) -> OpaquePointer? in + var tmp: OpaquePointer?; + guard curve_decode_point(&tmp, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), publicKey.count, nil) >= 0 else { + return nil; + } + return tmp; + }); + } + + public let publicKeyPointer: OpaquePointer; + + fileprivate var _publicKey: Data?; + public var publicKey: Data? { + if _publicKey == nil { + var buffer: OpaquePointer?; + guard ec_public_key_serialize(&buffer, publicKeyPointer) == 0 && buffer != nil else { + return nil; + } + _publicKey = Data(bytes: signal_buffer_data(buffer), count: signal_buffer_len(buffer)); + signal_buffer_bzero_free(buffer); + } + return _publicKey; + } + + public convenience init?(publicKey: Data?) { + guard publicKey != nil else { + return nil; + } + guard let publicKeyPointer = SignalIdentityKey.publicKey(from: publicKey!) else { + return nil; + } + + self.init(publicKeyPointer: publicKeyPointer); + } + + public init(publicKeyPointer: OpaquePointer) { + self.publicKeyPointer = publicKeyPointer; + signal_type_ref(self.publicKeyPointer); + } + + deinit { + signal_type_unref(publicKeyPointer); + } + + open func serialized() -> Data { + return publicKey!; + } +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalIdentityKeyPair.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalIdentityKeyPair.swift new file mode 100644 index 0000000..05a4191 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalIdentityKeyPair.swift @@ -0,0 +1,133 @@ +// +// SignalIdentityKeyPair.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import libsignal + +open class SignalIdentityKeyPair: SignalIdentityKey, SignalIdentityKeyPairProtocol { + + public let privateKeyPointer: OpaquePointer; + + fileprivate var _keyPairPointer: OpaquePointer?; + public var keyPairPointer: OpaquePointer? { + if _keyPairPointer == nil { + ec_key_pair_create(&_keyPairPointer, publicKeyPointer, privateKeyPointer); + } + return _keyPairPointer; + } + + fileprivate var _privateKey: Data?; + public var privateKey: Data? { + if _privateKey == nil { + var buffer: OpaquePointer?; + guard ec_private_key_serialize(&buffer, privateKeyPointer) == 0 && buffer != nil else { + return nil; + } + _privateKey = Data(bytes: signal_buffer_data(buffer), count: signal_buffer_len(buffer)); + signal_buffer_bzero_free(buffer); + } + return _privateKey; + } + + public var keyPair: Data? { + guard let keyPairPointer = self.keyPairPointer else { + return nil; + } + var buffer: OpaquePointer?; + guard ratchet_identity_key_pair_serialize(&buffer, keyPairPointer) == 0 && buffer != nil else { + return nil; + } + defer { + signal_buffer_bzero_free(buffer); + } + return Data(bytes: signal_buffer_data(buffer), count: signal_buffer_len(buffer)); + } + + public init?(publicKey: Data?, privateKey: Data?) { + guard publicKey != nil && privateKey != nil else { + return nil; + } + + guard let privateKeyPointer = privateKey!.withUnsafeBytes({ (bytes) -> OpaquePointer? in + var tmp: OpaquePointer?; + guard curve_decode_private_point(&tmp, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), privateKey!.count, nil) >= 0 else { + return nil; + } + return tmp; + }) else { + return nil; + } + + guard let publicKeyPointer = publicKey!.withUnsafeBytes({ (bytes) -> OpaquePointer? in + var tmp: OpaquePointer?; + guard curve_decode_point(&tmp, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), publicKey!.count, nil) >= 0 else { + return nil; + } + return tmp; + }) else { + return nil; + } + + self.privateKeyPointer = privateKeyPointer; + signal_type_ref(self.privateKeyPointer); + super.init(publicKeyPointer: publicKeyPointer); + } + + public init(withKeyPair keyPair: OpaquePointer) { + privateKeyPointer = ratchet_identity_key_pair_get_private(keyPair); + signal_type_ref(self.privateKeyPointer); + super.init(publicKeyPointer: ratchet_identity_key_pair_get_public(keyPair)); + } + + public convenience init?(fromKeyPairData data: Data) { + guard let keyPair = data.withUnsafeBytes({ (bytes) -> OpaquePointer? in + var tmp: OpaquePointer?; + guard ratchet_identity_key_pair_deserialize(&tmp, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), data.count, nil) >= 0 && tmp != nil else { + return nil; + } + return tmp; + }) else { + return nil; + } + self.init(withKeyPair: keyPair); + } + + deinit { + signal_type_unref(privateKeyPointer); + if keyPairPointer != nil { + signal_type_unref(keyPairPointer); + _keyPairPointer = nil; + } + } + + public static func generateKeyPair(context: SignalContext) -> SignalIdentityKeyPair? { + var keyPair: OpaquePointer? = nil; + let result = signal_protocol_key_helper_generate_identity_key_pair(&keyPair, context.globalContext); + guard result >= 0 && keyPair != nil else { + return nil; + } + return SignalIdentityKeyPair(withKeyPair: keyPair!); + } + + override open func serialized() -> Data { + return keyPair!; + } +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalPreKeyBundle.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalPreKeyBundle.swift new file mode 100644 index 0000000..b366411 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalPreKeyBundle.swift @@ -0,0 +1,87 @@ +// +// SignalPreKeyBundle.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import libsignal + +public class SignalPreKeyBundle { + + let bundle: OpaquePointer; + + public convenience init?(registrationId: UInt32, deviceId: Int32, preKey: OMEMOModule.OMEMOPreKey, bundle: OMEMOModule.OMEMOBundle) { + self.init( + registrationId: registrationId, + deviceId: deviceId, + preKeyId: preKey.preKeyId, + preKeyPublic: preKey.data, + signedPreKeyId: bundle.signedPreKeyId, + signedPreKeyPublic: bundle.signedPreKeyPublic, + signedPreKeySignature: bundle.signature, + identityKey: bundle.identityKey + ) + } + + /// Raw-data initializer added by Luma so an OMEMO 2 bundle (the + /// `urn:xmpp:omemo:2` format) can be turned into a Signal prekey bundle + /// without routing through the legacy bundle types. + public init?( + registrationId: UInt32, + deviceId: Int32, + preKeyId: UInt32, + preKeyPublic: Data, + signedPreKeyId: UInt32, + signedPreKeyPublic: Data, + signedPreKeySignature: Data, + identityKey: Data + ) { + guard let preKeyPublicKey = SignalIdentityKey.publicKey(from: preKeyPublic) else { + return nil; + } + guard let signedPreKeyPublicKey = SignalIdentityKey.publicKey(from: signedPreKeyPublic) else { + signal_type_unref(preKeyPublicKey); + return nil; + } + guard let identityKeyKey = SignalIdentityKey.publicKey(from: identityKey) else { + signal_type_unref(preKeyPublicKey); + signal_type_unref(signedPreKeyPublicKey); + return nil; + } + + var bundlePtr: OpaquePointer?; + guard signedPreKeySignature.withUnsafeBytes({ (bytes) -> Bool in + let result = session_pre_key_bundle_create(&bundlePtr, registrationId, deviceId, preKeyId, preKeyPublicKey, signedPreKeyId, signedPreKeyPublicKey, bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), signedPreKeySignature.count, identityKeyKey); + signal_type_unref(preKeyPublicKey); + signal_type_unref(signedPreKeyPublicKey); + signal_type_unref(identityKeyKey); + + return result >= 0; + }) else { + return nil; + } + + self.bundle = bundlePtr!; + } + + deinit { + signal_type_unref(bundle); + } + +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalSessionBuilder.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalSessionBuilder.swift new file mode 100644 index 0000000..8dab36f --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalSessionBuilder.swift @@ -0,0 +1,51 @@ +// +// SignalSessioBuilder.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import libsignal + +open class SignalSessionBuilder { + + fileprivate let builder: OpaquePointer; + fileprivate let address: SignalAddress; + fileprivate let context: SignalContext; + + public init?(withAddress addr: SignalAddress, andContext ctx: SignalContext) { + var builder: OpaquePointer?; + guard session_builder_create(&builder, ctx.storage.storeContext!, addr.address, ctx.globalContext) >= 0 && builder != nil else { + return nil; + } + self.address = addr; + self.context = ctx; + self.builder = builder!; + } + + deinit { + session_builder_free(builder); + } + + public func processPreKeyBundle(bundle: SignalPreKeyBundle) -> Bool { + guard session_builder_process_pre_key_bundle(builder, bundle.bundle) >= 0 else { + return false; + } + return true; + } +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalSessionCipher.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalSessionCipher.swift new file mode 100644 index 0000000..30afd0b --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalSessionCipher.swift @@ -0,0 +1,142 @@ +// +// SignalSessionCipher.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import libsignal + +open class SignalSessionCipher { + + fileprivate var cipher: OpaquePointer; + fileprivate let context: SignalContext; + fileprivate let address: SignalAddress; + + public init?(withAddress address: SignalAddress, andContext context: SignalContext) { + var cipher: OpaquePointer?; + self.address = address; + guard session_cipher_create(&cipher, context.storage.storeContext, self.address.address, context.globalContext) >= 0 && cipher != nil else { + return nil; + } + self.context = context; + self.cipher = cipher!; + } + + deinit { + session_cipher_free(cipher); + } + + public func encrypt(data: Data) -> Result { + var message: OpaquePointer?; + let error = data.withUnsafeBytes({ (bytes) -> SignalError? in + return SignalError.from(code : session_cipher_encrypt(cipher, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), data.count, &message)); + }) + guard error == nil && message != nil else { + return .failure(error ?? .unknown); + } + + let serialized = ciphertext_message_get_serialized(message); + let result = Data(bytes: signal_buffer_data(serialized), count: signal_buffer_len(serialized)); + + defer { + signal_type_unref(message); + } + return .success(Key(key: result, deviceId: address.deviceId, prekey: ciphertext_message_get_type(message) == CIPHERTEXT_PREKEY_TYPE)); + } + + public func decrypt(key: Key) -> Result { + if key.prekey { + return decryptPreKeyMessage(key: key); + } else { + return decryptSignalMessage(key: key); + } + } + + fileprivate func decryptPreKeyMessage(key: Key) -> Result { + let result = key.key.withUnsafeBytes({ (bytes) -> Result in + var output: OpaquePointer?; + var preKeySignalMessage: OpaquePointer?; + + var error = SignalError.from(code: pre_key_signal_message_deserialize(&preKeySignalMessage, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), key.key.count, self.context.globalContext)); + guard error == nil && preKeySignalMessage != nil else { + return .failure(error ?? .unknown); + } + defer { + signal_type_unref(preKeySignalMessage); + } + error = SignalError.from(code: session_cipher_decrypt_pre_key_signal_message(cipher, preKeySignalMessage, nil, &output)); + guard error == nil && output != nil else { + return .failure(error ?? .unknown); + } + return .success(output!); + }) + switch result { + case .failure(let error): + return .failure(error); + case .success(let preKeySignalMessage): + defer { + signal_buffer_free(preKeySignalMessage); + } + return .success(Data(bytes: signal_buffer_data(preKeySignalMessage), count: signal_buffer_len(preKeySignalMessage))); + } + } + + fileprivate func decryptSignalMessage(key: Key) -> Result { + let result = key.key.withUnsafeBytes({ (bytes) -> Result in + var output: OpaquePointer?; + var signalMessage: OpaquePointer?; + var error = SignalError.from(code: signal_message_deserialize(&signalMessage, bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), key.key.count, self.context.globalContext)); + guard error == nil && signalMessage != nil else { + return .failure(error ?? .unknown); + } + defer { + signal_type_unref(signalMessage); + } + error = SignalError.from(code: session_cipher_decrypt_signal_message(cipher, signalMessage, nil, &output)); + guard error == nil && output != nil else { + return .failure(error ?? .unknown); + } + return .success(output!); + }); + switch result { + case .failure(let error): + return .failure(error); + case .success(let signalMessage): + defer { + signal_buffer_free(signalMessage); + } + return .success(Data(bytes: signal_buffer_data(signalMessage), count: signal_buffer_len(signalMessage))); + } + } + + open class Key { + + public let key: Data; + public let deviceId: Int32; + public let prekey: Bool; + + public init(key: Data, deviceId: Int32, prekey: Bool) { + self.key = key; + self.deviceId = deviceId; + self.prekey = prekey; + } + + } + +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalStorage.swift b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalStorage.swift new file mode 100644 index 0000000..7cdc309 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/SignalStorage.swift @@ -0,0 +1,396 @@ +// +// SignalStorage.swift +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// + +import Foundation +import libsignal + +open class SignalStorage { + + public let sessionStore: SignalSessionStoreProtocol; + public let preKeyStore: SignalPreKeyStoreProtocol; + public let signedPreKeyStore: SignalSignedPreKeyStoreProtocol; + public let identityKeyStore: SignalIdentityKeyStoreProtocol; + public let senderKeyStore: SignalSenderKeyStoreProtocol; + + fileprivate(set) var storeContext: OpaquePointer?; + + public init(sessionStore: SignalSessionStoreProtocol, preKeyStore: SignalPreKeyStoreProtocol, signedPreKeyStore: SignalSignedPreKeyStoreProtocol, identityKeyStore: SignalIdentityKeyStoreProtocol, senderKeyStore: SignalSenderKeyStoreProtocol) { + self.sessionStore = sessionStore; + self.preKeyStore = preKeyStore; + self.signedPreKeyStore = signedPreKeyStore; + self.identityKeyStore = identityKeyStore; + self.senderKeyStore = senderKeyStore; + } + + deinit { + if storeContext != nil { + signal_protocol_store_context_destroy(storeContext); + } + storeContext = nil; + } + + open func setup(withContext context: SignalContext) { + signal_protocol_store_context_create(&storeContext, context.globalContext); + + var sessionStoreCallbacks = signal_protocol_session_store(); + sessionStoreCallbacks.load_session_func = load_session_func; + sessionStoreCallbacks.get_sub_device_sessions_func = get_sub_device_sessions_func; + sessionStoreCallbacks.store_session_func = store_session_func; + sessionStoreCallbacks.contains_session_func = contains_session_func; + sessionStoreCallbacks.delete_session_func = delete_session_func; + sessionStoreCallbacks.delete_all_sessions_func = delete_all_sessions_func; + sessionStoreCallbacks.destroy_func = destroy_func; + sessionStoreCallbacks.user_data = SignalContext.bridge(self.sessionStore as AnyObject); + + signal_protocol_store_context_set_session_store(storeContext, &sessionStoreCallbacks); + + var preKeyStoreCallbacks = signal_protocol_pre_key_store(); + preKeyStoreCallbacks.load_pre_key = load_pre_key; + preKeyStoreCallbacks.store_pre_key = store_pre_key; + preKeyStoreCallbacks.contains_pre_key = contains_pre_key; + preKeyStoreCallbacks.remove_pre_key = remove_pre_key; + preKeyStoreCallbacks.destroy_func = destroy_func; + preKeyStoreCallbacks.user_data = SignalContext.bridge(self.preKeyStore as AnyObject); + + signal_protocol_store_context_set_pre_key_store(storeContext, &preKeyStoreCallbacks); + + var signedPreKeyStoreCallbacks = signal_protocol_signed_pre_key_store(); + signedPreKeyStoreCallbacks.load_signed_pre_key = load_signed_pre_key; + signedPreKeyStoreCallbacks.store_signed_pre_key = store_signed_pre_key; + signedPreKeyStoreCallbacks.contains_signed_pre_key = contains_signed_pre_key; + signedPreKeyStoreCallbacks.remove_signed_pre_key = remove_signed_pre_key; + signedPreKeyStoreCallbacks.destroy_func = destroy_func; + signedPreKeyStoreCallbacks.user_data = SignalContext.bridge(self.signedPreKeyStore as AnyObject); + + signal_protocol_store_context_set_signed_pre_key_store(storeContext, &signedPreKeyStoreCallbacks); + + var identityKeyStoreCallbacks = signal_protocol_identity_key_store(); + identityKeyStoreCallbacks.get_identity_key_pair = get_identity_key_pair; + identityKeyStoreCallbacks.get_local_registration_id = get_local_registration_id; + identityKeyStoreCallbacks.save_identity = save_identity; + identityKeyStoreCallbacks.is_trusted_identity = is_trusted_identity; + identityKeyStoreCallbacks.destroy_func = destroy_func; + identityKeyStoreCallbacks.user_data = SignalContext.bridge(self.identityKeyStore as AnyObject); + + signal_protocol_store_context_set_identity_key_store(storeContext, &identityKeyStoreCallbacks); + + var senderKeyStoreCallbacks = signal_protocol_sender_key_store(); + senderKeyStoreCallbacks.store_sender_key = store_sender_key; + senderKeyStoreCallbacks.load_sender_key = load_sender_key; + senderKeyStoreCallbacks.destroy_func = destroy_func; + identityKeyStoreCallbacks.user_data = SignalContext.bridge(self.senderKeyStore as AnyObject); + + signal_protocol_store_context_set_sender_key_store(storeContext, &senderKeyStoreCallbacks); + } + + open func regenerateKeys(wipe: Bool = false) -> Bool { + return false; + }; +} + +public protocol SignalSessionStoreProtocol: AnyObject { + + func sessionRecord(forAddress address: SignalAddress) -> Data?; + + func allDevices(for: String, activeAndTrusted: Bool) -> [Int32]; + + func storeSessionRecord(_ data: Data, forAddress: SignalAddress) -> Bool; + + func containsSessionRecord(forAddress: SignalAddress) -> Bool; + + func deleteSessionRecord(forAddress: SignalAddress) -> Bool; + + func deleteAllSessions(for: String) -> Bool; +} + +public protocol SignalPreKeyStoreProtocol: AnyObject { + + func currentPreKeyId() -> UInt32; + + func loadPreKey(withId: UInt32) -> Data?; + + func storePreKey(_ data: Data, withId: UInt32) -> Bool; + + func containsPreKey(withId: UInt32) -> Bool; + + func deletePreKey(withId: UInt32) -> Bool; + + func flushDeletedPreKeys() -> Bool; +} + +public protocol SignalSignedPreKeyStoreProtocol: AnyObject { + + func countSignedPreKeys() -> Int; + + func loadSignedPreKey(withId: UInt32) -> Data?; + + func storeSignedPreKey(_ data: Data, withId: UInt32) -> Bool; + + func containsSignedPreKey(withId: UInt32) -> Bool; + + func deleteSignedPreKey(withId: UInt32) -> Bool; +} + +public protocol SignalIdentityKeyStoreProtocol: AnyObject { + + func keyPair() -> SignalIdentityKeyPairProtocol?; + func localRegistrationId() -> UInt32; + + func save(identity: SignalAddress, key: SignalIdentityKeyProtocol?) -> Bool; + func isTrusted(identity: SignalAddress, key: SignalIdentityKeyProtocol?) -> Bool; + func save(identity: SignalAddress, publicKeyData: Data?) -> Bool; + func isTrusted(identity: SignalAddress, publicKeyData: Data?) -> Bool; + + func setStatus(_ status: IdentityStatus, forIdentity: SignalAddress) -> Bool; + func setStatus(active: Bool, forIdentity: SignalAddress) -> Bool; + + func identities(forName: String) -> [Identity]; + func identityFingerprint(forAddress address: SignalAddress) -> String? +} + +public protocol SignalIdentityKeyProtocol: AnyObject { + var publicKeyPointer: OpaquePointer { get } + var publicKey: Data? { get } + + func serialized() -> Data; +} + +public protocol SignalIdentityKeyPairProtocol: SignalIdentityKeyProtocol { + var keyPairPointer: OpaquePointer? { get } + var keyPair: Data? { get } + var privateKeyPointer: OpaquePointer { get } + var privateKey: Data? { get } +} + +public protocol SignalSenderKeyStoreProtocol: AnyObject { + + func storeSenderKey(_ key: Data, address: SignalAddress?, groupId: String?) -> Bool; + func loadSenderKey(forAddress: SignalAddress?, groupId: String?) -> Data?; +} + +fileprivate func load_session_func(record: UnsafeMutablePointer?, userRecord: UnsafeMutablePointer?, address: UnsafePointer?, userData: UnsafeMutableRawPointer?) -> CInt { + let sessionStore: SignalSessionStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSessionStoreProtocol; + guard let addr = SignalAddress(from: address) else { + return -1; + } + + guard let data = sessionStore.sessionRecord(forAddress: addr) else { + return 0; + } + + data.withUnsafeBytes({ (ptr: UnsafeRawBufferPointer) -> Void in + record?.initialize(to: signal_buffer_create(ptr.baseAddress!.assumingMemoryBound(to: UInt8.self), data.count)); + }) + return 1; +} + +fileprivate func get_sub_device_sessions_func(sessions: UnsafeMutablePointer?, name namePtr: UnsafePointer?, nameLen: Int, userData: UnsafeMutableRawPointer?) -> CInt { + let sessionStore: SignalSessionStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSessionStoreProtocol; + guard let name = String(validatingUTF8: namePtr!) else { + return -1; + } + let devices = sessionStore.allDevices(for: name, activeAndTrusted: false); + guard let list = signal_int_list_alloc() else { + return -1; + } + + devices.forEach { device in + signal_int_list_push_back(list, device); + } + return CInt(devices.count); +} + +fileprivate func store_session_func(address: UnsafePointer?, record: UnsafeMutablePointer?, recordLen: Int, userRecord: UnsafeMutablePointer?, userRecordLen: Int, userData: UnsafeMutableRawPointer?) -> CInt { + let sessionStore: SignalSessionStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSessionStoreProtocol; + guard let addr = SignalAddress(from: address) else { + return -1; + } + let data = Data(bytes: record!, count: recordLen); + return sessionStore.storeSessionRecord(data, forAddress: addr) ? 0 : -1; +} + +fileprivate func contains_session_func(address: UnsafePointer?, userData: UnsafeMutableRawPointer?) -> CInt { + let sessionStore: SignalSessionStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSessionStoreProtocol; + guard let addr = SignalAddress(from: address) else { + return -1; + } + return sessionStore.containsSessionRecord(forAddress: addr) ? 1 : 0; +} + +fileprivate func delete_session_func(address: UnsafePointer?, userData: UnsafeMutableRawPointer?) -> CInt { + let sessionStore: SignalSessionStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSessionStoreProtocol; + guard let addr = SignalAddress(from: address) else { + return -1; + } + return sessionStore.deleteSessionRecord(forAddress: addr) ? 1 : 0; +} + +fileprivate func delete_all_sessions_func(name namePtr: UnsafePointer?, nameLen: Int, userData: UnsafeMutableRawPointer?) -> CInt { + let sessionStore: SignalSessionStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSessionStoreProtocol; + guard let name = String(validatingUTF8: namePtr!) else { + return -1; + } + + return sessionStore.deleteAllSessions(for: name) ? 1 : 0; +} + +fileprivate func destroy_func(userData: UnsafeMutableRawPointer?) { + +} + +fileprivate func load_pre_key(record: UnsafeMutablePointer?, preKeyId: UInt32, userData: UnsafeMutableRawPointer?) -> CInt { + let preKeyStore: SignalPreKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalPreKeyStoreProtocol; + guard let preKey = preKeyStore.loadPreKey(withId: preKeyId) else { + return SG_ERR_INVALID_KEY_ID; + } + + preKey.withUnsafeBytes({ (bytes: UnsafeRawBufferPointer)->Void in + let buffer = signal_buffer_create(bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), preKey.count); + record?.initialize(to: buffer); + }) + return SG_SUCCESS; +} + +fileprivate func store_pre_key(preKeyId: UInt32, record: UnsafeMutablePointer?, recordLen: Int, userData: UnsafeMutableRawPointer?) -> CInt { + let preKeyStore: SignalPreKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalPreKeyStoreProtocol; + let data = Data(bytes: record!, count: recordLen); + return preKeyStore.storePreKey(data, withId: preKeyId) ? 0 : -1; +} + +fileprivate func contains_pre_key(preKeyId: UInt32, userData: UnsafeMutableRawPointer?) -> CInt { + let preKeyStore: SignalPreKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalPreKeyStoreProtocol; + return preKeyStore.containsPreKey(withId: preKeyId) ? 1 : 0; +} + +fileprivate func remove_pre_key(preKeyId: UInt32, userData: UnsafeMutableRawPointer?) -> CInt { + let preKeyStore: SignalPreKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalPreKeyStoreProtocol; + return preKeyStore.deletePreKey(withId: preKeyId) ? 0 : -1; +} + +fileprivate func load_signed_pre_key(record: UnsafeMutablePointer?, signedPreKeyId: UInt32, userData: UnsafeMutableRawPointer?) -> CInt { + let signedPreKeyStore: SignalSignedPreKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSignedPreKeyStoreProtocol; + guard let preKey = signedPreKeyStore.loadSignedPreKey(withId: signedPreKeyId) else { + return SG_ERR_INVALID_KEY_ID; + } + + preKey.withUnsafeBytes({ (bytes: UnsafeRawBufferPointer)->Void in + let buffer = signal_buffer_create(bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), preKey.count); + record?.initialize(to: buffer); + }) + return SG_SUCCESS; +} + +fileprivate func store_signed_pre_key(signedPreKeyId: UInt32, record: UnsafeMutablePointer?, recordLen: Int, userData: UnsafeMutableRawPointer?) -> CInt { + let signedPreKeyStore: SignalSignedPreKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSignedPreKeyStoreProtocol; + let data = Data(bytes: record!, count: recordLen); + return signedPreKeyStore.storeSignedPreKey(data, withId: signedPreKeyId) ? 0 : -1; +} + +fileprivate func contains_signed_pre_key(signedPreKeyId: UInt32, userData: UnsafeMutableRawPointer?) -> CInt { + let signedPreKeyStore: SignalSignedPreKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSignedPreKeyStoreProtocol; + return signedPreKeyStore.containsSignedPreKey(withId: signedPreKeyId) ? 1 : 0; +} + +fileprivate func remove_signed_pre_key(signedPreKeyId: UInt32, userData: UnsafeMutableRawPointer?) -> CInt { + let signedPreKeyStore: SignalSignedPreKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSignedPreKeyStoreProtocol; + return signedPreKeyStore.deleteSignedPreKey(withId: signedPreKeyId) ? 0 : -1; +} + +fileprivate func get_identity_key_pair(publicData: UnsafeMutablePointer?, privateData: UnsafeMutablePointer?, userData: UnsafeMutableRawPointer?) -> CInt { + let identityKeyStore: SignalIdentityKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalIdentityKeyStoreProtocol; + guard let keyPair = identityKeyStore.keyPair() else { + return -1; + } + + if let publicKey = keyPair.publicKey { + publicKey.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> Void in + let buffer = signal_buffer_create(bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), publicKey.count); + publicData?.initialize(to: buffer); + } + } + + if let privateKey = keyPair.privateKey { + privateKey.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> Void in + let buffer = signal_buffer_create(bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), privateKey.count); + privateData?.initialize(to: buffer); + } + } + + return 0; +} + +fileprivate func get_local_registration_id(userData: UnsafeMutableRawPointer?, registrationId: UnsafeMutablePointer?) -> CInt { + let identityKeyStore: SignalIdentityKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalIdentityKeyStoreProtocol; + let regId = identityKeyStore.localRegistrationId(); + if regId > 0 { + registrationId?.initialize(to: regId); + return 0; + } else { + return -1; + } +} + +fileprivate func save_identity(address: UnsafePointer?, keyData: UnsafeMutablePointer?, keyLen: Int, userData: UnsafeMutableRawPointer?) -> CInt { + let identityKeyStore: SignalIdentityKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalIdentityKeyStoreProtocol; + guard let addr = SignalAddress(from: address) else { + return -1; + } + return identityKeyStore.save(identity: addr, publicKeyData: keyData == nil ? nil : Data(bytes: keyData!, count: keyLen)) ? 0 : -1; +} + +fileprivate func is_trusted_identity(address: UnsafePointer?, keyData: UnsafeMutablePointer?, keyLen: Int, userData: UnsafeMutableRawPointer?) -> CInt { + let identityKeyStore: SignalIdentityKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalIdentityKeyStoreProtocol; + guard let addr = SignalAddress(from: address) else { + return -1; + } + return identityKeyStore.isTrusted(identity: addr, publicKeyData: Data(bytes: keyData!, count: keyLen)) ? 1 : 0; +} + +fileprivate func store_sender_key(senderKeyName: UnsafePointer?, record: UnsafeMutablePointer?, recordLen: Int, userRecord: UnsafeMutablePointer?, userRecordLen: Int, userData: UnsafeMutableRawPointer?) -> CInt { + let senderKeyStore: SignalSenderKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSenderKeyStoreProtocol; + + var sender = senderKeyName?.pointee.sender; + let groupId = senderKeyName?.pointee.group_id; + let group = groupId != nil ? String(validatingUTF8: groupId!) : nil; + let addr = SignalAddress(from: &sender) + let key = Data(bytes: record!, count: recordLen); + + return senderKeyStore.storeSenderKey(key, address: addr, groupId: group) ? 0 : -1; +} + +fileprivate func load_sender_key(record: UnsafeMutablePointer?, userRecord: UnsafeMutablePointer?, senderKeyName: UnsafePointer?, userData: UnsafeMutableRawPointer?) -> CInt { + let senderKeyStore: SignalSenderKeyStoreProtocol = SignalContext.bridge(fromPointer: userData!) as! SignalSenderKeyStoreProtocol; + + var sender = senderKeyName?.pointee.sender; + let groupId = senderKeyName?.pointee.group_id; + let group = groupId != nil ? String(validatingUTF8: groupId!) : nil; + let addr = SignalAddress(from: &sender) + + if let key = senderKeyStore.loadSenderKey(forAddress: addr, groupId: group) { + key.withUnsafeBytes({ (bytes: UnsafeRawBufferPointer) -> Void in + let buffer = signal_buffer_create(bytes.baseAddress!.assumingMemoryBound(to: UInt8.self), key.count); + record?.initialize(to: buffer); + }); + return 1; + } else { + return 0; + } +} diff --git a/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/TigaseSwift_OMEMO.h b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/TigaseSwift_OMEMO.h new file mode 100644 index 0000000..2357a4d --- /dev/null +++ b/ThirdParty/MartinOMEMO/Sources/MartinOMEMO/TigaseSwift_OMEMO.h @@ -0,0 +1,37 @@ +// +// TigaseSwift_OMEMO.h +// +// TigaseSwift OMEMO +// Copyright (C) 2019 "Tigase, Inc." +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. Look for COPYING file in the top folder. +// If not, see https://www.gnu.org/licenses/. +// +// + +#import "protocol.h" +#import "signal_protocol.h" +#import "key_helper.h" +#import "session_builder.h" +#import "session_cipher.h" + +//! Project version number for TigaseSwift_OMEMO. +//FOUNDATION_EXPORT double TigaseSwift_OMEMOVersionNumber; + +//! Project version string for TigaseSwift_OMEMO. +//FOUNDATION_EXPORT const unsigned char TigaseSwift_OMEMOVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/ThirdParty/MartinOMEMO/Tests/LinuxMain.swift b/ThirdParty/MartinOMEMO/Tests/LinuxMain.swift new file mode 100644 index 0000000..effe3b6 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import MartinOMEMOTests + +var tests = [XCTestCaseEntry]() +tests += MartinOMEMOTests.allTests() +XCTMain(tests) diff --git a/ThirdParty/MartinOMEMO/Tests/MartinOMEMOTests/MartinOMEMOTests.swift b/ThirdParty/MartinOMEMO/Tests/MartinOMEMOTests/MartinOMEMOTests.swift new file mode 100644 index 0000000..2290bb5 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Tests/MartinOMEMOTests/MartinOMEMOTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import MartinOMEMO + +final class MartinOMEMOTests: XCTestCase { + func testExample() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + + } + + static var allTests = [ + ("testExample", testExample), + ] +} diff --git a/ThirdParty/MartinOMEMO/Tests/MartinOMEMOTests/XCTestManifests.swift b/ThirdParty/MartinOMEMO/Tests/MartinOMEMOTests/XCTestManifests.swift new file mode 100644 index 0000000..ba24694 --- /dev/null +++ b/ThirdParty/MartinOMEMO/Tests/MartinOMEMOTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(MartinOMEMOTests.allTests), + ] +} +#endif diff --git a/UITests/LayoutCheckUITests.swift b/UITests/LayoutCheckUITests.swift new file mode 100644 index 0000000..7bda412 --- /dev/null +++ b/UITests/LayoutCheckUITests.swift @@ -0,0 +1,92 @@ +import XCTest + +/// Layout regression checks: the inline search fields must live at the top +/// of the Chats and Contacts tabs, above the lists (they used to be pushed +/// to the bottom when implemented via `.searchable`). +final class LayoutCheckUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-luma-ui-test-chat"] + app.launch() + return app + } + + func testChatsAndContactsSearchFieldsAreAtTop() throws { + let app = launchApp() + dismissAnyAlert(app) + + // Keep the clean screen on display long enough for external + // `simctl io screenshot` captures of the tab bar. + Thread.sleep(forTimeInterval: 12) + + // The Telegram-style tabs render `.searchable` fields as + // XCUIElementTypeSearchField (a TextField subtype in newer runtimes, + // but queried as `searchFields` on the iOS 26 simulator). + let chatSearch = app.searchFields["Поиск чатов"] + XCTAssertTrue(chatSearch.waitForExistence(timeout: 30)) + addScreenshot(named: "chats-search-top", app: app) + + let chatRow = app.staticTexts["uitest-peer"] + XCTAssertTrue(chatRow.waitForExistence(timeout: 15)) + XCTAssertLessThan( + chatSearch.frame.midY, + chatRow.frame.midY, + "The chats search field must sit above the chat list" + ) + + app.buttons["Контакты"].tap() + + let contactSearch = app.searchFields["Поиск контактов"] + XCTAssertTrue(contactSearch.waitForExistence(timeout: 15)) + XCTAssertLessThan( + contactSearch.frame.minY, + app.frame.height * 0.4, + "The contacts search field must sit near the top of the screen" + ) + addScreenshot(named: "contacts-search-top", app: app) + } + + func testTabBarHidesInsideChat() throws { + let app = launchApp() + dismissAnyAlert(app) + + let chatRow = app.staticTexts["uitest-peer"] + XCTAssertTrue(chatRow.waitForExistence(timeout: 30)) + chatRow.tap() + + let timeline = app.descendants(matching: .any).matching(identifier: "chat-timeline").firstMatch + XCTAssertTrue(timeline.waitForExistence(timeout: 30)) + + // A pushed conversation takes the whole screen: no tab bar. + let tabBar = app.tabBars.firstMatch + XCTAssertFalse(tabBar.exists, "The tab bar must be hidden inside a chat") + + // Popping back to the list restores the tab bar. + app.navigationBars.buttons.firstMatch.tap() + XCTAssertTrue(tabBar.waitForExistence(timeout: 15)) + } + + private func addScreenshot(named name: String, app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + /// The unsigned debug build can present a system "missing rights" alert + /// on the simulator; dismiss it so it does not cover the UI. + private func dismissAnyAlert(_ app: XCUIApplication) { + let hosts = [app, XCUIApplication(bundleIdentifier: "com.apple.springboard")] + for host in hosts { + let ok = host.alerts.buttons["OK"] + if ok.waitForExistence(timeout: 8) { + ok.tap() + return + } + } + } +} diff --git a/UITests/TimelineUITests.swift b/UITests/TimelineUITests.swift new file mode 100644 index 0000000..869f13c --- /dev/null +++ b/UITests/TimelineUITests.swift @@ -0,0 +1,81 @@ +import XCTest + +final class TimelineUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + private func launchApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = ["-luma-ui-test-chat"] + app.launch() + return app + } + + private func bubble(_ id: String, in app: XCUIApplication) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: "bubble-\(id)").firstMatch + } + + private func openChat(_ app: XCUIApplication) { + let chatRow = app.staticTexts["uitest-peer"] + XCTAssertTrue(chatRow.waitForExistence(timeout: 30)) + chatRow.tap() + XCTAssertTrue( + app.descendants(matching: .any).matching(identifier: "chat-timeline").firstMatch + .waitForExistence(timeout: 30) + ) + XCTAssertTrue(bubble("uitest-msg-60", in: app).waitForExistence(timeout: 30)) + } + + func testTimelineVerticalScrollWorks() throws { + let app = launchApp() + openChat(app) + + for _ in 0..<7 { + app.swipeDown() + } + XCTAssertTrue(bubble("uitest-msg-1", in: app).waitForExistence(timeout: 10)) + + for _ in 0..<9 { + app.swipeUp() + } + XCTAssertTrue(bubble("uitest-msg-60", in: app).waitForExistence(timeout: 10)) + } + + func testReplySwipeShowsPlateAndScrollStillWorks() throws { + let app = launchApp() + openChat(app) + + // The initial scroll position varies slightly between runs, so pick + // the first hittable bubble from the bottom area of the timeline. + let candidates = [60, 59, 58, 55, 50, 45].map { + bubble("uitest-msg-\($0)", in: app) + } + // A controlled right-to-left drag: the stock swipeLeft() flicks too + // fast for the gesture's horizontal lock to engage reliably. The drag + // itself can occasionally be swallowed, so retry on the next hittable + // bubble until the reply plate appears. + let banner = app.descendants(matching: .any).matching(identifier: "reply-banner").firstMatch + var bannerAppeared = false + for _ in 0..<3 where !bannerAppeared { + guard let target = candidates.first(where: { $0.exists && $0.isHittable }) else { + break + } + let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) + start.press(forDuration: 0.15, thenDragTo: start.withOffset(CGVector(dx: -200, dy: 0))) + bannerAppeared = banner.waitForExistence(timeout: 2) + } + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "after-drag" + attachment.lifetime = .keepAlways + add(attachment) + XCTAssertTrue(bannerAppeared, "A left swipe must open the reply plate") + + app.buttons["Отменить ответ"].tap() + for _ in 0..<7 { + app.swipeDown() + } + XCTAssertTrue(bubble("uitest-msg-1", in: app).waitForExistence(timeout: 10)) + } +} + diff --git a/project.yml b/project.yml index 0548e7c..f51a8ee 100644 --- a/project.yml +++ b/project.yml @@ -16,9 +16,10 @@ settings: base: SWIFT_VERSION: 5.9 CURRENT_PROJECT_VERSION: 31 - MARKETING_VERSION: 0.10.6 + MARKETING_VERSION: 20261013.0 ENABLE_USER_SCRIPT_SANDBOXING: true CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: true + COMPANION_BUNDLE_IDENTIFIER: app.luma.chat configs: Debug: SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG @@ -30,28 +31,35 @@ packages: url: https://github.com/tigase/Martin.git exactVersion: 3.2.4 MartinOMEMO: - url: https://github.com/tigase/MartinOMEMO.git - exactVersion: 2.2.3 + path: ThirdParty/MartinOMEMO WebRTC: url: https://github.com/stasel/WebRTC.git exactVersion: 150.0.0 + OpenSSL: + url: https://github.com/krzyzanowskim/OpenSSL.git + from: 3.4.0 +# SwiftChat: +# url: https://github.com/unionst/swift-chat.git +# from: 1.0.0 targets: Luma: type: application platform: iOS - deploymentTarget: "17.0" + deploymentTarget: "26.0" sources: - path: Sources/App - path: Sources/Shared - resources: - path: Resources/Shared.xcassets + buildPhase: resources - path: Resources/AppAssets.xcassets + buildPhase: resources dependencies: - package: Martin - package: MartinOMEMO - package: WebRTC - - target: LumaWatch + - package: OpenSSL + # - package: SwiftChat info: path: Config/Luma-Info.plist properties: @@ -60,6 +68,15 @@ targets: CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION) UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight UIBackgroundModes: - audio - fetch @@ -70,15 +87,21 @@ targets: NSMicrophoneUsageDescription: Luma использует микрофон для голосовых сообщений и звонков. NSLocalNetworkUsageDescription: Luma использует локальную сеть для прямого соединения аудио- и видеозвонков. NSLocationWhenInUseUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате. + NSFaceIDUsageDescription: Luma использует Face ID для разблокировки приложения. + NSPhotoLibraryAddUsageDescription: Luma сохраняет фотографии и видео в вашу медиатеку. entitlements: path: Config/Luma.entitlements properties: {} settings: base: - PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat + PRODUCT_BUNDLE_IDENTIFIER: $(COMPANION_BUNDLE_IDENTIFIER) PRODUCT_NAME: Luma TARGETED_DEVICE_FAMILY: "1,2" + # The remaining diagnostics come from APIs that Martin still requires + # for XMPP/TLS compatibility (QueueDispatcher and SecureTransport). + SWIFT_SUPPRESS_WARNINGS: true SUPPORTS_MACCATALYST: false + DEVELOPMENT_TEAM: K3AS449A74 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: true @@ -87,17 +110,19 @@ targets: LumaMac: type: application platform: macOS - deploymentTarget: "14.0" + deploymentTarget: "26.0" sources: - path: Sources/App - path: Sources/Shared - resources: - path: Resources/Shared.xcassets + buildPhase: resources - path: Resources/AppAssets.xcassets + buildPhase: resources dependencies: - package: Martin - package: MartinOMEMO - package: WebRTC + - package: OpenSSL info: path: Config/LumaMac-Info.plist properties: @@ -110,6 +135,7 @@ targets: NSCameraUsageDescription: Luma использует камеру для фотографий, видеосообщений и видеозвонков. NSMicrophoneUsageDescription: Luma использует микрофон для голосовых сообщений и звонков. NSLocationUsageDescription: Luma использует геопозицию только когда вы выбираете отправку точки в чате. + NSFaceIDUsageDescription: Luma использует Touch ID для разблокировки приложения. entitlements: path: Config/LumaMac.entitlements properties: @@ -117,6 +143,7 @@ targets: com.apple.security.network.client: true com.apple.security.network.server: true com.apple.security.files.user-selected.read-only: true + com.apple.security.files.user-selected.read-write: true com.apple.security.device.camera: true com.apple.security.device.audio-input: true com.apple.security.personal-information.location: true @@ -124,6 +151,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.macos PRODUCT_NAME: Luma + DEVELOPMENT_TEAM: K3AS449A74 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: true @@ -136,12 +164,13 @@ targets: # try to produce Luma.app/Luma. type: application platform: watchOS - deploymentTarget: "10.0" + deploymentTarget: "26.0" sources: - path: Sources/Watch - resources: - path: Resources/Shared.xcassets + buildPhase: resources - path: Resources/AppAssets.xcassets + buildPhase: resources info: path: Config/LumaWatch-Info.plist properties: @@ -152,7 +181,7 @@ targets: CFBundleVersion: $(CURRENT_PROJECT_VERSION) NSMicrophoneUsageDescription: Luma использует микрофон Apple Watch для голосовых сообщений. WKApplication: true - WKCompanionAppBundleIdentifier: app.luma.chat + WKCompanionAppBundleIdentifier: $(COMPANION_BUNDLE_IDENTIFIER) settings: base: PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.watchkitapp @@ -161,6 +190,7 @@ targets: PRODUCT_NAME: LumaWatch GENERATE_INFOPLIST_FILE: NO TARGETED_DEVICE_FAMILY: "4" + DEVELOPMENT_TEAM: K3AS449A74 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: true @@ -169,7 +199,7 @@ targets: LumaTests: type: bundle.unit-test platform: iOS - deploymentTarget: "17.0" + deploymentTarget: "26.0" sources: - path: Tests dependencies: @@ -179,6 +209,19 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.tests GENERATE_INFOPLIST_FILE: true + LumaUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "26.0" + sources: + - path: UITests + dependencies: + - target: Luma + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: app.luma.chat.uitests + GENERATE_INFOPLIST_FILE: true + schemes: Luma: build: @@ -191,6 +234,7 @@ schemes: config: Debug targets: - LumaTests + - LumaUITests archive: config: Release LumaMac: