Compare commits

213 Commits

Author SHA1 Message Date
b32d8ed061 feat(chat-input): привести lock flow записи ГС к Telegram (геометрия и анимации) 2026-04-19 21:37:55 +05:00
5e6d66b762 refactor: декомпозировать runtime и chat-архитектуру, вынести use-case в domain и убрать UiEntryPoint 2026-04-19 16:51:52 +05:00
15bca1ec34 Рефакторинг network-runtime: выделены ProtocolRuntimeCore, DeviceRuntimeService и OutgoingMessagePipelineService 2026-04-18 20:42:19 +05:00
aa0fa3fdb1 Продолжение рефакторинга 2026-04-18 18:11:32 +05:00
cedbd204c2 Архитектурный рефакторинг: единый SessionStore/SessionReducer, Hilt DI и декомпозиция ProtocolManager 2026-04-18 18:11:21 +05:00
660ba12c8c refactor: split protocol/session architecture and fix auth navigation regressions 2026-04-18 01:28:30 +05:00
7f4684082e fix: Фикс бага с подключением при первичной регистрации юзера 2026-04-17 23:45:52 +05:00
1a57d8f4d0 dev: перенос текущих фиксов протокола, синка и send-flow 2026-04-17 21:49:51 +05:00
1cf645ea3f Хотфиксы чатов: камера, эмодзи и стабильность синхронизации
All checks were successful
Android Kernel Build / build (push) Successful in 20m11s
2026-04-17 14:33:46 +05:00
17f37b06ec Уточнить Release Notes для релиза 1.5.4 2026-04-17 14:32:01 +05:00
d008485a9d Merge branch 'dev' 2026-04-17 14:31:16 +05:00
95ec00547c Релиз 1.5.4: обновить версию и Release Notes 2026-04-17 14:22:15 +05:00
edd0e73de9 Исправить анимацию waveform после перемотки ГС без отката к началу 2026-04-17 14:19:32 +05:00
7199e174f1 Merge branch 'dev'
All checks were successful
Android Kernel Build / build (push) Successful in 20m25s
2026-04-17 01:38:38 +05:00
7521b9a11b Релиз 1.5.3: хотфиксы протокола, синка и больших логов 2026-04-17 01:31:06 +05:00
484c02c867 Релиз 1.5.3: хотфиксы протокола, синка и больших логов 2026-04-17 01:30:57 +05:00
53e2119feb Надёжный фикс Protocol: singleton, connection generation и single-flight reconnect через Mutex
All checks were successful
Android Kernel Build / build (push) Successful in 21m24s
2026-04-17 00:39:46 +05:00
664f9fd7ae Merge branch 'dev' into master
All checks were successful
Android Kernel Build / build (push) Successful in 20m23s
2026-04-16 23:04:39 +05:00
103ae134a5 Критический фикс отправки после верификации устройства
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-04-16 23:02:18 +05:00
2066eb9f03 Критический фикс отправки после верификации устройства и релиз 1.5.2 2026-04-16 23:00:07 +05:00
2fc652cacb Релиз 1.5.2: обновление версии и ReleaseNotes
All checks were successful
Android Kernel Build / build (push) Successful in 20m45s
2026-04-16 22:37:14 +05:00
6242e3c34f Исправлена перемотка голосовых и устранены конфликты жестов
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-04-16 22:32:03 +05:00
0c150a3113 Merge branch 'master' into dev 2026-04-16 03:36:17 +05:00
ab9145c77a Исправлен race инициализации аккаунта после device verification 2026-04-16 03:35:37 +05:00
45134665b3 Фикс UI: ограничение аккаунтов в сайдбаре и корректное позиционирование кнопки записи
All checks were successful
Android Kernel Build / build (push) Successful in 21m19s
2026-04-15 22:00:07 +05:00
38ae9bca66 Релиз 1.5.1: merge dev в master и обновление ReleaseNotes
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-04-15 21:36:59 +05:00
0d21769399 Доработан UI чатов и звонков (запись ГС, экран звонков, профиль) 2026-04-15 21:27:56 +05:00
060d0cbd12 Чат/звонки/коннект: Telegram-like UX и ряд фиксов 2026-04-15 02:29:08 +05:00
4396611355 Доработан мини-плеер голосовых: интеграция в чат, smooth UI, фикс баг с auto-play при смене скорости 2026-04-14 13:53:01 +05:00
ce7f913de7 fix: Большое количество изменений 2026-04-14 04:19:34 +05:00
cb920b490d Смена иконки приложения — калькулятор, погода, заметки + экран выбора в настройка 2026-04-12 23:59:04 +05:00
b1fc623f5e Выделение текста + фикс ANR при записи ГС 2026-04-12 23:05:55 +05:00
ad08af7f0c Выделение текста — selection mode, handles, toolbar, magnifier 2026-04-12 18:37:38 +05:00
9fe5f35923 fix: посимвольное выделение + magnifier на позиции handle + haptic на каждый символ
Было: word snap при drag handle → нельзя выделить часть слова
Стало: посимвольно при drag (word snap только при первом long press)

Magnifier: показывается на позиции handle (текущий символ),
а не на позиции пальца. По Y — центр строки текста.

Haptic: TEXT_HANDLE_MOVE на каждый символ (не на каждое слово).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:17:32 +05:00
78925dd61d fix: magnifier правильные координаты + haptic при изменении выделения
Magnifier:
- Конвертация overlay-local → view-local координаты для Magnifier.show()
- Builder: 240×64px, cornerRadius 12, elevation 4, offset -80 (над текстом)

Haptic:
- TEXT_HANDLE_MOVE при каждом изменении selectionStart/selectionEnd
- Как в Telegram: вибрация при перемещении handle по словам

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:12:29 +05:00
1ac3d93f74 fix: правильные координаты text selection — window→overlay-local конвертация
Root cause: overlay Canvas рисует в локальных координатах, но LayoutInfo
возвращает позицию в window coordinates. Разница = position status bar,
toolbar, и parent padding → highlight смещался вниз.

Фикс:
- onGloballyPositioned на overlay Box → знаем overlayWindowX/Y
- Canvas: offsetX/Y = info.windowX - overlayWindowX (window→local)
- getCharOffsetFromCoords: overlay-local → text-local через ту же delta
- Handle positions теперь в overlay-local координатах → drag работает

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:08:12 +05:00
6ad24974e0 feat: magnifier view setup + unit тесты для TextSelectionHelper
- setMagnifierView(view) в ChatDetailScreen через LaunchedEffect
- 9 unit тестов: initial state, clear, getSelectedText, boundary checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:15:50 +05:00
e825a1ef30 feat: добавить floating toolbar (Copy/Select All) и Magnifier (API 28+) для text selection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:14:30 +05:00
7fcf1195e1 feat: интегрировать TextSelectionHelper в ChatDetailScreen и MessageBubble
- TextSelectionHelper инстанс в ChatDetailScreen
- TextSelectionOverlay поверх LazyColumn
- Clear selection при scroll и при message selection mode
- onTextLongPress + onViewCreated проброшены через MessageBubble к AppleEmojiText

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:12:16 +05:00
a10482b794 feat: добавить onTextLongPress callback и getLayoutInfo() в AppleEmojiTextView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:05:25 +05:00
419761e34d feat: добавить TextSelectionOverlay — highlight, handles, drag interaction
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:03:35 +05:00
988896c080 feat: добавить TextSelectionHelper — core state, word snap, char offset
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:44:46 +05:00
b57e48fe20 fix: зависание записи ГС — race condition в startVoiceRecording + утечка isVoiceRecordTransitioning
Root cause 1: startVoiceRecording() проверял только isVoiceRecording,
но isVoiceRecording=true ставился через 192ms в scope.launch. При быстром
двойном тапе два MediaRecorder создавались, первый терялся (утечка).
Фикс: добавлен guard на isVoiceRecordTransitioning и voiceRecorder!=null.

Root cause 2: isVoiceRecordTransitioning=true ставился перед scope.launch,
но если launch крашился или composable disposed, transitioning навсегда
оставался true — gesture guard блокировал все записи до перезапуска.
Фикс: try/catch в launch + reset в DisposableEffect.

Root cause 3: DisposableEffect проверял только isVoiceRecording, но не
voiceRecorder!=null — если recorder создан но isVoiceRecording ещё false,
recorder не освобождался при dispose.
Фикс: проверка voiceRecorder!=null в dispose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:17:03 +05:00
5c02ff6fd3 fix: LOCKED panel 1:1 с Telegram — полностью другой layout при lock
Telegram при LOCKED: таймер и dot СКРЫТЫ, вместо них:
- [Delete 44dp] — красная иконка удаления слева
- [Waveform] — заполняет оставшееся место
- Lock→Pause кнопка наверху (отдельный overlay)
- Circle = Send (без blob)

При RECORDING (без изменений):
- [dot][timer] [◀ Slide to cancel] [Circle+Blob]

Реализация: AnimatedContent crossfade между двумя полностью
разными panel layouts. RecordLockedControls больше не используется
в панели — delete в самой панели, pause в LockIcon overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:33:26 +05:00
7630aa6874 fix: LOCKED UI как в Telegram — CANCEL текст вместо ✕, без blob при lock
Telegram LOCKED layout: [timer] [waveform] [CANCEL] [⏸] [Send]

Изменения:
- RecordLockedControls: убрана круглая ✕ кнопка delete
- Вместо неё: текст "CANCEL" синим bold 15sp (как в Telegram)
- Пауза иконка увеличена 12→14dp, фон 15% alpha
- Blob анимация скрыта при LOCKED/PAUSED (Telegram: solid circle)
- Spacing 8→12dp между CANCEL и паузой

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:15:09 +05:00
afebbf6acb fix: slide-to-cancel не работает при LOCKED/PAUSED — как в Telegram
Telegram: при sendButtonVisible=true gesture handler возвращает false,
полностью блокируя горизонтальный свайп. Slide-to-cancel исчезает,
вместо него кнопка Cancel.

Изменения:
- Gesture handler: только RECORDING обрабатывает slide (было RECORDING||LOCKED)
- slideDx/slideDy не обновляются при LOCKED/PAUSED
- При lock: slideDx=0, slideDy=0 — сбрасываем горизонтальное смещение
- AnimatedContent уже переключает SlideToCancel→waveform при LOCKED

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:08:33 +05:00
aa3cc76646 fix: переписать SlideToCancel 1:1 с Telegram — chevron arrow, пульсация, entry animation
Telegram-exact SlideTextView:
- Chevron arrow: Canvas-drawn path 4×5dp, stroke 1.6dp, round caps (не текст ◀)
- Пульсация: ±6dp ТОЛЬКО при slideProgress > 0.8, скорость 12dp/s (3dp/250ms)
- Frame-based animation через LaunchedEffect (не infiniteTransition)
- Entry: slide in from right (translationX 20dp→0, 200ms) + fade in
- Текст: "Slide to cancel" 15sp normal weight (было 13sp medium)
- Цвет: #8E8E93 (Telegram key_chat_recordTime)
- Translation: finger × 0.3 damping + pulse offset × slideProgress
- Alpha: slideProgress × entryAlpha (плавно появляется и исчезает при свайпе)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:57:10 +05:00
8dac52c2eb Голосовые сообщения: lock UI как в Telegram — замок, пауза, slide-to-cancel, анимации 2026-04-11 22:17:46 +05:00
946ba7838c fix: переписать LockIcon 1:1 с Telegram — правильный замок с keyhole и idle animation
Telegram-exact lock icon:
- Body: 16×16dp прямоугольник, radius 3dp (заливка)
- Shackle: 8×8dp полукруг (stroke 1.7dp) + две ножки
- Левая ножка: idle "breathing" animation (1.2s cycle)
- Левая ножка: удлиняется при snap lock
- Keyhole: 4dp точка в центре body (цвет фона)
- Pause transform: body раздваивается с gap 1.66dp (Telegram exact)
- Pill background: 36×50dp с тенью
- Lock виден сразу при начале записи (не ждёт свайпа)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:01:53 +05:00
78fbe0b3c8 fix: переписать recording layout 1:1 с Telegram — правильные пропорции и overlay
Telegram dimensions:
- Circle: 48dp layout → 82dp visual (scale 1.71x), как circleRadius=41dp
- Lock: 50dp→36dp pill, 70dp выше центра круга
- Panel bar: full width Row с end=52dp для overlap
- Blob: 1.7x scale = 82dp visual (Telegram blob minRadius)
- Controls: 36dp (delete + pause)
- Tooltip: 90dp левее, 70dp выше

Layout architecture:
- Layer 1: Panel bar (Row с clip RoundedCornerShape)
- Layer 2: Circle overlay (graphicsLayer scale, NO clip)
- Layer 3: Lock overlay (graphicsLayer translationY, NO clip)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:59:03 +05:00
b13cdb7ea1 fix: переделать layout записи — layered архитектура вместо cramming в 40dp panel
- Panel bar (timer + slide-to-cancel/waveform) как Layer 1
- Mic/Send circle (48dp) как overlay Layer 2 поверх панели
- LockIcon как Layer 3 над кругом через graphicsLayer (без clip)
- Убран padding(end=94dp), заменён на padding(end=44dp)
- Убран offset(x=8dp) который толкал круг за экран
- Controls увеличены 28dp→36dp для лучшей тач-зоны
- Blob scale 2.05→1.8 пропорционально новому 48dp размеру

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:41:29 +05:00
b6055c98a5 polish: анимации записи 1:1 с Telegram — lock growth, staggered snap, EaseOutQuint, exit animation
- LockIcon: размер 50dp→36dp при свайпе, Y-позиция анимируется
- Staggered snap: rotation(250ms EASE_OUT_QUINT) + translate(350ms, delay 100ms)
- Двухфазный snap rotation с snapRotateBackProgress (порог 40%)
- SlideToCancel: пульсация ±6dp при >80%, демпфирование 0.3
- Send кнопка: scale-анимация 0→1 (150ms)
- Exit: AnimatedVisibility с fadeOut+shrinkVertically (300ms)
- Cancel distance: 92dp→140dp
- Фикс прыжка инпута при смене Voice/Video

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:26:40 +05:00
3e3f501b9b test: добавить unit тесты для helper функций записи голоса, очистка старого UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:47:18 +05:00
620200ca44 feat: интегрировать LockIcon, SlideToCancel, waveform и controls в панель записи
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:44:36 +05:00
47a6e20834 feat: добавить composable компоненты LockIcon, SlideToCancel, LockTooltip, VoiceWaveformBar, RecordLockedControls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:39:30 +05:00
fad8bfb1d1 feat: добавить состояние PAUSED и функции pause/resume для голосовых сообщений
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:36:51 +05:00
5e5c4c11ac Доработал голосовые сообщения и Telegram-подобный UI ввода 2026-04-11 02:06:15 +05:00
8d8b02a3ec Фикс: reply в группах с Desktop не отображался (hex key fallback для reply blob). Оптимизация circular reveal (prewarm bitmap). Логи reply парсинга в rosettadev1. Серые миниатюры в медиа (BlurHash). Анимация онбординга на Animatable вместо while-loop. 2026-04-10 22:34:57 +05:00
6124a52c84 Фикс: серые миниатюры в медиа-галерее — BlurHash декодирование превью
preview содержит формат UUID::blurhash, а парсился как raw Base64 → серый фон.
Теперь сначала пробует BlurHash.decode, fallback на Base64.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:57:52 +05:00
3485cb458f Merge branch 'master' of https://git.rosetta.im/Rosetta/mobile-android
All checks were successful
Android Kernel Build / build (push) Successful in 20m16s
2026-04-10 02:34:15 +05:00
0dd3255cfe Релиз v1.5.0: расшифровка групповых фото (Desktop v1.2.1 parity), анимация удаления, image logs, фикс caption
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:30:40 +05:00
accf34f233 Релиз v1.5.0: расшифровка групповых фото (Desktop v1.2.1 parity), анимация удаления, image logs, фикс caption
Some checks failed
Android Kernel Build / build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:27:58 +05:00
30327fade2 Фикс: расшифровка групповых фото — fallback на hex group key (Desktop v1.2.1 parity)
Desktop теперь шифрует аттачменты в группах hex-версией ключа.
Android пробует raw key, при неудаче — hex key. Фикс в 3 местах:
processDownloadedImage, downloadAndDecryptImage, loadBitmapForViewerImage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:20:46 +05:00
e5ff42ce1d Анимация удаления сообщений (Telegram-style): shrink + fade out 250ms
Двухэтапное удаление: pendingDeleteIds → AnimatedVisibility(shrinkVertically + fadeOut) → remove.
Остальные сообщения плавно сдвигаются на место удалённого.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:55:26 +05:00
06f43b9d4e Фикс: зашифрованные ключи не отображаются как caption в фото viewer
decryptStoredMessageText возвращал зашифрованный текст при неудачной расшифровке.
Теперь возвращает пустую строку. Дополнительно: фильтр base64-like строк в caption viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:43:38 +05:00
655cc10a3e Фикс: передача transportTag в SharedMedia viewer + логи загрузки фото в rosettadev1
SharedPhotoItem не передавал transportTag/transportServer в ViewableImage —
фото из медиа-галереи профиля не загружались в полноэкранном режиме.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:35:26 +05:00
d02f03516c Фикс: откат агрессивной проверки в updateMessageStatusInDb — ломала все статусы
All checks were successful
Android Kernel Build / build (push) Successful in 19m55s
Оставлена защита только в updateMessageStatusAndAttachmentsInDb (для фото race condition).
findMessageById обёрнут в отдельный try/catch чтобы ошибка в нём не блокировала основной update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:11:48 +05:00
d94b3ec37a Фикс: статус доставки фото в группах — БД больше не откатывает DELIVERED на WAITING
Some checks failed
Android Kernel Build / build (push) Has been cancelled
updateMessageStatusInDb и updateMessageStatusAndAttachmentsInDb теперь проверяют
текущий delivered в БД и никогда не понижают (DELIVERED→WAITING race condition).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:54:06 +05:00
73d3b2baf6 Merge dev → master: Релиз v1.4.9
All checks were successful
Android Kernel Build / build (push) Successful in 20m19s
QR-коды, forward desktop/iOS parity, анимированные звонки, переработанный онбординг, UI Telegram-style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:10:41 +05:00
66cc21fc29 Релиз 1.4.9: QR-коды, forward parity, звонки, онбординг, UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:36:51 +05:00
3bef589274 Фикс: Смен цвета текста на белый 2026-04-08 22:19:34 +05:00
7f79e4e0be QR экран: блокировка спама при смене темы — тема меняется только через circular reveal, нажатия игнорируются пока анимация не завершена. 2026-04-08 22:12:47 +05:00
ae78e4a162 QR экран: обои вместо градиентов, circular reveal анимация при смене обоев и темы, кнопка sun/moon переключает тему приложения, cooldown 600ms. Убрана кнопка Start New Call с экрана звонков. 2026-04-08 20:06:56 +05:00
325073fc09 QR-коды: экран профиля в стиле Telegram (5 тем, цветной QR, логотип, аватар), сканер (CameraX + ML Kit), deep links (rosetta:// + rosetta.im), Scan QR в drawer, Share/Copy. Фикс base64 prefix в аватарках. Call: кнопка на чужом профиле, анимированный градиентный фон (iOS parity), мгновенный rejected call. Статус-бар: чёрные иконки на белом фоне + restore при уходе. Удалены dev-логи. 2026-04-08 19:10:53 +05:00
8bfbba3159 Фикс: монотонный статус доставки — DELIVERED больше не откатывается на SENT. Логирование отправки/delivery в rosettadev1. 2026-04-08 16:24:11 +05:00
0427e2ba17 Фикс: полноэкранный просмотр фото — fallback на transportTag когда preview не содержит CDN тег. Логирование загрузки в rosettadev1. Глобальный ImageBitmapCache в viewer. Emoji-safe обрезка в reply. 2026-04-08 14:47:57 +05:00
1e259f52ee UI: унифицированы иконки навигации (ChevronLeft), фикс обрезки эмодзи в reply-превью, проверка доступности username при регистрации, отдельный экран биометрии, клавиатура прячется при скролле профиля и навигации, плавная анимация navbar при смене темы, аватарки в поиске. 2026-04-08 09:22:27 +05:00
299c84cb89 Онбординг: отдельный экран биометрии, новый UI пароля (Telegram-style), Skip на всех шагах. Биометрия per-account. Навбар плавно анимируется при смене темы. Поиск: аватарки в результатах. Профиль: клавиатура прячется при скролле. Фокус сбрасывается при навигации. 2026-04-08 02:56:53 +05:00
14d7fc6eb1 Forward: фикс размера пузыря и отправки. Дубли дат убраны.
- Forward пузырь подстраивается под размер контента (как Telegram)
- Длинные имена обрезаются "Forwarded from Alex M..." вместо растяжения
- Исправлена отправка forward — consumeForwardMessagesForChat при открытии чата
- Floating date header показывается только при активном скролле (убраны дубли дат)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 00:50:25 +05:00
9fafa52483 Группы: добавлен выбор участников при создании + авто-приглашение. Forward: убран ре-аплоад картинок, добавлен chacha_key_plain для кросс-платформы. Онбординг: экран профиля (имя + username + аватар), биометрия на экране пароля, убран экран подтверждения фразы. Звонки: аватарки в уведомлениях и на экране входящего. Reply: исправлена расшифровка фото (chachaKey оригинала). Уведомления: фикс декодирования аватарки (base64 prefix). UI: подсказка эмодзи в стиле Telegram, стрелка на Safety, сепараторы участников. 2026-04-07 23:29:37 +05:00
ecac56773a Фикс: UI групп 2026-04-07 17:23:55 +05:00
43422bb131 Фикс: Новый флоу создания групп 2026-04-07 17:16:24 +05:00
b81b38f40d Релиз 1.4.8: Фиксы мелких UI / UX моментов
All checks were successful
Android Kernel Build / build (push) Successful in 19m24s
2026-04-07 03:27:19 +05:00
19508090a5 Повышение версии в DEV 2026-04-07 03:18:41 +05:00
6d14881fa2 Фикс: поведение синхронизации, проработка UX, проработка UI в групповых чатах, проработка анимаций AuthFlow 2026-04-07 03:00:54 +05:00
081bdb6d30 Доработки интерфейса и поведения настроек, профиля и групп 2026-04-06 21:41:37 +05:00
ead84a8a53 Фикс: убрал start new call
All checks were successful
Android Kernel Build / build (push) Successful in 19m17s
2026-04-05 18:12:20 +05:00
152106eda1 Фикс FCM: убран неиспользуемый импорт CallUiState
All checks were successful
Android Kernel Build / build (push) Successful in 19m8s
2026-04-05 16:46:57 +05:00
b8c5529b29 Push: обработка read-событий в тихих уведомлениях 2026-04-05 14:10:43 +05:00
9d04ec07e8 Релиз 1.4.7: фиксы lockscreen, звонков и стабильности 2026-04-05 13:06:29 +05:00
9e14724ae2 Релиз 1.4.6: обновление протокола звонков
All checks were successful
Android Kernel Build / build (push) Successful in 23m10s
2026-04-04 23:32:00 +05:00
2bb3281ccf Переход Android звонков на новый серверный протокол 2026-04-04 23:18:23 +05:00
7d4b9a8fc4 Релиз 1.4.5: стабилизация звонков, фиксы UI
All checks were successful
Android Kernel Build / build (push) Successful in 19m24s
- Звонок не сбрасывается при переподключении WebSocket
- Убрано мелькание "Unknown" при завершении (флаг resetting)
- Фикс placeholderColor в ChatDetailScreen (release build)
- ReleaseNotes.kt обновлён с детальным описанием всех изменений
2026-04-04 15:52:54 +05:00
6886a6cef1 Доработки звонков и чатов: typing, UI и стабильность 2026-04-04 15:17:47 +05:00
a9be1282c6 Релиз 1.4.4: обновление протокола WebRTC, фиксы звонков
All checks were successful
Android Kernel Build / build (push) Successful in 19m16s
- PacketWebRTC: добавлены publicKey и deviceId (новый серверный протокол)
- Фикс ForegroundServiceDidNotStartInTimeException (safeStopForeground)
- Фикс бесконечного "Exchanging keys" (ретрай KEY_EXCHANGE, auto-bind)
- Фикс "Unknown" при сбросе звонка
- Decline работает во всех фазах
- Кастомный WebRTC AAR в git для CI
2026-04-02 21:47:42 +05:00
3217aeaeeb Добавлен кастомный WebRTC AAR для CI + фиксы звонков
All checks were successful
Android Kernel Build / build (push) Successful in 18m55s
- libwebrtc-custom.aar закоммичен (был в .gitignore, CI использовал Maven без relative vtables → SIGSEGV)
- Фикс ForegroundServiceDidNotStartInTimeException (safeStopForeground)
- Фикс бесконечного "Exchanging keys" (ретрай KEY_EXCHANGE, auto-bind account)
- Фикс "Unknown" при сбросе звонка (stop ForegroundService до сброса state)
- Decline работает во всех фазах звонка
2026-04-02 12:23:50 +05:00
c90136f563 fix: fix CMAKE
All checks were successful
Android Kernel Build / build (push) Successful in 20m9s
2026-04-02 01:36:54 +05:00
8bc1f15bdd Фикс CI: NDK версия 25.1.8937393 (совпадает с android.ndkVersion) 2026-04-02 01:24:41 +05:00
876c1ab4df Релиз 1.4.3: полноэкранные входящие звонки, аватарки в уведомлениях, фиксы
Some checks failed
Android Kernel Build / build (push) Failing after 4m6s
Звонки:
- IncomingCallActivity — полноэкранный UI входящего звонка поверх lock screen
- fullScreenIntent на нотификации для Android 12+
- ForegroundService синхронизируется при смене фазы и имени
- Запрос fullScreenIntent permission на Android 14+
- dispose() PeerConnection при завершении звонка
- Защита от CREATE_ROOM без ключей (звонок на другом устройстве)
- Дедупликация push + WebSocket сигналов
- setIncomingFromPush — CallManager сразу в INCOMING по push
- Accept ждёт до 5 сек если WebSocket не доставил сигнал
- Decline работает во всех фазах (не только INCOMING)
- Баннер активного звонка внутри диалога

Уведомления:
- Аватарки и имена по publicKey в уведомлениях (message + call)
- Настройка "Avatars in Notifications" в разделе Notifications

UI:
- Ограничение fontScale до 1.3x (вёрстка не ломается на огромном тексте)
- Новые обои: Light 1-3 для светлой темы, убраны старые back_*
- ContentScale.Crop для превью обоев (без растяжения)

CI/CD:
- NDK/CMake в CI, local.properties, ANDROID_NDK_HOME
- Ограничение JVM heap для CI раннера

Диагностика:
- Логирование call notification flow в crash_reports (rosettadev1)
- FCM токен в crash_reports
2026-04-02 01:18:20 +05:00
803fda9abe Релиз 1.4.2: защита от звонков с другого устройства, лог FCM токена
All checks were successful
Android Kernel Build / build (push) Successful in 21m53s
- CREATE_ROOM без ключей шифрования — сброс (звонок принят на другом устройстве)
- dispose PeerConnection при завершении звонка (фикс зависания портов ~30с)
- Сохранение FCM токена в crash_reports для rosettadev1
2026-04-01 18:28:15 +05:00
7beb722c65 fix: dispose PeerConnection on call end to release ICE ports
All checks were successful
Android Kernel Build / build (push) Successful in 21m43s
close() alone does not free native WebRTC resources (ICE agent,
ports, threads). Without dispose() the old PC holds ports for ~30s,
blocking the next call from connecting.
2026-04-01 17:42:25 +05:00
89ad59b1f8 ci: install NDK and CMake for native E2EE module build
NDK and CMake were missing from sdkmanager install, causing
the native rosetta_e2ee.so to not be compiled in CI builds.
2026-04-01 17:33:37 +05:00
fe1a7fed3d Release 1.4.1: hotfix E2EE call diagnostics
All checks were successful
Android Kernel Build / build (push) Successful in 19m25s
- Enable E2EE diag logging for all builds
- Add native frame count / bad streak health checks
- Reduce scan receiver log spam
2026-04-01 17:09:29 +05:00
480fc9a1d0 Add E2EE diagnostic logging for debugging call encryption
All checks were successful
Android Kernel Build / build (push) Successful in 19m17s
- Enable diag file for all builds (was DEBUG-only)
- Add native frame count + bad streak query methods (JNI)
- Add periodic E2EE-HEALTH log with enc/dec frame counts
- Reduce scan receivers spam (only log on state change)
- Log E2EE state on call connected
- Log when attachSender/attachReceiver skips due to missing key
2026-04-01 16:28:23 +05:00
0558a57942 Bump Android version to 1.4.0 (versionCode 42)
All checks were successful
Android Kernel Build / build (push) Successful in 19m30s
2026-04-01 00:22:03 +05:00
566e1f6c2e feat: add tokenType and deviceId to push notification packet
Support FCM/VOIP_APNS token types and device identification
for improved push notification routing.
2026-04-01 00:21:49 +05:00
676c205666 Release 1.3.9: fix calls, verified badge on call screens, dark wallpapers
All checks were successful
Android Kernel Build / build (push) Successful in 19m11s
- Revert CallManager to 1.3.6 base (fix broken call encryption)
- Add 45s incoming ring timeout with proper peer notification
- Add verified badge on call history and active call screens
- Add dark wallpapers to theme selector
- Fix wallpaper selector item sizing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 20:03:32 +05:00
b9ac7791f6 feat: add wallpapers
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-31 19:57:44 +05:00
20bef53869 Фикс звонков
All checks were successful
Android Kernel Build / build (push) Successful in 19m36s
2026-03-31 19:03:30 +05:00
2ff1383b13 Bump Android version to 1.3.8 (versionCode 40)
All checks were successful
Android Kernel Build / build (push) Successful in 19m27s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 16:15:16 +05:00
727b902df7 Push: поддержка новых типов и супер-уведомления для звонков
All checks were successful
Android Kernel Build / build (push) Successful in 20m0s
2026-03-30 22:46:59 +05:00
89259b2a46 Релиз 1.3.7: новый Stream, транспорт вложений и фиксы совместимости
All checks were successful
Android Kernel Build / build (push) Successful in 19m57s
2026-03-29 23:16:38 +05:00
ce6bc985be Пуши: учитывать mute и имя отправителя из payload 2026-03-29 23:12:29 +05:00
ff854e919e Улучшена обработка звонков и вложений: нормализация входящих аттачментов, обновление UI карточек звонков
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:45:25 +05:00
434ccef30c Устранены лишние многоточия в статусных сообщениях для исходящих и подключающихся звонков
All checks were successful
Android Kernel Build / build (push) Successful in 20m23s
2026-03-29 14:12:13 +05:00
26f4597c3b Релиз 1.3.6: hotfix качества звонков (возврат call-core к 1.3.3)
Some checks failed
Android Kernel Build / build (push) Failing after 12m11s
2026-03-29 13:30:00 +05:00
fa1288479f Релиз 1.3.5: ForegroundService звонков и фиксы клавиатуры
All checks were successful
Android Kernel Build / build (push) Successful in 19m26s
2026-03-28 17:24:08 +05:00
46b1b3a6f1 Релиз 1.3.4: sticky-плашка звонка и поиск сообщений в диалоге
All checks were successful
Android Kernel Build / build (push) Successful in 19m40s
2026-03-28 15:17:58 +05:00
aa40f5287c Релиз 1.3.3: merge dev в master
Some checks failed
Android Kernel Build / build (push) Failing after 11m18s
2026-03-28 13:07:36 +05:00
b271917594 Обновлены ReleaseNotes для версии 1.3.3 2026-03-28 13:07:08 +05:00
4cfa9f1d48 Фикс сборки 2026-03-28 13:05:49 +05:00
20c6696fdf Закрытие клавиатуры на звонке 2026-03-28 13:05:48 +05:00
3eac17d9a8 Оптимизация 2026-03-27 23:10:13 +05:00
84aad5f094 Добавлен модуль macrobenchmark и сценарии замера производительности 2026-03-27 22:30:50 +05:00
e7efe0856c Оптимизация приложения 2026-03-27 19:19:15 +05:00
93a2de315a Слияние dev: экран Calls и обновления звонков
All checks were successful
Android Kernel Build / build (push) Successful in 19m41s
2026-03-27 18:22:53 +05:00
c3e97eee56 Добавлен экран Calls в сайдбар и улучшено управление историей звонков 2026-03-27 18:22:21 +05:00
39b0b0e107 Поправлен визуал пузырьков со звонками 2026-03-27 17:22:51 +05:00
51f76b5073 Возврат dev к legacy-формату Stream 2026-03-27 15:20:31 +05:00
c9fa12a690 Возврат Stream к новому серверному формату 2026-03-27 14:43:15 +05:00
ec541a2c0c Откат Stream к legacy-формату для совместимости с текущим сервером 2026-03-27 14:29:49 +05:00
454402938c Port Stream implementation from desktop/dev 2026-03-27 11:52:40 +05:00
0fc637b42a Merge dev into master for release 1.3.2
All checks were successful
Android Kernel Build / build (push) Successful in 20m4s
# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt
2026-03-27 03:16:20 +05:00
83f6b49ba3 Release 1.3.2: bump version and update release notes 2026-03-27 03:15:10 +05:00
b663450db5 Работающие звонки 2026-03-27 03:12:04 +05:00
9cca071bd8 android: save all pending changes 2026-03-26 21:37:31 +05:00
0af4e6587e android: expand native e2ee diagnostics 2026-03-26 21:27:17 +05:00
31db795c56 Фикс оптимизации 2026-03-26 13:18:16 +05:00
9202204094 Фикс error parsing: 1 frame = 1 packet и safe handshake fallback 2026-03-26 13:17:33 +05:00
03282eb478 Стабилизация sync и логов: heartbeat антиспам + Connection Logs через rosettadev2 2026-03-26 13:17:33 +05:00
59addf4373 Фикс оптимизации
All checks were successful
Android Kernel Build / build (push) Successful in 17m14s
2026-03-26 03:01:52 +05:00
3953d93207 Фикс error parsing: 1 frame = 1 packet и safe handshake fallback
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-26 02:53:21 +05:00
de958e10a1 Стабилизация sync и логов: heartbeat антиспам + Connection Logs через rosettadev2
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-26 02:45:16 +05:00
3fffbd0392 Промежуточный коммит со звонками 2026-03-26 00:31:35 +05:00
bc7efbfbd9 add license 2026-03-25 22:21:00 +05:00
eea650face WIP: стабилизация звонков и E2EE + инструменты сборки WebRTC 2026-03-25 22:20:24 +05:00
530047c5d0 Попытка обновления шифрования звонков и работа над UI 2026-03-25 01:47:12 +05:00
419101a4a9 Проработан UI звонков и частичная реализация 2026-03-23 18:25:25 +05:00
9778e3b196 Реализованы звонки в диалоге и полный permission flow Android 2026-03-23 10:56:52 +05:00
4664aa9482 Синхронизированы пакеты звонков с desktop/wss 2026-03-23 03:01:54 +05:00
ebb95905b5 PacketRead parity: корректные read-статусы и update release notes 1.3.0
Some checks failed
Android Kernel Build / build (push) Failing after 10m41s
2026-03-22 21:25:04 +05:00
f915333a44 Синхронизация 1.3.0: parity с desktop/server и стабилизация sync-цикла 2026-03-22 19:47:23 +05:00
69c0c377d1 Добавлено кэширование Android SDK и Gradle wrapper для ускорения сборки 2026-03-22 17:01:27 +05:00
30fbc41245 Добавил lint { checkReleaseBuilds = false; abortOnError = false } в build.gradle.kts
Some checks failed
Android Kernel Build / build (push) Failing after 6m30s
2026-03-22 16:19:33 +05:00
677a5f2ab2 Добавлен комментарий в MainActivity.kt
Some checks failed
Android Kernel Build / build (push) Failing after 18m34s
2026-03-22 15:40:38 +05:00
db55225d84 Релиз 1.2.9: fullscreen фото edge-to-edge без черных бордеров
Some checks failed
Android Kernel Build / build (push) Failing after 9m58s
2026-03-22 02:21:03 +05:00
7a188a2dbc Релиз 1.2.8: emoji iOS, fullscreen фото, сохранение в галерею и UI-фиксы
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-22 02:00:21 +05:00
a3973b616e Merge branch 'master' into dev
# Conflicts:
#	app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt
2026-03-21 21:59:01 +05:00
3a595c02b3 убран коммент
Some checks failed
Android Kernel Build / build (push) Failing after 14m42s
2026-03-21 21:55:58 +05:00
8e743e710a v1.2.7: поиск сообщений, скелетон, анимация перехода и правка бейджа Requests 2026-03-21 21:53:30 +05:00
8fdbfb4e5f Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и UX-фиксы
Что вошло:\n- Добавлен полноценный Messages-tab в SearchScreen: поиск по тексту сообщений по всей базе, батчевый проход, параллельная дешифровка, кеш расшифровки, подсветка совпадений, сниппеты и быстрый переход в нужный диалог.\n- В Chats-tab добавлены алиасы для Saved Messages (saved/saved messages/избранное/сохраненные и др.), чтобы чат открывался по текстовому поиску даже без точного username/public key.\n- Для search-бэкенда расширен DAO: getAllMessagesPaged() для постраничного обхода сообщений аккаунта.\n- Исправлена логика клика по @тэгам в сообщениях:\n  - переход теперь ведет сразу в чат пользователя (а не в профиль);\n  - добавлен fallback-резолв username -> user через локальный диалог, кеш протокола и PacketSearch;\n  - добавлен DAO getDialogByUsername() (регистронезависимо и с игнором @).\n- Усилена обработка PacketSearch в ProtocolManager:\n  - добавлена очередь ожидания pendingSearchQueries;\n  - нормализация query (без @, lowercase);\n  - устойчивый матч ответов сервера (raw/normalized/by username);\n  - добавлены методы getCachedUserByUsername() и searchUsers().\n- Исправлен конфликт тачей между ClickableSpan и bubble-menu:\n  - в AppleEmojiText/AppleEmojiTextView добавлен callback начала тапа по span;\n  - улучшен hit-test по span (включая пограничные offset/layout fallback);\n  - suppress performClick на span-тапах;\n  - в MessageBubble добавлен тайм-guard, чтобы tap по span не открывал context menu.\n- Стабилизирован verified-бейдж в заголовке чата: агрегируется из переданного user, кеша протокола, локальной БД и серверного resolve; отображается консистентно в личных чатах.\n- Улучшен пустой экран Saved Messages при обоях: добавлена аккуратная подложка/бордер и выровненный текст, чтобы контент оставался читабельным на любом фоне.\n- Реализована автосвязка обоев между светлой/темной темами:\n  - добавлены pairGroup и mapToTheme/resolveWallpaperForTheme в ThemeWallpapers;\n  - добавлены отдельные prefs-ключи для light/dark wallpaper;\n  - MainActivity теперь автоматически подбирает и сохраняет обои под активную тему и сохраняет выбор по теме.\n- Биометрия: если на устройстве нет hardware fingerprint, экран включения биометрии не показывается (и доступность возвращает NotAvailable).\n- Небольшие UI-фиксы: поправлено позиционирование галочки в сайдбаре.\n- Техдолг: удалена неиспользуемая зависимость jsoup из build.gradle.
2026-03-21 21:53:30 +05:00
ce16802ac3 v1.2.7: поиск сообщений, скелетон, анимация перехода и правка бейджа Requests 2026-03-21 21:48:36 +05:00
9d3e5bcb10 Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и UX-фиксы
Что вошло:\n- Добавлен полноценный Messages-tab в SearchScreen: поиск по тексту сообщений по всей базе, батчевый проход, параллельная дешифровка, кеш расшифровки, подсветка совпадений, сниппеты и быстрый переход в нужный диалог.\n- В Chats-tab добавлены алиасы для Saved Messages (saved/saved messages/избранное/сохраненные и др.), чтобы чат открывался по текстовому поиску даже без точного username/public key.\n- Для search-бэкенда расширен DAO: getAllMessagesPaged() для постраничного обхода сообщений аккаунта.\n- Исправлена логика клика по @тэгам в сообщениях:\n  - переход теперь ведет сразу в чат пользователя (а не в профиль);\n  - добавлен fallback-резолв username -> user через локальный диалог, кеш протокола и PacketSearch;\n  - добавлен DAO getDialogByUsername() (регистронезависимо и с игнором @).\n- Усилена обработка PacketSearch в ProtocolManager:\n  - добавлена очередь ожидания pendingSearchQueries;\n  - нормализация query (без @, lowercase);\n  - устойчивый матч ответов сервера (raw/normalized/by username);\n  - добавлены методы getCachedUserByUsername() и searchUsers().\n- Исправлен конфликт тачей между ClickableSpan и bubble-menu:\n  - в AppleEmojiText/AppleEmojiTextView добавлен callback начала тапа по span;\n  - улучшен hit-test по span (включая пограничные offset/layout fallback);\n  - suppress performClick на span-тапах;\n  - в MessageBubble добавлен тайм-guard, чтобы tap по span не открывал context menu.\n- Стабилизирован verified-бейдж в заголовке чата: агрегируется из переданного user, кеша протокола, локальной БД и серверного resolve; отображается консистентно в личных чатах.\n- Улучшен пустой экран Saved Messages при обоях: добавлена аккуратная подложка/бордер и выровненный текст, чтобы контент оставался читабельным на любом фоне.\n- Реализована автосвязка обоев между светлой/темной темами:\n  - добавлены pairGroup и mapToTheme/resolveWallpaperForTheme в ThemeWallpapers;\n  - добавлены отдельные prefs-ключи для light/dark wallpaper;\n  - MainActivity теперь автоматически подбирает и сохраняет обои под активную тему и сохраняет выбор по теме.\n- Биометрия: если на устройстве нет hardware fingerprint, экран включения биометрии не показывается (и доступность возвращает NotAvailable).\n- Небольшие UI-фиксы: поправлено позиционирование галочки в сайдбаре.\n- Техдолг: удалена неиспользуемая зависимость jsoup из build.gradle.
2026-03-21 21:12:52 +05:00
d90554aa9f commit
All checks were successful
Android Kernel Build / build (push) Successful in 27m13s
2026-03-20 23:29:33 +05:00
c929685e04 Релиз 1.2.6: sync-статусы, emoji-подсказки и UI-фиксы
Some checks failed
Android Kernel Build / build (push) Failing after 27m7s
2026-03-20 21:56:52 +05:00
b2558653b7 Merge branch 'master' into dev 2026-03-20 19:49:22 +05:00
58455cf32a Исправлены ложные галочки и синхронизация статусов сообщений
Some checks failed
Android Kernel Build / build (push) Failing after 44m56s
2026-03-20 19:43:02 +05:00
e5a68439f8 v1.2.5: версия и release notes 2026-03-20 19:42:54 +05:00
b85c553507 Исправлены ложные галочки и синхронизация статусов сообщений 2026-03-20 19:20:06 +05:00
9afbbae5c9 v1.2.4: реальная пауза скачивания с resume по Range
All checks were successful
Android Kernel Build / build (push) Successful in 50m9s
2026-03-20 14:48:17 +05:00
4440016d5f v1.2.4: фиксы медиапикера, файловых загрузок и UI групп 2026-03-20 14:29:12 +05:00
0353f845a5 Фикс скелетона и залипания вкладок в профиле 2026-03-20 12:26:33 +05:00
004b54ec7c Релиз 1.2.4: фиксы чатов, медиа и release notes 2026-03-20 00:44:18 +05:00
5ecb2a8db4 Универсальные обои для всех разрешений 2026-03-19 23:35:28 +05:00
f34e520d03 Починил optimistic и сохранение групповых фото при отправке 2026-03-19 22:34:00 +05:00
1ba173be54 Довел pull-анимацию реквестов: моментальный показ первым элементом 2026-03-19 22:22:01 +05:00
d41674ff78 Сделал плавную вытягивающуюся анимацию реквестов в чат-листе 2026-03-19 20:00:02 +05:00
bd6e033ed3 Исправил скрытие реквестов в чат-листе как у архива Telegram 2026-03-19 19:53:07 +05:00
72a2cf1b70 Переделал механику реквестов: отдельный pull-gesture и ручка раскрытия 2026-03-19 19:41:59 +05:00
2cf64e80eb Сделал мгновенное раскрытие реквестов при pull вниз 2026-03-19 19:28:05 +05:00
2602084764 Починил повторное появление реквестов при прокрутке в чат-листе 2026-03-19 19:22:23 +05:00
420ea6e560 Убрал рывки анимации блока реквестов в списке чатов 2026-03-19 19:09:09 +05:00
53946e2e6e Сделал стабильное появление реквестов при оттягивании списка 2026-03-19 16:50:13 +05:00
4d4130fefd Исправил прыжки списка чатов при пустых запросах 2026-03-19 16:35:41 +05:00
09df7586e7 Разделил обои на наборы для темной и светлой темы 2026-03-19 16:28:18 +05:00
13b61cf720 Сделал скрытие клавиатуры на back-свайпе во всех экранах 2026-03-19 16:19:06 +05:00
4640b0128f обновлена версия в release notes
All checks were successful
Android Kernel Build / build (push) Successful in 1h11m56s
2026-03-19 16:03:14 +05:00
5a754f6643 Исправил определение обоев для белого system-текста в группах 2026-03-19 15:58:19 +05:00
c6e9acdac6 Сделал белым сообщение о входе в группу в темной теме и на обоях 2026-03-19 15:46:06 +05:00
6e14213b5c Релиз 1.2.3: обновлены версия и release notes
All checks were successful
Android Kernel Build / build (push) Successful in 24m33s
2026-03-19 15:32:09 +05:00
af4a3a5f27 Синхронизация статусов и времени существующих сообщений, добавление новых сообщений в кэш
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-19 15:29:09 +05:00
9a411ac473 Исправлены аватар в сайдбаре и медиа-пузыри в группах 2026-03-19 15:28:44 +05:00
75d0f4726b Фикс галочки в сайдбаре
All checks were successful
Android Kernel Build / build (push) Successful in 1h6m55s
2026-03-19 07:21:22 +05:00
581a44b270 Слит dev в master: изменения после 1.2.1 и обновлены release notes
All checks were successful
Android Kernel Build / build (push) Successful in 1h10m41s
2026-03-18 23:55:19 +05:00
cd325bea87 Бамп версии до 1.2.2 и обновление release notes после 1.2.1
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-18 23:41:11 +05:00
b918b45603 Сделан интерактивный drag медиа-пикеров и обновлены release notes
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-18 23:35:38 +05:00
5e66437239 Bump version to 1.2.1 and increment version code to 23
All checks were successful
Android Kernel Build / build (push) Successful in 16h11m23s
2026-03-17 15:36:11 +07:00
670093c8fe Merge dev into master (resolve conflicts: keep dev release notes)
All checks were successful
Android Kernel Build / build (push) Successful in 17h43m23s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:00:27 +07:00
c2198b624d Фикс цвет галочек
Some checks are pending
Android Kernel Build / build (push) Waiting to run
2026-03-16 15:20:53 +07:00
807309a812 Скрытие клавиатуры при свайпе назад на экране поиска
Some checks failed
Android Kernel Build / build (push) Failing after 16h11m23s
Добавлена обработка горизонтального свайпа вправо для автоматического скрытия клавиатуры на SearchScreen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:36:06 +07:00
479fdd0074 Обновлены release notes: добавлены новые функции и исправления интерфейса
All checks were successful
Android Kernel Build / build (push) Successful in 16h27m5s
2026-03-16 01:08:38 +07:00
b1d4458484 Добавлены release notes для версии 1.2.0 (с 1.1.9) 2026-03-16 01:05:54 +07:00
b5398e2f1d Релиз 1.2.0: синхронизированы статусы, скролл и UI-выравнивание
All checks were successful
Android Kernel Build / build (push) Successful in 17h2m4s
- Поднята версия приложения до 1.2.0 (versionCode 22)\n- Синхронизированы статусы отправки между чат-листом и диалогом: SENT отображается как часы до delivery, ERROR теперь стабильно приходит в открытый диалог\n- Доработан Telegram-подобный skeleton в диалоге: shimmer, геометрия пузырей, поддержка групповых аватаров\n- Добавлен плавный автоскролл к баннеру подтверждения нового устройства в чат-листе\n- Выровнены verified-галочки с именами в профилях и в сайдбаре\n- Кнопка Copy Seed Phrase в светлой теме приведена к белому тексту\n- Мелкие UI-правки в чате и компонентах ввода/эмодзи
2026-03-16 00:02:27 +07:00
bae665f89d Merge branch 'dev'
All checks were successful
Android Kernel Build / build (push) Successful in 16h26m43s
2026-03-14 22:04:49 +07:00
a5ec0595ad Синхронизирована логика read-индикаторов в диалоге с чат-листом
All checks were successful
Android Kernel Build / build (push) Successful in 16h17m58s
2026-03-14 21:17:21 +07:00
d78fb184c6 Скрыт инпут и оверлеи при рисовании на фото
All checks were successful
Android Kernel Build / build (push) Successful in 16h20m6s
2026-03-14 15:10:46 +07:00
ddd98a8065 Исправлен черный gesture navigation bar при fullscreen фото
All checks were successful
Android Kernel Build / build (push) Successful in 16h12m7s
2026-03-14 14:24:25 +07:00
d9c54b2d05 Исправлен цвет галочки верификации в профилях и попапах по теме
All checks were successful
Android Kernel Build / build (push) Successful in 16h18m46s
2026-03-14 01:18:20 +07:00
494b459e39 Исправлен цвет галочки верификации в сайдбаре по теме
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-14 01:15:43 +07:00
8c2e30b4d8 Merge branch 'dev'
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-14 01:05:19 +07:00
0aa34e75c9 Выпуск 1.1.7: слияние dev в master
All checks were successful
Android Kernel Build / build (push) Successful in 16h25m22s
2026-03-11 23:03:48 +07:00
43bcfdff1b Выпуск 1.1.6: слияние dev в master
All checks were successful
Android Kernel Build / build (push) Successful in 16h13m12s
2026-03-10 23:25:16 +05:00
982dfc5dff Релиз 1.1.5: ускорено подключение и исправлены группы
All checks were successful
Android Kernel Build / build (push) Successful in 16h12m4s
2026-03-09 20:20:54 +05:00
258 changed files with 42506 additions and 11927 deletions

View File

@@ -41,6 +41,12 @@ jobs:
export JAVA_HOME="$JAVA_DIR"
echo "JAVA_HOME set to $JAVA_HOME"
- name: Cache Android SDK
uses: actions/cache@v3
with:
path: ~/android-sdk
key: android-sdk-34-ndk26
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
@@ -61,9 +67,20 @@ jobs:
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-34" \
"build-tools;34.0.0" \
"platform-tools"
"platform-tools" \
"ndk;25.1.8937393" \
"cmake;3.22.1"
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $GITHUB_ENV
- name: Cache Gradle wrapper
uses: actions/cache@v3
with:
path: |
~/.gradle/wrapper/dists
~/.gradle/caches
key: gradle-wrapper-8.14.3
- name: Restore debug keystore
run: |
@@ -76,10 +93,35 @@ jobs:
- name: Setup Gradle wrapper
run: |
chmod +x ./gradlew
./gradlew --version
GRADLE_VERSION="8.14.3"
GRADLE_DIST_DIR="$HOME/.gradle/wrapper/dists/gradle-${GRADLE_VERSION}-bin"
# Проверяем — если Gradle уже распакован в кэше, пропускаем скачивание
if find "$GRADLE_DIST_DIR" -name "gradle-${GRADLE_VERSION}" -type d 2>/dev/null | grep -q .; then
echo "Gradle ${GRADLE_VERSION} found in cache, skipping download"
else
echo "Gradle not found in cache, downloading..."
mkdir -p /opt/gradle-download
curl -fL --retry 3 --retry-delay 5 \
"https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
-o "/opt/gradle-download/gradle-${GRADLE_VERSION}-bin.zip"
mkdir -p /opt/gradle
unzip -q "/opt/gradle-download/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle
export PATH="/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH"
echo "PATH=/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH" >> $GITHUB_ENV
fi
./gradlew --no-daemon --version
- name: Configure local.properties
run: |
echo "sdk.dir=$ANDROID_HOME" > local.properties
echo "ndk.dir=$ANDROID_HOME/ndk/25.1.8937393" >> local.properties
echo "cmake.dir=$ANDROID_HOME/cmake/3.22.1" >> local.properties
cat local.properties
- name: Build Release APK
run: ./gradlew assembleRelease
run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx2g" assembleRelease
- name: Check if APK exists
run: |

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Built application files
*.apk
*.aar
# *.aar — кастомный WebRTC разрешён в app/libs/
!app/libs/*.aar
*.ap_
*.aab

593
Architecture.md Normal file
View File

@@ -0,0 +1,593 @@
# Rosetta Android — Architecture
> Документ отражает текущее состояние `rosetta-android` (ветка `dev`) по коду на 2026-04-19.
## 1. Архитектурный профиль
Приложение сейчас устроено как layered + service-oriented архитектура:
- UI: `MainActivity` + Compose-экраны + ViewModel.
- Chat feature orchestration: `ChatViewModel` (host-state) + feature-facade VM + coordinators.
- DI: Hilt (`@HiltAndroidApp`, `@AndroidEntryPoint`, модули в `di/AppContainer.kt`).
- Runtime orchestration: `ProtocolGateway`/`ProtocolRuntime` -> `RuntimeComposition` (+ legacy facade `ProtocolManager`), `CallManager`, `TransportManager`, `UpdateManager`.
- Session/Identity runtime state: `SessionStore`, `SessionReducer`, `IdentityStore`.
- Domain сценарии отправки чата: `domain/chats/usecase/*` (text/media/forward/voice/typing/read-receipt/attachments/upload).
- Data: `MessageRepository`, `GroupRepository`, `AccountManager`, `PreferencesManager`.
- Persistence: Room (`RosettaDatabase`) + DataStore/SharedPreferences.
Основная runtime-логика сети вынесена в `RuntimeComposition`, а DI-вход в runtime идет напрямую через `ProtocolRuntime`.
`ProtocolManager` переведен в минимальный legacy compatibility facade поверх `ProtocolRuntimeAccess`.
DI-вход в network core идет через `ProtocolRuntime` (Hilt singleton).
---
## 2. Слои и границы
```mermaid
flowchart TB
subgraph ENTRY["Android Entry Points"]
E1["RosettaApplication"]
E2["MainActivity"]
E3["RosettaFirebaseMessagingService"]
E4["IncomingCallActivity / CallForegroundService"]
end
subgraph DI["Hilt Singleton Graph"]
D1["ProtocolGateway -> ProtocolRuntime"]
D2["SessionCoordinator"]
D3["IdentityGateway"]
D4["AccountManager / PreferencesManager"]
D5["MessageRepository / GroupRepository"]
end
subgraph CHAT_UI["Chat UI Orchestration"]
C1["ChatDetailScreen / ChatsListScreen"]
C2["ChatViewModel (host-state)"]
C3["Feature VM: Messages/Voice/Attachments/Typing"]
C4["Coordinators: Messages/Forward/Attachments"]
end
subgraph CHAT_DOMAIN["Chat Domain UseCases"]
U1["SendText / SendMedia / SendForward"]
U2["SendVoice / SendTyping / SendReadReceipt"]
U3["CreateAttachment / EncryptAndUpload / VideoCircle"]
end
subgraph SESSION["Session / Identity Runtime"]
S1["SessionStore / SessionReducer"]
S2["IdentityStore / AppSessionCoordinator"]
end
subgraph NET["Network Runtime"]
N0["ProtocolRuntime"]
N1["RuntimeComposition (wiring only)"]
N2["RuntimeConnectionControlFacade"]
N3["RuntimeDirectoryFacade"]
N4["RuntimePacketIoFacade"]
N5["Assemblies: Transport / Messaging / State / Routing"]
N6["ProtocolInstanceManager -> Protocol"]
N7["ProtocolManager (legacy compat)"]
end
subgraph DATA["Data + Persistence"]
R1["MessageRepository / GroupRepository"]
R2["Room: RosettaDatabase"]
end
ENTRY --> DI
DI --> SESSION
DI --> DATA
DI --> CHAT_UI
DI --> N0
CHAT_UI --> CHAT_DOMAIN
CHAT_UI --> R1
CHAT_DOMAIN --> D1
D1 --> N0
N0 --> N1
N1 --> N2
N1 --> N3
N1 --> N4
N1 --> N5
N5 --> N6
N7 --> N0
SESSION --> N0
R1 --> N0
R1 --> R2
```
---
## 3. DI и composition root
### 3.1 Hilt
- `RosettaApplication` помечен `@HiltAndroidApp`.
- Entry points уровня Android-компонентов: `MainActivity`, `IncomingCallActivity`, `CallForegroundService`, `RosettaFirebaseMessagingService`.
- Основные модули:
- `AppDataModule`: `AccountManager`, `PreferencesManager`.
- `AppGatewayModule`: биндинги `ProtocolGateway`, `SessionCoordinator`, `IdentityGateway`, `ProtocolClient`.
- `ProtocolGateway` теперь биндится напрямую на `ProtocolRuntime` (без отдельного `ProtocolGatewayImpl` proxy-класса).
- `ProtocolClientImpl` остается узким техническим adapter-слоем для repository (`send/sendWithRetry/addLog/wait/unwait`) и делегирует в `ProtocolRuntime` через `Provider<ProtocolRuntime>`.
### 3.2 UI bridge для composable-слоя
UI-композаблы больше не получают runtime-зависимости через `UiEntryPoint`/`EntryPointAccessors`.
`UiDependencyAccess.get(...)` из `ui/*` удален (DoD: 0 вхождений).
Для non-Hilt `object`-ов (`CallManager`, `TransportManager`, `UpdateManager`, utils)
используется `ProtocolRuntimeAccess` + `ProtocolRuntimePort`:
- runtime ставится в `RosettaApplication` через `ProtocolRuntimeAccess.install(protocolRuntime)`;
- доступ до install запрещен (fail-fast), чтобы не было тихого отката в legacy facade.
### 3.3 Разрыв DI-cycle (Hilt)
После перехода на `ProtocolRuntime` был закрыт цикл зависимостей:
`MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository`.
Текущее решение:
- `ProtocolClientImpl` получает `Provider<ProtocolRuntime>` (ленивая резолюция).
- `ProtocolRuntime` остается singleton-композицией для `MessageRepository/GroupRepository/AccountManager`.
- На `assembleDebug/assembleRelease` больше нет `Dagger/DependencyCycle`.
---
## 4. Session lifecycle: единый source of truth
### 4.1 Модель состояния
`SessionState`:
- `LoggedOut`
- `AuthInProgress(publicKey?, reason)`
- `Ready(account, reason)`
### 4.2 Модель событий
`SessionAction`:
- `LoggedOut`
- `AuthInProgress`
- `Ready`
- `SyncFromCachedAccount`
### 4.3 Контур изменения состояния
- Только `SessionStore` владеет `MutableStateFlow<SessionState>`.
- Только `SessionReducer` вычисляет next-state.
- `SessionCoordinator`/`AppSessionCoordinator` больше не мутируют состояние напрямую, а делают `dispatch(action)`.
- `SessionStore.dispatch(...)` синхронно обновляет `IdentityStore` для консистентности account/profile/auth-runtime.
```mermaid
flowchart LR
A["AuthFlow / MainActivity / Unlock / SetPassword"] --> B["SessionCoordinator.dispatch(action)"]
B --> C["SessionStore.dispatch(action)"]
C --> D["SessionReducer.reduce(current, action)"]
D --> E["StateFlow<SessionState>"]
C --> F["IdentityStore sync"]
```
### 4.4 State machine
```mermaid
stateDiagram-v2
[*] --> LoggedOut
LoggedOut --> AuthInProgress: dispatch(AuthInProgress)
AuthInProgress --> Ready: dispatch(Ready)
AuthInProgress --> LoggedOut: dispatch(LoggedOut)
Ready --> LoggedOut: dispatch(LoggedOut)
Ready --> Ready: dispatch(SyncFromCachedAccount(account))
```
---
## 5. Network orchestration после декомпозиции
`ProtocolRuntime` — DI-фасад runtime слоя и реализация `ProtocolGateway`/`ProtocolRuntimePort`.
`RuntimeComposition` — composition-root runtime слоя (сборка service graph + orchestration wiring) и делегирует отдельные зоны ответственности:
- Публичные runtime API proxy-методы (connect/auth/directory/packet I/O) убраны из `RuntimeComposition`; публичный runtime surface теперь удерживается в `ProtocolRuntime` + `Runtime*Facade`.
- `RuntimeTransportAssembly`: отдельный assembly-блок transport/network wiring (`NetworkReconnectWatcher`, `NetworkConnectivityFacade`, `ProtocolInstanceManager`, `PacketSubscriptionRegistry/Facade`).
- `RuntimeMessagingAssembly`: отдельный assembly-блок packet/message/sync wiring (`PacketRouter`, `OutgoingMessagePipelineService`, `PresenceTypingService`, `SyncCoordinator`, `CallSignalBridge`, `InboundPacketHandlerRegistrar`).
- `RuntimeStateAssembly`: отдельный assembly-блок connection-state wiring (`ReadyPacketGate`, `BootstrapCoordinator`, `RuntimeLifecycleStateMachine`, `OwnProfileFallbackTimerService`, `ProtocolLifecycleStateStoreImpl`).
- `RuntimeRoutingAssembly`: отдельный assembly-блок event-routing wiring (`ConnectionEventRouter` + `ProtocolConnectionSupervisor` как единый orchestration-шаг).
- `RuntimeConnectionControlFacade`: high-level connection/session control API (`initialize*`, `connect/reconnect/sync/auth`, `disconnect/destroy`, auth/connect checks).
- `RuntimeDirectoryFacade`: directory/device/typing API (`resolve/search user`, cached user lookup, own-profile signal, device accept/decline, typing snapshot by dialog).
- `RuntimePacketIoFacade`: packet I/O API (`send/sendWithRetry/resolveRetry`, call/webrtc/ice bridge, `wait/unwait/packetFlow`).
- `ProtocolInstanceManager`: singleton lifecycle `Protocol` (create/state/lastError/disconnect/destroy/isAuthenticated/isConnected).
- `RuntimeLifecycleStateMachine`: runtime lifecycle state (`ConnectionLifecycleState` + `ConnectionBootstrapContext`) и пересчет transition-логики через `BootstrapCoordinator`.
- `RuntimeInitializationCoordinator`: one-time bootstrap runtime (`initialize`, регистрация packet handlers, старт state monitoring, проверка bound DI dependencies).
- `ProtocolLifecycleStateStoreImpl`: отдельное lifecycle-state хранилище (`bootstrapContext`, `sessionGeneration`, last-subscribed-token clear hooks, own-profile fallback timer hooks).
- `OwnProfileFallbackTimerService`: управление таймером own-profile fallback (`schedule/cancel`) с генерацией timeout-события.
- `AuthRestoreService`: восстановление auth-handshake credentials из локального кеша аккаунта (`preferredPublicKey`/fallback + validation + authenticate trigger).
- `RuntimeShutdownCoordinator`: централизованный graceful runtime shutdown (`stop watcher`, `destroy subscriptions/protocol`, `clear runtime state/services`, `cancel scope`).
- `ConnectionEventRouter`: маршрутизация `ConnectionEvent` к соответствующим coordinator/service handlers без `when(event)` внутри core.
- `NetworkConnectivityFacade`: единая обертка network-availability/wait/stop policy поверх `NetworkReconnectWatcher`.
- `ConnectionOrchestrator`: connect/reconnect/authenticate + network-aware поведение.
- `ProtocolLifecycleCoordinator`: lifecycle/auth/bootstrap transitions (`ProtocolStateChanged`, `SyncCompleted`, own-profile resolved/fallback).
- `ProtocolAccountSessionCoordinator`: account-bound transitions (`InitializeAccount`, `Disconnect`) и reset account/session state.
- `ReadyPacketDispatchCoordinator`: обработка `SendPacket` через ready-gate (`bypass/enqueue/flush trigger + reconnect policy`).
- `ProtocolPostAuthBootstrapCoordinator`: post-auth orchestration (`canRun/tryRun bootstrap`, own profile fetch, push subscribe, post-sync retry/missing-user-info).
- `BootstrapCoordinator`: пересчет lifecycle (`AUTHENTICATED`/`BOOTSTRAPPING`/`READY`) и работа с `ReadyPacketGate`.
- `SyncCoordinator`: sync state machine (request/timeout, BATCH_START/BATCH_END/NOT_NEEDED, foreground/manual sync).
- `PresenceTypingService`: in-memory typing presence с TTL и snapshot `StateFlow`.
- `PacketRouter`: user/search cache + resolve/search continuation routing.
- `OwnProfileSyncService`: применение собственного профиля из search и синхронизация `IdentityStore`.
- `RetryQueueService`: retry очереди отправки `PacketMessage`.
- `AuthBootstrapCoordinator`: session-aware post-auth bootstrap (transport/update/profile/sync/push).
- `NetworkReconnectWatcher`: единый watcher ожидания сети и fast-reconnect триггеры.
- `DeviceVerificationService`: состояние списка устройств + pending verification + resolve packets.
- `DeviceRuntimeService`: device-id/handshake device + device verification orchestration.
- `CallSignalBridge`: call/webrtc/ice signal send+subscribe bridge.
- `PacketSubscriptionFacade`: thin bridge `waitPacket/unwaitPacket/packetFlow` API поверх `PacketSubscriptionRegistry`.
- `PacketSubscriptionRegistry`: централизованные подписки на пакеты и fan-out.
- `InboundPacketHandlerRegistrar`: централизованная регистрация inbound packet handlers (`0x03/0x05/0x06/0x07/0x08/0x09/0x0B/0x0F/0x14/0x17/0x19`) и делегирование в sync/repository/device/typing/profile сервисы.
- `InboundTaskQueueService`: sequential inbound task queue (`enqueue` + `whenTasksFinish`) для Desktop parity (`dialogQueue` semantics).
- `OutgoingMessagePipelineService`: отправка `PacketMessage` с retry/error policy.
- `ProtocolDebugLogService`: буферизация UI-логов, throttle flush и персистентный protocol trace.
На hot-path `ProtocolRuntime` берет runtime API (`RuntimeConnectionControlFacade`/`RuntimeDirectoryFacade`/`RuntimePacketIoFacade`) напрямую из `RuntimeComposition`, поэтому лишний proxy-hop через публичные методы composition не используется.
```mermaid
flowchart TB
PR["ProtocolRuntime (ProtocolGateway impl)"] --> RC["RuntimeComposition"]
RC --> RCC["RuntimeConnectionControlFacade"]
RC --> RDF["RuntimeDirectoryFacade"]
RC --> RPF["RuntimePacketIoFacade"]
RC --> RTA["RuntimeTransportAssembly"]
RC --> RMA["RuntimeMessagingAssembly"]
RC --> RSA["RuntimeStateAssembly"]
RC --> RRA["RuntimeRoutingAssembly"]
RTA --> PIM["ProtocolInstanceManager"]
RTA --> PSF["PacketSubscriptionFacade"]
RTA --> NCF["NetworkConnectivityFacade"]
RMA --> SC["SyncCoordinator"]
RMA --> PROUTER["PacketRouter"]
RMA --> OMPS["OutgoingMessagePipelineService"]
RMA --> CSB["CallSignalBridge"]
RMA --> IPR["InboundPacketHandlerRegistrar"]
RSA --> RLSM["RuntimeLifecycleStateMachine"]
RSA --> BC["BootstrapCoordinator"]
RSA --> RPG["ReadyPacketGate"]
RSA --> PLSS["ProtocolLifecycleStateStoreImpl"]
RRA --> SUP["ProtocolConnectionSupervisor"]
RRA --> CER["ConnectionEventRouter"]
CER --> CO["ConnectionOrchestrator"]
CER --> PLC["ProtocolLifecycleCoordinator"]
CER --> PAC["ProtocolAccountSessionCoordinator"]
CER --> RPDC["ReadyPacketDispatchCoordinator"]
PIM --> P["Protocol (WebSocket + packet codec)"]
```
---
## 6. Централизация packet-subscriptions
Проблема дублирующихся low-level подписок закрыта через `PacketSubscriptionRegistry`:
- На каждый `packetId` создается один bus и один bridge на `Protocol.waitPacket(...)`.
- Дальше packet fan-out идет в:
- callback API (`waitPacket/unwaitPacket`),
- `SharedFlow` (`packetFlow(packetId)`).
```mermaid
sequenceDiagram
participant Feature as Feature/Service
participant PR as ProtocolRuntime
participant RPF as RuntimePacketIoFacade
participant PSF as PacketSubscriptionFacade
participant REG as PacketSubscriptionRegistry
participant P as Protocol
Feature->>PR: waitPacket(0x03, callback)
PR->>RPF: waitPacket(0x03, callback)
RPF->>PSF: waitPacket(0x03, callback)
PSF->>REG: addCallback(0x03, callback)
REG->>P: waitPacket(0x03, protocolBridge) [once per packetId]
P-->>REG: Packet(0x03)
REG-->>Feature: callback(packet)
REG-->>Feature: packetFlow(0x03).emit(packet)
```
---
## 7. Чат-модуль: декомпозиция и message pipeline
### 7.1 Domain слой для сценариев отправки
Use-case слой вынесен из UI-пакета в `domain/chats/usecase`:
- `SendTextMessageUseCase`
- `SendMediaMessageUseCase`
- `SendForwardUseCase`
- `SendVoiceMessageUseCase`
- `SendTypingIndicatorUseCase`
- `SendReadReceiptUseCase`
- `CreateFileAttachmentUseCase`
- `CreateAvatarAttachmentUseCase`
- `CreateVideoCircleAttachmentUseCase`
- `EncryptAndUploadAttachmentUseCase`
Роли use-case слоя:
- `SendTextMessageUseCase`/`SendMediaMessageUseCase`: сборка `PacketMessage` + dispatch через `ProtocolGateway` (с учетом `isSavedMessages`).
- `SendForwardUseCase`: сборка forward-reply JSON, сборка forward attachment и dispatch.
- `SendVoiceMessageUseCase`/`SendTypingIndicatorUseCase`: normalization/decision логика (preview waveform, throttle/guard).
- `SendReadReceiptUseCase`: отдельный сценарий отправки `PacketRead`.
- `Create*AttachmentUseCase`: типобезопасная сборка attachment-моделей.
- `EncryptAndUploadAttachmentUseCase`: общий шаг `encrypt + upload` с возвратом `transportTag/transportServer`.
Текущий поток отправки:
1. Feature VM/Coordinator через `ChatViewModel`-host формирует command + encryption context.
2. UseCase строит payload/decision (`PacketMessage` или typed decision model).
3. `ProtocolGateway.sendMessageWithRetry(...)` уводит пакет в network runtime.
4. `RuntimeComposition` (через `ProtocolRuntime`) регистрирует пакет в `RetryQueueService` и отправляет в сеть.
5. До `READY` пакет буферизуется через `ReadyPacketGate`, затем flush.
```mermaid
flowchart LR
FVM["Feature ViewModel"] --> CVM["ChatViewModel (host)"]
CVM --> COORD["Messages/Forward/Attachments Coordinator"]
CVM --> UC["domain/chats/usecase/*"]
COORD --> UC
UC --> GW["ProtocolGateway.send / sendMessageWithRetry"]
GW --> PR["ProtocolRuntime"]
PR --> RPF["RuntimePacketIoFacade"]
RPF --> OMP["OutgoingMessagePipelineService"]
OMP --> RQ["RetryQueueService"]
OMP --> RR["RuntimeRoutingAssembly"]
RR --> RG["ReadyPacketGate / ReadyPacketDispatchCoordinator"]
RG --> P["Protocol.sendPacket"]
```
### 7.2 Декомпозиция ChatViewModel (host + feature/coordinator слой)
Для UI-слоя введены feature-facade viewmodel-классы:
- `MessagesViewModel`
- `VoiceRecordingViewModel`
- `AttachmentsViewModel`
- `TypingViewModel`
Они живут в `ui/chats/ChatFeatureViewModels.kt` и компонуются внутри `ChatViewModel`.
Текущий статус:
- `VoiceRecordingViewModel` содержит реальный send-pipeline голосовых сообщений.
- `TypingViewModel` содержит реальную отправку typing indicator (throttle + packet send).
- `MessagesViewModel` содержит orchestration-level entrypoint (`sendMessage`, `retryMessage`), а core text send pipeline вынесен в `MessagesCoordinator` (pending recovery/throttle + reply/forward packet assembly).
- `ForwardCoordinator` вынесен из `ChatViewModel`: `sendForwardDirectly` + forward rewrite/re-upload helper-ветка (включая payload resolve из cache/download).
- `AttachmentsCoordinator` вынесен из `ChatViewModel`: `updateOptimisticImageMessage`, `sendImageMessageInternal`, `sendVideoCircleMessageInternal` + local cache/update (`localUri` cleanup после отправки).
- `AttachmentsFeatureCoordinator` вынесен из `AttachmentsViewModel`: high-level media orchestration для `sendImageGroup*`, `sendFileMessage`, `sendVideoCircleFromUri`, `sendAvatarMessage`.
- `AttachmentsViewModel` теперь концентрируется на facade-методах и `sendImageFromUri`/`sendImageMessage`, делегируя крупные media-ветки в coordinator-слой.
```mermaid
flowchart TB
CD["ChatDetailScreen"] --> MVM["MessagesViewModel"]
CD --> TVM["TypingViewModel"]
CD --> VVM["VoiceRecordingViewModel"]
CD --> AVM["AttachmentsViewModel"]
MVM --> CVM["ChatViewModel (host-state)"]
TVM --> CVM
VVM --> CVM
AVM --> CVM
CVM --> MCO["MessagesCoordinator"]
CVM --> FCO["ForwardCoordinator"]
CVM --> ACO["AttachmentsCoordinator"]
AVM --> AFCO["AttachmentsFeatureCoordinator"]
CVM --> U["domain/chats/usecase/*"]
MCO --> U
FCO --> U
ACO --> U
AFCO --> U
```
Важно: после вынесения `MessagesCoordinator`, `ForwardCoordinator` и `AttachmentsCoordinator` `ChatViewModel` выступает как host-state и bridge для feature/coordinator подсистем.
### 7.3 Декомпозиция ChatsListScreen
Из `ChatsListScreen.kt` вынесены отдельные composable-секции:
- `ChatItem` -> `ChatsListChatItem.kt`
- `RequestsSection` -> `ChatsListRequestsSection.kt`
- `DrawerContent` -> `ChatsListDrawerContent.kt`
Результат:
- основной файл экрана меньше и проще для навигации;
- повторно используемые куски UI имеют явные file boundaries;
- дальнейший рефакторинг drawer/request/chat list можно делать независимо.
---
## 8. Auth/bootstrap: фактический runtime flow
```mermaid
sequenceDiagram
participant UI as Auth UI (SetPassword/Unlock)
participant SC as SessionCoordinatorImpl
participant SS as SessionStore
participant PG as ProtocolGateway
participant PR as ProtocolRuntime
participant RCC as RuntimeConnectionControlFacade
participant RRA as RuntimeRoutingAssembly
participant RSA as RuntimeStateAssembly
participant AM as AccountManager
UI->>SC: bootstrapAuthenticatedSession(account, reason)
SC->>SS: dispatch(AuthInProgress)
SC->>PG: initializeAccount(public, private)
SC->>PG: connect()
SC->>PG: authenticate(public, privateHash)
SC->>PG: reconnectNowIfNeeded(...)
SC->>AM: setCurrentAccount(public)
SC->>SS: dispatch(Ready)
PG->>PR: runtime API calls
PR->>RCC: connection/auth commands
RCC->>RRA: post(ConnectionEvent.*)
RRA-->>RRA: Supervisor + Router route events
RRA-->>RSA: apply lifecycle transitions
RSA-->>RSA: AUTHENTICATED -> BOOTSTRAPPING -> READY
```
Важно: `SessionState.Ready` (app-session готова) и `connectionLifecycleState = READY` (сеть готова) — это разные state-модели.
---
## 9. Состояния соединения (network lifecycle)
`RuntimeComposition.connectionLifecycleState`:
- `DISCONNECTED`
- `CONNECTING`
- `HANDSHAKING`
- `AUTHENTICATED`
- `BOOTSTRAPPING`
- `READY`
- `DEVICE_VERIFICATION_REQUIRED`
```mermaid
stateDiagram-v2
[*] --> DISCONNECTED
DISCONNECTED --> CONNECTING
CONNECTING --> HANDSHAKING
HANDSHAKING --> DEVICE_VERIFICATION_REQUIRED
HANDSHAKING --> AUTHENTICATED
AUTHENTICATED --> BOOTSTRAPPING
BOOTSTRAPPING --> READY
READY --> HANDSHAKING
AUTHENTICATED --> DISCONNECTED
BOOTSTRAPPING --> DISCONNECTED
READY --> DISCONNECTED
DEVICE_VERIFICATION_REQUIRED --> CONNECTING
```
---
## 10. Ключевые файлы новой архитектуры
- `app/src/main/java/com/rosetta/messenger/di/AppContainer.kt`
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt`
- `app/src/main/java/com/rosetta/messenger/network/RuntimeComposition.kt`
- `app/src/main/java/com/rosetta/messenger/network/RuntimeTransportAssembly.kt`
- `app/src/main/java/com/rosetta/messenger/network/RuntimeMessagingAssembly.kt`
- `app/src/main/java/com/rosetta/messenger/network/RuntimeStateAssembly.kt`
- `app/src/main/java/com/rosetta/messenger/network/RuntimeRoutingAssembly.kt`
- `app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt`
- `app/src/main/java/com/rosetta/messenger/network/RuntimeDirectoryFacade.kt`
- `app/src/main/java/com/rosetta/messenger/network/RuntimePacketIoFacade.kt`
- `app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt`
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeAccess.kt`
- `app/src/main/java/com/rosetta/messenger/session/AppSessionCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/session/SessionStore.kt`
- `app/src/main/java/com/rosetta/messenger/session/SessionReducer.kt`
- `app/src/main/java/com/rosetta/messenger/session/SessionAction.kt`
- `app/src/main/java/com/rosetta/messenger/session/IdentityStore.kt`
- `app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt`
- `app/src/main/java/com/rosetta/messenger/network/PacketSubscriptionRegistry.kt`
- `app/src/main/java/com/rosetta/messenger/network/ProtocolConnectionModels.kt`
- `app/src/main/java/com/rosetta/messenger/network/ProtocolConnectionSupervisor.kt`
- `app/src/main/java/com/rosetta/messenger/network/ReadyPacketGate.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ConnectionOrchestrator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolInstanceManager.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeLifecycleStateMachine.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeInitializationCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleStateStoreImpl.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileFallbackTimerService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/AuthRestoreService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeShutdownCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ConnectionEventRouter.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/NetworkConnectivityFacade.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/PacketSubscriptionFacade.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolAccountSessionCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ReadyPacketDispatchCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolPostAuthBootstrapCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/BootstrapCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/SyncCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/PresenceTypingService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/AuthBootstrapCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/NetworkReconnectWatcher.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/DeviceVerificationService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/DeviceRuntimeService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/CallSignalBridge.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/InboundPacketHandlerRegistrar.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/InboundTaskQueueService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/PacketRouter.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileSyncService.kt`
- `app/src/main/java/com/rosetta/messenger/network/connection/RetryQueueService.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ForwardCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsFeatureCoordinator.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingEncryptionContext.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingSendContext.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListChatItem.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsSection.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerContent.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerSections.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsScreen.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTextMessageUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendMediaMessageUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendForwardUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendVoiceMessageUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTypingIndicatorUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendReadReceiptUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/CreateAttachmentUseCases.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/EncryptAndUploadAttachmentUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/VideoCircleMediaUseCases.kt`
---
## 11. Что осталось как технический долг
Актуальные открытые хвосты:
- `RuntimeComposition` остается composition-root (около 501 строки): публичные proxy-методы уже убраны, но внутри все еще смешаны wiring и часть helper-логики (`setupStateMonitoring`, event-bridge, log helpers). Следующий шаг: вынести эти helper-блоки в отдельные adapters/services.
- `ProtocolRuntime` + `ProtocolRuntimePort` все еще имеют широкий API surface (connection + directory + packet IO + call signaling + debug). Нужен audit и сужение публичных контрактов по use-case группам.
- `ChatViewModel` остается очень крупным host-классом (около 4391 строки) с большим bridge/proxy surface к feature/coordinator/use-case слоям.
- `AttachmentsFeatureCoordinator` остается крупным (около 761 строки): high-level media сценарии стоит резать на более узкие upload/transform/packet-assembly сервисы.
- Тестовое покрытие архитектурно-критичных слоев недостаточно: `app/src/test` = 7, `app/src/androidTest` = 1; не покрыты runtime-routing/lifecycle компоненты (`RuntimeRoutingAssembly`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat coordinators (`Messages/Forward/Attachments*`).
- В runtime все еще несколько точек входа (`ProtocolRuntime`, `ProtocolRuntimeAccess`, `ProtocolManager` legacy), что повышает cognitive load; целевой шаг — дальнейшее сокращение legacy/static call-sites.
Уже закрыто и больше не считается техдолгом:
- `UiDependencyAccess.get(...)` удален из `ui/*`.
- `UiEntryPoint`/`EntryPointAccessors` убраны из UI-экранов (явная передача зависимостей через `MainActivity`/`ViewModel`).
- DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` закрыт через `Provider<ProtocolRuntime>`.
- `ProtocolManager` переведен в минимальный legacy compatibility API (тонкие прокси к `ProtocolRuntimeAccess`).
---
## 12. Guardrails против переусложнения
Чтобы декомпозиция не превращалась в «архитектуру ради архитектуры», применяются следующие правила:
1. Лимит глубины runtime-цепочки вызова: не более 3 логических слоев после DI-entry (`ProtocolRuntime -> Runtime*Facade -> service`; `RuntimeComposition` остается composition-root/wiring-слоем, а не обязательным proxy-hop).
2. Новый слой/класс допускается только если он дает измеримый выигрыш:
- убирает минимум 80-120 строк связанной orchestration-логики из текущего класса, или
- убирает минимум 2 внешние зависимости из текущего класса.
3. Каждый шаг рефакторинга считается завершенным только после: `compileDebugKotlin` + минимум одного smoke-сценария по затронутому флоу + обновления `Architecture.md`.
4. Если после выноса сложность чтения/изменения не снизилась (по факту код не стал проще), такой вынос считается кандидатом на откат/консолидацию.
5. Для event-driven runtime-chain (`ProtocolConnectionSupervisor` + `ConnectionEventRouter`) эти два элемента считаются одним orchestration-этапом при анализе hop-depth.
6. `ProtocolClientImpl` трактуется как инфраструктурный DI-adapter и учитывается отдельно от business-flow hop budget.
---
## 13. Плюсы и минусы текущей архитектуры
### 13.1 Плюсы
- Четко выделены слои: UI, domain use-cases, network runtime, session/identity, data/persistence.
- DI через Hilt и `ProtocolGateway`/`SessionCoordinator` снижает прямую связанность между UI и transport/runtime.
- Убраны `UiEntryPoint`/`EntryPointAccessors` из UI-экранов, что улучшило явность зависимостей.
- Закрыт критичный DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` через `Provider<ProtocolRuntime>`.
- Network runtime декомпозирован на отдельные сервисы/coordinator-ы с более узкими зонами ответственности.
- Сокращен DI runtime path: `ProtocolGateway` биндится напрямую на `ProtocolRuntime`, runtime работает напрямую с `RuntimeComposition`.
- Централизован packet subscription fan-out (`PacketSubscriptionRegistry` + `PacketSubscriptionFacade`), что снижает риск дублирующих low-level подписок.
- В chat-модуле выделен domain use-case слой и вынесены крупные сценарии в coordinators.
### 13.2 Минусы
- `RuntimeComposition` и `ChatViewModel` остаются очень крупными hotspot-классами и концентрируют много связей.
- Runtime API-слой пока широкий: много proxy-методов усложняют контроль границ и эволюцию surface API.
- В части chat/media orchestration (`AttachmentsFeatureCoordinator`, `MessagesCoordinator`, `ForwardCoordinator`) сохраняются большие high-level сценарии.
- Мало unit/integration тестов на архитектурно-критичные runtime/chat orchestration компоненты.
- В проекте остаются несколько точек доступа к runtime (`ProtocolRuntime`, `ProtocolRuntimePort`, `ProtocolManager` legacy), что повышает cognitive load для новых разработчиков.
- Стоимость входа в кодовую базу выросла: для трассировки одного бизнес-флоу нужно проходить больше слоев, чем раньше.
### 13.3 Итог оценки
- Текущая архитектура стала заметно лучше по управляемости зависимостей и изоляции ответственности.
- Главные риски сместились из “монолитного класса” в “размер composition/API surface и недотестированность orchestration”.
- При соблюдении guardrails (секция 12) и фокусе на тестах/дальнейшей локальной декомпозиции архитектура остается управляемой и не уходит в избыточную сложность.

View File

@@ -1,5 +1,59 @@
# Release Notes
## 1.4.2
### Звонки
- Полноэкранный incoming call через ForegroundService — кнопки Accept/Decline, будит экран, работает когда приложение свёрнуто или убито (и из push, и из WebSocket).
- Синхронизация ForegroundService с фазами звонка — notification обновляется при INCOMING → CONNECTING → ACTIVE → IDLE.
- Защита от CREATE_ROOM без ключей шифрования — сброс сессии если звонок принят на другом устройстве.
- Корректное освобождение PeerConnection (`dispose()`) при завершении звонка — фикс зависания ICE портов ~30 сек.
### E2EE диагностика
- Диагностический файл E2EE включён для всех билдов (был только debug).
- Периодический health-лог E2EE с счётчиками фреймов enc/dec из нативного кода.
- Уменьшен спам scan receivers — логирование только при изменении состояния.
- Нативные методы `FrameCount()` / `BadStreak()` для мониторинга шифрования в реальном времени.
### Push-уведомления
- Добавлены `tokenType` и `deviceId` в пакет push-подписки (совместимость с новым сервером).
- Сохранение FCM токена в crash_reports для просмотра через rosettadev1.
### CI/CD
- Установка NDK и CMake в CI для сборки нативного модуля `rosetta_e2ee.so`.
## 1.3.4
### Звонки и UI
- Реализован Telegram-style фон звонка в приложении: full-screen звонок теперь можно свернуть в закрепленную верхнюю плашку в чат-листе.
- Плашка звонка перенесена внутрь `ChatsListScreen` и ведет обратно в экран звонка по нажатию.
- Обновлен UI звонка: иконка сворачивания в стиле Telegram, улучшено поведение call overlay.
- Исправлено автоматическое скрытие клавиатуры при открытии экрана звонка.
### Поиск в диалоге
- В kebab-меню каждого чата добавлен пункт `Search`.
- Добавлен встроенный поиск сообщений внутри текущего диалога (через локальный индекс `message_search_index` и `dialog_key`).
- Добавлена навигация по результатам (`prev/next`) со скроллом и подсветкой найденного сообщения.
## 1.3.3
### E2EE, чаты и производительность
- В release-сборке отключена frame-диагностика E2EE (детальный frame dump теперь только в debug).
- В `ChatsListScreen` убран двойной `collectAsState(chatsState)` и вынесены route-блоки в подкомпоненты (`CallsRouteContent`, `RequestsRouteContent`, общий `SwipeBackContainer`).
- Добавлена денормализация `primary_attachment_type` в таблице `messages` + индекс `(account, primary_attachment_type, timestamp)`.
- Обновлена миграция БД `14 -> 15`: добавление колонки, индекс и backfill значения типа вложения для уже сохраненных сообщений.
- Поисковые и call-history запросы переведены на `primary_attachment_type` с fallback на legacy `attachments LIKE` для старых записей.
## 1.2.3
### Групповые чаты и медиа
- Исправлено отображение групповых баблов: логика стеков и аватаров приведена ближе к desktop-версии.
- Исправлено позиционирование аватарки в группе: аватар и имя теперь отображаются на одном сообщении (без «разъезда»).
- Исправлена обрезка имени отправителя в медиа-баблах группового чата.
- Исправлено растяжение и кривые пропорции фото в forwarded/media-пузырях.
### Sidebar
- Убрана лишняя рамка (border) вокруг аватарки в сайдбаре.
## 1.2.1
### Синхронизация Android ↔ iOS

View File

@@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
}
@@ -23,8 +24,9 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.2.1"
val rosettaVersionCode = 23 // Increment on each release
val rosettaVersionName = "1.5.4"
val rosettaVersionCode = 56 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {
namespace = "com.rosetta.messenger"
@@ -43,6 +45,19 @@ android {
// Optimize Lottie animations
manifestPlaceholders["enableLottieOptimizations"] = "true"
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
externalNativeBuild {
cmake { cppFlags("-std=c++17") }
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
signingConfigs {
@@ -69,6 +84,14 @@ android {
// Enable baseline profiles in debug builds too for testing
// Remove this in production
}
create("benchmark") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("release")
matchingFallbacks += listOf("release")
isDebuggable = false
isMinifyEnabled = false
isShrinkResources = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
@@ -84,6 +107,10 @@ android {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
jniLibs { useLegacyPackaging = true }
}
lint {
checkReleaseBuilds = false
abortOnError = false
}
applicationVariants.all {
outputs.all {
@@ -93,6 +120,10 @@ android {
}
}
kapt {
correctErrorTypes = true
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
@@ -129,9 +160,6 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
// Jsoup for HTML parsing (Link Preview OG tags)
implementation("org.jsoup:jsoup:1.17.2")
// uCrop for image cropping
implementation("com.github.yalantis:ucrop:2.2.8")
@@ -159,6 +187,11 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// Hilt DI
implementation("com.google.dagger:hilt-android:2.51.1")
kapt("com.google.dagger:hilt-compiler:2.51.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")
@@ -168,14 +201,26 @@ dependencies {
implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view:1.3.1")
// WebRTC for voice calls.
// If app/libs/libwebrtc-custom.aar exists, prefer it (custom E2EE-enabled build).
if (customWebRtcAar.exists()) {
implementation(files(customWebRtcAar))
} else {
implementation("io.github.webrtc-sdk:android:125.6422.07")
}
// Baseline Profiles for startup performance
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
// Firebase Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
// QR Code generation (ZXing) + scanning (ML Kit)
implementation("com.google.zxing:core:3.5.3")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// Testing dependencies
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")

3158
app/libs/LICENSE.md Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -7,6 +7,18 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
@@ -15,10 +27,8 @@
<application
android:name=".RosettaApplication"
android:allowBackup="true"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -36,13 +46,104 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait">
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="rosetta" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="rosetta.im" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<!-- App Icon Aliases: only one enabled at a time -->
<activity-alias
android:name=".MainActivityDefault"
android:targetActivity=".MainActivity"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityCalculator"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_calc"
android:roundIcon="@mipmap/ic_launcher_calc"
android:label="Calculator">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityWeather"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_weather"
android:roundIcon="@mipmap/ic_launcher_weather"
android:label="Weather">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityNotes"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_notes"
android:roundIcon="@mipmap/ic_launcher_notes"
android:label="Notes">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".IncomingCallActivity"
android:exported="false"
android:theme="@style/Theme.RosettaAndroid"
android:launchMode="singleTask"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:screenOrientation="portrait"
android:excludeFromRecents="true"
android:taskAffinity="com.rosetta.messenger.call" />
<!-- FileProvider for camera images -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -63,6 +164,11 @@
</intent-filter>
</service>
<service
android:name=".network.CallForegroundService"
android:exported="false"
android:foregroundServiceType="microphone|mediaPlayback|phoneCall" />
<!-- Firebase notification icon (optional, for better looking notifications) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"

View File

@@ -0,0 +1,33 @@
cmake_minimum_required(VERSION 3.22.1)
project(rosetta_e2ee LANGUAGES C CXX)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
add_library(rosetta_e2ee SHARED
crypto.c
rosetta_e2ee.cpp
)
target_include_directories(rosetta_e2ee PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
# Hide all C++ symbols to avoid ODR clashes with WebRTC's .so
set_target_properties(rosetta_e2ee PROPERTIES
CXX_VISIBILITY_PRESET hidden
C_VISIBILITY_PRESET hidden
)
# Match WebRTC SDK build flags:
# -fno-rtti -fno-exceptions — standard WebRTC flags
# -fexperimental-relative-c++-abi-vtables — WebRTC uses relative vtables
# (32-bit offsets instead of 64-bit absolute pointers in vtable).
# Without this, setFrameEncryptor crashes with SIGSEGV because WebRTC
# reads our 64-bit pointers as 32-bit offsets.
target_compile_options(rosetta_e2ee PRIVATE
-fno-rtti
-fno-exceptions
-fexperimental-relative-c++-abi-vtables
)
find_library(log-lib log)
target_link_libraries(rosetta_e2ee ${log-lib})

248
app/src/main/cpp/crypto.c Normal file
View File

@@ -0,0 +1,248 @@
/**
* Minimal crypto primitives for Rosetta E2EE:
* - HSalsa20 (for nacl.box.before() compatible key exchange)
* - HChaCha20 + ChaCha20-IETF → XChaCha20 (for frame encryption)
*
* Based on the public-domain algorithms by D.J. Bernstein.
*/
#include "crypto.h"
#include <string.h>
/* ── helpers ─────────────────────────────────────────────────── */
#define ROTL32(v, n) (((v) << (n)) | ((v) >> (32 - (n))))
static uint32_t load32_le(const uint8_t *p) {
return (uint32_t)p[0]
| (uint32_t)p[1] << 8
| (uint32_t)p[2] << 16
| (uint32_t)p[3] << 24;
}
static void store32_le(uint8_t *p, uint32_t v) {
p[0] = (uint8_t)(v);
p[1] = (uint8_t)(v >> 8);
p[2] = (uint8_t)(v >> 16);
p[3] = (uint8_t)(v >> 24);
}
/* "expand 32-byte k" as four little-endian uint32 */
static const uint32_t SIGMA[4] = {
0x61707865u, 0x3320646eu, 0x79622d32u, 0x6b206574u
};
/* ── HSalsa20 (Salsa20 family) ──────────────────────────────── */
void rosetta_hsalsa20(uint8_t out[32],
const uint8_t inp[16],
const uint8_t key[32])
{
uint32_t x[16];
x[ 0] = SIGMA[0];
x[ 1] = load32_le(key + 0);
x[ 2] = load32_le(key + 4);
x[ 3] = load32_le(key + 8);
x[ 4] = load32_le(key + 12);
x[ 5] = SIGMA[1];
x[ 6] = load32_le(inp + 0);
x[ 7] = load32_le(inp + 4);
x[ 8] = load32_le(inp + 8);
x[ 9] = load32_le(inp + 12);
x[10] = SIGMA[2];
x[11] = load32_le(key + 16);
x[12] = load32_le(key + 20);
x[13] = load32_le(key + 24);
x[14] = load32_le(key + 28);
x[15] = SIGMA[3];
for (int i = 0; i < 20; i += 2) {
/* column round */
x[ 4] ^= ROTL32(x[ 0] + x[12], 7);
x[ 8] ^= ROTL32(x[ 4] + x[ 0], 9);
x[12] ^= ROTL32(x[ 8] + x[ 4], 13);
x[ 0] ^= ROTL32(x[12] + x[ 8], 18);
x[ 9] ^= ROTL32(x[ 5] + x[ 1], 7);
x[13] ^= ROTL32(x[ 9] + x[ 5], 9);
x[ 1] ^= ROTL32(x[13] + x[ 9], 13);
x[ 5] ^= ROTL32(x[ 1] + x[13], 18);
x[14] ^= ROTL32(x[10] + x[ 6], 7);
x[ 2] ^= ROTL32(x[14] + x[10], 9);
x[ 6] ^= ROTL32(x[ 2] + x[14], 13);
x[10] ^= ROTL32(x[ 6] + x[ 2], 18);
x[ 3] ^= ROTL32(x[15] + x[11], 7);
x[ 7] ^= ROTL32(x[ 3] + x[15], 9);
x[11] ^= ROTL32(x[ 7] + x[ 3], 13);
x[15] ^= ROTL32(x[11] + x[ 7], 18);
/* row round */
x[ 1] ^= ROTL32(x[ 0] + x[ 3], 7);
x[ 2] ^= ROTL32(x[ 1] + x[ 0], 9);
x[ 3] ^= ROTL32(x[ 2] + x[ 1], 13);
x[ 0] ^= ROTL32(x[ 3] + x[ 2], 18);
x[ 6] ^= ROTL32(x[ 5] + x[ 4], 7);
x[ 7] ^= ROTL32(x[ 6] + x[ 5], 9);
x[ 4] ^= ROTL32(x[ 7] + x[ 6], 13);
x[ 5] ^= ROTL32(x[ 4] + x[ 7], 18);
x[11] ^= ROTL32(x[10] + x[ 9], 7);
x[ 8] ^= ROTL32(x[11] + x[10], 9);
x[ 9] ^= ROTL32(x[ 8] + x[11], 13);
x[10] ^= ROTL32(x[ 9] + x[ 8], 18);
x[12] ^= ROTL32(x[15] + x[14], 7);
x[13] ^= ROTL32(x[12] + x[15], 9);
x[14] ^= ROTL32(x[13] + x[12], 13);
x[15] ^= ROTL32(x[14] + x[13], 18);
}
/* output words: 0, 5, 10, 15, 6, 7, 8, 9 */
store32_le(out + 0, x[ 0]);
store32_le(out + 4, x[ 5]);
store32_le(out + 8, x[10]);
store32_le(out + 12, x[15]);
store32_le(out + 16, x[ 6]);
store32_le(out + 20, x[ 7]);
store32_le(out + 24, x[ 8]);
store32_le(out + 28, x[ 9]);
}
/* ── HChaCha20 (ChaCha20 family) ────────────────────────────── */
static void hchacha20(uint8_t out[32],
const uint8_t inp[16],
const uint8_t key[32])
{
uint32_t x[16];
x[ 0] = SIGMA[0];
x[ 1] = SIGMA[1];
x[ 2] = SIGMA[2];
x[ 3] = SIGMA[3];
x[ 4] = load32_le(key + 0);
x[ 5] = load32_le(key + 4);
x[ 6] = load32_le(key + 8);
x[ 7] = load32_le(key + 12);
x[ 8] = load32_le(key + 16);
x[ 9] = load32_le(key + 20);
x[10] = load32_le(key + 24);
x[11] = load32_le(key + 28);
x[12] = load32_le(inp + 0);
x[13] = load32_le(inp + 4);
x[14] = load32_le(inp + 8);
x[15] = load32_le(inp + 12);
for (int i = 0; i < 20; i += 2) {
/* column round */
#define QR(a, b, c, d) \
a += b; d ^= a; d = ROTL32(d, 16); \
c += d; b ^= c; b = ROTL32(b, 12); \
a += b; d ^= a; d = ROTL32(d, 8); \
c += d; b ^= c; b = ROTL32(b, 7);
QR(x[0], x[4], x[8], x[12]);
QR(x[1], x[5], x[9], x[13]);
QR(x[2], x[6], x[10], x[14]);
QR(x[3], x[7], x[11], x[15]);
/* diagonal round */
QR(x[0], x[5], x[10], x[15]);
QR(x[1], x[6], x[11], x[12]);
QR(x[2], x[7], x[8], x[13]);
QR(x[3], x[4], x[9], x[14]);
#undef QR
}
/* output words: 0, 1, 2, 3, 12, 13, 14, 15 */
store32_le(out + 0, x[ 0]);
store32_le(out + 4, x[ 1]);
store32_le(out + 8, x[ 2]);
store32_le(out + 12, x[ 3]);
store32_le(out + 16, x[12]);
store32_le(out + 20, x[13]);
store32_le(out + 24, x[14]);
store32_le(out + 28, x[15]);
}
/* ── ChaCha20-IETF (RFC 8439) ───────────────────────────────── */
static void chacha20_block(uint8_t out[64],
const uint8_t key[32],
uint32_t counter,
const uint8_t nonce[12])
{
uint32_t s[16], x[16];
s[ 0] = SIGMA[0];
s[ 1] = SIGMA[1];
s[ 2] = SIGMA[2];
s[ 3] = SIGMA[3];
for (int i = 0; i < 8; i++) s[4 + i] = load32_le(key + i * 4);
s[12] = counter;
s[13] = load32_le(nonce + 0);
s[14] = load32_le(nonce + 4);
s[15] = load32_le(nonce + 8);
memcpy(x, s, sizeof(x));
for (int i = 0; i < 20; i += 2) {
#define QR(a, b, c, d) \
a += b; d ^= a; d = ROTL32(d, 16); \
c += d; b ^= c; b = ROTL32(b, 12); \
a += b; d ^= a; d = ROTL32(d, 8); \
c += d; b ^= c; b = ROTL32(b, 7);
QR(x[0], x[4], x[8], x[12]);
QR(x[1], x[5], x[9], x[13]);
QR(x[2], x[6], x[10], x[14]);
QR(x[3], x[7], x[11], x[15]);
QR(x[0], x[5], x[10], x[15]);
QR(x[1], x[6], x[11], x[12]);
QR(x[2], x[7], x[8], x[13]);
QR(x[3], x[4], x[9], x[14]);
#undef QR
}
for (int i = 0; i < 16; i++) store32_le(out + i * 4, x[i] + s[i]);
}
static void chacha20_ietf_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t nonce[12],
const uint8_t key[32],
uint32_t initial_counter)
{
uint8_t block[64];
uint32_t ctr = initial_counter;
size_t off = 0;
while (off < len) {
chacha20_block(block, key, ctr++, nonce);
size_t chunk = len - off;
if (chunk > 64) chunk = 64;
for (size_t i = 0; i < chunk; i++) {
out[off + i] = in[off + i] ^ block[i];
}
off += chunk;
}
memset(block, 0, sizeof(block));
}
/* ── XChaCha20 XOR ───────────────────────────────────────────── */
void rosetta_xchacha20_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t nonce[24],
const uint8_t key[32])
{
/* Step 1: derive sub-key with HChaCha20(key, nonce[0..15]) */
uint8_t subkey[32];
hchacha20(subkey, nonce, key);
/* Step 2: ChaCha20-IETF with sub-key and nonce' = [0,0,0,0, nonce[16..23]] */
uint8_t sub_nonce[12] = {0};
memcpy(sub_nonce + 4, nonce + 16, 8);
chacha20_ietf_xor(out, in, len, sub_nonce, subkey, 0);
memset(subkey, 0, sizeof(subkey));
}

33
app/src/main/cpp/crypto.h Normal file
View File

@@ -0,0 +1,33 @@
#ifndef ROSETTA_CRYPTO_H
#define ROSETTA_CRYPTO_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* HSalsa20 core — used by nacl.box.before() to derive shared key.
* out: 32 bytes, inp: 16 bytes (nonce, zeros for box.before), key: 32 bytes
*/
void rosetta_hsalsa20(uint8_t out[32],
const uint8_t inp[16],
const uint8_t key[32]);
/**
* XChaCha20 XOR (encrypt = decrypt, symmetric stream cipher).
* out and in may overlap. nonce: 24 bytes, key: 32 bytes.
*/
void rosetta_xchacha20_xor(uint8_t *out,
const uint8_t *in,
size_t len,
const uint8_t nonce[24],
const uint8_t key[32]);
#ifdef __cplusplus
}
#endif
#endif /* ROSETTA_CRYPTO_H */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
// Minimal stub matching WebRTC M125 api/array_view.h
#ifndef API_ARRAY_VIEW_H_
#define API_ARRAY_VIEW_H_
#include <cstddef>
namespace rtc {
template <typename T>
class ArrayView final {
public:
constexpr ArrayView() noexcept : ptr_(nullptr), size_(0) {}
constexpr ArrayView(T* ptr, size_t size) noexcept : ptr_(ptr), size_(size) {}
constexpr T* data() const { return ptr_; }
constexpr size_t size() const { return size_; }
private:
T* ptr_;
size_t size_;
};
} // namespace rtc
#endif // API_ARRAY_VIEW_H_

View File

@@ -0,0 +1,42 @@
// Minimal stub matching WebRTC M125 api/crypto/frame_decryptor_interface.h
#ifndef API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
#define API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
#include <cstddef>
#include <cstdint>
#include <vector>
#include "webrtc/rtc_base/ref_count.h"
#include "webrtc/api/array_view.h"
#include "webrtc/api/media_types.h"
namespace webrtc {
class FrameDecryptorInterface : public rtc::RefCountInterface {
public:
struct Result {
enum class Status { kOk = 0, kRecoverable, kFailedToDecrypt };
Result(Status s, size_t bw) : status(s), bytes_written(bw) {}
bool IsOk() const { return status == Status::kOk; }
Status status;
size_t bytes_written;
};
virtual Result Decrypt(cricket::MediaType media_type,
const std::vector<uint32_t>& csrcs,
rtc::ArrayView<const uint8_t> additional_data,
rtc::ArrayView<const uint8_t> encrypted_frame,
rtc::ArrayView<uint8_t> frame) = 0;
virtual size_t GetMaxPlaintextByteSize(cricket::MediaType media_type,
size_t encrypted_frame_size) = 0;
protected:
~FrameDecryptorInterface() override {}
};
} // namespace webrtc
#endif // API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_

View File

@@ -0,0 +1,32 @@
// Minimal stub matching WebRTC M125 api/crypto/frame_encryptor_interface.h
#ifndef API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
#define API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
#include <cstddef>
#include <cstdint>
#include "webrtc/rtc_base/ref_count.h"
#include "webrtc/api/array_view.h"
#include "webrtc/api/media_types.h"
namespace webrtc {
class FrameEncryptorInterface : public rtc::RefCountInterface {
public:
virtual int Encrypt(cricket::MediaType media_type,
uint32_t ssrc,
rtc::ArrayView<const uint8_t> additional_data,
rtc::ArrayView<const uint8_t> frame,
rtc::ArrayView<uint8_t> encrypted_frame,
size_t* bytes_written) = 0;
virtual size_t GetMaxCiphertextByteSize(cricket::MediaType media_type,
size_t frame_size) = 0;
protected:
~FrameEncryptorInterface() override {}
};
} // namespace webrtc
#endif // API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_

View File

@@ -0,0 +1,14 @@
// Minimal stub matching WebRTC M125 api/media_types.h
#ifndef API_MEDIA_TYPES_H_
#define API_MEDIA_TYPES_H_
namespace cricket {
enum MediaType {
MEDIA_TYPE_AUDIO,
MEDIA_TYPE_VIDEO,
MEDIA_TYPE_DATA,
MEDIA_TYPE_UNSUPPORTED
};
} // namespace cricket
#endif // API_MEDIA_TYPES_H_

View File

@@ -0,0 +1,21 @@
// Minimal stub matching WebRTC M125 rtc_base/ref_count.h
#ifndef RTC_BASE_REF_COUNT_H_
#define RTC_BASE_REF_COUNT_H_
namespace rtc {
enum class RefCountReleaseStatus { kDroppedLastRef, kOtherRefsRemained };
// Must match the EXACT virtual layout of the real rtc::RefCountInterface.
class RefCountInterface {
public:
virtual void AddRef() const = 0;
virtual RefCountReleaseStatus Release() const = 0;
protected:
virtual ~RefCountInterface() {}
};
} // namespace rtc
#endif // RTC_BASE_REF_COUNT_H_

View File

@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/**
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
@@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition(
wasEmojiShown = true
}
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
// Emoji закрылся после того как был открыт = переход emoji→keyboard
isTransitioningToKeyboard = true
// Keep reserved space only if keyboard is actually opening.
// For back-swipe/back-press close there is no keyboard open request,
// so we must drop the emoji box immediately to avoid an empty gap.
val keyboardIsComing =
coordinator.currentState == KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD ||
coordinator.isKeyboardVisible ||
coordinator.keyboardHeight > 0.dp
isTransitioningToKeyboard = keyboardIsComing
if (!keyboardIsComing) {
wasEmojiShown = false
}
}
// 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji
@@ -64,6 +73,19 @@ fun AnimatedKeyboardTransition(
wasEmojiShown = false
}
// Failsafe for interrupted gesture/back navigation: if keyboard never started opening,
// don't keep an invisible fixed-height box.
if (
isTransitioningToKeyboard &&
!showEmojiPicker &&
coordinator.currentState != KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD &&
!coordinator.isKeyboardVisible &&
coordinator.keyboardHeight == 0.dp
) {
isTransitioningToKeyboard = false
wasEmojiShown = false
}
// 🎯 Целевая прозрачность
val targetAlpha = if (showEmojiPicker) 1f else 0f
@@ -110,19 +132,3 @@ fun AnimatedKeyboardTransition(
}
}
}
/**
* Алиас для обратной совместимости
*/
@Composable
fun SimpleAnimatedKeyboardTransition(
coordinator: KeyboardTransitionCoordinator,
showEmojiPicker: Boolean,
content: @Composable () -> Unit
) {
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker,
content = content
)
}

View File

@@ -4,7 +4,7 @@ import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
var currentState by mutableStateOf(TransitionState.IDLE)
private set
var transitionProgress by mutableFloatStateOf(0f)
private set
// ============ Высоты ============
var keyboardHeight by mutableStateOf(0.dp)
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
// Используется для отключения imePadding пока Box виден
var isEmojiBoxVisible by mutableStateOf(false)
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
private var pendingShowEmojiCallback: (() -> Unit)? = null
// 📊 Для умного логирования (не каждый фрейм)
private var lastLogTime = 0L
private var lastLoggedHeight = -1f
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
currentState = TransitionState.IDLE
isTransitioning = false
// Очищаем pending callback - больше не нужен
pendingShowEmojiCallback = null
}
// ============ Главный метод: Emoji → Keyboard ============
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
* плавно скрыть emoji.
*/
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
if (pendingShowEmojiCallback != null) {
pendingShowEmojiCallback = null
}
currentState = TransitionState.EMOJI_TO_KEYBOARD
isTransitioning = true
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
}
/** Обновить высоту emoji панели. */
fun updateEmojiHeight(height: Dp) {
if (height > 0.dp && height != emojiHeight) {
emojiHeight = height
}
}
/**
* Синхронизировать высоты (emoji = keyboard).
*
@@ -292,35 +272,6 @@ class KeyboardTransitionCoordinator {
}
}
/**
* Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать
* максимум из двух.
*/
fun getReservedHeight(): Dp {
return when {
isKeyboardVisible -> keyboardHeight
isEmojiVisible -> emojiHeight
isTransitioning -> maxOf(keyboardHeight, emojiHeight)
else -> 0.dp
}
}
/** Проверка, можно ли начать новый переход. */
fun canStartTransition(): Boolean {
return !isTransitioning
}
/** Сброс состояния (для отладки). */
fun reset() {
currentState = TransitionState.IDLE
isTransitioning = false
isKeyboardVisible = false
isEmojiVisible = false
transitionProgress = 0f
}
/** Логирование текущего состояния. */
fun logState() {}
}
/** Composable для создания и запоминания coordinator'а. */

View File

@@ -0,0 +1,196 @@
package com.rosetta.messenger
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.*
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.calls.CallOverlay
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Лёгкая Activity для показа входящего звонка на lock screen.
* Показывается поверх экрана блокировки, без auth/splash.
* При Accept → переходит в MainActivity. При Decline → закрывается.
*/
@AndroidEntryPoint
class IncomingCallActivity : ComponentActivity() {
@Inject lateinit var accountManager: AccountManager
companion object {
private const val TAG = "IncomingCallActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
try {
super.onCreate(savedInstanceState)
} catch (e: Throwable) {
Log.e(TAG, "super.onCreate CRASHED", e)
callLog("super.onCreate CRASHED: ${e.message}")
finish()
return
}
callLog("onCreate START")
// Показываем поверх lock screen и включаем экран
callLog("setting lock screen flags, SDK=${Build.VERSION.SDK_INT}")
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
)
}
// Важно: не снимаем keyguard автоматически.
// Экран звонка может отображаться поверх lockscreen, но разблокировку делает только пользователь.
try {
CallManager.initialize(applicationContext)
callLog("CallManager initialized, phase=${CallManager.state.value.phase}")
} catch (e: Throwable) {
callLog("CallManager.initialize CRASHED: ${e.message}")
Log.e(TAG, "CallManager init failed", e)
}
callLog("calling setContent")
setContent {
val callState by CallManager.state.collectAsState()
// Ждём до 10 сек пока WebSocket доставит сигнал (CallManager перейдёт из IDLE)
var wasIncoming by remember { mutableStateOf(false) }
var lastPeerIdentity by remember { mutableStateOf(Triple("", "", "")) }
LaunchedEffect(callState.phase) {
callLog("phase changed: ${callState.phase}")
if (callState.phase == CallPhase.INCOMING) wasIncoming = true
// Закрываем только когда звонок завершился
if (callState.phase == CallPhase.IDLE && wasIncoming) {
callLog("IDLE after INCOMING → finish()")
finish()
}
// НЕ закрываемся при CONNECTING/ACTIVE — остаёмся на экране звонка
// IncomingCallActivity показывает полный CallOverlay, не нужно переходить в MainActivity
}
LaunchedEffect(callState.peerPublicKey, callState.peerTitle, callState.peerUsername) {
val hasIdentity =
callState.peerPublicKey.isNotBlank() ||
callState.peerTitle.isNotBlank() ||
callState.peerUsername.isNotBlank()
if (hasIdentity) {
lastPeerIdentity =
Triple(callState.peerPublicKey, callState.peerTitle, callState.peerUsername)
}
}
// Показываем INCOMING в IDLE только до первого реального входящего состояния.
// Иначе после Decline/END на мгновение мелькает "Unknown".
val shouldShowProvisionalIncoming =
callState.phase == CallPhase.IDLE &&
!wasIncoming &&
(callState.peerPublicKey.isNotBlank() ||
callState.peerTitle.isNotBlank() ||
callState.peerUsername.isNotBlank())
val displayState = if (shouldShowProvisionalIncoming) {
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...")
} else if (callState.phase == CallPhase.IDLE && wasIncoming) {
// Во время закрытия Activity сохраняем последнее известное имя/peer, чтобы не мигал Unknown.
callState.copy(
peerPublicKey = lastPeerIdentity.first,
peerTitle = lastPeerIdentity.second,
peerUsername = lastPeerIdentity.third
)
} else {
callState
}
val avatarRepository = remember {
val accountKey = accountManager.getLastLoggedPublicKey().orEmpty()
if (accountKey.isNotBlank()) {
val db = RosettaDatabase.getDatabase(applicationContext)
AvatarRepository(
context = applicationContext,
avatarDao = db.avatarDao(),
currentPublicKey = accountKey
)
} else null
}
RosettaAndroidTheme(darkTheme = true) {
CallOverlay(
state = displayState,
isDarkTheme = true,
avatarRepository = avatarRepository,
isExpanded = true,
onAccept = {
callLog("onAccept tapped, phase=${callState.phase}")
if (callState.phase == CallPhase.INCOMING) {
val result = CallManager.acceptIncomingCall()
callLog("acceptIncomingCall result=$result")
// Остаёмся на IncomingCallActivity — она покажет CONNECTING → ACTIVE
} else {
callLog("onAccept: phase=${callState.phase}, trying accept anyway")
CallManager.acceptIncomingCall()
}
},
onDecline = {
callLog("onDecline tapped")
CallManager.declineIncomingCall()
finish()
},
onEnd = {
callLog("onEnd tapped")
CallManager.endCall()
finish()
},
onToggleMute = { CallManager.toggleMute() },
onToggleSpeaker = { CallManager.toggleSpeaker() }
)
}
}
}
private fun openMainActivity() {
callLog("openMainActivity")
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
startActivity(intent)
}
private fun callLog(msg: String) {
Log.d(TAG, msg)
try {
val ctx = applicationContext ?: return
val dir = java.io.File(ctx.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val f = java.io.File(dir, "call_notification_log.txt")
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
f.appendText("$ts [IncomingCallActivity] $msg\n")
} catch (e: Throwable) {
Log.e(TAG, "callLog write failed: ${e.message}")
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,30 @@ package com.rosetta.messenger
import android.app.Application
import com.airbnb.lottie.L
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.ProtocolRuntime
import com.rosetta.messenger.network.ProtocolRuntimeAccess
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.update.UpdateManager
import com.rosetta.messenger.utils.CrashReportManager
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
/**
* Application класс для инициализации глобальных компонентов приложения
*/
@HiltAndroidApp
class RosettaApplication : Application() {
@Inject lateinit var messageRepository: MessageRepository
@Inject lateinit var groupRepository: GroupRepository
@Inject lateinit var accountManager: AccountManager
@Inject lateinit var protocolRuntime: ProtocolRuntime
companion object {
private const val TAG = "RosettaApplication"
}
@@ -25,6 +39,9 @@ class RosettaApplication : Application() {
// Инициализируем crash reporter
initCrashReporting()
// Install instance-based protocol runtime for non-Hilt singleton objects.
ProtocolRuntimeAccess.install(protocolRuntime)
// Инициализируем менеджер черновиков
DraftManager.init(this)
@@ -34,6 +51,11 @@ class RosettaApplication : Application() {
// Инициализируем менеджер обновлений (SDU)
UpdateManager.init(this)
CallManager.bindDependencies(
messageRepository = messageRepository,
accountManager = accountManager
)
}
/**

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.biometric
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException
@@ -52,7 +53,15 @@ class BiometricAuthManager(private val context: Context) {
* Проверяет доступность STRONG биометрической аутентификации
* BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой)
*/
fun isFingerprintHardwareAvailable(): Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
}
fun isBiometricAvailable(): BiometricAvailability {
if (!isFingerprintHardwareAvailable()) {
return BiometricAvailability.NotAvailable("Отпечаток пальца не поддерживается")
}
val biometricManager = BiometricManager.from(context)
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {

View File

@@ -15,20 +15,18 @@ import kotlinx.coroutines.withContext
* Безопасное хранилище настроек биометрической аутентификации
* Использует EncryptedSharedPreferences с MasterKey из Android Keystore
*
* Уровни защиты:
* - AES256_GCM для шифрования значений
* - AES256_SIV для шифрования ключей
* - MasterKey хранится в Android Keystore (TEE/StrongBox)
* Биометрия привязана к конкретному аккаунту (per-account), не глобальная.
*/
class BiometricPreferences(private val context: Context) {
companion object {
private const val TAG = "BiometricPreferences"
private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs"
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_BIOMETRIC_ENABLED_PREFIX = "biometric_enabled_"
private const val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_"
// Shared between all BiometricPreferences instances so UI in different screens
// receives updates immediately (ProfileScreen <-> BiometricEnableScreen).
// Legacy key (global) — for migration
private const val KEY_BIOMETRIC_ENABLED_LEGACY = "biometric_enabled"
// Shared state for reactive UI updates
private val biometricEnabledState = MutableStateFlow(false)
}
@@ -39,23 +37,11 @@ class BiometricPreferences(private val context: Context) {
createEncryptedPreferences()
}
init {
// Загружаем начальное значение
try {
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
} catch (e: Exception) {
}
}
/**
* Создает EncryptedSharedPreferences с максимальной защитой
*/
private fun createEncryptedPreferences(): SharedPreferences {
try {
// Создаем MasterKey с максимальной защитой
val masterKey = MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.setUserAuthenticationRequired(false) // Биометрия проверяется отдельно в BiometricAuthManager
.setUserAuthenticationRequired(false)
.build()
return EncryptedSharedPreferences.create(
@@ -66,77 +52,93 @@ class BiometricPreferences(private val context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
// Fallback на обычные SharedPreferences в случае ошибки (не должно произойти)
return appContext.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE)
}
}
/**
* Включена ли биометрическая аутентификация
*/
val isBiometricEnabled: Flow<Boolean> = _isBiometricEnabled.asStateFlow()
/**
* Включить биометрическую аутентификацию
* Загрузить состояние биометрии для конкретного аккаунта
*/
fun loadForAccount(publicKey: String) {
try {
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
val perAccount = encryptedPrefs.getBoolean(key, false)
// Migration: если per-account нет, проверяем legacy глобальный ключ
if (!perAccount && encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false)) {
// Мигрируем: копируем глобальное значение в per-account
encryptedPrefs.edit().putBoolean(key, true).apply()
_isBiometricEnabled.value = true
} else {
_isBiometricEnabled.value = perAccount
}
} catch (e: Exception) {
_isBiometricEnabled.value = false
}
}
/**
* Включить биометрическую аутентификацию для аккаунта
*/
suspend fun enableBiometric(publicKey: String) = withContext(Dispatchers.IO) {
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
encryptedPrefs.edit().putBoolean(key, true).commit()
_isBiometricEnabled.value = true
}
/**
* Отключить биометрическую аутентификацию для аккаунта
*/
suspend fun disableBiometric(publicKey: String) = withContext(Dispatchers.IO) {
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
encryptedPrefs.edit().putBoolean(key, false).commit()
_isBiometricEnabled.value = false
}
/**
* Проверить включена ли биометрия для аккаунта (синхронно)
*/
fun isBiometricEnabledForAccount(publicKey: String): Boolean {
return try {
encryptedPrefs.getBoolean("$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey", false)
} catch (_: Exception) { false }
}
// --- Legacy compat: old callers without publicKey ---
@Deprecated("Use enableBiometric(publicKey) instead")
suspend fun enableBiometric() = withContext(Dispatchers.IO) {
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).commit()
if (!success) {
Log.w(TAG, "Failed to persist biometric enabled state")
}
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, true).commit()
_isBiometricEnabled.value = true
}
/**
* Отключить биометрическую аутентификацию
*/
@Deprecated("Use disableBiometric(publicKey) instead")
suspend fun disableBiometric() = withContext(Dispatchers.IO) {
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).commit()
if (!success) {
Log.w(TAG, "Failed to persist biometric disabled state")
}
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false).commit()
_isBiometricEnabled.value = false
}
/**
* Сохранить зашифрованный пароль для аккаунта
* Пароль уже зашифрован BiometricAuthManager, здесь добавляется второй слой шифрования
*/
suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) = withContext(Dispatchers.IO) {
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
encryptedPrefs.edit().putString(key, encryptedPassword).apply()
}
/**
* Получить зашифрованный пароль для аккаунта
*/
suspend fun getEncryptedPassword(publicKey: String): String? = withContext(Dispatchers.IO) {
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
encryptedPrefs.getString(key, null)
}
/**
* Удалить зашифрованный пароль для аккаунта
*/
suspend fun removeEncryptedPassword(publicKey: String) = withContext(Dispatchers.IO) {
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
encryptedPrefs.edit().remove(key).apply()
}
/**
* Удалить все биометрические данные
*/
suspend fun clearAll() = withContext(Dispatchers.IO) {
val success = encryptedPrefs.edit().clear().commit()
if (!success) {
Log.w(TAG, "Failed to clear biometric preferences")
}
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
encryptedPrefs.edit().clear().commit()
_isBiometricEnabled.value = false
}
/**
* Проверить, есть ли сохраненный зашифрованный пароль для аккаунта
*/
suspend fun hasEncryptedPassword(publicKey: String): Boolean {
return getEncryptedPassword(publicKey) != null
}

View File

@@ -45,6 +45,10 @@ object CryptoManager {
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
// расшифровке
private const val DECRYPTION_CACHE_SIZE = 2000
// Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key
// и хранения гигантских plaintext в памяти.
private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024
private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
init {
@@ -298,17 +302,21 @@ object CryptoManager {
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
*/
fun decryptWithPassword(encryptedData: String, password: String): String? {
val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS
val cacheKey = if (useCache) "$password:$encryptedData" else null
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
val cacheKey = "$password:$encryptedData"
if (cacheKey != null) {
decryptionCache[cacheKey]?.let {
return it
}
}
return try {
val result = decryptWithPasswordInternal(encryptedData, password)
// 🚀 Сохраняем в кэш (lock-free)
if (result != null) {
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
// Ограничиваем размер кэша
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
// Удаляем ~10% самых старых записей

View File

@@ -1327,15 +1327,17 @@ object MessageCrypto {
/**
* Собираем пароль-кандидаты для полной desktop совместимости:
* - full key+nonce (56 bytes) и legacy key-only (32 bytes)
* - hex password (актуальный desktop формат для attachments)
* - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer")
* - WHATWG/Node UTF-8 decode
* - JVM UTF-8 / Latin1 fallback
*/
private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List<String> {
val candidates = LinkedHashSet<String>(12)
val candidates = LinkedHashSet<String>(16)
fun addVariants(bytes: ByteArray) {
if (bytes.isEmpty()) return
candidates.add(bytes.joinToString("") { "%02x".format(it) })
candidates.add(bytesToBufferPolyfillUtf8String(bytes))
candidates.add(bytesToJsUtf8String(bytes))
candidates.add(String(bytes, Charsets.UTF_8))
@@ -1592,7 +1594,6 @@ object MessageCrypto {
// Reset bounds to default after first continuation
lowerBoundary = 0x80
upperBoundary = 0xBF
if (bytesSeen == bytesNeeded) {
// Sequence complete — emit code point
if (codePoint <= 0xFFFF) {

View File

@@ -21,6 +21,7 @@ class AccountManager(private val context: Context) {
private val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
private const val PREFS_NAME = "rosetta_account_prefs"
private const val KEY_LAST_LOGGED = "last_logged_public_key"
private const val KEY_LAST_LOGGED_PRIVATE_HASH = "last_logged_private_hash"
}
// Use SharedPreferences for last logged account - more reliable for immediate reads
@@ -44,12 +45,18 @@ class AccountManager(private val context: Context) {
return publicKey
}
fun getLastLoggedPrivateKeyHash(): String? {
return sharedPrefs.getString(KEY_LAST_LOGGED_PRIVATE_HASH, null)
}
// Synchronous write to SharedPreferences
fun setLastLoggedPublicKey(publicKey: String) {
val success = sharedPrefs.edit().putString(KEY_LAST_LOGGED, publicKey).commit() // commit() is synchronous
sharedPrefs.edit().putString(KEY_LAST_LOGGED, publicKey).commit() // commit() is synchronous
}
// Verify immediately
val saved = sharedPrefs.getString(KEY_LAST_LOGGED, null)
fun setLastLoggedPrivateKeyHash(privateKeyHash: String) {
if (privateKeyHash.isBlank()) return
sharedPrefs.edit().putString(KEY_LAST_LOGGED_PRIVATE_HASH, privateKeyHash).apply()
}
suspend fun saveAccount(account: EncryptedAccount) {
@@ -98,6 +105,7 @@ class AccountManager(private val context: Context) {
context.accountDataStore.edit { preferences ->
preferences[IS_LOGGED_IN] = false
}
sharedPrefs.edit().remove(KEY_LAST_LOGGED_PRIVATE_HASH).apply()
}
/**
@@ -140,12 +148,21 @@ class AccountManager(private val context: Context) {
// Clear SharedPreferences if this was the last logged account
val lastLogged = sharedPrefs.getString(KEY_LAST_LOGGED, null)
if (lastLogged == publicKey) {
sharedPrefs.edit().remove(KEY_LAST_LOGGED).commit()
sharedPrefs
.edit()
.remove(KEY_LAST_LOGGED)
.remove(KEY_LAST_LOGGED_PRIVATE_HASH)
.commit()
}
}
suspend fun clearAll() {
context.accountDataStore.edit { it.clear() }
sharedPrefs
.edit()
.remove(KEY_LAST_LOGGED)
.remove(KEY_LAST_LOGGED_PRIVATE_HASH)
.apply()
}
private fun serializeAccounts(accounts: List<EncryptedAccount>): String {

View File

@@ -46,19 +46,24 @@ object DraftManager {
fun saveDraft(opponentKey: String, text: String) {
if (currentAccount.isEmpty()) return
val trimmed = text.trim()
val currentDrafts = _drafts.value.toMutableMap()
val hasContent = text.any { !it.isWhitespace() }
val existing = _drafts.value[opponentKey]
if (trimmed.isEmpty()) {
if (!hasContent) {
if (existing == null) return
val currentDrafts = _drafts.value.toMutableMap()
// Удаляем черновик если текст пустой
currentDrafts.remove(opponentKey)
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
} else {
currentDrafts[opponentKey] = trimmed
prefs?.edit()?.putString(prefKey(opponentKey), trimmed)?.apply()
}
_drafts.value = currentDrafts
} else {
// Ничего не делаем, если текст не изменился — это частый путь при больших вставках.
if (existing == text) return
val currentDrafts = _drafts.value.toMutableMap()
currentDrafts[opponentKey] = text
prefs?.edit()?.putString(prefKey(opponentKey), text)?.apply()
_drafts.value = currentDrafts
}
}
/** Получить черновик для диалога */

View File

@@ -30,7 +30,8 @@ object ForwardManager {
val senderPublicKey: String, // publicKey отправителя сообщения
val originalChatPublicKey: String, // publicKey чата откуда пересылается
val senderName: String = "", // Имя отправителя для атрибуции
val attachments: List<MessageAttachment> = emptyList()
val attachments: List<MessageAttachment> = emptyList(),
val chachaKeyPlain: String = "" // Hex plainKeyAndNonce оригинального сообщения
)
// Сообщения для пересылки

View File

@@ -14,17 +14,24 @@ import com.rosetta.messenger.network.PacketGroupInfo
import com.rosetta.messenger.network.PacketGroupInviteInfo
import com.rosetta.messenger.network.PacketGroupJoin
import com.rosetta.messenger.network.PacketGroupLeave
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolClient
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.SecureRandom
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
class GroupRepository private constructor(context: Context) {
@Singleton
class GroupRepository @Inject constructor(
@ApplicationContext context: Context,
private val messageRepository: MessageRepository,
private val protocolClient: ProtocolClient
) {
private val appContext = context.applicationContext
private val db = RosettaDatabase.getDatabase(context.applicationContext)
private val groupDao = db.groupDao()
private val messageDao = db.messageDao()
@@ -38,15 +45,6 @@ class GroupRepository private constructor(context: Context) {
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
private const val GROUP_CREATED_MARKER = "\$a=Group created"
@Volatile
private var INSTANCE: GroupRepository? = null
fun getInstance(context: Context): GroupRepository {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: GroupRepository(context).also { INSTANCE = it }
}
}
}
data class ParsedGroupInvite(
@@ -155,7 +153,7 @@ class GroupRepository private constructor(context: Context) {
this.groupId = groupId
this.members = emptyList()
}
ProtocolManager.send(packet)
protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupInfo>(
packetId = 0x12,
@@ -189,7 +187,7 @@ class GroupRepository private constructor(context: Context) {
this.membersCount = 0
this.groupStatus = GroupStatus.NOT_JOINED
}
ProtocolManager.send(packet)
protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupInviteInfo>(
packetId = 0x13,
@@ -217,7 +215,7 @@ class GroupRepository private constructor(context: Context) {
}
val createPacket = PacketCreateGroup()
ProtocolManager.send(createPacket)
protocolClient.send(createPacket)
val response = awaitPacketOnce<PacketCreateGroup>(
packetId = 0x11,
@@ -268,7 +266,7 @@ class GroupRepository private constructor(context: Context) {
groupString = encodedGroupStringForServer
groupStatus = GroupStatus.NOT_JOINED
}
ProtocolManager.send(packet)
protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupJoin>(
packetId = 0x14,
@@ -376,7 +374,7 @@ class GroupRepository private constructor(context: Context) {
val packet = PacketGroupLeave().apply {
this.groupId = groupId
}
ProtocolManager.send(packet)
protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupLeave>(
packetId = 0x15,
@@ -402,7 +400,7 @@ class GroupRepository private constructor(context: Context) {
this.groupId = groupId
this.publicKey = targetPublicKey
}
ProtocolManager.send(packet)
protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupBan>(
packetId = 0x16,
@@ -456,6 +454,7 @@ class GroupRepository private constructor(context: Context) {
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
plainMessage = encryptedPlainMessage,
attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogPublicKey
)
)
@@ -478,9 +477,8 @@ class GroupRepository private constructor(context: Context) {
dialogPublicKey: String
) {
try {
val messages = MessageRepository.getInstance(appContext)
messages.initialize(accountPublicKey, accountPrivateKey)
messages.sendMessage(
messageRepository.initialize(accountPublicKey, accountPrivateKey)
messageRepository.sendMessage(
toPublicKey = dialogPublicKey,
text = GROUP_CREATED_MARKER
)
@@ -511,13 +509,13 @@ class GroupRepository private constructor(context: Context) {
callback = { packet ->
val typedPacket = packet as? T
if (typedPacket != null && predicate(typedPacket)) {
ProtocolManager.unwaitPacket(packetId, callback)
protocolClient.unwaitPacket(packetId, callback)
continuation.resume(typedPacket)
}
}
ProtocolManager.waitPacket(packetId, callback)
protocolClient.waitPacket(packetId, callback)
continuation.invokeOnCancellation {
ProtocolManager.unwaitPacket(packetId, callback)
protocolClient.unwaitPacket(packetId, callback)
}
}
}

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.data
import android.content.Context
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.database.*
@@ -8,7 +9,11 @@ import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.json.JSONArray
@@ -29,7 +34,6 @@ data class Message(
val replyToMessageId: String? = null
)
/** UI модель диалога */
data class Dialog(
val opponentKey: String,
val opponentTitle: String,
@@ -43,7 +47,11 @@ data class Dialog(
)
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */
class MessageRepository private constructor(private val context: Context) {
@Singleton
class MessageRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val protocolClient: ProtocolClient
) {
private val database = RosettaDatabase.getDatabase(context)
private val messageDao = database.messageDao()
@@ -51,6 +59,7 @@ class MessageRepository private constructor(private val context: Context) {
private val avatarDao = database.avatarDao()
private val syncTimeDao = database.syncTimeDao()
private val groupDao = database.groupDao()
private val searchIndexDao = database.messageSearchIndexDao()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -95,8 +104,6 @@ class MessageRepository private constructor(private val context: Context) {
private var currentPrivateKey: String? = null
companion object {
@Volatile private var INSTANCE: MessageRepository? = null
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
@@ -134,16 +141,6 @@ class MessageRepository private constructor(private val context: Context) {
/** Очистка кэша (вызывается при logout) */
fun clearProcessedCache() = processedMessageIds.clear()
fun getInstance(context: Context): MessageRepository {
return INSTANCE
?: synchronized(this) {
INSTANCE
?: MessageRepository(context.applicationContext).also {
INSTANCE = it
}
}
}
/**
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
@@ -207,12 +204,32 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey
)
)
if (inserted == -1L) return
val insertedMessage =
MessageEntity(
account = account,
fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY,
toPublicKey = account,
content = "",
timestamp = timestamp,
chachaKey = "",
read = 0,
fromMe = 0,
delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey
)
upsertSearchIndex(account, insertedMessage, messageText)
val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
dialogDao.insertDialog(
DialogEntity(
@@ -223,6 +240,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
?: SYSTEM_SAFE_USERNAME,
lastMessage = encryptedPlainMessage,
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
hasContent = 1,
lastMessageFromMe = 0,
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
lastMessageRead = 0,
lastMessageAttachments = "[]",
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
@@ -243,7 +267,7 @@ class MessageRepository private constructor(private val context: Context) {
try {
CryptoManager.encryptWithPassword(messageText, privateKey)
} catch (e: Exception) {
android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
return null
}
@@ -266,12 +290,32 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey
)
)
if (inserted == -1L) return null
val insertedMessage =
MessageEntity(
account = account,
fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY,
toPublicKey = account,
content = "",
timestamp = timestamp,
chachaKey = "",
read = 0,
fromMe = 0,
delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = "[]",
primaryAttachmentType = -1,
dialogKey = dialogKey
)
upsertSearchIndex(account, insertedMessage, messageText)
val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
dialogDao.insertDialog(
DialogEntity(
@@ -282,6 +326,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
?: SYSTEM_UPDATES_USERNAME,
lastMessage = encryptedPlainMessage,
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
hasContent = 1,
lastMessageFromMe = 0,
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
lastMessageRead = 0,
lastMessageAttachments = "[]",
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
@@ -301,12 +352,12 @@ class MessageRepository private constructor(private val context: Context) {
suspend fun checkAndSendVersionUpdateMessage() {
val account = currentAccount
if (account == null) {
android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
return
}
val privateKey = currentPrivateKey
if (privateKey == null) {
android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
return
}
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
@@ -314,7 +365,7 @@ class MessageRepository private constructor(private val context: Context) {
val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}"
android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
if (lastNoticeKey != currentKey) {
// Delete the previous message for this version (if any)
@@ -325,15 +376,15 @@ class MessageRepository private constructor(private val context: Context) {
}
val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion))
android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
if (messageId != null) {
prefs.edit()
.putString("lastNoticeKey", currentKey)
.putString("lastNoticeMessageId_$currentVersion", messageId)
.apply()
android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
} else {
android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
}
}
}
@@ -406,6 +457,18 @@ class MessageRepository private constructor(private val context: Context) {
return if (raw < 1_000_000_000_000L) raw * 1000L else raw
}
/**
* Normalize incoming message timestamp for chat ordering:
* 1) accept both seconds and milliseconds;
* 2) never allow a message timestamp from the future on this device.
*/
private fun normalizeIncomingPacketTimestamp(rawTimestamp: Long, receivedAtMs: Long): Long {
val normalizedRaw =
if (rawTimestamp in 1..999_999_999_999L) rawTimestamp * 1000L else rawTimestamp
if (normalizedRaw <= 0L) return receivedAtMs
return minOf(normalizedRaw, receivedAtMs)
}
/** Получить поток сообщений для диалога */
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
val dialogKey = getDialogKey(opponentKey)
@@ -477,15 +540,18 @@ class MessageRepository private constructor(private val context: Context) {
scope.launch {
val startTime = System.currentTimeMillis()
try {
// Шифрование
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
val encryptedContent = encryptResult.ciphertext
val encryptedKey = encryptResult.encryptedKey
// Шифрование (пропускаем для пустого текста — напр. CALL-сообщения)
val hasContent = text.trim().isNotEmpty()
val encryptResult = if (hasContent) MessageCrypto.encryptForSending(text.trim(), toPublicKey) else null
val encryptedContent = encryptResult?.ciphertext ?: ""
val encryptedKey = encryptResult?.encryptedKey ?: ""
val aesChachaKey =
if (encryptResult != null) {
CryptoManager.encryptWithPassword(
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
privateKey
)
} else ""
// 📝 LOG: Шифрование успешно
MessageLogger.logEncryptionSuccess(
@@ -525,10 +591,13 @@ class MessageRepository private constructor(private val context: Context) {
messageId = messageId,
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentType(attachments),
replyToMessageId = replyToMessageId,
dialogKey = dialogKey
)
messageDao.insertMessage(entity)
upsertSearchIndex(account, entity, text.trim())
// 📝 LOG: Сохранено в БД
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
@@ -539,6 +608,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
dialogDao.updateDialogFromMessages(account, toPublicKey)
// Notify listeners (ChatViewModel) that a new message was persisted
// so the chat UI reloads from DB. Without this, messages produced by
// non-input flows (e.g. CallManager's missed-call attachment) only
// appear after the user re-enters the chat.
_newMessageEvents.tryEmit(dialogKey)
// 📁 Для saved messages - гарантируем создание/обновление dialog
if (isSavedMessages) {
val existing = dialogDao.getDialog(account, account)
@@ -556,6 +631,17 @@ class MessageRepository private constructor(private val context: Context) {
lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 0,
iHaveSent = 1,
hasContent =
if (
encryptedPlainMessage.isNotBlank() ||
attachments.isNotEmpty()
) {
1
} else {
0
},
lastMessageAttachmentType = resolvePrimaryAttachmentType(attachments),
lastSenderKey = account,
lastMessageFromMe = 1,
lastMessageDelivered = 1,
lastMessageRead = 1,
@@ -603,7 +689,7 @@ class MessageRepository private constructor(private val context: Context) {
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
ProtocolManager.sendMessageWithRetry(packet)
protocolClient.sendMessageWithRetry(packet)
// 📝 LOG: Успешная отправка
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
@@ -652,6 +738,13 @@ class MessageRepository private constructor(private val context: Context) {
val isOwnMessage = packet.fromPublicKey == account
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
val normalizedPacketTimestamp =
normalizeIncomingPacketTimestamp(packet.timestamp, startTime)
if (normalizedPacketTimestamp != packet.timestamp) {
MessageLogger.debug(
"📥 TIMESTAMP normalized: raw=${packet.timestamp} -> local=$normalizedPacketTimestamp"
)
}
// 🔥 Проверяем, не заблокирован ли отправитель
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
@@ -686,13 +779,6 @@ class MessageRepository private constructor(private val context: Context) {
return true
}
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
val isDuplicate = messageDao.messageExists(account, messageId)
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
if (isDuplicate) {
return true
}
val dialogOpponentKey =
when {
isGroupMessage -> packet.toPublicKey
@@ -701,6 +787,33 @@ class MessageRepository private constructor(private val context: Context) {
}
val dialogKey = getDialogKey(dialogOpponentKey)
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
val isDuplicate = messageDao.messageExists(account, messageId)
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
if (isDuplicate) {
// Desktop/server parity:
// own messages that arrive via sync must be treated as delivered.
// If a local optimistic row already exists (WAITING/ERROR), normalize it.
if (isOwnMessage) {
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.DELIVERED.value)
messageCache[dialogKey]?.let { flow ->
flow.value =
flow.value.map { msg ->
if (msg.messageId == messageId) {
msg.copy(deliveryStatus = DeliveryStatus.DELIVERED)
} else {
msg
}
}
}
_deliveryStatusEvents.tryEmit(
DeliveryStatusUpdate(dialogKey, messageId, DeliveryStatus.DELIVERED)
)
dialogDao.updateDialogFromMessages(account, dialogOpponentKey)
}
return true
}
try {
val groupKey =
if (isGroupMessage) {
@@ -716,12 +829,20 @@ class MessageRepository private constructor(private val context: Context) {
}
if (isGroupMessage && groupKey.isNullOrBlank()) {
val requiresGroupKey =
(packet.content.isNotBlank() && isProbablyEncryptedPayload(packet.content)) ||
packet.attachments.any { it.blob.isNotBlank() }
if (requiresGroupKey) {
MessageLogger.debug(
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
)
processedMessageIds.remove(messageId)
return false
}
protocolClient.addLog(
"⚠️ GROUP fallback without key: ${messageId.take(8)}..., contentLikelyPlain=true"
)
}
val plainKeyAndNonce =
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank()) {
@@ -732,7 +853,7 @@ class MessageRepository private constructor(private val context: Context) {
}
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
ProtocolManager.addLog(
protocolClient.addLog(
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
)
}
@@ -743,28 +864,43 @@ class MessageRepository private constructor(private val context: Context) {
)
}
// Расшифровываем
// Расшифровываем (CALL и attachment-only сообщения могут иметь пустой или
// зашифрованный пустой content — обрабатываем оба случая безопасно)
val isAttachmentOnly = packet.content.isBlank() ||
(packet.attachments.isNotEmpty() && packet.chachaKey.isBlank())
val plainText =
if (isGroupMessage) {
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
?: throw IllegalStateException("Failed to decrypt group payload")
if (isAttachmentOnly) {
""
} else if (isGroupMessage) {
val decryptedGroupPayload =
groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
} else if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else {
try {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
} catch (e: Exception) {
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
""
}
}
val normalizedIncomingAttachments =
normalizeIncomingAttachments(packet.attachments, plainText)
// 📝 LOG: Расшифровка успешна
MessageLogger.logDecryptionSuccess(
messageId = messageId,
plainTextLength = plainText.length,
attachmentsCount = packet.attachments.size
attachmentsCount = normalizedIncomingAttachments.size
)
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
val attachmentsJson =
serializeAttachmentsWithDecryption(
packet.attachments,
normalizedIncomingAttachments,
packet.chachaKey,
privateKey,
plainKeyAndNonce,
@@ -773,7 +909,7 @@ class MessageRepository private constructor(private val context: Context) {
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
processImageAttachments(
packet.attachments,
normalizedIncomingAttachments,
packet.chachaKey,
privateKey,
plainKeyAndNonce,
@@ -786,7 +922,7 @@ class MessageRepository private constructor(private val context: Context) {
val avatarOwnerKey =
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
processAvatarAttachments(
packet.attachments,
normalizedIncomingAttachments,
avatarOwnerKey,
packet.chachaKey,
privateKey,
@@ -806,6 +942,11 @@ class MessageRepository private constructor(private val context: Context) {
packet.chachaKey
}
// Desktop parity (useSynchronize.ts):
// own messages received via PacketMessage sync are inserted as DELIVERED immediately.
// WAITING is used only for messages created locally on this device before PacketDelivery.
val initialDeliveredStatus = DeliveryStatus.DELIVERED.value
// Создаем entity для кэша и возможной вставки
val entity =
MessageEntity(
@@ -813,14 +954,16 @@ class MessageRepository private constructor(private val context: Context) {
fromPublicKey = packet.fromPublicKey,
toPublicKey = packet.toPublicKey,
content = packet.content,
timestamp = packet.timestamp,
timestamp = normalizedPacketTimestamp,
chachaKey = storedChachaKey,
read = 0,
fromMe = if (isOwnMessage) 1 else 0,
delivered = DeliveryStatus.DELIVERED.value,
delivered = initialDeliveredStatus,
messageId = messageId, // 🔥 Используем сгенерированный messageId!
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
attachments = attachmentsJson,
primaryAttachmentType =
resolvePrimaryAttachmentType(normalizedIncomingAttachments),
dialogKey = dialogKey
)
@@ -830,6 +973,7 @@ class MessageRepository private constructor(private val context: Context) {
if (!stillExists) {
// Сохраняем в БД только если сообщения нет
messageDao.insertMessage(entity)
upsertSearchIndex(account, entity, plainText)
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
} else {
MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
@@ -846,11 +990,10 @@ class MessageRepository private constructor(private val context: Context) {
unreadCount = dialog?.unreadCount ?: 0
)
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
// Desktop parity: always re-fetch on incoming message so renamed contacts
// get their new name/username updated in the chat list.
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа.
// Важно: не форсим повторный запрос на каждый входящий пакет — это создает
// шторм PacketSearch во время sync и заметно тормозит обработку.
if (!isGroupDialogKey(dialogOpponentKey)) {
requestedUserInfoKeys.remove(dialogOpponentKey)
requestUserInfo(dialogOpponentKey)
} else {
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
@@ -879,8 +1022,10 @@ class MessageRepository private constructor(private val context: Context) {
} catch (e: Exception) {
// 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e)
ProtocolManager.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
protocolClient.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " +
"group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " +
"aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: "<no-message>"}"
)
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
processedMessageIds.remove(messageId)
@@ -893,15 +1038,12 @@ class MessageRepository private constructor(private val context: Context) {
suspend fun handleDelivery(packet: PacketDelivery) {
val account = currentAccount ?: return
// 📝 LOG: Получено подтверждение доставки
MessageLogger.logDeliveryStatus(
messageId = packet.messageId,
toPublicKey = packet.toPublicKey,
status = "DELIVERED"
)
// Desktop parity: update both delivery status AND timestamp on delivery confirmation.
// Desktop sets timestamp = Date.now() when PacketDelivery arrives (useSynchronize.ts).
val deliveryTimestamp = System.currentTimeMillis()
messageDao.updateDeliveryStatusAndTimestamp(
account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp
@@ -926,6 +1068,50 @@ class MessageRepository private constructor(private val context: Context) {
dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
}
/**
* Save an incoming call event locally (for CALLEE side).
* Creates a message as if received from the peer, with CALL attachment.
*/
suspend fun saveIncomingCallEvent(fromPublicKey: String, durationSec: Int) {
val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return
val messageId = java.util.UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val dialogKey = getDialogKey(fromPublicKey)
val attId = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
val attachmentsJson = org.json.JSONArray().apply {
put(org.json.JSONObject().apply {
put("id", attId)
put("type", com.rosetta.messenger.network.AttachmentType.CALL.value)
put("preview", durationSec.toString())
put("blob", "")
})
}.toString()
val encryptedPlainMessage = com.rosetta.messenger.crypto.CryptoManager.encryptWithPassword("", privateKey)
val entity = com.rosetta.messenger.database.MessageEntity(
account = account,
fromPublicKey = fromPublicKey,
toPublicKey = account,
content = "",
timestamp = timestamp,
chachaKey = "",
read = 1,
fromMe = 0,
delivered = com.rosetta.messenger.network.DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = attachmentsJson,
primaryAttachmentType = com.rosetta.messenger.network.AttachmentType.CALL.value,
dialogKey = dialogKey
)
messageDao.insertMessage(entity)
dialogDao.updateDialogFromMessages(account, fromPublicKey)
_newMessageEvents.tryEmit(dialogKey)
}
/**
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
* fromPublicKey - кто прочитал (собеседник)
@@ -946,18 +1132,38 @@ class MessageRepository private constructor(private val context: Context) {
// Desktop parity (group): from=groupMember, to=groupId -> mark own group messages as read.
if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
val dialogKey = getDialogKey(toPublicKey)
messageDao.markAllAsRead(account, toPublicKey)
val updatedRows = messageDao.markAllAsRead(account, toPublicKey)
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
val readCount =
messageCache[dialogKey]?.value?.count {
it.isFromMe && !it.isRead
} ?: 0
messageCache[dialogKey]?.let { flow ->
flow.value =
flow.value.map { msg ->
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
if (msg.isFromMe && !msg.isRead) {
msg.copy(
isRead = true,
deliveryStatus =
if (
msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ
) {
DeliveryStatus.READ
} else {
msg.deliveryStatus
}
)
} else {
msg
}
}
}
if (updatedRows > 0) {
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = readCount)
}
MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = minOf(readCount, updatedRows))
dialogDao.updateDialogFromMessages(account, toPublicKey)
return
}
@@ -981,20 +1187,40 @@ class MessageRepository private constructor(private val context: Context) {
}
// Opponent read our outgoing messages.
messageDao.markAllAsRead(account, opponentKey)
val updatedRows = messageDao.markAllAsRead(account, opponentKey)
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
val readCount =
messageCache[dialogKey]?.value?.count {
it.isFromMe && !it.isRead
} ?: 0
messageCache[dialogKey]?.let { flow ->
flow.value =
flow.value.map { msg ->
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
if (msg.isFromMe && !msg.isRead) {
msg.copy(
isRead = true,
deliveryStatus =
if (
msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ
) {
DeliveryStatus.READ
} else {
msg.deliveryStatus
}
)
} else {
msg
}
}
}
// Notify current dialog UI: all outgoing messages are now read.
// Notify current dialog UI only when there are real DB read updates.
if (updatedRows > 0) {
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
}
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = minOf(readCount, updatedRows))
dialogDao.updateDialogFromMessages(account, opponentKey)
}
@@ -1036,7 +1262,7 @@ class MessageRepository private constructor(private val context: Context) {
this.toPublicKey = toPublicKey
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
}
ProtocolManager.send(packet)
protocolClient.send(packet)
}
}
@@ -1090,17 +1316,34 @@ class MessageRepository private constructor(private val context: Context) {
val privateKey = currentPrivateKey ?: return
val now = System.currentTimeMillis()
// Desktop parity recovery:
// historically, own synced direct messages ("sync:*" chacha_key) could be saved as WAITING/ERROR
// on Android and then incorrectly shown with failed status.
// Desktop stores them as DELIVERED from the beginning.
val syncedOpponentsWithWrongStatus =
messageDao.getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account)
val normalizedSyncedCount = messageDao.markSyncedOwnMessagesAsDelivered(account)
if (normalizedSyncedCount > 0) {
syncedOpponentsWithWrongStatus.forEach { opponentKey ->
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
}
if (BuildConfig.DEBUG) android.util.Log.i(
"MessageRepository",
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
)
}
// Mark expired messages as ERROR (older than 80 seconds)
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (expiredCount > 0) {
android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
}
// Get remaining WAITING messages (younger than 80s)
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (waitingMessages.isEmpty()) return
android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
if (BuildConfig.DEBUG) android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
for (entity in waitingMessages) {
// Skip saved messages (should not happen, but guard)
@@ -1124,7 +1367,7 @@ class MessageRepository private constructor(private val context: Context) {
privateKey
)
} catch (e: Exception) {
android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
""
}
}
@@ -1150,10 +1393,10 @@ class MessageRepository private constructor(private val context: Context) {
}
// iOS parity: use retry mechanism for reconnect-resent messages too
ProtocolManager.sendMessageWithRetry(packet)
android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
protocolClient.sendMessageWithRetry(packet)
if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
} catch (e: Exception) {
android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
if (BuildConfig.DEBUG) android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
// Mark as ERROR if retry fails
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
val dialogKey = getDialogKey(entity.toPublicKey)
@@ -1254,7 +1497,7 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* Public API for ProtocolManager to update delivery status (e.g., marking as ERROR on retry timeout).
* Runtime API to update delivery status (e.g., marking as ERROR on retry timeout).
*/
suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
val account = currentAccount ?: return
@@ -1310,7 +1553,8 @@ class MessageRepository private constructor(private val context: Context) {
opponentKey = opponentKey,
lastMessage = encryptedLastMessage,
lastMessageTimestamp = timestamp,
unreadCount = unreadCount
unreadCount = unreadCount,
hasContent = if (encryptedLastMessage.isNotBlank()) 1 else 0
)
)
}
@@ -1414,7 +1658,7 @@ class MessageRepository private constructor(private val context: Context) {
this.privateKey = privateKeyHash
this.search = dialog.opponentKey
}
ProtocolManager.send(packet)
protocolClient.send(packet)
// Small delay to avoid flooding the server with search requests
kotlinx.coroutines.delay(50)
}
@@ -1451,7 +1695,7 @@ class MessageRepository private constructor(private val context: Context) {
this.privateKey = privateKeyHash
this.search = publicKey
}
ProtocolManager.send(packet)
protocolClient.send(packet)
}
}
@@ -1537,12 +1781,84 @@ class MessageRepository private constructor(private val context: Context) {
put("preview", attachment.preview)
put("width", attachment.width)
put("height", attachment.height)
put("localUri", attachment.localUri)
put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer)
}
jsonArray.put(jsonObj)
}
return jsonArray.toString()
}
private fun resolvePrimaryAttachmentType(attachments: List<MessageAttachment>): Int {
if (attachments.isEmpty()) return -1
return attachments.first().type.value
}
/**
* Desktop иногда присылает attachment звонка с некорректным type при поврежденном/пограничном
* пакете (в UI это превращается в пустой пузырь). Для attachment-only сообщения мягко
* нормализуем такой кейс к CALL.
*/
private fun normalizeIncomingAttachments(
attachments: List<MessageAttachment>,
plainText: String
): List<MessageAttachment> {
if (attachments.isEmpty() || plainText.isNotBlank() || attachments.size != 1) {
return attachments
}
val first = attachments.first()
if (!isLikelyCallAttachment(first, plainText)) {
return attachments
}
return when (first.type) {
AttachmentType.CALL -> attachments
else -> {
MessageLogger.debug(
"📥 ATTACHMENT FIXUP: coerced ${first.type} -> CALL for ${first.id.take(8)}..."
)
listOf(first.copy(type = AttachmentType.CALL))
}
}
}
private fun isLikelyCallAttachment(attachment: MessageAttachment, plainText: String): Boolean {
if (plainText.isNotBlank()) return false
if (attachment.blob.isNotBlank()) return false
if (attachment.width > 0 || attachment.height > 0) return false
val preview = attachment.preview.trim()
if (preview.isEmpty()) return true
val tail = preview.substringAfterLast("::", preview).trim()
if (tail.toIntOrNull() != null) return true
return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(preview)
}
private suspend fun upsertSearchIndex(account: String, entity: MessageEntity, plainText: String) {
val opponentKey =
if (entity.fromMe == 1) entity.toPublicKey.trim() else entity.fromPublicKey.trim()
val normalized = plainText.lowercase(Locale.ROOT)
searchIndexDao.upsert(
listOf(
MessageSearchIndexEntity(
account = account,
messageId = entity.messageId,
dialogKey = entity.dialogKey,
opponentKey = opponentKey,
timestamp = entity.timestamp,
fromMe = entity.fromMe,
plainText = plainText,
plainTextNormalized = normalized
)
)
)
}
/**
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
* получении attachment с типом AVATAR - сохраняем в avatar_cache
@@ -1564,7 +1880,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1621,7 +1937,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1685,7 +2001,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1710,6 +2026,9 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} else {
// Fallback - пустой blob для IMAGE/FILE
jsonObj.put("id", attachment.id)
@@ -1718,6 +2037,9 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
} catch (e: Exception) {
// Fallback - пустой blob
@@ -1727,6 +2049,9 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
} else {
// Для IMAGE/FILE - НЕ сохраняем blob (пустой)
@@ -1736,10 +2061,35 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
jsonArray.put(jsonObj)
}
return jsonArray.toString()
}
/**
* Desktop parity for group attachment blobs:
* old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes).
*/
private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? {
if (encryptedBlob.isBlank() || groupKey.isBlank()) return null
val rawAttempt = runCatching {
CryptoManager.decryptWithPassword(encryptedBlob, groupKey)
}.getOrNull()
if (rawAttempt != null) return rawAttempt
val hexKey =
groupKey.toByteArray(Charsets.ISO_8859_1)
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
if (hexKey == groupKey) return null
return runCatching {
CryptoManager.decryptWithPassword(encryptedBlob, hexKey)
}.getOrNull()
}
}

View File

@@ -28,12 +28,15 @@ class PreferencesManager(private val context: Context) {
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
val CHAT_WALLPAPER_ID_LIGHT = stringPreferencesKey("chat_wallpaper_id_light")
val CHAT_WALLPAPER_ID_DARK = stringPreferencesKey("chat_wallpaper_id_dark")
// Notifications
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
val NOTIFICATION_SOUND_ENABLED = booleanPreferencesKey("notification_sound_enabled")
val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled")
val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled")
val NOTIFICATION_AVATAR_ENABLED = booleanPreferencesKey("notification_avatar_enabled")
// Chat Settings
val MESSAGE_TEXT_SIZE = intPreferencesKey("message_text_size") // 0=small, 1=medium, 2=large
@@ -55,6 +58,9 @@ class PreferencesManager(private val context: Context) {
val BACKGROUND_BLUR_COLOR_ID =
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
// App Icon disguise: "default", "calculator", "weather", "notes"
val APP_ICON = stringPreferencesKey("app_icon")
// Pinned Chats (max 3)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
@@ -104,10 +110,21 @@ class PreferencesManager(private val context: Context) {
val chatWallpaperId: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
val chatWallpaperIdLight: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_LIGHT] ?: "" }
val chatWallpaperIdDark: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_DARK] ?: "" }
suspend fun setChatWallpaperId(value: String) {
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
}
suspend fun setChatWallpaperIdForTheme(isDarkTheme: Boolean, value: String) {
val key = if (isDarkTheme) CHAT_WALLPAPER_ID_DARK else CHAT_WALLPAPER_ID_LIGHT
context.dataStore.edit { preferences -> preferences[key] = value }
}
// ═════════════════════════════════════════════════════════════
// 🔔 NOTIFICATIONS
// ═════════════════════════════════════════════════════════════
@@ -130,6 +147,11 @@ class PreferencesManager(private val context: Context) {
preferences[NOTIFICATION_PREVIEW_ENABLED] ?: true
}
val notificationAvatarEnabled: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[NOTIFICATION_AVATAR_ENABLED] ?: true
}
suspend fun setNotificationsEnabled(value: Boolean) {
context.dataStore.edit { preferences -> preferences[NOTIFICATIONS_ENABLED] = value }
}
@@ -146,6 +168,10 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[NOTIFICATION_PREVIEW_ENABLED] = value }
}
suspend fun setNotificationAvatarEnabled(value: Boolean) {
context.dataStore.edit { preferences -> preferences[NOTIFICATION_AVATAR_ENABLED] = value }
}
// ═════════════════════════════════════════════════════════════
// 💬 CHAT SETTINGS
// ═════════════════════════════════════════════════════════════
@@ -310,6 +336,19 @@ class PreferencesManager(private val context: Context) {
return wasPinned
}
// ═════════════════════════════════════════════════════════════
// 🎨 APP ICON
// ═════════════════════════════════════════════════════════════
val appIcon: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[APP_ICON] ?: "default"
}
suspend fun setAppIcon(value: String) {
context.dataStore.edit { preferences -> preferences[APP_ICON] = value }
}
// ═════════════════════════════════════════════════════════════
// 🔕 MUTED CHATS
// ═════════════════════════════════════════════════════════════

View File

@@ -17,14 +17,11 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Синхронизация Android ↔ iOS
- Исправлена проблема: сообщения зависали на «часиках» при одновременном использовании Android и iOS
- Добавлен механизм автоматического повтора отправки: 3 попытки с интервалом 4 сек, таймаут 80 сек
- Исправлена нормализация sync-курсора для корректной синхронизации между устройствами
Интерфейс
- Дата «today/yesterday» и пустой стейт чата теперь белые при тёмных обоях или тёмной теме
- Исправлена обрезка имени отправителя в групповых чатах
- Исправлена перемотка голосовых: waveform продолжается с текущей позиции после seek, без перерисовки с нуля
- Стабилизирован вход и переподключение после подтверждения или отклонения верификации на другом устройстве
- Исправлена отправка сообщений и синхронизация после повторного запроса входа
- Восстановлена совместимость старых вложений и голосовых между версиями приложения
- Улучшен запрос Full Screen Intent для звонков на Android 14+
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -81,7 +81,9 @@ data class LastMessageStatus(
[
Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]),
Index(value = ["account", "message_id"], unique = true),
Index(value = ["account", "dialog_key", "timestamp"])]
Index(value = ["account", "dialog_key", "timestamp"]),
Index(value = ["account", "timestamp"]),
Index(value = ["account", "primary_attachment_type", "timestamp"])]
)
data class MessageEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -99,18 +101,47 @@ data class MessageEntity(
@ColumnInfo(name = "plain_message")
val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД
@ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений
@ColumnInfo(name = "primary_attachment_type", defaultValue = "-1")
val primaryAttachmentType: Int = -1, // Денормализованный тип 1-го вложения (-1 если нет)
@ColumnInfo(name = "reply_to_message_id")
val replyToMessageId: String? = null, // ID цитируемого сообщения
@ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки
)
/** Локальный денормализованный индекс для поиска по сообщениям без повторной дешифровки. */
@Entity(
tableName = "message_search_index",
primaryKeys = ["account", "message_id"],
indices =
[
Index(value = ["account", "timestamp"]),
Index(value = ["account", "opponent_key", "timestamp"])]
)
data class MessageSearchIndexEntity(
@ColumnInfo(name = "account") val account: String,
@ColumnInfo(name = "message_id") val messageId: String,
@ColumnInfo(name = "dialog_key") val dialogKey: String,
@ColumnInfo(name = "opponent_key") val opponentKey: String,
@ColumnInfo(name = "timestamp") val timestamp: Long,
@ColumnInfo(name = "from_me") val fromMe: Int = 0,
@ColumnInfo(name = "plain_text") val plainText: String,
@ColumnInfo(name = "plain_text_normalized") val plainTextNormalized: String
)
/** Entity для диалогов (кэш последнего сообщения) */
@Entity(
tableName = "dialogs",
indices =
[
Index(value = ["account", "opponent_key"], unique = true),
Index(value = ["account", "last_message_timestamp"])]
Index(value = ["account", "last_message_timestamp"]),
Index(
value =
[
"account",
"i_have_sent",
"has_content",
"last_message_timestamp"])]
)
data class DialogEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@@ -129,6 +160,12 @@ data class DialogEntity(
@ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован
@ColumnInfo(name = "i_have_sent", defaultValue = "0")
val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1)
@ColumnInfo(name = "has_content", defaultValue = "0")
val hasContent: Int = 0, // Есть ли контент в диалоге (0/1)
@ColumnInfo(name = "last_message_attachment_type", defaultValue = "-1")
val lastMessageAttachmentType: Int = -1, // Денормализованный тип вложения последнего сообщения
@ColumnInfo(name = "last_sender_key", defaultValue = "''")
val lastSenderKey: String = "", // Для групп: публичный ключ последнего отправителя
@ColumnInfo(name = "last_message_from_me", defaultValue = "0")
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
@ColumnInfo(name = "last_message_delivered", defaultValue = "0")
@@ -174,6 +211,16 @@ interface GroupDao {
suspend fun deleteAllByAccount(account: String): Int
}
/** Строка истории звонков (messages + данные собеседника из dialogs) */
data class CallHistoryRow(
@Embedded val message: MessageEntity,
@ColumnInfo(name = "peer_key") val peerKey: String,
@ColumnInfo(name = "peer_title") val peerTitle: String?,
@ColumnInfo(name = "peer_username") val peerUsername: String?,
@ColumnInfo(name = "peer_verified") val peerVerified: Int?,
@ColumnInfo(name = "peer_online") val peerOnline: Int?
)
/** DAO для работы с сообщениями */
@Dao
interface MessageDao {
@@ -191,7 +238,7 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC
ORDER BY timestamp DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
@@ -213,7 +260,7 @@ interface MessageDao {
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account
ORDER BY timestamp DESC, message_id DESC
ORDER BY timestamp DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
@@ -239,7 +286,7 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp ASC, message_id ASC
ORDER BY timestamp ASC, id ASC
"""
)
fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
@@ -272,7 +319,7 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC
ORDER BY timestamp DESC, id DESC
LIMIT :limit
"""
)
@@ -331,7 +378,7 @@ interface MessageDao {
AND dialog_key = :dialogKey
AND from_public_key = :fromPublicKey
AND timestamp BETWEEN :timestampFrom AND :timestampTo
ORDER BY timestamp ASC, message_id ASC
ORDER BY timestamp ASC, id ASC
LIMIT 1
"""
)
@@ -393,6 +440,10 @@ interface MessageDao {
)
suspend fun messageExists(account: String, messageId: String): Boolean
/** Найти сообщение по ID */
@Query("SELECT * FROM messages WHERE account = :account AND message_id = :messageId LIMIT 1")
suspend fun findMessageById(account: String, messageId: String): MessageEntity?
/**
* Отметить все исходящие сообщения к собеседнику как прочитанные Используется когда приходит
* PacketRead от собеседника.
@@ -400,10 +451,13 @@ interface MessageDao {
@Query(
"""
UPDATE messages SET read = 1
WHERE account = :account AND to_public_key = :opponent AND from_me = 1
WHERE account = :account
AND to_public_key = :opponent
AND from_me = 1
AND read != 1
"""
)
suspend fun markAllAsRead(account: String, opponent: String)
suspend fun markAllAsRead(account: String, opponent: String): Int
/** 🔥 DEBUG: Получить последнее сообщение в диалоге для отладки */
@Query(
@@ -412,7 +466,7 @@ interface MessageDao {
WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1
ORDER BY timestamp DESC, id DESC LIMIT 1
"""
)
suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity?
@@ -427,7 +481,7 @@ interface MessageDao {
WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1
ORDER BY timestamp DESC, id DESC LIMIT 1
"""
)
suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus?
@@ -442,7 +496,7 @@ interface MessageDao {
WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1
ORDER BY timestamp DESC, id DESC LIMIT 1
"""
)
suspend fun getLastMessageAttachments(account: String, opponent: String): String?
@@ -458,6 +512,7 @@ interface MessageDao {
WHERE account = :account
AND from_me = 1
AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp >= :minTimestamp
ORDER BY timestamp ASC
"""
@@ -474,11 +529,41 @@ interface MessageDao {
WHERE account = :account
AND from_me = 1
AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp < :maxTimestamp
"""
)
suspend fun markExpiredWaitingAsError(account: String, maxTimestamp: Long): Int
/**
* Desktop parity recovery:
* own direct messages synced from another device are stored with chacha_key "sync:*"
* and should always be DELIVERED (never WAITING/ERROR).
*/
@Query(
"""
UPDATE messages
SET delivered = 1
WHERE account = :account
AND from_me = 1
AND delivered != 1
AND chacha_key LIKE 'sync:%'
"""
)
suspend fun markSyncedOwnMessagesAsDelivered(account: String): Int
@Query(
"""
SELECT DISTINCT to_public_key
FROM messages
WHERE account = :account
AND from_me = 1
AND delivered != 1
AND chacha_key LIKE 'sync:%'
"""
)
suspend fun getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account: String): List<String>
/**
* Update delivery status AND timestamp on delivery confirmation.
* Desktop parity: useDialogFiber.ts sets timestamp = Date.now() on PacketDelivery.
@@ -503,8 +588,7 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":0%'
AND primary_attachment_type = 0
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
@@ -519,13 +603,139 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":2%'
AND primary_attachment_type = 2
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
/**
* 📞 История звонков на основе CALL attachments (type: 4)
* LEFT JOIN на dialogs нужен для имени/username/verified без дополнительных запросов.
*/
@Query(
"""
SELECT
m.*,
CASE
WHEN m.from_me = 1 THEN m.to_public_key
ELSE m.from_public_key
END AS peer_key,
d.opponent_title AS peer_title,
d.opponent_username AS peer_username,
d.verified AS peer_verified,
d.is_online AS peer_online
FROM messages m
LEFT JOIN dialogs d
ON d.account = m.account
AND d.opponent_key = CASE
WHEN m.from_me = 1 THEN m.to_public_key
ELSE m.from_public_key
END
WHERE m.account = :account
AND m.primary_attachment_type = 4
ORDER BY m.timestamp DESC, m.id DESC
LIMIT :limit
"""
)
fun getCallHistoryFlow(account: String, limit: Int = 300): Flow<List<CallHistoryRow>>
/** Пиры, у которых есть call attachments (нужно для пересчета dialogs после удаления). */
@Query(
"""
SELECT DISTINCT
CASE
WHEN from_me = 1 THEN to_public_key
ELSE from_public_key
END AS peer_key
FROM messages
WHERE account = :account
AND primary_attachment_type = 4
"""
)
suspend fun getCallHistoryPeers(account: String): List<String>
/** Удалить все call events из messages для аккаунта. */
@Query(
"""
DELETE FROM messages
WHERE account = :account
AND primary_attachment_type = 4
"""
)
suspend fun deleteAllCallMessages(account: String): Int
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
@Query(
"""
SELECT * FROM messages
WHERE account = :account
AND plain_message != ''
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List<MessageEntity>
}
@Dao
interface MessageSearchIndexDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(items: List<MessageSearchIndexEntity>)
@Query(
"""
SELECT * FROM message_search_index
WHERE account = :account
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun search(
account: String,
queryNormalized: String,
limit: Int,
offset: Int = 0
): List<MessageSearchIndexEntity>
@Query(
"""
SELECT * FROM message_search_index
WHERE account = :account
AND dialog_key = :dialogKey
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun searchInDialog(
account: String,
dialogKey: String,
queryNormalized: String,
limit: Int,
offset: Int = 0
): List<MessageSearchIndexEntity>
@Query(
"""
SELECT m.* FROM messages m
LEFT JOIN message_search_index s
ON s.account = m.account
AND s.message_id = m.message_id
WHERE m.account = :account
AND m.plain_message != ''
AND s.message_id IS NULL
ORDER BY m.timestamp DESC
LIMIT :limit
"""
)
suspend fun getUnindexedMessages(account: String, limit: Int): List<MessageEntity>
@Query("DELETE FROM message_search_index WHERE account = :account")
suspend fun deleteByAccount(account: String): Int
}
/** DAO для работы с диалогами */
@@ -549,7 +759,7 @@ interface DialogDao {
OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002'
)
AND (last_message != '' OR last_message_attachments != '[]')
AND has_content = 1
ORDER BY last_message_timestamp DESC
LIMIT 30
"""
@@ -566,7 +776,7 @@ interface DialogDao {
OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002'
)
AND (last_message != '' OR last_message_attachments != '[]')
AND has_content = 1
ORDER BY last_message_timestamp DESC
LIMIT :limit OFFSET :offset
"""
@@ -584,7 +794,7 @@ interface DialogDao {
AND i_have_sent = 0
AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]')
AND has_content = 1
ORDER BY last_message_timestamp DESC
LIMIT 30
"""
@@ -599,7 +809,7 @@ interface DialogDao {
AND i_have_sent = 0
AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]')
AND has_content = 1
"""
)
fun getRequestsCountFlow(account: String): Flow<Int>
@@ -612,7 +822,7 @@ interface DialogDao {
@Query("""
SELECT * FROM dialogs
WHERE account = :account
AND (last_message != '' OR last_message_attachments != '[]')
AND has_content = 1
AND opponent_key NOT LIKE '#group:%'
AND (
opponent_title = ''
@@ -626,12 +836,25 @@ interface DialogDao {
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
suspend fun getDialog(account: String, opponentKey: String): DialogEntity?
/** Найти direct-диалог по username собеседника (без учета регистра и '@'). */
@Query(
"""
SELECT * FROM dialogs
WHERE account = :account
AND opponent_key NOT LIKE '#group:%'
AND LOWER(REPLACE(TRIM(opponent_username), '@', '')) = LOWER(REPLACE(TRIM(:username), '@', ''))
LIMIT 1
"""
)
suspend fun getDialogByUsername(account: String, username: String): DialogEntity?
/** Обновить последнее сообщение */
@Query(
"""
UPDATE dialogs SET
last_message = :lastMessage,
last_message_timestamp = :timestamp
last_message_timestamp = :timestamp,
has_content = CASE WHEN TRIM(:lastMessage) != '' THEN 1 ELSE has_content END
WHERE account = :account AND opponent_key = :opponentKey
"""
)
@@ -761,7 +984,7 @@ interface DialogDao {
"""
SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC LIMIT 1
ORDER BY timestamp DESC, id DESC LIMIT 1
"""
)
suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity?
@@ -860,6 +1083,16 @@ interface DialogDao {
val hasSent = hasSentByDialogKey(account, dialogKey)
// 5. Один INSERT OR REPLACE с вычисленными данными
val hasContent =
if (
lastMsg.plainMessage.isNotBlank() ||
(lastMsg.attachments.isNotBlank() &&
lastMsg.attachments.trim() != "[]")
) {
1
} else {
0
}
insertDialog(
DialogEntity(
id = existing?.id ?: 0,
@@ -875,6 +1108,9 @@ interface DialogDao {
verified = existing?.verified ?: 0,
// Desktop parity: request flag is always derived from message history.
iHaveSent = if (hasSent || isSystemDialog) 1 else 0,
hasContent = hasContent,
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
lastSenderKey = lastMsg.fromPublicKey,
lastMessageFromMe = lastMsg.fromMe,
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,
@@ -894,6 +1130,16 @@ interface DialogDao {
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
val existing = getDialog(account, account)
val hasContent =
if (
lastMsg.plainMessage.isNotBlank() ||
(lastMsg.attachments.isNotBlank() &&
lastMsg.attachments.trim() != "[]")
) {
1
} else {
0
}
insertDialog(
DialogEntity(
@@ -909,6 +1155,9 @@ interface DialogDao {
lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 0,
iHaveSent = 1,
hasContent = hasContent,
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
lastSenderKey = lastMsg.fromPublicKey,
lastMessageFromMe = 1,
lastMessageDelivered = 1,
lastMessageRead = 1,

View File

@@ -12,13 +12,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase
[
EncryptedAccountEntity::class,
MessageEntity::class,
MessageSearchIndexEntity::class,
DialogEntity::class,
BlacklistEntity::class,
AvatarCacheEntity::class,
AccountSyncTimeEntity::class,
GroupEntity::class,
PinnedMessageEntity::class],
version = 14,
version = 17,
exportSchema = false
)
abstract class RosettaDatabase : RoomDatabase() {
@@ -30,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun syncTimeDao(): SyncTimeDao
abstract fun groupDao(): GroupDao
abstract fun pinnedMessageDao(): PinnedMessageDao
abstract fun messageSearchIndexDao(): MessageSearchIndexDao
companion object {
@Volatile private var INSTANCE: RosettaDatabase? = null
@@ -202,6 +204,154 @@ abstract class RosettaDatabase : RoomDatabase() {
}
}
/**
* 🧱 МИГРАЦИЯ 14->15: Денормализованный тип вложения для ускорения фильтров (media/files/calls)
*/
private val MIGRATION_14_15 =
object : Migration(14, 15) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE messages ADD COLUMN primary_attachment_type INTEGER NOT NULL DEFAULT -1"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_messages_account_primary_attachment_type_timestamp ON messages (account, primary_attachment_type, timestamp)"
)
// Best-effort backfill для уже сохраненных сообщений.
database.execSQL(
"""
UPDATE messages
SET primary_attachment_type = CASE
WHEN attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]' THEN -1
WHEN attachments LIKE '%"type":0%' OR attachments LIKE '%"type": 0%' THEN 0
WHEN attachments LIKE '%"type":1%' OR attachments LIKE '%"type": 1%' THEN 1
WHEN attachments LIKE '%"type":2%' OR attachments LIKE '%"type": 2%' THEN 2
WHEN attachments LIKE '%"type":3%' OR attachments LIKE '%"type": 3%' THEN 3
WHEN attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%' THEN 4
ELSE -1
END
"""
)
}
}
/**
* 🧱 МИГРАЦИЯ 15->16: Денормализованный has_content для быстрых выборок dialogs/requests
*/
private val MIGRATION_15_16 =
object : Migration(15, 16) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN has_content INTEGER NOT NULL DEFAULT 0"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_dialogs_account_i_have_sent_has_content_last_message_timestamp ON dialogs (account, i_have_sent, has_content, last_message_timestamp)"
)
database.execSQL(
"""
UPDATE dialogs
SET has_content = CASE
WHEN TRIM(last_message) != '' THEN 1
WHEN last_message_attachments IS NOT NULL
AND TRIM(last_message_attachments) != ''
AND TRIM(last_message_attachments) != '[]' THEN 1
ELSE 0
END
"""
)
}
}
/**
* 🧱 МИГРАЦИЯ 16->17:
* - dialogs: last_message_attachment_type + last_sender_key
* - messages: индекс (account, timestamp)
* - локальный message_search_index для поиска без повторной дешифровки
*/
private val MIGRATION_16_17 =
object : Migration(16, 17) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_message_attachment_type INTEGER NOT NULL DEFAULT -1"
)
database.execSQL(
"ALTER TABLE dialogs ADD COLUMN last_sender_key TEXT NOT NULL DEFAULT ''"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_messages_account_timestamp ON messages (account, timestamp)"
)
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS message_search_index (
account TEXT NOT NULL,
message_id TEXT NOT NULL,
dialog_key TEXT NOT NULL,
opponent_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
from_me INTEGER NOT NULL DEFAULT 0,
plain_text TEXT NOT NULL,
plain_text_normalized TEXT NOT NULL,
PRIMARY KEY(account, message_id)
)
"""
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_message_search_index_account_timestamp ON message_search_index (account, timestamp)"
)
database.execSQL(
"CREATE INDEX IF NOT EXISTS index_message_search_index_account_opponent_key_timestamp ON message_search_index (account, opponent_key, timestamp)"
)
database.execSQL(
"""
UPDATE dialogs
SET last_message_attachment_type = CASE
WHEN last_message_attachments IS NULL
OR TRIM(last_message_attachments) = ''
OR TRIM(last_message_attachments) = '[]' THEN -1
WHEN last_message_attachments LIKE '%"type":0%' OR last_message_attachments LIKE '%"type": 0%' THEN 0
WHEN last_message_attachments LIKE '%"type":1%' OR last_message_attachments LIKE '%"type": 1%' THEN 1
WHEN last_message_attachments LIKE '%"type":2%' OR last_message_attachments LIKE '%"type": 2%' THEN 2
WHEN last_message_attachments LIKE '%"type":3%' OR last_message_attachments LIKE '%"type": 3%' THEN 3
WHEN last_message_attachments LIKE '%"type":4%' OR last_message_attachments LIKE '%"type": 4%' THEN 4
ELSE -1
END
"""
)
database.execSQL(
"""
UPDATE dialogs
SET last_sender_key = COALESCE(
(
SELECT m.from_public_key
FROM messages m
WHERE m.account = dialogs.account
AND m.dialog_key = CASE
WHEN dialogs.opponent_key = dialogs.account THEN dialogs.account
WHEN LOWER(dialogs.opponent_key) LIKE '#group:%' OR LOWER(dialogs.opponent_key) LIKE 'group:%'
THEN dialogs.opponent_key
WHEN dialogs.account < dialogs.opponent_key
THEN dialogs.account || ':' || dialogs.opponent_key
ELSE dialogs.opponent_key || ':' || dialogs.account
END
ORDER BY m.timestamp DESC, m.id DESC
LIMIT 1
),
''
)
"""
)
database.execSQL(
"""
CREATE TRIGGER IF NOT EXISTS trg_message_search_index_delete
AFTER DELETE ON messages
BEGIN
DELETE FROM message_search_index
WHERE account = OLD.account AND message_id = OLD.message_id;
END
"""
)
}
}
fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE
?: synchronized(this) {
@@ -224,7 +374,10 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13,
MIGRATION_13_14
MIGRATION_13_14,
MIGRATION_14_15,
MIGRATION_15_16,
MIGRATION_16_17
)
.fallbackToDestructiveMigration() // Для разработки - только
// если миграция не

View File

@@ -0,0 +1,199 @@
package com.rosetta.messenger.di
import android.content.Context
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketMessage
import com.rosetta.messenger.network.ProtocolClient
import com.rosetta.messenger.network.ProtocolRuntime
import com.rosetta.messenger.network.ProtocolRuntimePort
import com.rosetta.messenger.network.RuntimeComposition
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.session.SessionAction
import com.rosetta.messenger.session.IdentityStateSnapshot
import com.rosetta.messenger.session.IdentityStore
import com.rosetta.messenger.session.SessionState
import com.rosetta.messenger.session.SessionStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject
import javax.inject.Singleton
import javax.inject.Provider
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
interface ProtocolGateway : ProtocolRuntimePort {
val syncInProgress: StateFlow<Boolean>
val pendingDeviceVerification: StateFlow<DeviceEntry?>
val typingUsers: StateFlow<Set<String>>
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>>
val ownProfileUpdated: StateFlow<Long>
fun initialize(context: Context)
fun initializeAccount(publicKey: String, privateKey: String)
fun connect()
fun authenticate(publicKey: String, privateHash: String)
fun disconnect()
fun getPrivateHash(): String?
fun subscribePushTokenIfAvailable(forceToken: String? = null)
fun enableUILogs(enabled: Boolean)
fun clearLogs()
fun resolveOutgoingRetry(messageId: String)
fun getCachedUserByUsername(username: String): SearchUser?
fun getCachedUserName(publicKey: String): String?
fun acceptDevice(deviceId: String)
fun declineDevice(deviceId: String)
fun sendMessageWithRetry(packet: PacketMessage)
fun packetFlow(packetId: Int): SharedFlow<Packet>
fun notifyOwnProfileUpdated()
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
}
interface SessionCoordinator {
val sessionState: StateFlow<SessionState>
fun dispatch(action: SessionAction)
fun markLoggedOut(reason: String = "") =
dispatch(SessionAction.LoggedOut(reason = reason))
fun markAuthInProgress(publicKey: String? = null, reason: String = "") =
dispatch(
SessionAction.AuthInProgress(
publicKey = publicKey,
reason = reason
)
)
fun markReady(account: DecryptedAccount, reason: String = "") =
dispatch(SessionAction.Ready(account = account, reason = reason))
fun syncFromCachedAccount(account: DecryptedAccount?) =
dispatch(SessionAction.SyncFromCachedAccount(account = account))
suspend fun bootstrapAuthenticatedSession(account: DecryptedAccount, reason: String)
}
interface IdentityGateway {
val state: StateFlow<IdentityStateSnapshot>
fun updateOwnProfile(
publicKey: String,
displayName: String? = null,
username: String? = null,
verified: Int? = null,
resolved: Boolean = true,
reason: String = ""
)
}
@Singleton
/**
* Thin infrastructure adapter for repositories.
*
* This bridge is intentionally excluded from business-flow hop-depth accounting and exists
* to keep lazy runtime access (`Provider<ProtocolRuntime>`) and avoid DI cycles.
*/
class ProtocolClientImpl @Inject constructor(
private val runtimeProvider: Provider<ProtocolRuntime>
) : ProtocolClient {
override fun send(packet: Packet) = runtimeProvider.get().send(packet)
override fun sendMessageWithRetry(packet: PacketMessage) =
runtimeProvider.get().sendMessageWithRetry(packet)
override fun addLog(message: String) = runtimeProvider.get().addLog(message)
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
runtimeProvider.get().waitPacket(packetId, callback)
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
runtimeProvider.get().unwaitPacket(packetId, callback)
}
@Singleton
class SessionCoordinatorImpl @Inject constructor(
private val accountManager: AccountManager,
private val protocolGateway: ProtocolGateway
) : SessionCoordinator {
override val sessionState: StateFlow<SessionState> = SessionStore.state
override fun dispatch(action: SessionAction) {
SessionStore.dispatch(action)
}
override suspend fun bootstrapAuthenticatedSession(account: DecryptedAccount, reason: String) {
dispatch(SessionAction.AuthInProgress(publicKey = account.publicKey, reason = reason))
protocolGateway.initializeAccount(account.publicKey, account.privateKey)
protocolGateway.connect()
protocolGateway.authenticate(account.publicKey, account.privateKeyHash)
protocolGateway.reconnectNowIfNeeded("session_bootstrap_$reason")
accountManager.setCurrentAccount(account.publicKey)
dispatch(SessionAction.Ready(account = account, reason = reason))
}
}
@Singleton
class IdentityGatewayImpl @Inject constructor() : IdentityGateway {
override val state: StateFlow<IdentityStateSnapshot> = IdentityStore.state
override fun updateOwnProfile(
publicKey: String,
displayName: String?,
username: String?,
verified: Int?,
resolved: Boolean,
reason: String
) {
IdentityStore.updateOwnProfile(
publicKey = publicKey,
displayName = displayName,
username = username,
verified = verified,
resolved = resolved,
reason = reason
)
}
}
@Module
@InstallIn(SingletonComponent::class)
object AppDataModule {
@Provides
@Singleton
fun provideAccountManager(@ApplicationContext context: Context): AccountManager =
AccountManager(context)
@Provides
@Singleton
fun providePreferencesManager(@ApplicationContext context: Context): PreferencesManager =
PreferencesManager(context)
@Provides
@Singleton
fun provideRuntimeComposition(): RuntimeComposition = RuntimeComposition()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AppGatewayModule {
@Binds
@Singleton
abstract fun bindProtocolGateway(runtime: ProtocolRuntime): ProtocolGateway
@Binds
@Singleton
abstract fun bindSessionCoordinator(impl: SessionCoordinatorImpl): SessionCoordinator
@Binds
@Singleton
abstract fun bindIdentityGateway(impl: IdentityGatewayImpl): IdentityGateway
@Binds
@Singleton
abstract fun bindProtocolClient(impl: ProtocolClientImpl): ProtocolClient
}

View File

@@ -0,0 +1,71 @@
package com.rosetta.messenger.domain.chats.usecase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import javax.inject.Inject
data class CreateFileAttachmentCommand(
val attachmentId: String,
val preview: String,
val blob: String = "",
val transportTag: String = "",
val transportServer: String = ""
)
class CreateFileAttachmentUseCase @Inject constructor() {
operator fun invoke(command: CreateFileAttachmentCommand): MessageAttachment =
MessageAttachment(
id = command.attachmentId,
blob = command.blob,
type = AttachmentType.FILE,
preview = command.preview,
transportTag = command.transportTag,
transportServer = command.transportServer
)
}
data class CreateAvatarAttachmentCommand(
val attachmentId: String,
val preview: String,
val blob: String = "",
val transportTag: String = "",
val transportServer: String = ""
)
class CreateAvatarAttachmentUseCase @Inject constructor() {
operator fun invoke(command: CreateAvatarAttachmentCommand): MessageAttachment =
MessageAttachment(
id = command.attachmentId,
blob = command.blob,
type = AttachmentType.AVATAR,
preview = command.preview,
transportTag = command.transportTag,
transportServer = command.transportServer
)
}
data class CreateVideoCircleAttachmentCommand(
val attachmentId: String,
val preview: String,
val width: Int,
val height: Int,
val blob: String = "",
val localUri: String = "",
val transportTag: String = "",
val transportServer: String = ""
)
class CreateVideoCircleAttachmentUseCase @Inject constructor() {
operator fun invoke(command: CreateVideoCircleAttachmentCommand): MessageAttachment =
MessageAttachment(
id = command.attachmentId,
blob = command.blob,
type = AttachmentType.VIDEO_CIRCLE,
preview = command.preview,
width = command.width,
height = command.height,
localUri = command.localUri,
transportTag = command.transportTag,
transportServer = command.transportServer
)
}

View File

@@ -0,0 +1,45 @@
package com.rosetta.messenger.domain.chats.usecase
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.network.TransportManager
import javax.inject.Inject
data class EncryptAndUploadAttachmentCommand(
val payload: String,
val attachmentPassword: String,
val attachmentId: String,
val isSavedMessages: Boolean
)
data class EncryptAndUploadAttachmentResult(
val encryptedBlob: String,
val transportTag: String,
val transportServer: String
)
class EncryptAndUploadAttachmentUseCase @Inject constructor() {
suspend operator fun invoke(command: EncryptAndUploadAttachmentCommand): EncryptAndUploadAttachmentResult {
val encryptedBlob = CryptoManager.encryptWithPassword(command.payload, command.attachmentPassword)
if (command.isSavedMessages) {
return EncryptAndUploadAttachmentResult(
encryptedBlob = encryptedBlob,
transportTag = "",
transportServer = ""
)
}
val uploadTag = TransportManager.uploadFile(command.attachmentId, encryptedBlob)
val transportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
return EncryptAndUploadAttachmentResult(
encryptedBlob = encryptedBlob,
transportTag = uploadTag,
transportServer = transportServer
)
}
}

View File

@@ -0,0 +1,104 @@
package com.rosetta.messenger.domain.chats.usecase
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.PacketMessage
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
data class ForwardPayloadMessage(
val messageId: String,
val senderPublicKey: String,
val senderName: String,
val text: String,
val timestamp: Long,
val chachaKeyPlain: String,
val attachments: List<MessageAttachment>
)
class SendForwardUseCase @Inject constructor(
private val protocolGateway: ProtocolGateway
) {
fun buildForwardReplyJson(
messages: List<ForwardPayloadMessage>,
rewrittenAttachments: Map<String, MessageAttachment>,
rewrittenMessageIds: Set<String>,
outgoingForwardPlainKeyHex: String,
includeLocalUri: Boolean,
rewriteKey: (messageId: String, attachmentId: String) -> String
): JSONArray {
val replyJsonArray = JSONArray()
messages.forEach { message ->
val attachmentsArray = JSONArray()
message.attachments.forEach { attachment ->
val rewritten =
rewrittenAttachments[rewriteKey(message.messageId, attachment.id)]
val effectiveAttachment = rewritten ?: attachment
attachmentsArray.put(
JSONObject().apply {
put("id", effectiveAttachment.id)
put("type", effectiveAttachment.type.value)
put("preview", effectiveAttachment.preview)
put("width", effectiveAttachment.width)
put("height", effectiveAttachment.height)
put("blob", "")
put("transportTag", effectiveAttachment.transportTag)
put("transportServer", effectiveAttachment.transportServer)
put(
"transport",
JSONObject().apply {
put("transport_tag", effectiveAttachment.transportTag)
put("transport_server", effectiveAttachment.transportServer)
}
)
if (includeLocalUri && effectiveAttachment.localUri.isNotEmpty()) {
put("localUri", effectiveAttachment.localUri)
}
}
)
}
val effectiveForwardPlainKey =
if (message.messageId in rewrittenMessageIds && outgoingForwardPlainKeyHex.isNotEmpty()) {
outgoingForwardPlainKeyHex
} else {
message.chachaKeyPlain
}
replyJsonArray.put(
JSONObject().apply {
put("message_id", message.messageId)
put("publicKey", message.senderPublicKey)
put("message", message.text)
put("timestamp", message.timestamp)
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", message.senderName)
if (effectiveForwardPlainKey.isNotEmpty()) {
put("chacha_key_plain", effectiveForwardPlainKey)
}
}
)
}
return replyJsonArray
}
fun buildForwardAttachment(
replyAttachmentId: String,
encryptedReplyBlob: String
): MessageAttachment =
MessageAttachment(
id = replyAttachmentId,
blob = encryptedReplyBlob,
type = AttachmentType.MESSAGES,
preview = ""
)
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
if (!isSavedMessages) {
protocolGateway.sendMessageWithRetry(packet)
}
}
}

View File

@@ -0,0 +1,49 @@
package com.rosetta.messenger.domain.chats.usecase
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.PacketMessage
import javax.inject.Inject
data class SendMediaMessageCommand(
val fromPublicKey: String,
val toPublicKey: String,
val encryptedContent: String,
val encryptedKey: String,
val aesChachaKey: String,
val privateKeyHash: String,
val messageId: String,
val timestamp: Long,
val mediaAttachments: List<MessageAttachment>,
val isSavedMessages: Boolean
)
class SendMediaMessageUseCase @Inject constructor(
private val protocolGateway: ProtocolGateway
) {
operator fun invoke(command: SendMediaMessageCommand): PacketMessage {
val packet =
PacketMessage().apply {
fromPublicKey = command.fromPublicKey
toPublicKey = command.toPublicKey
content = command.encryptedContent
chachaKey = command.encryptedKey
aesChachaKey = command.aesChachaKey
privateKey = command.privateKeyHash
messageId = command.messageId
timestamp = command.timestamp
attachments = command.mediaAttachments
}
if (!command.isSavedMessages) {
protocolGateway.sendMessageWithRetry(packet)
}
return packet
}
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
if (!isSavedMessages) {
protocolGateway.sendMessageWithRetry(packet)
}
}
}

View File

@@ -0,0 +1,25 @@
package com.rosetta.messenger.domain.chats.usecase
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.PacketRead
import javax.inject.Inject
data class SendReadReceiptCommand(
val privateKeyHash: String,
val fromPublicKey: String,
val toPublicKey: String
)
class SendReadReceiptUseCase @Inject constructor(
private val protocolGateway: ProtocolGateway
) {
operator fun invoke(command: SendReadReceiptCommand) {
val packet =
PacketRead().apply {
privateKey = command.privateKeyHash
fromPublicKey = command.fromPublicKey
toPublicKey = command.toPublicKey
}
protocolGateway.send(packet)
}
}

View File

@@ -0,0 +1,49 @@
package com.rosetta.messenger.domain.chats.usecase
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.PacketMessage
import javax.inject.Inject
data class SendTextMessageCommand(
val fromPublicKey: String,
val toPublicKey: String,
val encryptedContent: String,
val encryptedKey: String,
val aesChachaKey: String,
val privateKeyHash: String,
val messageId: String,
val timestamp: Long,
val attachments: List<MessageAttachment> = emptyList(),
val isSavedMessages: Boolean
)
class SendTextMessageUseCase @Inject constructor(
private val protocolGateway: ProtocolGateway
) {
operator fun invoke(command: SendTextMessageCommand): PacketMessage {
val packet =
PacketMessage().apply {
fromPublicKey = command.fromPublicKey
toPublicKey = command.toPublicKey
content = command.encryptedContent
chachaKey = command.encryptedKey
aesChachaKey = command.aesChachaKey
privateKey = command.privateKeyHash
messageId = command.messageId
timestamp = command.timestamp
attachments = command.attachments
}
if (!command.isSavedMessages) {
protocolGateway.sendMessageWithRetry(packet)
}
return packet
}
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
if (!isSavedMessages) {
protocolGateway.sendMessageWithRetry(packet)
}
}
}

View File

@@ -0,0 +1,42 @@
package com.rosetta.messenger.domain.chats.usecase
import javax.inject.Inject
data class SendTypingIndicatorCommand(
val nowMs: Long,
val lastSentMs: Long,
val throttleMs: Long,
val opponentPublicKey: String?,
val senderPublicKey: String?,
val isGroupDialog: Boolean,
val isOpponentOnline: Boolean
)
data class SendTypingIndicatorDecision(
val shouldSend: Boolean,
val nextLastSentMs: Long
)
class SendTypingIndicatorUseCase @Inject constructor() {
operator fun invoke(command: SendTypingIndicatorCommand): SendTypingIndicatorDecision {
if (command.nowMs - command.lastSentMs < command.throttleMs) {
return SendTypingIndicatorDecision(shouldSend = false, nextLastSentMs = command.lastSentMs)
}
val opponent = command.opponentPublicKey?.trim().orEmpty()
val sender = command.senderPublicKey?.trim().orEmpty()
if (opponent.isBlank() || sender.isBlank()) {
return SendTypingIndicatorDecision(shouldSend = false, nextLastSentMs = command.lastSentMs)
}
if (opponent.equals(sender, ignoreCase = true)) {
return SendTypingIndicatorDecision(shouldSend = false, nextLastSentMs = command.lastSentMs)
}
if (!command.isGroupDialog && !command.isOpponentOnline) {
return SendTypingIndicatorDecision(shouldSend = false, nextLastSentMs = command.lastSentMs)
}
return SendTypingIndicatorDecision(shouldSend = true, nextLastSentMs = command.nowMs)
}
}

View File

@@ -0,0 +1,42 @@
package com.rosetta.messenger.domain.chats.usecase
import java.util.Locale
import javax.inject.Inject
data class SendVoiceMessageCommand(
val voiceHex: String,
val durationSec: Int,
val waves: List<Float>,
val maxWaveCount: Int = 120
)
data class VoiceMessagePayload(
val normalizedVoiceHex: String,
val durationSec: Int,
val normalizedWaves: List<Float>,
val preview: String
)
class SendVoiceMessageUseCase @Inject constructor() {
operator fun invoke(command: SendVoiceMessageCommand): VoiceMessagePayload? {
val normalizedVoiceHex = command.voiceHex.trim()
if (normalizedVoiceHex.isEmpty()) return null
val normalizedDuration = command.durationSec.coerceAtLeast(1)
val normalizedWaves =
command.waves
.asSequence()
.map { it.coerceIn(0f, 1f) }
.take(command.maxWaveCount)
.toList()
val wavesPreview = normalizedWaves.joinToString(",") { String.format(Locale.US, "%.3f", it) }
val preview = "$normalizedDuration::$wavesPreview"
return VoiceMessagePayload(
normalizedVoiceHex = normalizedVoiceHex,
durationSec = normalizedDuration,
normalizedWaves = normalizedWaves,
preview = preview
)
}
}

View File

@@ -0,0 +1,107 @@
package com.rosetta.messenger.domain.chats.usecase
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.webkit.MimeTypeMap
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class ResolveVideoCircleMetaCommand(
val context: Context,
val videoUri: Uri
)
data class VideoCircleMeta(
val durationSec: Int,
val width: Int,
val height: Int,
val mimeType: String
)
class ResolveVideoCircleMetaUseCase @Inject constructor() {
operator fun invoke(command: ResolveVideoCircleMetaCommand): VideoCircleMeta {
var durationSec = 1
var width = 0
var height = 0
val mimeType =
command.context.contentResolver.getType(command.videoUri)?.trim().orEmpty().ifBlank {
val ext =
MimeTypeMap.getFileExtensionFromUrl(command.videoUri.toString())
?.lowercase(Locale.ROOT)
.orEmpty()
MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "video/mp4"
}
runCatching {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(command.context, command.videoUri)
val durationMs =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull()
?: 0L
val rawWidth =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
?.toIntOrNull()
?: 0
val rawHeight =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
?.toIntOrNull()
?: 0
val rotation =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull()
?: 0
retriever.release()
durationSec = ((durationMs + 999L) / 1000L).toInt().coerceAtLeast(1)
val rotated = rotation == 90 || rotation == 270
width = if (rotated) rawHeight else rawWidth
height = if (rotated) rawWidth else rawHeight
}
return VideoCircleMeta(
durationSec = durationSec,
width = width.coerceAtLeast(0),
height = height.coerceAtLeast(0),
mimeType = mimeType
)
}
}
data class EncodeVideoUriToHexCommand(
val context: Context,
val videoUri: Uri
)
class EncodeVideoUriToHexUseCase @Inject constructor() {
suspend operator fun invoke(command: EncodeVideoUriToHexCommand): String? {
return withContext(Dispatchers.IO) {
runCatching {
command.context.contentResolver.openInputStream(command.videoUri)?.use { stream ->
val bytes = stream.readBytes()
if (bytes.isEmpty()) {
null
} else {
bytesToHex(bytes)
}
}
}.getOrNull()
}
}
private fun bytesToHex(bytes: ByteArray): String {
val hexChars = "0123456789abcdef".toCharArray()
val output = CharArray(bytes.size * 2)
var index = 0
bytes.forEach { byte ->
val value = byte.toInt() and 0xFF
output[index++] = hexChars[value ushr 4]
output[index++] = hexChars[value and 0x0F]
}
return String(output)
}
}

View File

@@ -7,9 +7,13 @@ enum class AttachmentType(val value: Int) {
IMAGE(0), // Изображение
MESSAGES(1), // Reply (цитата сообщения)
FILE(2), // Файл
AVATAR(3); // Аватар пользователя
AVATAR(3), // Аватар пользователя
CALL(4), // Событие звонка (пропущен/принят/завершен)
VOICE(5), // Голосовое сообщение
VIDEO_CIRCLE(6), // Видео-кружок (video note)
UNKNOWN(-1); // Неизвестный тип
companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: UNKNOWN
}
}

View File

@@ -0,0 +1,551 @@
package com.rosetta.messenger.network
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Person
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.os.Build
import android.os.IBinder
import android.util.Base64
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.utils.AvatarFileManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Keeps call alive while app goes to background.
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
*/
@AndroidEntryPoint
class CallForegroundService : Service() {
@Inject lateinit var preferencesManager: PreferencesManager
private data class Snapshot(
val phase: CallPhase,
val displayName: String,
val statusText: String,
val durationSec: Int,
val peerPublicKey: String = ""
)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_SYNC
CallManager.initialize(applicationContext)
val phaseNow = CallManager.state.value.phase
notifLog("onStartCommand action=$action phase=$phaseNow")
when (action) {
ACTION_STOP -> {
if (phaseNow == CallPhase.IDLE) {
notifLog("ACTION_STOP → stopSelf")
safeStopForeground()
return START_NOT_STICKY
}
// Может прилететь поздний STOP от прошлой сессии, не глушим живой звонок.
notifLog("ACTION_STOP ignored: phase=$phaseNow")
}
ACTION_END -> {
notifLog("ACTION_END → endCall")
CallManager.endCall()
safeStopForeground()
return START_NOT_STICKY
}
ACTION_DECLINE -> {
val phase = CallManager.state.value.phase
notifLog("ACTION_DECLINE phase=$phase")
if (phase == CallPhase.INCOMING) {
CallManager.declineIncomingCall()
} else {
CallManager.endCall()
}
safeStopForeground()
return START_NOT_STICKY
}
ACTION_ACCEPT -> {
notifLog("ACTION_ACCEPT → acceptIncomingCall phase=${CallManager.state.value.phase}")
// Если push пришёл раньше WebSocket — CallManager ещё в IDLE.
// Ждём до 5 сек пока реальный CALL сигнал придёт по WebSocket.
CoroutineScope(Dispatchers.Main).launch {
var accepted = false
for (i in 1..50) { // 50 * 100ms = 5 sec
val phase = CallManager.state.value.phase
if (phase == CallPhase.INCOMING) {
val result = CallManager.acceptIncomingCall()
notifLog("ACTION_ACCEPT attempt #$i result=$result")
if (result == CallActionResult.STARTED) {
openCallUi()
notifLog("ACTION_ACCEPT → openCallUi()")
accepted = true
}
break
} else if (phase != CallPhase.IDLE) {
notifLog("ACTION_ACCEPT phase=$phase (not INCOMING/IDLE), opening UI")
openCallUi()
accepted = true
break
}
delay(100)
}
if (!accepted) {
notifLog("ACTION_ACCEPT: timed out waiting for INCOMING, phase=${CallManager.state.value.phase}")
}
}
}
else -> Unit
}
val snapshot = extractSnapshot(intent)
notifLog("snapshot: phase=${snapshot.phase} name=${snapshot.displayName} status=${snapshot.statusText}")
if (snapshot.phase == CallPhase.IDLE) {
notifLog("phase=IDLE → stopSelf")
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ensureNotificationChannel()
val notification = buildNotification(snapshot)
val hasFullScreen = snapshot.phase == CallPhase.INCOMING
notifLog("buildNotification OK, hasFullScreenIntent=$hasFullScreen, starting foreground")
startForegroundCompat(notification, snapshot.phase)
notifLog("startForeground OK, phase=${snapshot.phase}")
// Проверяем canUseFullScreenIntent на Android 14+
if (Build.VERSION.SDK_INT >= 34) {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
val canFsi = nm.canUseFullScreenIntent()
notifLog("Android 14+: canUseFullScreenIntent=$canFsi")
}
return START_STICKY
}
private fun extractSnapshot(intent: Intent?): Snapshot {
val state = CallManager.state.value
val payloadIntent = intent
if (payloadIntent == null || !payloadIntent.hasExtra(EXTRA_PHASE)) {
return Snapshot(
phase = state.phase,
displayName = state.displayName,
statusText = state.statusText,
durationSec = state.durationSec,
peerPublicKey = state.peerPublicKey
)
}
val rawPhase = payloadIntent.getStringExtra(EXTRA_PHASE).orEmpty()
val phase = runCatching { CallPhase.valueOf(rawPhase) }.getOrElse { state.phase }
val displayName =
payloadIntent.getStringExtra(EXTRA_DISPLAY_NAME)
.orEmpty()
.ifBlank { state.displayName }
.ifBlank { "Unknown" }
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
val peerPublicKey = payloadIntent.getStringExtra(EXTRA_PEER_PUBLIC_KEY)
.orEmpty()
.ifBlank { state.peerPublicKey }
return Snapshot(
phase = phase,
displayName = displayName,
statusText = statusText,
durationSec = durationSec.coerceAtLeast(0),
peerPublicKey = peerPublicKey
)
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel =
NotificationChannel(
CHANNEL_ID,
"Calls",
NotificationManager.IMPORTANCE_MAX
).apply {
description = "Incoming and ongoing calls"
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
setShowBadge(false)
enableVibration(true)
vibrationPattern = longArrayOf(0, 1000, 500, 1000)
setBypassDnd(true)
}
manager.createNotificationChannel(channel)
}
private fun buildNotification(snapshot: Snapshot): Notification {
// При INCOMING — нажатие открывает IncomingCallActivity (полноэкранный звонок)
// При остальных фазах — открывает MainActivity
val contentActivity = if (snapshot.phase == CallPhase.INCOMING) {
com.rosetta.messenger.IncomingCallActivity::class.java
} else {
MainActivity::class.java
}
val openAppPendingIntent = PendingIntent.getActivity(
this,
REQUEST_OPEN_APP,
Intent(this, contentActivity).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
if (contentActivity == MainActivity::class.java) {
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val endCallPendingIntent = PendingIntent.getService(
this,
REQUEST_END_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_END),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val declinePendingIntent = PendingIntent.getService(
this,
REQUEST_DECLINE_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_DECLINE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val answerPendingIntent = PendingIntent.getService(
this,
REQUEST_ACCEPT_CALL,
Intent(this, CallForegroundService::class.java).setAction(ACTION_ACCEPT),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
// fullScreenIntent открывает лёгкую IncomingCallActivity поверх lock screen
val fullScreenPendingIntent = if (snapshot.phase == CallPhase.INCOMING) {
PendingIntent.getActivity(
this,
REQUEST_FULL_SCREEN,
Intent(this, com.rosetta.messenger.IncomingCallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
} else null
val defaultStatus =
when (snapshot.phase) {
CallPhase.INCOMING -> "Incoming call"
CallPhase.OUTGOING -> "Calling"
CallPhase.CONNECTING -> "Connecting"
CallPhase.ACTIVE -> "Call in progress"
CallPhase.IDLE -> "Call ended"
}
val contentText = snapshot.statusText.ifBlank { defaultStatus }
val avatarBitmap = loadAvatarBitmap(snapshot.peerPublicKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true)
if (avatarBitmap != null) {
personBuilder.setIcon(Icon.createWithBitmap(avatarBitmap))
}
val person = personBuilder.build()
val style =
if (snapshot.phase == CallPhase.INCOMING) {
Notification.CallStyle.forIncomingCall(
person,
declinePendingIntent,
answerPendingIntent
)
} else {
Notification.CallStyle.forOngoingCall(person, endCallPendingIntent)
}
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName)
.setContentText(contentText)
.setContentIntent(openAppPendingIntent)
.setOngoing(true)
.setCategory(Notification.CATEGORY_CALL)
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setStyle(style)
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
.apply {
if (fullScreenPendingIntent != null) {
setFullScreenIntent(fullScreenPendingIntent, true)
}
}
.apply {
if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true)
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
}
}
.build()
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName)
.apply { if (avatarBitmap != null) setLargeIcon(avatarBitmap) }
.setContentText(contentText)
.setContentIntent(openAppPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setOngoing(true)
.apply {
if (fullScreenPendingIntent != null) {
setFullScreenIntent(fullScreenPendingIntent, true)
}
}
.apply {
if (snapshot.phase == CallPhase.INCOMING) {
addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent)
addAction(android.R.drawable.ic_menu_close_clear_cancel, "Decline", declinePendingIntent)
} else {
addAction(android.R.drawable.ic_menu_close_clear_cancel, "End", endCallPendingIntent)
}
}
.apply {
if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true)
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
}
}
.build()
}
private fun startForegroundCompat(notification: Notification, phase: CallPhase) {
val started =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val preferredType =
when (phase) {
CallPhase.INCOMING -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
CallPhase.OUTGOING,
CallPhase.CONNECTING,
CallPhase.ACTIVE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
CallPhase.IDLE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
}
startForegroundTyped(notification, preferredType) ||
startForegroundTyped(notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) ||
startForegroundUntyped(notification)
} else {
startForegroundUntyped(notification)
}
if (!started) {
Log.e(TAG, "Failed to start foreground service safely; stopping service")
stopSelf()
}
}
private fun startForegroundTyped(notification: Notification, type: Int): Boolean {
return try {
startForeground(NOTIFICATION_ID, notification, type)
notifLog("startForeground OK type=$type")
true
} catch (error: Throwable) {
notifLog("startForeground FAILED type=$type: ${error.message}")
Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}")
false
}
}
private fun startForegroundUntyped(notification: Notification): Boolean {
return try {
startForeground(NOTIFICATION_ID, notification)
notifLog("startForeground (untyped) OK")
true
} catch (error: Throwable) {
notifLog("startForeground (untyped) FAILED: ${error.message}")
Log.w(TAG, "Untyped startForeground failed: ${error.message}")
false
}
}
@Suppress("DEPRECATION")
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
stopForeground(true)
}
}
/** Безопасная остановка: startForeground → stopForeground → stopSelf.
* Предотвращает ForegroundServiceDidNotStartInTimeException. */
private fun safeStopForeground() {
ensureNotificationChannel()
try {
startForeground(NOTIFICATION_ID, buildPlaceholderNotification())
} catch (_: Throwable) {}
stopForegroundCompat()
stopSelf()
}
private fun buildPlaceholderNotification(): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Rosetta")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.build()
}
companion object {
private const val TAG = "CallForegroundService"
private const val CHANNEL_ID = "rosetta_calls"
private const val NOTIFICATION_ID = 9010
private const val REQUEST_OPEN_APP = 9011
private const val REQUEST_END_CALL = 9012
private const val REQUEST_DECLINE_CALL = 9013
private const val REQUEST_ACCEPT_CALL = 9014
private const val REQUEST_FULL_SCREEN = 9015
private const val NOTIF_LOG_FILE = "call_notification_log.txt"
private const val ACTION_SYNC = "com.rosetta.messenger.call.ACTION_SYNC"
private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END"
private const val ACTION_DECLINE = "com.rosetta.messenger.call.ACTION_DECLINE"
private const val ACTION_ACCEPT = "com.rosetta.messenger.call.ACTION_ACCEPT"
private const val ACTION_STOP = "com.rosetta.messenger.call.ACTION_STOP"
private const val EXTRA_PHASE = "extra_phase"
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
private const val EXTRA_STATUS_TEXT = "extra_status_text"
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
private const val EXTRA_PEER_PUBLIC_KEY = "extra_peer_public_key"
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
fun syncWithCallState(context: Context, state: CallUiState) {
val appContext = context.applicationContext
if (state.phase == CallPhase.IDLE) {
// Используем ACTION_STOP вместо stopService — он вызовет safeStopForeground
val stopIntent = Intent(appContext, CallForegroundService::class.java).setAction(ACTION_STOP)
runCatching { appContext.startService(stopIntent) }
.onFailure { appContext.stopService(Intent(appContext, CallForegroundService::class.java)) }
return
}
val intent =
Intent(appContext, CallForegroundService::class.java)
.setAction(ACTION_SYNC)
.putExtra(EXTRA_PHASE, state.phase.name)
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
.putExtra(EXTRA_PEER_PUBLIC_KEY, state.peerPublicKey)
runCatching { ContextCompat.startForegroundService(appContext, intent) }
.onFailure { error ->
Log.w(TAG, "Failed to start foreground service: ${error.message}")
}
}
fun stop(context: Context) {
val appContext = context.applicationContext
val intent = Intent(appContext, CallForegroundService::class.java).setAction(ACTION_STOP)
runCatching { appContext.startService(intent) }
.onFailure {
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
}
}
}
private fun loadAvatarBitmap(publicKey: String): Bitmap? {
if (publicKey.isBlank()) return null
// Проверяем настройку
val avatarEnabled = runCatching {
runBlocking(Dispatchers.IO) {
preferencesManager.notificationAvatarEnabled.first()
}
}.getOrDefault(true)
if (!avatarEnabled) return null
return runCatching {
val db = RosettaDatabase.getDatabase(applicationContext)
val entity = runBlocking(Dispatchers.IO) {
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
} ?: return null
val rawBase64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
?: return null
val base64 = if (rawBase64.contains(",")) rawBase64.substringAfter(",") else rawBase64
val bytes = Base64.decode(base64, Base64.DEFAULT)
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
toCircleBitmap(original)
}.getOrNull()
}
private fun toCircleBitmap(source: Bitmap): Bitmap {
val size = minOf(source.width, source.height)
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(output)
val paint = android.graphics.Paint().apply { isAntiAlias = true }
val rect = android.graphics.Rect(0, 0, size, size)
canvas.drawARGB(0, 0, 0, 0)
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN)
canvas.drawBitmap(source, rect, rect, paint)
return output
}
private fun openCallUi() {
notifLog("openCallUi → MainActivity")
val intent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
runCatching { startActivity(intent) }
.onSuccess { notifLog("openCallUi → started OK") }
.onFailure { error ->
notifLog("openCallUi FAILED: ${error.message}")
Log.w(TAG, "Failed to open call UI: ${error.message}")
}
}
private fun openIncomingCallUi() {
notifLog("openIncomingCallUi → IncomingCallActivity")
val intent =
Intent(this, com.rosetta.messenger.IncomingCallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
runCatching { startActivity(intent) }
.onSuccess { notifLog("openIncomingCallUi → started OK") }
.onFailure { error ->
notifLog("openIncomingCallUi FAILED: ${error.message}")
Log.w(TAG, "Failed to open incoming call UI: ${error.message}")
}
}
/** Пишет лог в crash_reports/call_notification_log.txt — виден через rosettadev1 */
private fun notifLog(msg: String) {
Log.d(TAG, msg)
try {
val dir = java.io.File(applicationContext.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val f = java.io.File(dir, NOTIF_LOG_FILE)
// Ограничиваем размер файла — перезаписываем если больше 100KB
if (f.exists() && f.length() > 100_000) f.writeText("")
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
f.appendText("$ts $msg\n")
} catch (_: Throwable) {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,140 @@
package com.rosetta.messenger.network
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.PowerManager
import android.util.Log
/**
* Controls proximity screen-off behavior during active calls.
* Uses PROXIMITY_SCREEN_OFF_WAKE_LOCK to mimic phone-call UX.
*/
object CallProximityManager : SensorEventListener {
private const val TAG = "CallProximityManager"
private const val WAKE_LOCK_TAG = "Rosetta:CallProximity"
private val lock = Any()
private var sensorManager: SensorManager? = null
private var proximitySensor: Sensor? = null
private var wakeLock: PowerManager.WakeLock? = null
private var enabled: Boolean = false
private var listenerRegistered: Boolean = false
private var lastNearState: Boolean? = null
fun initialize(context: Context) {
synchronized(lock) {
if (sensorManager != null) return
val app = context.applicationContext
sensorManager = app.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
proximitySensor = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY)
val powerManager = app.getSystemService(Context.POWER_SERVICE) as? PowerManager
val wakeSupported =
powerManager?.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) == true
wakeLock =
if (wakeSupported) {
powerManager
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, WAKE_LOCK_TAG)
?.apply { setReferenceCounted(false) }
} else {
null
}
Log.i(
TAG,
"initialize: sensor=${proximitySensor != null} wakeLockSupported=$wakeSupported"
)
}
}
fun setEnabled(context: Context, shouldEnable: Boolean) {
initialize(context)
synchronized(lock) {
if (enabled == shouldEnable) return
enabled = shouldEnable
if (shouldEnable) {
registerListenerLocked()
} else {
unregisterListenerLocked()
releaseWakeLockLocked()
lastNearState = null
}
}
}
fun shutdown() {
synchronized(lock) {
enabled = false
unregisterListenerLocked()
releaseWakeLockLocked()
lastNearState = null
}
}
override fun onSensorChanged(event: SensorEvent?) {
val ev = event ?: return
val near = isNear(ev)
synchronized(lock) {
if (!enabled) return
if (lastNearState == near) return
lastNearState = near
if (near) {
acquireWakeLockLocked()
} else {
releaseWakeLockLocked()
}
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
private fun registerListenerLocked() {
if (listenerRegistered) return
val sm = sensorManager
val sensor = proximitySensor
if (sm == null || sensor == null) {
Log.w(TAG, "register skipped: no proximity sensor")
return
}
listenerRegistered = sm.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL)
Log.i(TAG, "registerListener: ok=$listenerRegistered")
}
private fun unregisterListenerLocked() {
if (!listenerRegistered) return
runCatching { sensorManager?.unregisterListener(this) }
listenerRegistered = false
Log.i(TAG, "unregisterListener")
}
private fun acquireWakeLockLocked() {
val wl = wakeLock ?: return
if (wl.isHeld) return
runCatching { wl.acquire() }
.onSuccess { Log.i(TAG, "wakeLock acquired (near)") }
.onFailure { Log.w(TAG, "wakeLock acquire failed: ${it.message}") }
}
private fun releaseWakeLockLocked() {
val wl = wakeLock ?: return
if (!wl.isHeld) return
runCatching { wl.release() }
.onSuccess { Log.i(TAG, "wakeLock released (far/disabled)") }
.onFailure { Log.w(TAG, "wakeLock release failed: ${it.message}") }
}
private fun isNear(event: SensorEvent): Boolean {
val value = event.values.firstOrNull() ?: return false
val maxRange = event.sensor.maximumRange
// Treat as "near" if below max range and below a common 5cm threshold.
return value < maxRange && value < 5f
}
}

View File

@@ -0,0 +1,148 @@
package com.rosetta.messenger.network
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import com.rosetta.messenger.R
/**
* Manages call sounds (ringtone, calling, connected, end_call).
* Matches desktop CallProvider.tsx sound behavior.
*/
object CallSoundManager {
private const val TAG = "CallSoundManager"
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var currentSound: CallSound? = null
enum class CallSound {
RINGTONE, // Incoming call — loops
CALLING, // Outgoing call — loops
CONNECTED, // Call connected — plays once
END_CALL // Call ended — plays once
}
fun initialize(context: Context) {
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
vm?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
}
/**
* Play a call sound. Stops any currently playing sound first.
* RINGTONE and CALLING loop. CONNECTED and END_CALL play once.
*/
fun play(context: Context, sound: CallSound) {
stop()
currentSound = sound
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
val ringerMode = audioManager?.ringerMode ?: AudioManager.RINGER_MODE_NORMAL
val allowAudible = ringerMode == AudioManager.RINGER_MODE_NORMAL
val allowVibration =
sound == CallSound.RINGTONE &&
(ringerMode == AudioManager.RINGER_MODE_NORMAL ||
ringerMode == AudioManager.RINGER_MODE_VIBRATE)
if (!allowAudible) {
if (allowVibration) {
startVibration()
}
Log.i(TAG, "Skip audible $sound due to ringerMode=$ringerMode")
return
}
val resId = when (sound) {
CallSound.RINGTONE -> R.raw.call_ringtone
CallSound.CALLING -> R.raw.call_calling
CallSound.CONNECTED -> R.raw.call_connected
CallSound.END_CALL -> R.raw.call_end
}
val loop = sound == CallSound.RINGTONE || sound == CallSound.CALLING
try {
val player = MediaPlayer.create(context, resId)
if (player == null) {
Log.e(TAG, "Failed to create MediaPlayer for $sound")
return
}
player.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(
if (sound == CallSound.RINGTONE)
AudioAttributes.USAGE_NOTIFICATION_RINGTONE
else
AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING
)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
player.isLooping = loop
player.setOnCompletionListener {
if (!loop) {
stop()
}
}
player.start()
mediaPlayer = player
// Vibrate for incoming calls
if (allowVibration) {
startVibration()
}
Log.i(TAG, "Playing $sound (loop=$loop)")
} catch (e: Exception) {
Log.e(TAG, "Error playing $sound", e)
}
}
/**
* Stop any currently playing sound and vibration.
*/
fun stop() {
try {
mediaPlayer?.let { player ->
if (player.isPlaying) player.stop()
player.release()
}
} catch (_: Exception) {}
mediaPlayer = null
currentSound = null
stopVibration()
}
private fun startVibration() {
try {
val v = vibrator ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val pattern = longArrayOf(0, 500, 300, 500, 300, 500, 1000)
v.vibrate(VibrationEffect.createWaveform(pattern, 0))
} else {
@Suppress("DEPRECATION")
v.vibrate(longArrayOf(0, 500, 300, 500, 300, 500, 1000), 0)
}
} catch (_: Exception) {}
}
private fun stopVibration() {
try {
vibrator?.cancel()
} catch (_: Exception) {}
}
}

View File

@@ -1,6 +1,5 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import kotlinx.coroutines.*
@@ -20,6 +19,7 @@ data class FileDownloadState(
enum class FileDownloadStatus {
QUEUED,
DOWNLOADING,
PAUSED,
DECRYPTING,
DONE,
ERROR
@@ -35,6 +35,29 @@ object FileDownloadManager {
/** Текущие Job'ы — чтобы не запускать повторно */
private val jobs = mutableMapOf<String, Job>()
/** Последние параметры скачивания — нужны для resume */
private val requests = mutableMapOf<String, DownloadRequest>()
/** Флаг, что cancel произошёл именно как user pause */
private val pauseRequested = mutableSetOf<String>()
/** Если пользователь очень быстро тапнул pause→resume, запускаем resume после фактической паузы */
private val resumeAfterPause = mutableSetOf<String>()
private data class DownloadRequest(
val attachmentId: String,
val downloadTag: String,
val transportServer: String,
val chachaKey: String,
val privateKey: String,
val accountPublicKey: String,
val fileName: String,
val savedFile: File
)
private fun encryptedPartFile(request: DownloadRequest): File {
val parent = request.savedFile.parentFile ?: request.savedFile.absoluteFile.parentFile
val safeId = request.attachmentId.take(32).replace(Regex("[^A-Za-z0-9._-]"), "_")
return File(parent, ".dl_${safeId}.part")
}
// ─── helpers ───
@@ -67,9 +90,16 @@ object FileDownloadManager {
*/
fun isDownloading(attachmentId: String): Boolean {
val state = _downloads.value[attachmentId] ?: return false
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
return state.status == FileDownloadStatus.QUEUED ||
state.status == FileDownloadStatus.DOWNLOADING ||
state.status == FileDownloadStatus.DECRYPTING
}
fun isPaused(attachmentId: String): Boolean =
_downloads.value[attachmentId]?.status == FileDownloadStatus.PAUSED
fun stateOf(attachmentId: String): FileDownloadState? = _downloads.value[attachmentId]
/**
* Возвращает Flow<FileDownloadState?> для конкретного attachment
*/
@@ -81,154 +111,272 @@ object FileDownloadManager {
* Скачивание продолжается даже если пользователь вышел из чата.
*/
fun download(
context: Context,
attachmentId: String,
downloadTag: String,
transportServer: String = "",
chachaKey: String,
privateKey: String,
accountPublicKey: String,
fileName: String,
savedFile: File
) {
// Уже в процессе?
if (jobs[attachmentId]?.isActive == true) return
val normalizedAccount = accountPublicKey.trim()
val savedPath = savedFile.absolutePath
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath)
jobs[attachmentId] = scope.launch {
try {
update(
attachmentId,
fileName,
FileDownloadStatus.DOWNLOADING,
0f,
normalizedAccount,
savedPath
)
// Запускаем polling прогресса из TransportManager
val progressJob = launch {
TransportManager.downloading.collect { list ->
val entry = list.find { it.id == attachmentId }
if (entry != null) {
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
val p = (entry.progress / 100f) * 0.8f
update(
attachmentId,
fileName,
FileDownloadStatus.DOWNLOADING,
p,
normalizedAccount,
savedPath
)
}
}
}
val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(chachaKey)) {
downloadGroupFile(
val request = DownloadRequest(
attachmentId = attachmentId,
downloadTag = downloadTag,
transportServer = transportServer.trim(),
chachaKey = chachaKey,
privateKey = privateKey,
accountPublicKey = accountPublicKey.trim(),
fileName = fileName,
savedFile = savedFile,
accountPublicKey = normalizedAccount,
savedPath = savedPath
savedFile = savedFile
)
} else {
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
fileName = fileName,
savedFile = savedFile,
accountPublicKey = normalizedAccount,
savedPath = savedPath
)
}
requests[attachmentId] = request
startDownload(request)
}
progressJob.cancel()
fun pause(attachmentId: String) {
val current = _downloads.value[attachmentId] ?: return
if (
current.status == FileDownloadStatus.DONE ||
current.status == FileDownloadStatus.ERROR
) return
if (success) {
pauseRequested.add(attachmentId)
val pausedProgress = current.progress.coerceIn(0f, 0.98f)
update(
attachmentId,
fileName,
FileDownloadStatus.DONE,
1f,
normalizedAccount,
savedPath
)
} else {
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
id = attachmentId,
fileName = current.fileName,
status = FileDownloadStatus.PAUSED,
progress = pausedProgress,
accountPublicKey = current.accountPublicKey,
savedPath = current.savedPath
)
TransportManager.cancelDownload(attachmentId)
jobs[attachmentId]?.cancel()
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} catch (_: OutOfMemoryError) {
System.gc()
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} finally {
jobs.remove(attachmentId)
// Автоочистка через 5 секунд после завершения
scope.launch {
delay(5000)
_downloads.update { it - attachmentId }
}
}
fun resume(attachmentId: String) {
val request = requests[attachmentId] ?: return
if (jobs[attachmentId]?.isActive == true) {
resumeAfterPause.add(attachmentId)
return
}
pauseRequested.remove(attachmentId)
startDownload(request)
}
/**
* Отменяет скачивание
*/
fun cancel(attachmentId: String) {
requests[attachmentId]?.let { req ->
encryptedPartFile(req).delete()
}
pauseRequested.remove(attachmentId)
resumeAfterPause.remove(attachmentId)
requests.remove(attachmentId)
TransportManager.cancelDownload(attachmentId)
jobs[attachmentId]?.cancel()
jobs.remove(attachmentId)
_downloads.update { it - attachmentId }
}
private fun startDownload(request: DownloadRequest) {
val attachmentId = request.attachmentId
if (jobs[attachmentId]?.isActive == true) return
pauseRequested.remove(attachmentId)
val savedPath = request.savedFile.absolutePath
val encryptedPart = encryptedPartFile(request)
val resumeBase =
(_downloads.value[attachmentId]
?.takeIf { it.status == FileDownloadStatus.PAUSED }
?.progress
?: 0f).coerceIn(0f, 0.8f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.QUEUED,
resumeBase,
request.accountPublicKey,
savedPath
)
jobs[attachmentId] = scope.launch {
var progressJob: Job? = null
try {
update(
attachmentId,
request.fileName,
FileDownloadStatus.DOWNLOADING,
resumeBase,
request.accountPublicKey,
savedPath
)
// Запускаем polling прогресса из TransportManager.
// Держим прогресс монотонным, чтобы он не дёргался вниз.
progressJob = launch {
TransportManager.downloading.collect { list ->
val entry = list.find { it.id == attachmentId } ?: return@collect
val rawCdn = (entry.progress / 100f) * 0.8f
val current = _downloads.value[attachmentId]?.progress ?: 0f
val stable = maxOf(current, rawCdn).coerceIn(0f, 0.8f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.DOWNLOADING,
stable,
request.accountPublicKey,
savedPath
)
}
}
val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(request.chachaKey)) {
downloadGroupFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
transportServer = request.transportServer,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
savedFile = request.savedFile,
encryptedPartFile = encryptedPart,
accountPublicKey = request.accountPublicKey,
savedPath = savedPath
)
} else {
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = request.downloadTag,
transportServer = request.transportServer,
chachaKey = request.chachaKey,
privateKey = request.privateKey,
fileName = request.fileName,
savedFile = request.savedFile,
encryptedPartFile = encryptedPart,
accountPublicKey = request.accountPublicKey,
savedPath = savedPath
)
}
}
if (success) {
update(
attachmentId,
request.fileName,
FileDownloadStatus.DONE,
1f,
request.accountPublicKey,
savedPath
)
encryptedPart.delete()
requests.remove(attachmentId)
} else {
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
}
} catch (e: CancellationException) {
if (pauseRequested.remove(attachmentId)) {
val current = _downloads.value[attachmentId]
val pausedProgress = (current?.progress ?: resumeBase).coerceIn(0f, 0.98f)
update(
attachmentId,
request.fileName,
FileDownloadStatus.PAUSED,
pausedProgress,
request.accountPublicKey,
savedPath
)
} else {
throw e
}
} catch (e: Exception) {
e.printStackTrace()
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
} catch (_: OutOfMemoryError) {
System.gc()
update(
attachmentId,
request.fileName,
FileDownloadStatus.ERROR,
0f,
request.accountPublicKey,
savedPath
)
} finally {
progressJob?.cancel()
jobs.remove(attachmentId)
if (resumeAfterPause.remove(attachmentId)) {
scope.launch { startDownload(request) }
}
// Автоочистка только терминальных состояний.
val terminalStatus = _downloads.value[attachmentId]?.status
if (
terminalStatus == FileDownloadStatus.DONE ||
terminalStatus == FileDownloadStatus.ERROR
) {
scope.launch {
delay(5000)
val current = _downloads.value[attachmentId]
if (
current?.status == FileDownloadStatus.DONE ||
current?.status == FileDownloadStatus.ERROR
) {
_downloads.update { it - attachmentId }
}
}
}
}
}
}
// ─── internal download logic (moved from FileAttachment) ───
private suspend fun downloadGroupFile(
attachmentId: String,
downloadTag: String,
transportServer: String,
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File,
encryptedPartFile: File,
accountPublicKey: String,
savedPath: String
): Boolean {
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
val encryptedFile =
TransportManager.downloadFileRawResumable(
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes,
transportServer = transportServer
)
val encryptedContent = withContext(Dispatchers.IO) {
encryptedFile.readText(Charsets.UTF_8)
}
update(
attachmentId,
fileName,
@@ -279,15 +427,24 @@ object FileDownloadManager {
private suspend fun downloadDirectFile(
attachmentId: String,
downloadTag: String,
transportServer: String,
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File,
encryptedPartFile: File,
accountPublicKey: String,
savedPath: String
): Boolean {
// Streaming: скачиваем во temp file
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
val tempFile =
TransportManager.downloadFileRawResumable(
id = attachmentId,
tag = downloadTag,
targetFile = encryptedPartFile,
resumeFromBytes = resumeBytes,
transportServer = transportServer
)
update(
attachmentId,
fileName,
@@ -316,7 +473,7 @@ object FileDownloadManager {
savedFile
)
} finally {
tempFile.delete()
encryptedPartFile.delete()
}
}
update(
@@ -339,12 +496,19 @@ object FileDownloadManager {
savedPath: String
) {
_downloads.update { map ->
val previous = map[id]
val normalizedProgress =
when (status) {
FileDownloadStatus.DONE -> 1f
FileDownloadStatus.ERROR -> progress.coerceIn(0f, 1f)
else -> maxOf(progress, previous?.progress ?: 0f).coerceIn(0f, 1f)
}
map + (
id to FileDownloadState(
attachmentId = id,
fileName = fileName,
status = status,
progress = progress,
progress = normalizedProgress,
accountPublicKey = accountPublicKey,
savedPath = savedPath
)

View File

@@ -10,5 +10,9 @@ data class MessageAttachment(
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
val width: Int = 0,
val height: Int = 0,
val localUri: String = "" // 🚀 Локальный URI для мгновенного отображения (optimistic UI)
val localUri: String = "", // 🚀 Локальный URI для мгновенного отображения (optimistic UI)
val transportTag: String = "",
val transportServer: String = "",
val encodedFor: String = "",
val encoder: String = ""
)

View File

@@ -6,7 +6,8 @@ enum class HandshakeState(val value: Int) {
companion object {
fun fromValue(value: Int): HandshakeState {
return entries.firstOrNull { it.value == value } ?: COMPLETED
// Fail-safe: unknown value must not auto-authenticate.
return entries.firstOrNull { it.value == value } ?: NEED_DEVICE_VERIFICATION
}
}
}

View File

@@ -0,0 +1,47 @@
package com.rosetta.messenger.network
data class IceServer(
val url: String,
val username: String,
val credential: String,
val transport: String
)
/**
* ICE servers packet (ID: 0x1C / 28).
* Wire format mirrors desktop packet.ice.servers.ts.
*/
class PacketIceServers : Packet() {
var iceServers: List<IceServer> = emptyList()
override fun getPacketId(): Int = 0x1C
override fun receive(stream: Stream) {
val count = stream.readInt16()
val servers = ArrayList<IceServer>(count.coerceAtLeast(0))
for (i in 0 until count) {
servers.add(
IceServer(
url = stream.readString(),
username = stream.readString(),
credential = stream.readString(),
transport = stream.readString()
)
)
}
iceServers = servers
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt16(iceServers.size)
for (server in iceServers) {
stream.writeString(server.url)
stream.writeString(server.username)
stream.writeString(server.credential)
stream.writeString(server.transport)
}
return stream
}
}

View File

@@ -15,29 +15,48 @@ class PacketMessage : Packet() {
var aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя
var attachments: List<MessageAttachment> = emptyList()
private data class ParsedPacketMessage(
val fromPublicKey: String,
val toPublicKey: String,
val content: String,
val chachaKey: String,
val timestamp: Long,
val privateKey: String,
val messageId: String,
val attachments: List<MessageAttachment>,
val aesChachaKey: String
)
override fun getPacketId(): Int = 0x06
override fun receive(stream: Stream) {
fromPublicKey = stream.readString()
toPublicKey = stream.readString()
content = stream.readString()
chachaKey = stream.readString()
timestamp = stream.readInt64()
privateKey = stream.readString()
messageId = stream.readString()
val attachmentCount = stream.readInt8()
val attachmentsList = mutableListOf<MessageAttachment>()
for (i in 0 until attachmentCount) {
attachmentsList.add(MessageAttachment(
id = stream.readString(),
preview = stream.readString(),
blob = stream.readString(),
type = AttachmentType.fromInt(stream.readInt8())
))
val startPointer = stream.getReadPointerBits()
val parsed =
listOf(4, 2, 0)
.asSequence()
.mapNotNull { attachmentMetaFieldCount ->
stream.setReadPointerBits(startPointer)
parseFromStream(stream, attachmentMetaFieldCount)
?.takeIf { !stream.hasRemainingBits() }
}
attachments = attachmentsList
aesChachaKey = stream.readString()
.firstOrNull()
?: run {
stream.setReadPointerBits(startPointer)
parseFromStream(stream, 2)
?: throw IllegalStateException(
"Failed to parse PacketMessage payload"
)
}
fromPublicKey = parsed.fromPublicKey
toPublicKey = parsed.toPublicKey
content = parsed.content
chachaKey = parsed.chachaKey
timestamp = parsed.timestamp
privateKey = parsed.privateKey
messageId = parsed.messageId
attachments = parsed.attachments
aesChachaKey = parsed.aesChachaKey
}
override fun send(): Stream {
@@ -57,9 +76,80 @@ class PacketMessage : Packet() {
stream.writeString(attachment.preview)
stream.writeString(attachment.blob)
stream.writeInt8(attachment.type.value)
stream.writeString(attachment.transportTag)
stream.writeString(attachment.transportServer)
}
stream.writeString(aesChachaKey)
return stream
}
private fun parseFromStream(
parser: Stream,
attachmentMetaFieldCount: Int
): ParsedPacketMessage? {
return runCatching {
val parsedFromPublicKey = parser.readString()
val parsedToPublicKey = parser.readString()
val parsedContent = parser.readString()
val parsedChachaKey = parser.readString()
val parsedTimestamp = parser.readInt64()
val parsedPrivateKey = parser.readString()
val parsedMessageId = parser.readString()
val attachmentCount = parser.readInt8().coerceAtLeast(0)
val parsedAttachments = ArrayList<MessageAttachment>(attachmentCount)
repeat(attachmentCount) {
val id = parser.readString()
val preview = parser.readString()
val blob = parser.readString()
val type = AttachmentType.fromInt(parser.readInt8())
val transportTag: String
val transportServer: String
val encodedFor: String
val encoder: String
if (attachmentMetaFieldCount >= 2) {
transportTag = parser.readString()
transportServer = parser.readString()
} else {
transportTag = ""
transportServer = ""
}
if (attachmentMetaFieldCount >= 4) {
encodedFor = parser.readString()
encoder = parser.readString()
} else {
encodedFor = ""
encoder = ""
}
parsedAttachments.add(
MessageAttachment(
id = id,
preview = preview,
blob = blob,
type = type,
transportTag = transportTag,
transportServer = transportServer,
encodedFor = encodedFor,
encoder = encoder
)
)
}
val parsedAesChachaKey = parser.readString()
ParsedPacketMessage(
fromPublicKey = parsedFromPublicKey,
toPublicKey = parsedToPublicKey,
content = parsedContent,
chachaKey = parsedChachaKey,
timestamp = parsedTimestamp,
privateKey = parsedPrivateKey,
messageId = parsedMessageId,
attachments = parsedAttachments,
aesChachaKey = parsedAesChachaKey
)
}.getOrNull()
}
}

View File

@@ -8,14 +8,24 @@ enum class PushNotificationAction(val value: Int) {
UNSUBSCRIBE(1)
}
/**
* Token type for push notifications.
*/
enum class PushTokenType(val value: Int) {
FCM(0),
VOIP_APNS(1)
}
/**
* Push Notification packet (ID: 0x10)
* Отправка FCM/APNS токена на сервер для push-уведомлений (новый формат)
* Совместим с React Native версией
* Отправка FCM/APNS токена на сервер для push-уведомлений
* Передаёт tokenType (fcm/voip) и deviceId
*/
class PacketPushNotification : Packet() {
var notificationsToken: String = ""
var action: PushNotificationAction = PushNotificationAction.SUBSCRIBE
var tokenType: PushTokenType = PushTokenType.FCM
var deviceId: String = ""
override fun getPacketId(): Int = 0x10
@@ -25,6 +35,11 @@ class PacketPushNotification : Packet() {
1 -> PushNotificationAction.UNSUBSCRIBE
else -> PushNotificationAction.SUBSCRIBE
}
tokenType = when (stream.readInt8()) {
1 -> PushTokenType.VOIP_APNS
else -> PushTokenType.FCM
}
deviceId = stream.readString()
}
override fun send(): Stream {
@@ -32,6 +47,8 @@ class PacketPushNotification : Packet() {
stream.writeInt16(getPacketId())
stream.writeString(notificationsToken)
stream.writeInt8(action.value)
stream.writeInt8(tokenType.value)
stream.writeString(deviceId)
return stream
}
}

View File

@@ -1,31 +0,0 @@
package com.rosetta.messenger.network
/**
* Push Token packet (ID: 0x0A) - DEPRECATED
* Старый формат, заменен на PacketPushNotification (0x10)
*/
class PacketPushToken : Packet() {
var privateKey: String = ""
var publicKey: String = ""
var pushToken: String = ""
var platform: String = "android" // "android" или "ios"
override fun getPacketId(): Int = 0x0A
override fun receive(stream: Stream) {
privateKey = stream.readString()
publicKey = stream.readString()
pushToken = stream.readString()
platform = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(privateKey)
stream.writeString(publicKey)
stream.writeString(pushToken)
stream.writeString(platform)
return stream
}
}

View File

@@ -0,0 +1,85 @@
package com.rosetta.messenger.network
enum class SignalType(val value: Int) {
CALL(0),
KEY_EXCHANGE(1),
ACTIVE_CALL(2),
END_CALL(3),
ACTIVE(4),
END_CALL_BECAUSE_PEER_DISCONNECTED(5),
END_CALL_BECAUSE_BUSY(6),
ACCEPT(7),
RINGING_TIMEOUT(8);
companion object {
fun fromValue(value: Int): SignalType =
entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown SignalType code: $value")
}
}
/**
* Signaling packet (ID: 0x1A / 26).
* Wire format mirrors desktop packet.signal.peer.ts.
*/
class PacketSignalPeer : Packet() {
var src: String = ""
var dst: String = ""
var sharedPublic: String = ""
var signalType: SignalType = SignalType.CALL
var callId: String = ""
var joinToken: String = ""
override fun getPacketId(): Int = 0x1A
override fun receive(stream: Stream) {
signalType = SignalType.fromValue(stream.readInt8())
if (
signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED ||
signalType == SignalType.RINGING_TIMEOUT
) {
return
}
src = stream.readString()
dst = stream.readString()
if (signalType == SignalType.KEY_EXCHANGE) {
sharedPublic = stream.readString()
}
if (
signalType == SignalType.CALL ||
signalType == SignalType.ACCEPT ||
signalType == SignalType.END_CALL
) {
callId = stream.readString()
joinToken = stream.readString()
}
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt8(signalType.value)
if (
signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED ||
signalType == SignalType.RINGING_TIMEOUT
) {
return stream
}
stream.writeString(src)
stream.writeString(dst)
if (signalType == SignalType.KEY_EXCHANGE) {
stream.writeString(sharedPublic)
}
if (
signalType == SignalType.CALL ||
signalType == SignalType.ACCEPT ||
signalType == SignalType.END_CALL
) {
stream.writeString(callId)
stream.writeString(joinToken)
}
return stream
}
}

View File

@@ -0,0 +1,108 @@
package com.rosetta.messenger.network
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
/**
* Centralized packet subscription registry.
*
* Guarantees exactly one low-level Protocol.waitPacket subscription per packet id
* and fans out packets to:
* 1) legacy callback listeners (waitPacket/unwaitPacket API),
* 2) SharedFlow collectors in network/UI layers.
*/
class PacketSubscriptionRegistry(
private val protocolProvider: () -> Protocol,
private val scope: CoroutineScope,
private val addLog: (String) -> Unit
) {
private data class PacketBus(
val packetId: Int,
val callbacks: CopyOnWriteArrayList<(Packet) -> Unit>,
val sharedFlow: MutableSharedFlow<Packet>,
val protocolBridge: (Packet) -> Unit
)
private val buses = ConcurrentHashMap<Int, PacketBus>()
private fun ensureBus(packetId: Int): PacketBus {
buses[packetId]?.let { return it }
val callbacks = CopyOnWriteArrayList<(Packet) -> Unit>()
val sharedFlow =
MutableSharedFlow<Packet>(
replay = 0,
extraBufferCapacity = 128,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val bridge: (Packet) -> Unit = { packet ->
if (!sharedFlow.tryEmit(packet)) {
scope.launch { sharedFlow.emit(packet) }
}
callbacks.forEach { callback ->
runCatching { callback(packet) }
.onFailure { error ->
addLog("❌ PacketSubscriptionRegistry callback error: ${error.message}")
}
}
}
val created =
PacketBus(
packetId = packetId,
callbacks = callbacks,
sharedFlow = sharedFlow,
protocolBridge = bridge
)
val existing = buses.putIfAbsent(packetId, created)
if (existing == null) {
protocolProvider().waitPacket(packetId, bridge)
addLog(
"🧭 PacketSubscriptionRegistry attached id=0x${packetId.toString(16).uppercase()}"
)
return created
}
return existing
}
fun flow(packetId: Int): SharedFlow<Packet> = ensureBus(packetId).sharedFlow.asSharedFlow()
fun addCallback(packetId: Int, callback: (Packet) -> Unit) {
val bus = ensureBus(packetId)
if (bus.callbacks.contains(callback)) {
addLog(
"📝 registry waitPacket(0x${packetId.toString(16)}) skipped duplicate callback; callbacks=${bus.callbacks.size}"
)
return
}
bus.callbacks.add(callback)
addLog(
"📝 registry waitPacket(0x${packetId.toString(16)}) callback registered; callbacks=${bus.callbacks.size}"
)
}
fun removeCallback(packetId: Int, callback: (Packet) -> Unit) {
val bus = buses[packetId] ?: return
bus.callbacks.remove(callback)
addLog(
"📝 registry unwaitPacket(0x${packetId.toString(16)}) callback removed; callbacks=${bus.callbacks.size}"
)
}
fun destroy() {
buses.forEach { (packetId, bus) ->
runCatching {
protocolProvider().unwaitPacket(packetId, bus.protocolBridge)
}
}
buses.clear()
}
}

View File

@@ -0,0 +1,37 @@
package com.rosetta.messenger.network
enum class WebRTCSignalType(val value: Int) {
OFFER(0),
ANSWER(1),
ICE_CANDIDATE(2);
companion object {
fun fromValue(value: Int): WebRTCSignalType =
entries.firstOrNull { it.value == value }
?: throw IllegalArgumentException("Unknown WebRTCSignalType code: $value")
}
}
/**
* WebRTC exchange packet (ID: 0x1B / 27).
* Wire format mirrors desktop packet.webrtc.ts.
*/
class PacketWebRTC : Packet() {
var signalType: WebRTCSignalType = WebRTCSignalType.OFFER
var sdpOrCandidate: String = ""
override fun getPacketId(): Int = 0x1B
override fun receive(stream: Stream) {
signalType = WebRTCSignalType.fromValue(stream.readInt8())
sdpOrCandidate = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt8(signalType.value)
stream.writeString(sdpOrCandidate)
return stream
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
package com.rosetta.messenger.network
/**
* Infrastructure adapter contract used by repositories.
*
* Kept intentionally narrow and transport-oriented to avoid direct repository -> runtime wiring
* while preserving lazy runtime resolution through Provider in DI.
*/
interface ProtocolClient {
fun send(packet: Packet)
fun sendMessageWithRetry(packet: PacketMessage)
fun addLog(message: String)
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
}

View File

@@ -0,0 +1,33 @@
package com.rosetta.messenger.network
enum class ConnectionLifecycleState {
DISCONNECTED,
CONNECTING,
HANDSHAKING,
AUTHENTICATED,
BOOTSTRAPPING,
READY,
DEVICE_VERIFICATION_REQUIRED
}
sealed interface ConnectionEvent {
data class InitializeAccount(val publicKey: String, val privateKey: String) : ConnectionEvent
data class Connect(val reason: String) : ConnectionEvent
data class FastReconnect(val reason: String) : ConnectionEvent
data class Disconnect(val reason: String, val clearCredentials: Boolean) : ConnectionEvent
data class Authenticate(val publicKey: String, val privateHash: String) : ConnectionEvent
data class ProtocolStateChanged(val state: ProtocolState) : ConnectionEvent
data class SendPacket(val packet: Packet) : ConnectionEvent
data class SyncCompleted(val reason: String) : ConnectionEvent
data class OwnProfileResolved(val publicKey: String) : ConnectionEvent
data class OwnProfileFallbackTimeout(val sessionGeneration: Long) : ConnectionEvent
}
data class ConnectionBootstrapContext(
val accountPublicKey: String = "",
val accountInitialized: Boolean = false,
val protocolState: ProtocolState = ProtocolState.DISCONNECTED,
val authenticated: Boolean = false,
val syncCompleted: Boolean = false,
val ownProfileResolved: Boolean = false
)

View File

@@ -0,0 +1,48 @@
package com.rosetta.messenger.network
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
class ProtocolConnectionSupervisor(
private val scope: CoroutineScope,
private val onEvent: suspend (ConnectionEvent) -> Unit,
private val onError: (Throwable) -> Unit,
private val addLog: (String) -> Unit
) {
private val eventChannel = Channel<ConnectionEvent>(Channel.UNLIMITED)
private val lock = Any()
@Volatile private var job: Job? = null
fun start() {
if (job?.isActive == true) return
synchronized(lock) {
if (job?.isActive == true) return
job =
scope.launch {
for (event in eventChannel) {
try {
onEvent(event)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
addLog("❌ ConnectionSupervisor event failed: ${e.message}")
onError(e)
}
}
}
addLog("🧠 ConnectionSupervisor started")
}
}
fun post(event: ConnectionEvent) {
start()
val result = eventChannel.trySend(event)
if (result.isFailure) {
scope.launch { eventChannel.send(event) }
}
}
}

View File

@@ -0,0 +1,156 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.ProtocolGateway
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ProtocolRuntime @Inject constructor(
private val runtimeComposition: RuntimeComposition,
private val messageRepository: MessageRepository,
private val groupRepository: GroupRepository,
private val accountManager: AccountManager
) : ProtocolRuntimePort, ProtocolGateway {
init {
bindDependencies()
}
private val connectionControlApi by lazy { runtimeComposition.connectionControlApi() }
private val directoryApi by lazy { runtimeComposition.directoryApi() }
private val packetIoApi by lazy { runtimeComposition.packetIoApi() }
override val state: StateFlow<ProtocolState> get() = runtimeComposition.state
override val syncInProgress: StateFlow<Boolean> get() = runtimeComposition.syncInProgress
override val pendingDeviceVerification: StateFlow<DeviceEntry?> get() =
runtimeComposition.pendingDeviceVerification
override val typingUsers: StateFlow<Set<String>> get() = runtimeComposition.typingUsers
override val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> get() =
runtimeComposition.typingUsersByDialogSnapshot
override val debugLogs: StateFlow<List<String>> get() = runtimeComposition.debugLogs
override val ownProfileUpdated: StateFlow<Long> get() = runtimeComposition.ownProfileUpdated
override fun initialize(context: Context) {
bindDependencies()
connectionControlApi.initialize(context)
}
override fun initializeAccount(publicKey: String, privateKey: String) =
connectionControlApi.initializeAccount(publicKey, privateKey)
override fun connect() = connectionControlApi.connect()
override fun authenticate(publicKey: String, privateHash: String) =
connectionControlApi.authenticate(publicKey, privateHash)
override fun reconnectNowIfNeeded(reason: String) =
connectionControlApi.reconnectNowIfNeeded(reason)
override fun disconnect() = connectionControlApi.disconnect()
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()
override fun subscribePushTokenIfAvailable(forceToken: String?) =
connectionControlApi.subscribePushToken(forceToken)
override fun addLog(message: String) = runtimeComposition.addLog(message)
override fun enableUILogs(enabled: Boolean) = runtimeComposition.enableUILogs(enabled)
override fun clearLogs() = runtimeComposition.clearLogs()
override fun resolveOutgoingRetry(messageId: String) =
packetIoApi.resolveOutgoingRetry(messageId)
override fun getCachedUserByUsername(username: String): SearchUser? =
directoryApi.getCachedUserByUsername(username)
override fun getCachedUserName(publicKey: String): String? =
directoryApi.getCachedUserName(publicKey)
override fun getCachedUserInfo(publicKey: String): SearchUser? =
directoryApi.getCachedUserInfo(publicKey)
override fun acceptDevice(deviceId: String) = directoryApi.acceptDevice(deviceId)
override fun declineDevice(deviceId: String) = directoryApi.declineDevice(deviceId)
override fun send(packet: Packet) = packetIoApi.send(packet)
override fun sendPacket(packet: Packet) = packetIoApi.sendPacket(packet)
override fun sendMessageWithRetry(packet: PacketMessage) =
packetIoApi.sendMessageWithRetry(packet)
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
packetIoApi.waitPacket(packetId, callback)
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
packetIoApi.unwaitPacket(packetId, callback)
override fun packetFlow(packetId: Int): SharedFlow<Packet> =
packetIoApi.packetFlow(packetId)
override fun notifyOwnProfileUpdated() = directoryApi.notifyOwnProfileUpdated()
override fun restoreAuthFromStoredCredentials(
preferredPublicKey: String?,
reason: String
): Boolean = connectionControlApi.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
directoryApi.resolveUserName(publicKey, timeoutMs)
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
directoryApi.resolveUserInfo(publicKey, timeoutMs)
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
directoryApi.searchUsers(query, timeoutMs)
override fun requestIceServers() = packetIoApi.requestIceServers()
override fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit =
packetIoApi.waitCallSignal(callback)
override fun unwaitCallSignal(callback: (Packet) -> Unit) =
packetIoApi.unwaitCallSignal(callback)
override fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit =
packetIoApi.waitWebRtcSignal(callback)
override fun unwaitWebRtcSignal(callback: (Packet) -> Unit) =
packetIoApi.unwaitWebRtcSignal(callback)
override fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit =
packetIoApi.waitIceServers(callback)
override fun unwaitIceServers(callback: (Packet) -> Unit) =
packetIoApi.unwaitIceServers(callback)
override fun sendCallSignal(
signalType: SignalType,
src: String,
dst: String,
sharedPublic: String,
callId: String,
joinToken: String
) = packetIoApi.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
override fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) =
packetIoApi.sendWebRtcSignal(signalType, sdpOrCandidate)
private fun bindDependencies() {
runtimeComposition.bindDependencies(
messageRepository = messageRepository,
groupRepository = groupRepository,
accountManager = accountManager
)
}
}

View File

@@ -0,0 +1,60 @@
package com.rosetta.messenger.network
import kotlinx.coroutines.flow.StateFlow
/**
* Stable runtime port for layers that are not created by Hilt (object managers/services).
*/
interface ProtocolRuntimePort {
val state: StateFlow<ProtocolState>
val debugLogs: StateFlow<List<String>>
fun addLog(message: String)
fun send(packet: Packet)
fun sendPacket(packet: Packet)
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
fun requestIceServers()
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit
fun unwaitCallSignal(callback: (Packet) -> Unit)
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit
fun unwaitWebRtcSignal(callback: (Packet) -> Unit)
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit
fun unwaitIceServers(callback: (Packet) -> Unit)
fun getCachedUserInfo(publicKey: String): SearchUser?
fun isAuthenticated(): Boolean
fun restoreAuthFromStoredCredentials(
preferredPublicKey: String? = null,
reason: String = "background_restore"
): Boolean
fun reconnectNowIfNeeded(reason: String = "foreground_resume")
fun sendCallSignal(
signalType: SignalType,
src: String = "",
dst: String = "",
sharedPublic: String = "",
callId: String = "",
joinToken: String = ""
)
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String)
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser?
}
object ProtocolRuntimeAccess {
@Volatile private var runtime: ProtocolRuntimePort? = null
fun install(runtime: ProtocolRuntimePort) {
this.runtime = runtime
}
fun get(): ProtocolRuntimePort {
return runtime
?: error(
"ProtocolRuntimeAccess is not installed. Install runtime in RosettaApplication.onCreate() before using singleton managers."
)
}
fun isInstalled(): Boolean = runtime != null
}

View File

@@ -0,0 +1,88 @@
package com.rosetta.messenger.network
class ReadyPacketGate(
private val maxSize: Int,
private val ttlMs: Long
) {
private data class QueuedPacket(
val packet: Packet,
val accountPublicKey: String,
val queuedAtMs: Long
)
private val queue = ArrayDeque<QueuedPacket>()
fun clear(reason: String, addLog: (String) -> Unit) {
val clearedCount =
synchronized(queue) {
val count = queue.size
queue.clear()
count
}
if (clearedCount > 0) {
addLog("🧹 READY-GATE queue cleared: $clearedCount packet(s), reason=$reason")
}
}
fun enqueue(
packet: Packet,
accountPublicKey: String,
state: ConnectionLifecycleState,
shortKeyForLog: (String) -> String,
addLog: (String) -> Unit
) {
val now = System.currentTimeMillis()
val packetId = packet.getPacketId()
synchronized(queue) {
while (queue.isNotEmpty()) {
val oldest = queue.first()
if (now - oldest.queuedAtMs <= ttlMs) break
queue.removeFirst()
}
while (queue.size >= maxSize) {
queue.removeFirst()
}
queue.addLast(
QueuedPacket(
packet = packet,
accountPublicKey = accountPublicKey,
queuedAtMs = now
)
)
}
addLog(
"📦 READY-GATE queued id=0x${packetId.toString(16)} state=$state account=${shortKeyForLog(accountPublicKey)}"
)
}
fun drainForAccount(
activeAccountKey: String,
reason: String,
addLog: (String) -> Unit
): List<Packet> {
if (activeAccountKey.isBlank()) return emptyList()
val now = System.currentTimeMillis()
val packetsToSend = mutableListOf<Packet>()
synchronized(queue) {
val iterator = queue.iterator()
while (iterator.hasNext()) {
val queued = iterator.next()
val isExpired = now - queued.queuedAtMs > ttlMs
val accountMatches =
queued.accountPublicKey.isBlank() ||
queued.accountPublicKey.equals(activeAccountKey, ignoreCase = true)
if (!isExpired && accountMatches) {
packetsToSend += queued.packet
}
iterator.remove()
}
}
if (packetsToSend.isNotEmpty()) {
addLog("📬 READY-GATE flush: ${packetsToSend.size} packet(s), reason=$reason")
}
return packetsToSend
}
}

View File

@@ -0,0 +1,501 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.network.connection.AuthBootstrapCoordinator
import com.rosetta.messenger.network.connection.AuthRestoreService
import com.rosetta.messenger.network.connection.ProtocolAccountSessionCoordinator
import com.rosetta.messenger.network.connection.ConnectionOrchestrator
import com.rosetta.messenger.network.connection.DeviceRuntimeService
import com.rosetta.messenger.network.connection.OwnProfileSyncService
import com.rosetta.messenger.network.connection.ProtocolDebugLogService
import com.rosetta.messenger.network.connection.ProtocolLifecycleCoordinator
import com.rosetta.messenger.network.connection.ProtocolPostAuthBootstrapCoordinator
import com.rosetta.messenger.network.connection.ReadyPacketDispatchCoordinator
import com.rosetta.messenger.network.connection.RuntimeInitializationCoordinator
import com.rosetta.messenger.network.connection.RuntimeShutdownCoordinator
import com.rosetta.messenger.session.IdentityStore
import com.rosetta.messenger.utils.MessageLogger
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.*
/**
* Singleton manager for Protocol instance
* Ensures single connection across the app
*/
class RuntimeComposition {
private val TAG = "ProtocolRuntime"
private val MANUAL_SYNC_BACKTRACK_MS = 120_000L
private val SYNC_REQUEST_TIMEOUT_MS = 12_000L
private val MAX_DEBUG_LOGS = 600
private val DEBUG_LOG_FLUSH_DELAY_MS = 60L
private val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
private val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
private val PROTOCOL_TRACE_FILE_NAME = "protocol_wire_log.txt"
private val PROTOCOL_TRACE_MAX_BYTES = 2_000_000L
private val PROTOCOL_TRACE_KEEP_BYTES = 1_200_000
private val NETWORK_WAIT_TIMEOUT_MS = 20_000L
private val BOOTSTRAP_OWN_PROFILE_FALLBACK_MS = 2_500L
private val READY_PACKET_QUEUE_MAX = 500
private val READY_PACKET_QUEUE_TTL_MS = 120_000L
// Desktop parity: use the same primary WebSocket endpoint as desktop client.
private val SERVER_ADDRESS = "wss://wss.rosetta.im"
private var messageRepository: MessageRepository? = null
private var groupRepository: GroupRepository? = null
private var accountManager: AccountManager? = null
private var appContext: Context? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val transportAssembly =
RuntimeTransportAssembly(
scope = scope,
networkWaitTimeoutMs = NETWORK_WAIT_TIMEOUT_MS,
serverAddress = SERVER_ADDRESS,
getAppContext = { appContext },
addLog = ::addLog,
onFastReconnectRequested = ::onFastReconnectRequested
)
private val networkConnectivityFacade = transportAssembly.networkConnectivityFacade
private val protocolInstanceManager = transportAssembly.protocolInstanceManager
private val packetSubscriptionFacade = transportAssembly.packetSubscriptionFacade
// Guard: prevent duplicate FCM token subscribe within a single session
@Volatile
private var lastSubscribedToken: String? = null
private val stateAssembly =
RuntimeStateAssembly(
scope = scope,
readyPacketQueueMax = READY_PACKET_QUEUE_MAX,
readyPacketQueueTtlMs = READY_PACKET_QUEUE_TTL_MS,
ownProfileFallbackTimeoutMs = BOOTSTRAP_OWN_PROFILE_FALLBACK_MS,
addLog = ::addLog,
shortKeyForLog = ::shortKeyForLog,
sendPacketDirect = { packet -> getProtocol().sendPacket(packet) },
onOwnProfileFallbackTimeout = ::onOwnProfileFallbackTimeoutEvent,
clearLastSubscribedTokenValue = { lastSubscribedToken = null }
)
private val bootstrapCoordinator get() = stateAssembly.bootstrapCoordinator
private val lifecycleStateMachine get() = stateAssembly.lifecycleStateMachine
private val lifecycleStateStore get() = stateAssembly.lifecycleStateStore
private val deviceRuntimeService =
DeviceRuntimeService(
getAppContext = { appContext },
sendPacket = ::send
)
private val connectionOrchestrator =
ConnectionOrchestrator(
hasActiveInternet = networkConnectivityFacade::hasActiveInternet,
waitForNetworkAndReconnect = networkConnectivityFacade::waitForNetworkAndReconnect,
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
getProtocol = ::getProtocol,
persistHandshakeCredentials = { publicKey, privateHash ->
accountManager?.setLastLoggedPublicKey(publicKey)
accountManager?.setLastLoggedPrivateKeyHash(privateHash)
},
buildHandshakeDevice = deviceRuntimeService::buildHandshakeDevice
)
private val ownProfileSyncService =
OwnProfileSyncService(
isPlaceholderAccountName = ::isPlaceholderAccountName,
updateAccountName = { publicKey, name ->
accountManager?.updateAccountName(publicKey, name)
},
updateAccountUsername = { publicKey, username ->
accountManager?.updateAccountUsername(publicKey, username)
}
)
private val authBootstrapCoordinator =
AuthBootstrapCoordinator(
scope = scope,
addLog = ::addLog
)
private val messagingAssembly by lazy {
RuntimeMessagingAssembly(
tag = TAG,
scope = scope,
typingIndicatorTimeoutMs = TYPING_INDICATOR_TIMEOUT_MS,
syncRequestTimeoutMs = SYNC_REQUEST_TIMEOUT_MS,
manualSyncBacktrackMs = MANUAL_SYNC_BACKTRACK_MS,
deviceRuntimeService = deviceRuntimeService,
ownProfileSyncService = ownProfileSyncService,
isAuthenticated = ::isAuthenticated,
getProtocolPublicKey = { getProtocol().getPublicKey().orEmpty() },
getProtocolPrivateHash = {
try {
getProtocol().getPrivateHash()
} catch (_: Exception) {
null
}
},
getMessageRepository = { messageRepository },
getGroupRepository = { groupRepository },
sendSearchPacket = ::sendSearchPacketViaPacketIo,
sendMessagePacket = ::sendMessagePacketViaPacketIo,
sendSyncPacket = ::sendSyncPacketViaPacketIo,
sendPacket = ::send,
waitPacket = ::waitPacket,
unwaitPacket = ::unwaitPacket,
addLog = ::addLog,
shortKeyForLog = ::shortKeyForLog,
shortTextForLog = ::shortTextForLog,
onSyncCompleted = ::finishSyncCycle,
onInboundTaskQueued = ::onInboundTaskQueued,
onInboundTaskFailure = ::markInboundProcessingFailure,
resolveOutgoingRetry = ::resolveOutgoingRetry,
isGroupDialogKey = ::isGroupDialogKey,
setTransportServer = TransportManager::setTransportServer,
onOwnProfileResolved = ::onOwnProfileResolvedEvent
)
}
private val packetRouter get() = messagingAssembly.packetRouter
private val outgoingMessagePipelineService get() = messagingAssembly.outgoingMessagePipelineService
private val presenceTypingService get() = messagingAssembly.presenceTypingService
private val inboundTaskQueueService get() = messagingAssembly.inboundTaskQueueService
private val syncCoordinator get() = messagingAssembly.syncCoordinator
private val callSignalBridge get() = messagingAssembly.callSignalBridge
private val inboundPacketHandlerRegistrar get() = messagingAssembly.inboundPacketHandlerRegistrar
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
stateAssembly.connectionLifecycleState
private val postAuthBootstrapCoordinator =
ProtocolPostAuthBootstrapCoordinator(
tag = TAG,
scope = scope,
authBootstrapCoordinator = authBootstrapCoordinator,
syncCoordinator = syncCoordinator,
ownProfileSyncService = ownProfileSyncService,
deviceRuntimeService = deviceRuntimeService,
getMessageRepository = { messageRepository },
getAppContext = { appContext },
getProtocolPublicKey = { getProtocol().getPublicKey() },
getProtocolPrivateHash = { getProtocol().getPrivateHash() },
sendPacket = ::send,
requestTransportServer = TransportManager::requestTransportServer,
requestUpdateServer = { com.rosetta.messenger.update.UpdateManager.requestSduServer() },
addLog = ::addLog,
shortKeyForLog = { value -> shortKeyForLog(value) },
getLastSubscribedToken = { lastSubscribedToken },
setLastSubscribedToken = { token -> lastSubscribedToken = token }
)
private val lifecycleCoordinator =
ProtocolLifecycleCoordinator(
stateStore = lifecycleStateStore,
syncCoordinator = syncCoordinator,
authBootstrapCoordinator = authBootstrapCoordinator,
addLog = ::addLog,
shortKeyForLog = { value -> shortKeyForLog(value) },
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
cancelAllOutgoingRetries = ::cancelAllOutgoingRetries,
recomputeConnectionLifecycleState = stateAssembly::recomputeConnectionLifecycleState,
onAuthenticated = { postAuthBootstrapCoordinator.runPostAuthBootstrap("state_authenticated") },
onSyncCompletedSideEffects = postAuthBootstrapCoordinator::handleSyncCompletedSideEffects,
updateOwnProfileResolved = { publicKey, reason ->
IdentityStore.updateOwnProfile(
publicKey = publicKey,
resolved = true,
reason = reason
)
}
)
private val readyPacketDispatchCoordinator =
ReadyPacketDispatchCoordinator(
bootstrapCoordinator = bootstrapCoordinator,
getConnectionLifecycleState = lifecycleStateMachine::currentState,
resolveAccountPublicKey = {
messageRepository?.getCurrentAccountKey()?.trim().orEmpty().ifBlank {
lifecycleStateMachine.bootstrapContext.accountPublicKey
}
},
sendPacketDirect = { packet -> getProtocol().sendPacket(packet) },
isAuthenticated = ::isAuthenticated,
hasActiveInternet = networkConnectivityFacade::hasActiveInternet,
waitForNetworkAndReconnect = networkConnectivityFacade::waitForNetworkAndReconnect,
reconnectNowIfNeeded = { reason -> getProtocol().reconnectNowIfNeeded(reason) }
)
private val accountSessionCoordinator =
ProtocolAccountSessionCoordinator(
stateStore = lifecycleStateStore,
syncCoordinator = syncCoordinator,
authBootstrapCoordinator = authBootstrapCoordinator,
presenceTypingService = presenceTypingService,
deviceRuntimeService = deviceRuntimeService,
getMessageRepository = { messageRepository },
getProtocolState = { state.value },
isProtocolAuthenticated = ::isAuthenticated,
addLog = ::addLog,
shortKeyForLog = { value -> shortKeyForLog(value) },
clearReadyPacketQueue = readyPacketDispatchCoordinator::clearReadyPacketQueue,
recomputeConnectionLifecycleState = stateAssembly::recomputeConnectionLifecycleState,
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
disconnectProtocol = protocolInstanceManager::disconnect,
tryRunPostAuthBootstrap = postAuthBootstrapCoordinator::runPostAuthBootstrap,
launchVersionUpdateCheck = {
scope.launch {
messageRepository?.checkAndSendVersionUpdateMessage()
}
}
)
private val initializationCoordinator =
RuntimeInitializationCoordinator(
ensureConnectionSupervisor = ::ensureConnectionSupervisor,
setupPacketHandlers = ::setupPacketHandlers,
setupStateMonitoring = ::setupStateMonitoring,
setAppContext = { context -> appContext = context },
hasBoundDependencies = {
messageRepository != null && groupRepository != null && accountManager != null
},
addLog = ::addLog
)
private val authRestoreService =
AuthRestoreService(
getAccountManager = { accountManager },
addLog = ::addLog,
shortKeyForLog = ::shortKeyForLog,
authenticate = ::authenticate
)
private val runtimeShutdownCoordinator =
RuntimeShutdownCoordinator(
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
destroyPacketSubscriptionRegistry = transportAssembly::destroyPacketSubscriptions,
destroyProtocolInstance = protocolInstanceManager::destroy,
clearMessageRepositoryInitialization = {
messageRepository?.clearInitialization()
},
clearPresenceTyping = presenceTypingService::clear,
clearDeviceRuntime = deviceRuntimeService::clear,
resetSyncCoordinator = syncCoordinator::resetForDisconnect,
resetAuthBootstrap = authBootstrapCoordinator::reset,
cancelRuntimeScope = scope::cancel
)
private val routingAssembly by lazy {
RuntimeRoutingAssembly(
scope = scope,
tag = TAG,
addLog = ::addLog,
handleInitializeAccount = accountSessionCoordinator::handleInitializeAccount,
handleConnect = connectionOrchestrator::handleConnect,
handleFastReconnect = connectionOrchestrator::handleFastReconnect,
handleDisconnect = accountSessionCoordinator::handleDisconnect,
handleAuthenticate = connectionOrchestrator::handleAuthenticate,
handleProtocolStateChanged = lifecycleCoordinator::handleProtocolStateChanged,
handleSendPacket = readyPacketDispatchCoordinator::handleSendPacket,
handleSyncCompleted = lifecycleCoordinator::handleSyncCompleted,
handleOwnProfileResolved = lifecycleCoordinator::handleOwnProfileResolved,
handleOwnProfileFallbackTimeout = lifecycleCoordinator::handleOwnProfileFallbackTimeout
)
}
private val connectionControlFacade by lazy {
RuntimeConnectionControlFacade(
postConnectionEvent = ::postConnectionEvent,
initializationCoordinator = initializationCoordinator,
syncCoordinator = syncCoordinator,
authRestoreService = authRestoreService,
runtimeShutdownCoordinator = runtimeShutdownCoordinator,
protocolInstanceManager = protocolInstanceManager,
subscribePushTokenIfAvailable = postAuthBootstrapCoordinator::subscribePushTokenIfAvailable
)
}
private val directoryFacade by lazy {
RuntimeDirectoryFacade(
packetRouter = packetRouter,
ownProfileSyncService = ownProfileSyncService,
deviceRuntimeService = deviceRuntimeService,
presenceTypingService = presenceTypingService
)
}
private val packetIoFacade by lazy {
RuntimePacketIoFacade(
postConnectionEvent = ::postConnectionEvent,
outgoingMessagePipelineServiceProvider = { outgoingMessagePipelineService },
callSignalBridge = callSignalBridge,
packetSubscriptionFacade = packetSubscriptionFacade
)
}
private val debugLogService =
ProtocolDebugLogService(
scope = scope,
maxDebugLogs = MAX_DEBUG_LOGS,
debugLogFlushDelayMs = DEBUG_LOG_FLUSH_DELAY_MS,
heartbeatOkLogMinIntervalMs = HEARTBEAT_OK_LOG_MIN_INTERVAL_MS,
protocolTraceFileName = PROTOCOL_TRACE_FILE_NAME,
protocolTraceMaxBytes = PROTOCOL_TRACE_MAX_BYTES,
protocolTraceKeepBytes = PROTOCOL_TRACE_KEEP_BYTES,
appContextProvider = { appContext }
)
val debugLogs: StateFlow<List<String>> = debugLogService.debugLogs
val typingUsers: StateFlow<Set<String>> = presenceTypingService.typingUsers
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
presenceTypingService.typingUsersByDialogSnapshot
val devices: StateFlow<List<DeviceEntry>> = deviceRuntimeService.devices
val pendingDeviceVerification: StateFlow<DeviceEntry?> =
deviceRuntimeService.pendingDeviceVerification
// Сигнал обновления own profile (username/name загружены с сервера)
val ownProfileUpdated: StateFlow<Long> = ownProfileSyncService.ownProfileUpdated
val syncInProgress: StateFlow<Boolean> = syncCoordinator.syncInProgress
fun connectionControlApi(): RuntimeConnectionControlFacade = connectionControlFacade
fun directoryApi(): RuntimeDirectoryFacade = directoryFacade
fun packetIoApi(): RuntimePacketIoFacade = packetIoFacade
private fun sendSearchPacketViaPacketIo(packet: PacketSearch) {
packetIoFacade.send(packet)
}
private fun sendMessagePacketViaPacketIo(packet: PacketMessage) {
packetIoFacade.send(packet)
}
private fun sendSyncPacketViaPacketIo(packet: PacketSync) {
packetIoFacade.send(packet)
}
private fun ensureConnectionSupervisor() {
routingAssembly.start()
}
private fun postConnectionEvent(event: ConnectionEvent) {
routingAssembly.post(event)
}
private fun onFastReconnectRequested(reason: String) {
postConnectionEvent(ConnectionEvent.FastReconnect(reason))
}
private fun onOwnProfileResolvedEvent(publicKey: String) {
postConnectionEvent(ConnectionEvent.OwnProfileResolved(publicKey))
}
private fun onOwnProfileFallbackTimeoutEvent(generation: Long) {
postConnectionEvent(ConnectionEvent.OwnProfileFallbackTimeout(generation))
}
fun addLog(message: String) {
debugLogService.addLog(message)
}
fun enableUILogs(enabled: Boolean) {
debugLogService.enableUILogs(enabled)
MessageLogger.setEnabled(enabled)
}
fun clearLogs() {
debugLogService.clearLogs()
}
private fun markInboundProcessingFailure(reason: String, error: Throwable? = null) {
syncCoordinator.markInboundProcessingFailure()
if (error != null) {
android.util.Log.e(TAG, reason, error)
addLog("$reason: ${error.message ?: error.javaClass.simpleName}")
} else {
android.util.Log.w(TAG, reason)
addLog("⚠️ $reason")
}
}
private fun onInboundTaskQueued() {
syncCoordinator.trackInboundTaskQueued()
}
/**
* Inject process-wide dependencies from DI container.
*/
fun bindDependencies(
messageRepository: MessageRepository,
groupRepository: GroupRepository,
accountManager: AccountManager
) {
this.messageRepository = messageRepository
this.groupRepository = groupRepository
this.accountManager = accountManager
}
/**
* Backward-compatible alias kept while migrating call sites.
*/
fun bindRepositories(
messageRepository: MessageRepository,
groupRepository: GroupRepository
) {
this.messageRepository = messageRepository
this.groupRepository = groupRepository
}
private fun setupStateMonitoring() {
scope.launch {
state.collect { newState ->
postConnectionEvent(ConnectionEvent.ProtocolStateChanged(newState))
}
}
}
private fun setupPacketHandlers() {
inboundPacketHandlerRegistrar.register()
}
private fun isGroupDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase(Locale.ROOT)
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun finishSyncCycle(reason: String) {
postConnectionEvent(ConnectionEvent.SyncCompleted(reason))
}
private fun getProtocol(): Protocol {
return protocolInstanceManager.getOrCreateProtocol()
}
val state: StateFlow<ProtocolState>
get() = protocolInstanceManager.state
private fun send(packet: Packet) {
packetIoFacade.send(packet)
}
private fun resolveOutgoingRetry(messageId: String) {
packetIoFacade.resolveOutgoingRetry(messageId)
}
private fun cancelAllOutgoingRetries() {
packetIoFacade.clearOutgoingRetries()
}
private fun authenticate(publicKey: String, privateHash: String) {
postConnectionEvent(
ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash)
)
}
private fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetIoFacade.waitPacket(packetId, callback)
}
private fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetIoFacade.unwaitPacket(packetId, callback)
}
private fun shortKeyForLog(value: String, visible: Int = 8): String {
val trimmed = value.trim()
if (trimmed.isBlank()) return "<empty>"
return if (trimmed.length <= visible) trimmed else "${trimmed.take(visible)}"
}
private fun shortTextForLog(value: String, limit: Int = 80): String {
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
if (normalized.isBlank()) return "<empty>"
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}"
}
private fun isAuthenticated(): Boolean = connectionControlFacade.isAuthenticated()
}

View File

@@ -0,0 +1,86 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.network.connection.AuthRestoreService
import com.rosetta.messenger.network.connection.ProtocolInstanceManager
import com.rosetta.messenger.network.connection.RuntimeInitializationCoordinator
import com.rosetta.messenger.network.connection.RuntimeShutdownCoordinator
import com.rosetta.messenger.network.connection.SyncCoordinator
class RuntimeConnectionControlFacade(
private val postConnectionEvent: (ConnectionEvent) -> Unit,
private val initializationCoordinator: RuntimeInitializationCoordinator,
private val syncCoordinator: SyncCoordinator,
private val authRestoreService: AuthRestoreService,
private val runtimeShutdownCoordinator: RuntimeShutdownCoordinator,
private val protocolInstanceManager: ProtocolInstanceManager,
private val subscribePushTokenIfAvailable: (String?) -> Unit
) {
fun initialize(context: Context) {
initializationCoordinator.initialize(context)
}
fun initializeAccount(publicKey: String, privateKey: String) {
postConnectionEvent(
ConnectionEvent.InitializeAccount(publicKey = publicKey, privateKey = privateKey)
)
}
fun connect() {
postConnectionEvent(ConnectionEvent.Connect(reason = "api_connect"))
}
fun reconnectNowIfNeeded(reason: String = "foreground_resume") {
postConnectionEvent(ConnectionEvent.FastReconnect(reason = reason))
}
fun syncOnForeground() {
syncCoordinator.syncOnForeground()
}
fun forceSynchronize(backtrackMs: Long) {
if (!isAuthenticated()) {
reconnectNowIfNeeded("manual_sync_button")
return
}
syncCoordinator.forceSynchronize(backtrackMs)
}
fun authenticate(publicKey: String, privateHash: String) {
postConnectionEvent(
ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash)
)
}
fun restoreAuthFromStoredCredentials(
preferredPublicKey: String? = null,
reason: String = "background_restore"
): Boolean {
return authRestoreService.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
}
fun subscribePushToken(forceToken: String? = null) {
subscribePushTokenIfAvailable(forceToken)
}
fun disconnect() {
postConnectionEvent(
ConnectionEvent.Disconnect(
reason = "manual_disconnect",
clearCredentials = true
)
)
}
fun destroy() {
runtimeShutdownCoordinator.destroy()
}
fun isAuthenticated(): Boolean = protocolInstanceManager.isAuthenticated()
fun isConnected(): Boolean = protocolInstanceManager.isConnected()
fun getPrivateHashOrNull(): String? {
return runCatching { protocolInstanceManager.getOrCreateProtocol().getPrivateHash() }.getOrNull()
}
}

View File

@@ -0,0 +1,53 @@
package com.rosetta.messenger.network
import com.rosetta.messenger.network.connection.DeviceRuntimeService
import com.rosetta.messenger.network.connection.OwnProfileSyncService
import com.rosetta.messenger.network.connection.PacketRouter
import com.rosetta.messenger.network.connection.PresenceTypingService
class RuntimeDirectoryFacade(
private val packetRouter: PacketRouter,
private val ownProfileSyncService: OwnProfileSyncService,
private val deviceRuntimeService: DeviceRuntimeService,
private val presenceTypingService: PresenceTypingService
) {
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
return presenceTypingService.getTypingUsersForDialog(dialogKey)
}
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
return packetRouter.resolveUserName(publicKey = publicKey, timeoutMs = timeoutMs)
}
fun getCachedUserName(publicKey: String): String? {
return packetRouter.getCachedUserName(publicKey)
}
fun notifyOwnProfileUpdated() {
ownProfileSyncService.notifyOwnProfileUpdated()
}
fun getCachedUserInfo(publicKey: String): SearchUser? {
return packetRouter.getCachedUserInfo(publicKey)
}
fun getCachedUserByUsername(username: String): SearchUser? {
return packetRouter.getCachedUserByUsername(username)
}
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
return packetRouter.resolveUserInfo(publicKey = publicKey, timeoutMs = timeoutMs)
}
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
return packetRouter.searchUsers(query = query, timeoutMs = timeoutMs)
}
fun acceptDevice(deviceId: String) {
deviceRuntimeService.acceptDevice(deviceId)
}
fun declineDevice(deviceId: String) {
deviceRuntimeService.declineDevice(deviceId)
}
}

View File

@@ -0,0 +1,119 @@
package com.rosetta.messenger.network
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.connection.CallSignalBridge
import com.rosetta.messenger.network.connection.DeviceRuntimeService
import com.rosetta.messenger.network.connection.InboundPacketHandlerRegistrar
import com.rosetta.messenger.network.connection.InboundTaskQueueService
import com.rosetta.messenger.network.connection.OutgoingMessagePipelineService
import com.rosetta.messenger.network.connection.OwnProfileSyncService
import com.rosetta.messenger.network.connection.PacketRouter
import com.rosetta.messenger.network.connection.PresenceTypingService
import com.rosetta.messenger.network.connection.SyncCoordinator
import kotlinx.coroutines.CoroutineScope
internal class RuntimeMessagingAssembly(
tag: String,
scope: CoroutineScope,
typingIndicatorTimeoutMs: Long,
syncRequestTimeoutMs: Long,
manualSyncBacktrackMs: Long,
deviceRuntimeService: DeviceRuntimeService,
ownProfileSyncService: OwnProfileSyncService,
isAuthenticated: () -> Boolean,
getProtocolPublicKey: () -> String,
getProtocolPrivateHash: () -> String?,
getMessageRepository: () -> MessageRepository?,
getGroupRepository: () -> GroupRepository?,
sendSearchPacket: (PacketSearch) -> Unit,
sendMessagePacket: (PacketMessage) -> Unit,
sendSyncPacket: (PacketSync) -> Unit,
sendPacket: (Packet) -> Unit,
waitPacket: (Int, (Packet) -> Unit) -> Unit,
unwaitPacket: (Int, (Packet) -> Unit) -> Unit,
addLog: (String) -> Unit,
shortKeyForLog: (String, Int) -> String,
shortTextForLog: (String, Int) -> String,
onSyncCompleted: (String) -> Unit,
onInboundTaskQueued: () -> Unit,
onInboundTaskFailure: (String, Throwable?) -> Unit,
resolveOutgoingRetry: (String) -> Unit,
isGroupDialogKey: (String) -> Boolean,
setTransportServer: (String) -> Unit,
onOwnProfileResolved: (String) -> Unit
) {
val packetRouter =
PacketRouter(
sendSearchPacket = sendSearchPacket,
privateHashProvider = getProtocolPrivateHash
)
val outgoingMessagePipelineService =
OutgoingMessagePipelineService(
scope = scope,
getRepository = getMessageRepository,
sendPacket = sendMessagePacket,
isAuthenticated = isAuthenticated,
addLog = addLog
)
val presenceTypingService =
PresenceTypingService(
scope = scope,
typingIndicatorTimeoutMs = typingIndicatorTimeoutMs
)
val inboundTaskQueueService =
InboundTaskQueueService(
scope = scope,
onTaskQueued = onInboundTaskQueued,
onTaskFailure = onInboundTaskFailure
)
val syncCoordinator =
SyncCoordinator(
scope = scope,
syncRequestTimeoutMs = syncRequestTimeoutMs,
manualSyncBacktrackMs = manualSyncBacktrackMs,
addLog = addLog,
isAuthenticated = isAuthenticated,
getRepository = getMessageRepository,
getProtocolPublicKey = getProtocolPublicKey,
sendPacket = sendSyncPacket,
onSyncCompleted = onSyncCompleted,
whenInboundTasksFinish = inboundTaskQueueService::whenTasksFinish
)
val callSignalBridge =
CallSignalBridge(
sendPacket = sendPacket,
waitPacket = waitPacket,
unwaitPacket = unwaitPacket,
addLog = addLog,
shortKeyForLog = shortKeyForLog,
shortTextForLog = shortTextForLog
)
val inboundPacketHandlerRegistrar =
InboundPacketHandlerRegistrar(
tag = tag,
scope = scope,
syncCoordinator = syncCoordinator,
presenceTypingService = presenceTypingService,
deviceRuntimeService = deviceRuntimeService,
packetRouter = packetRouter,
ownProfileSyncService = ownProfileSyncService,
waitPacket = waitPacket,
launchInboundPacketTask = inboundTaskQueueService::enqueue,
getMessageRepository = getMessageRepository,
getGroupRepository = getGroupRepository,
getProtocolPublicKey = { getProtocolPublicKey().trim().orEmpty() },
addLog = addLog,
markInboundProcessingFailure = onInboundTaskFailure,
resolveOutgoingRetry = resolveOutgoingRetry,
isGroupDialogKey = isGroupDialogKey,
onOwnProfileResolved = onOwnProfileResolved,
setTransportServer = setTransportServer
)
}

View File

@@ -0,0 +1,88 @@
package com.rosetta.messenger.network
import com.rosetta.messenger.network.connection.CallSignalBridge
import com.rosetta.messenger.network.connection.OutgoingMessagePipelineService
import com.rosetta.messenger.network.connection.PacketSubscriptionFacade
import kotlinx.coroutines.flow.SharedFlow
class RuntimePacketIoFacade(
private val postConnectionEvent: (ConnectionEvent) -> Unit,
private val outgoingMessagePipelineServiceProvider: () -> OutgoingMessagePipelineService,
private val callSignalBridge: CallSignalBridge,
private val packetSubscriptionFacade: PacketSubscriptionFacade
) {
fun send(packet: Packet) {
postConnectionEvent(ConnectionEvent.SendPacket(packet))
}
fun sendMessageWithRetry(packet: PacketMessage) {
outgoingMessagePipelineServiceProvider().sendWithRetry(packet)
}
fun resolveOutgoingRetry(messageId: String) {
outgoingMessagePipelineServiceProvider().resolveOutgoingRetry(messageId)
}
fun clearOutgoingRetries() {
outgoingMessagePipelineServiceProvider().clearRetryQueue()
}
fun sendPacket(packet: Packet) {
send(packet)
}
fun sendCallSignal(
signalType: SignalType,
src: String = "",
dst: String = "",
sharedPublic: String = "",
callId: String = "",
joinToken: String = ""
) {
callSignalBridge.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
}
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
callSignalBridge.sendWebRtcSignal(signalType, sdpOrCandidate)
}
fun requestIceServers() {
callSignalBridge.requestIceServers()
}
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
return callSignalBridge.waitCallSignal(callback)
}
fun unwaitCallSignal(callback: (Packet) -> Unit) {
callSignalBridge.unwaitCallSignal(callback)
}
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
return callSignalBridge.waitWebRtcSignal(callback)
}
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
callSignalBridge.unwaitWebRtcSignal(callback)
}
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
return callSignalBridge.waitIceServers(callback)
}
fun unwaitIceServers(callback: (Packet) -> Unit) {
callSignalBridge.unwaitIceServers(callback)
}
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetSubscriptionFacade.waitPacket(packetId, callback)
}
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetSubscriptionFacade.unwaitPacket(packetId, callback)
}
fun packetFlow(packetId: Int): SharedFlow<Packet> {
return packetSubscriptionFacade.packetFlow(packetId)
}
}

View File

@@ -0,0 +1,51 @@
package com.rosetta.messenger.network
import android.util.Log
import com.rosetta.messenger.network.connection.ConnectionEventRouter
import kotlinx.coroutines.CoroutineScope
internal class RuntimeRoutingAssembly(
scope: CoroutineScope,
tag: String,
addLog: (String) -> Unit,
handleInitializeAccount: (publicKey: String, privateKey: String) -> Unit,
handleConnect: (reason: String) -> Unit,
handleFastReconnect: (reason: String) -> Unit,
handleDisconnect: (reason: String, clearCredentials: Boolean) -> Unit,
handleAuthenticate: (publicKey: String, privateHash: String) -> Unit,
handleProtocolStateChanged: (state: ProtocolState) -> Unit,
handleSendPacket: (packet: Packet) -> Unit,
handleSyncCompleted: (reason: String) -> Unit,
handleOwnProfileResolved: (publicKey: String) -> Unit,
handleOwnProfileFallbackTimeout: (sessionGeneration: Long) -> Unit
) {
private val connectionEventRouter =
ConnectionEventRouter(
handleInitializeAccount = handleInitializeAccount,
handleConnect = handleConnect,
handleFastReconnect = handleFastReconnect,
handleDisconnect = handleDisconnect,
handleAuthenticate = handleAuthenticate,
handleProtocolStateChanged = handleProtocolStateChanged,
handleSendPacket = handleSendPacket,
handleSyncCompleted = handleSyncCompleted,
handleOwnProfileResolved = handleOwnProfileResolved,
handleOwnProfileFallbackTimeout = handleOwnProfileFallbackTimeout
)
private val connectionSupervisor =
ProtocolConnectionSupervisor(
scope = scope,
onEvent = { event -> connectionEventRouter.route(event) },
onError = { error -> Log.e(tag, "ConnectionSupervisor event failed", error) },
addLog = addLog
)
fun start() {
connectionSupervisor.start()
}
fun post(event: ConnectionEvent) {
connectionSupervisor.post(event)
}
}

View File

@@ -0,0 +1,61 @@
package com.rosetta.messenger.network
import com.rosetta.messenger.network.connection.BootstrapCoordinator
import com.rosetta.messenger.network.connection.OwnProfileFallbackTimerService
import com.rosetta.messenger.network.connection.ProtocolLifecycleStateStoreImpl
import com.rosetta.messenger.network.connection.RuntimeLifecycleStateMachine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
internal class RuntimeStateAssembly(
scope: CoroutineScope,
readyPacketQueueMax: Int,
readyPacketQueueTtlMs: Long,
ownProfileFallbackTimeoutMs: Long,
addLog: (String) -> Unit,
shortKeyForLog: (String) -> String,
sendPacketDirect: (Packet) -> Unit,
onOwnProfileFallbackTimeout: (Long) -> Unit,
clearLastSubscribedTokenValue: () -> Unit
) {
private val readyPacketGate =
ReadyPacketGate(
maxSize = readyPacketQueueMax,
ttlMs = readyPacketQueueTtlMs
)
val bootstrapCoordinator =
BootstrapCoordinator(
readyPacketGate = readyPacketGate,
addLog = addLog,
shortKeyForLog = shortKeyForLog,
sendPacketDirect = sendPacketDirect
)
val lifecycleStateMachine =
RuntimeLifecycleStateMachine(
bootstrapCoordinator = bootstrapCoordinator,
addLog = addLog
)
private val ownProfileFallbackTimerService =
OwnProfileFallbackTimerService(
scope = scope,
fallbackTimeoutMs = ownProfileFallbackTimeoutMs,
onTimeout = onOwnProfileFallbackTimeout
)
val lifecycleStateStore =
ProtocolLifecycleStateStoreImpl(
lifecycleStateMachine = lifecycleStateMachine,
ownProfileFallbackTimerService = ownProfileFallbackTimerService,
clearLastSubscribedTokenValue = clearLastSubscribedTokenValue
)
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
lifecycleStateMachine.connectionLifecycleState
fun recomputeConnectionLifecycleState(reason: String) {
lifecycleStateMachine.recompute(reason)
}
}

View File

@@ -0,0 +1,54 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.network.connection.NetworkConnectivityFacade
import com.rosetta.messenger.network.connection.NetworkReconnectWatcher
import com.rosetta.messenger.network.connection.PacketSubscriptionFacade
import com.rosetta.messenger.network.connection.ProtocolInstanceManager
import kotlinx.coroutines.CoroutineScope
internal class RuntimeTransportAssembly(
scope: CoroutineScope,
networkWaitTimeoutMs: Long,
serverAddress: String,
getAppContext: () -> Context?,
addLog: (String) -> Unit,
onFastReconnectRequested: (String) -> Unit
) {
private val networkReconnectWatcher =
NetworkReconnectWatcher(
scope = scope,
networkWaitTimeoutMs = networkWaitTimeoutMs,
addLog = addLog,
onReconnectRequested = onFastReconnectRequested
)
val networkConnectivityFacade =
NetworkConnectivityFacade(
networkReconnectWatcher = networkReconnectWatcher,
getAppContext = getAppContext
)
val protocolInstanceManager =
ProtocolInstanceManager(
serverAddress = serverAddress,
addLog = addLog,
isNetworkAvailable = networkConnectivityFacade::hasActiveInternet,
onNetworkUnavailable = {
networkConnectivityFacade.waitForNetworkAndReconnect("protocol_connect")
}
)
private val packetSubscriptionRegistry =
PacketSubscriptionRegistry(
protocolProvider = protocolInstanceManager::getOrCreateProtocol,
scope = scope,
addLog = addLog
)
val packetSubscriptionFacade = PacketSubscriptionFacade(packetSubscriptionRegistry)
fun destroyPacketSubscriptions() {
packetSubscriptionRegistry.destroy()
}
}

View File

@@ -1,163 +1,318 @@
package com.rosetta.messenger.network
/**
* Binary stream for protocol packets
* Matches the React Native implementation exactly
* Binary stream for protocol packets.
*
* Parity with desktop/server:
* - signed: Int8/16/32/64 (two's complement)
* - unsigned: UInt8/16/32/64
* - String: length(UInt32) + chars(UInt16)
* - byte[]: length(UInt32) + raw bytes
*/
class Stream(stream: ByteArray = ByteArray(0)) {
private var _stream = mutableListOf<Int>()
private var _readPointer = 0
private var _writePointer = 0
class Stream(initial: ByteArray = byteArrayOf()) {
private var stream: ByteArray = initial.copyOf()
private var readPointer: Int = 0 // bits
private var writePointer: Int = stream.size shl 3 // bits
init {
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
fun getStream(): ByteArray = stream.copyOf(length())
fun setStream(value: ByteArray) {
stream = value.copyOf()
readPointer = 0
writePointer = stream.size shl 3
}
fun getStream(): ByteArray {
return _stream.map { it.toByte() }.toByteArray()
fun getBuffer(): ByteArray = getStream()
fun isEmpty(): Boolean = writePointer == 0
fun length(): Int = (writePointer + 7) shr 3
fun getReadPointerBits(): Int = readPointer
fun setReadPointerBits(bits: Int) {
readPointer = bits.coerceIn(0, writePointer)
}
fun getReadPointerBits(): Int = _readPointer
fun getTotalBits(): Int = writePointer
fun getTotalBits(): Int = _stream.size * 8
fun getRemainingBits(): Int = (writePointer - readPointer).coerceAtLeast(0)
fun getRemainingBits(): Int = getTotalBits() - _readPointer
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
fun setStream(stream: ByteArray) {
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
_readPointer = 0
}
fun writeInt8(value: Int) {
val negationBit = if (value < 0) 1 else 0
val int8Value = Math.abs(value) and 0xFF
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
_writePointer++
for (i in 0 until 8) {
val bit = (int8Value shr (7 - i)) and 1
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
_writePointer++
}
}
fun readInt8(): Int {
var value = 0
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
for (i in 0 until 8) {
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
value = value or (bit shl (7 - i))
_readPointer++
}
return if (negationBit == 1) -value else value
}
fun hasRemainingBits(): Boolean = readPointer < writePointer
fun writeBit(value: Int) {
val bit = value and 1
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
_writePointer++
writeBits((value and 1).toLong(), 1)
}
fun readBit(): Int {
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
return bit
}
fun readBit(): Int = readBits(1).toInt()
fun writeBoolean(value: Boolean) {
writeBit(if (value) 1 else 0)
}
fun readBoolean(): Boolean {
return readBit() == 1
fun readBoolean(): Boolean = readBit() == 1
fun writeByte(value: Byte) {
writeUInt8(value.toInt() and 0xFF)
}
fun readByte(): Byte = readUInt8().toByte()
fun writeUInt8(value: Int) {
val v = value and 0xFF
// Fast path when byte-aligned.
if ((writePointer and 7) == 0) {
reserveBits(8)
stream[writePointer shr 3] = v.toByte()
writePointer += 8
return
}
writeBits(v.toLong(), 8)
}
fun readUInt8(): Int {
if (remainingBits() < 8L) {
throw IllegalStateException("Not enough bits to read UInt8")
}
// Fast path when byte-aligned.
if ((readPointer and 7) == 0) {
val value = stream[readPointer shr 3].toInt() and 0xFF
readPointer += 8
return value
}
return readBits(8).toInt() and 0xFF
}
fun writeInt8(value: Int) {
writeUInt8(value)
}
fun readInt8(): Int = readUInt8().toByte().toInt()
fun writeUInt16(value: Int) {
val v = value and 0xFFFF
writeUInt8((v ushr 8) and 0xFF)
writeUInt8(v and 0xFF)
}
fun readUInt16(): Int {
val hi = readUInt8()
val lo = readUInt8()
return (hi shl 8) or lo
}
fun writeInt16(value: Int) {
writeInt8(value shr 8)
writeInt8(value and 0xFF)
writeUInt16(value)
}
fun readInt16(): Int {
val high = readInt8() shl 8
return high or readInt8()
fun readInt16(): Int = readUInt16().toShort().toInt()
fun writeUInt32(value: Long) {
if (value < 0L || value > 0xFFFF_FFFFL) {
throw IllegalArgumentException("UInt32 out of range: $value")
}
writeUInt8(((value ushr 24) and 0xFF).toInt())
writeUInt8(((value ushr 16) and 0xFF).toInt())
writeUInt8(((value ushr 8) and 0xFF).toInt())
writeUInt8((value and 0xFF).toInt())
}
fun readUInt32(): Long {
val b1 = readUInt8().toLong() and 0xFFL
val b2 = readUInt8().toLong() and 0xFFL
val b3 = readUInt8().toLong() and 0xFFL
val b4 = readUInt8().toLong() and 0xFFL
return (b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4
}
fun writeInt32(value: Int) {
writeInt16(value shr 16)
writeInt16(value and 0xFFFF)
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
}
fun readInt32(): Int {
val high = readInt16() shl 16
return high or readInt16()
fun readInt32(): Int = readUInt32().toInt()
/** Writes raw 64-bit pattern (UInt64 bit-pattern in Long). */
fun writeUInt64(value: Long) {
writeUInt8(((value ushr 56) and 0xFF).toInt())
writeUInt8(((value ushr 48) and 0xFF).toInt())
writeUInt8(((value ushr 40) and 0xFF).toInt())
writeUInt8(((value ushr 32) and 0xFF).toInt())
writeUInt8(((value ushr 24) and 0xFF).toInt())
writeUInt8(((value ushr 16) and 0xFF).toInt())
writeUInt8(((value ushr 8) and 0xFF).toInt())
writeUInt8((value and 0xFF).toInt())
}
fun writeInt64(value: Long) {
val high = (value shr 32).toInt()
val low = (value and 0xFFFFFFFF).toInt()
writeInt32(high)
writeInt32(low)
}
fun readInt64(): Long {
val high = readInt32().toLong()
val low = (readInt32().toLong() and 0xFFFFFFFFL)
/** Reads raw 64-bit pattern (UInt64 bit-pattern in Long). */
fun readUInt64(): Long {
val high = readUInt32() and 0xFFFF_FFFFL
val low = readUInt32() and 0xFFFF_FFFFL
return (high shl 32) or low
}
fun writeInt64(value: Long) {
writeUInt64(value)
}
fun readInt64(): Long = readUInt64()
fun writeFloat32(value: Float) {
writeInt32(java.lang.Float.floatToIntBits(value))
}
fun readFloat32(): Float = java.lang.Float.intBitsToFloat(readInt32())
/** String: length(UInt32) + chars(UInt16). */
fun writeString(value: String) {
writeInt32(value.length)
for (char in value) {
writeInt16(char.code)
writeUInt32(value.length.toLong())
if (value.isEmpty()) return
reserveBits(value.length.toLong() * 16L)
for (i in value.indices) {
writeUInt16(value[i].code and 0xFFFF)
}
}
fun readString(): String {
val length = readInt32()
// Desktop parity + safety: don't trust malformed string length.
val bytesAvailable = _stream.size - (_readPointer shr 3)
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
android.util.Log.w(
"RosettaStream",
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
)
return ""
val lenLong = readUInt32()
if (lenLong > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("String length too large: $lenLong")
}
val sb = StringBuilder()
for (i in 0 until length) {
sb.append(readInt16().toChar())
val length = lenLong.toInt()
val requiredBits = length.toLong() * 16L
if (requiredBits > remainingBits()) {
throw IllegalStateException("Not enough bits to read string")
}
val sb = StringBuilder(length)
repeat(length) {
sb.append(readUInt16().toChar())
}
return sb.toString()
}
/** byte[]: length(UInt32) + payload. */
fun writeBytes(value: ByteArray) {
writeInt32(value.size)
for (byte in value) {
writeInt8(byte.toInt())
writeUInt32(value.size.toLong())
if (value.isEmpty()) return
reserveBits(value.size.toLong() * 8L)
// Fast path when byte-aligned.
if ((writePointer and 7) == 0) {
val byteIndex = writePointer shr 3
ensureCapacity(byteIndex + value.size - 1)
System.arraycopy(value, 0, stream, byteIndex, value.size)
writePointer += value.size shl 3
return
}
value.forEach { writeUInt8(it.toInt() and 0xFF) }
}
fun readBytes(): ByteArray {
val length = readInt32()
val bytes = ByteArray(length)
for (i in 0 until length) {
bytes[i] = readInt8().toByte()
}
return bytes
val lenLong = readUInt32()
if (lenLong == 0L) return byteArrayOf()
if (lenLong > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("Byte array too large: $lenLong")
}
private fun ensureCapacity(index: Int) {
while (_stream.size <= index) {
_stream.add(0)
val length = lenLong.toInt()
val requiredBits = length.toLong() * 8L
if (requiredBits > remainingBits()) {
return byteArrayOf()
}
val out = ByteArray(length)
// Fast path when byte-aligned.
if ((readPointer and 7) == 0) {
val byteIndex = readPointer shr 3
System.arraycopy(stream, byteIndex, out, 0, length)
readPointer += length shl 3
return out
}
for (i in 0 until length) {
out[i] = readUInt8().toByte()
}
return out
}
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
private fun writeBits(value: Long, bits: Int) {
if (bits <= 0) return
reserveBits(bits.toLong())
for (i in bits - 1 downTo 0) {
val bit = ((value ushr i) and 1L).toInt()
val byteIndex = writePointer shr 3
val shift = 7 - (writePointer and 7)
stream[byteIndex] =
if (bit == 1) {
(stream[byteIndex].toInt() or (1 shl shift)).toByte()
} else {
(stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
}
writePointer++
}
}
private fun readBits(bits: Int): Long {
if (bits <= 0) return 0L
if (remainingBits() < bits.toLong()) {
throw IllegalStateException("Not enough bits to read")
}
var value = 0L
repeat(bits) {
val bit =
(stream[readPointer shr 3].toInt() ushr
(7 - (readPointer and 7))) and
1
value = (value shl 1) or bit.toLong()
readPointer++
}
return value
}
private fun reserveBits(bitsToWrite: Long) {
if (bitsToWrite <= 0L) return
val lastBitIndex = writePointer.toLong() + bitsToWrite - 1L
if (lastBitIndex < 0L) {
throw IllegalStateException("Bit index overflow")
}
val byteIndex = lastBitIndex ushr 3
if (byteIndex > Int.MAX_VALUE.toLong()) {
throw IllegalStateException("Stream too large")
}
ensureCapacity(byteIndex.toInt())
}
private fun ensureCapacity(byteIndex: Int) {
val requiredSize = byteIndex + 1
if (requiredSize <= stream.size) return
var newSize = if (stream.isEmpty()) 32 else stream.size
while (newSize < requiredSize) {
newSize = if (newSize <= Int.MAX_VALUE / 2) newSize shl 1 else requiredSize
}
stream = stream.copyOf(newSize)
}
}

View File

@@ -1,20 +1,25 @@
package com.rosetta.messenger.network
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.coroutines.coroutineContext
/**
* Состояние загрузки/скачивания файла
@@ -38,9 +43,11 @@ object TransportManager {
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
private val activeUploadCalls = ConcurrentHashMap<String, Call>()
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
private val activeDownloadCalls = ConcurrentHashMap<String, Call>()
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
@@ -72,7 +79,11 @@ object TransportManager {
* Получить активный сервер для скачивания/загрузки.
* Desktop parity: ждём сервер из PacketRequestTransport (0x0F), а не используем hardcoded CDN.
*/
private suspend fun getActiveServer(): String {
private suspend fun getActiveServer(serverOverride: String? = null): String {
val normalizedOverride = serverOverride?.trim()?.trimEnd('/').orEmpty()
if (normalizedOverride.isNotEmpty()) {
return normalizedOverride
}
transportServer?.let { return it }
requestTransportServer()
repeat(40) { // 10s total
@@ -93,6 +104,8 @@ object TransportManager {
repeat(MAX_RETRIES) { attempt ->
try {
return block()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
if (attempt < MAX_RETRIES - 1) {
@@ -108,7 +121,63 @@ object TransportManager {
*/
fun requestTransportServer() {
val packet = PacketRequestTransport()
ProtocolManager.sendPacket(packet)
ProtocolRuntimeAccess.get().sendPacket(packet)
}
/**
* Принудительно отменяет активный HTTP call для скачивания attachment.
* Нужен для pause/resume в file bubble.
*/
fun cancelDownload(id: String) {
activeDownloadCalls.remove(id)?.cancel()
_downloading.value = _downloading.value.filter { it.id != id }
}
/**
* Принудительно отменяет активный HTTP call для upload attachment.
*/
fun cancelUpload(id: String) {
activeUploadCalls.remove(id)?.cancel()
_uploading.value = _uploading.value.filter { it.id != id }
}
private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
suspendCancellableCoroutine { cont ->
val call = client.newCall(request)
activeDownloadCalls[id] = call
cont.invokeOnCancellation {
activeDownloadCalls.remove(id, call)
call.cancel()
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activeDownloadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Download cancelled"))
} else {
cont.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
activeDownloadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response)
}
})
}
private fun parseContentRangeTotal(value: String?): Long? {
if (value.isNullOrBlank()) return null
// Example: "bytes 100-999/12345"
val totalPart = value.substringAfter('/').trim()
if (totalPart.isEmpty() || totalPart == "*") return null
return totalPart.toLongOrNull()
}
/**
@@ -119,7 +188,7 @@ object TransportManager {
*/
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer()
ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server")
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
// Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0)
@@ -163,13 +232,31 @@ object TransportManager {
.post(requestBody)
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
val response = suspendCancellableCoroutine<Response> { cont ->
val call = client.newCall(request)
activeUploadCalls[id] = call
cont.invokeOnCancellation {
activeUploadCalls.remove(id, call)
call.cancel()
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activeUploadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Upload cancelled"))
} else {
cont.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
activeUploadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response)
}
})
@@ -188,16 +275,20 @@ object TransportManager {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
ProtocolRuntimeAccess.get().addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
tag
}
} catch (e: CancellationException) {
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
throw e
} catch (e: Exception) {
ProtocolManager.addLog(
ProtocolRuntimeAccess.get().addLog(
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
} finally {
activeUploadCalls.remove(id)?.cancel()
// Удаляем из списка загрузок
_uploading.value = _uploading.value.filter { it.id != id }
}
@@ -212,9 +303,13 @@ object TransportManager {
* @param tag Tag файла на сервере
* @return Содержимое файла
*/
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer()
ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
suspend fun downloadFile(
id: String,
tag: String,
transportServer: String? = null
): String = withContext(Dispatchers.IO) {
val server = getActiveServer(transportServer)
ProtocolRuntimeAccess.get().addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
// Добавляем в список скачиваний
_downloading.value = _downloading.value + TransportState(id, 0)
@@ -226,17 +321,7 @@ object TransportManager {
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
}
val response = awaitDownloadResponse(id, request)
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
@@ -251,7 +336,7 @@ object TransportManager {
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}")
ProtocolRuntimeAccess.get().addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}")
return@withRetry content
}
@@ -298,18 +383,19 @@ object TransportManager {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
ProtocolRuntimeAccess.get().addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
content
} finally {
tempFile.delete()
}
}
} catch (e: Exception) {
ProtocolManager.addLog(
ProtocolRuntimeAccess.get().addLog(
"❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
} finally {
activeDownloadCalls.remove(id)?.cancel()
// Удаляем из списка скачиваний
_downloading.value = _downloading.value.filter { it.id != id }
}
@@ -337,52 +423,104 @@ object TransportManager {
* @param tag Tag файла на сервере
* @return Временный файл с зашифрованным содержимым
*/
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
val server = getActiveServer()
ProtocolManager.addLog("📥 Download raw start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
suspend fun downloadFileRaw(
id: String,
tag: String,
transportServer: String? = null
): File = withContext(Dispatchers.IO) {
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
try {
downloadFileRawResumable(
id = id,
tag = tag,
targetFile = tempFile,
resumeFromBytes = 0L,
transportServer = transportServer
)
} catch (e: Exception) {
tempFile.delete()
throw e
}
}
_downloading.value = _downloading.value + TransportState(id, 0)
/**
* Resumable download with HTTP Range support.
* If server supports range (206), continues from `targetFile.length()`.
* If not, safely restarts from zero and rewrites target file.
*/
suspend fun downloadFileRawResumable(
id: String,
tag: String,
targetFile: File,
resumeFromBytes: Long = 0L,
transportServer: String? = null
): File = withContext(Dispatchers.IO) {
val server = getActiveServer(transportServer)
ProtocolRuntimeAccess.get().addLog(
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
)
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
try {
withRetry {
val request = Request.Builder()
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
.coerceAtMost(existingBytes)
val requestBuilder = Request.Builder()
.url("$server/d/$tag")
.get()
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
})
if (startOffset > 0L) {
requestBuilder.addHeader("Range", "bytes=$startOffset-")
}
val response = awaitDownloadResponse(id, requestBuilder.build())
if (!response.isSuccessful) {
throw IOException("Download failed: ${response.code}")
}
val body = response.body ?: throw IOException("Empty response body")
val contentLength = body.contentLength()
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
val rangeAccepted = response.code == 206
val writeFromOffset = if (rangeAccepted) startOffset else 0L
val incomingLength = body.contentLength().coerceAtLeast(0L)
val totalFromHeader = parseContentRangeTotal(response.header("Content-Range"))
val totalBytes = when {
totalFromHeader != null && totalFromHeader > 0L -> totalFromHeader
incomingLength > 0L -> writeFromOffset + incomingLength
else -> -1L
}
try {
var totalRead = 0L
if (writeFromOffset == 0L && targetFile.exists()) {
targetFile.delete()
}
targetFile.parentFile?.mkdirs()
val append = writeFromOffset > 0L
var totalRead = writeFromOffset
val buffer = ByteArray(64 * 1024)
body.byteStream().use { inputStream ->
tempFile.outputStream().use { outputStream ->
java.io.FileOutputStream(targetFile, append).use { outputStream ->
while (true) {
val bytesRead = inputStream.read(buffer)
coroutineContext.ensureActive()
val bytesRead = try {
inputStream.read(buffer)
} catch (e: IOException) {
if (!coroutineContext.isActive) {
throw CancellationException("Download cancelled", e)
}
throw e
}
if (bytesRead == -1) break
outputStream.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (contentLength > 0) {
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99)
if (totalBytes > 0L) {
val progress =
((totalRead * 100L) / totalBytes).toInt().coerceIn(0, 99)
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = progress) else it
}
@@ -391,31 +529,30 @@ object TransportManager {
}
}
if (contentLength > 0 && totalRead != contentLength) {
tempFile.delete()
throw IOException("Incomplete download: expected=$contentLength, got=$totalRead")
if (totalBytes > 0L && totalRead < totalBytes) {
throw IOException(
"Incomplete download: expected=$totalBytes, got=$totalRead"
)
}
if (totalRead == 0L) {
tempFile.delete()
throw IOException("Empty download: 0 bytes received")
}
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Download raw OK: id=${id.take(8)}, size=$totalRead")
tempFile
} catch (e: Exception) {
tempFile.delete()
throw e
}
ProtocolRuntimeAccess.get().addLog(
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
)
targetFile
}
} catch (e: Exception) {
ProtocolManager.addLog(
"❌ Download raw failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
ProtocolRuntimeAccess.get().addLog(
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
} finally {
activeDownloadCalls.remove(id)?.cancel()
_downloading.value = _downloading.value.filter { it.id != id }
}
}

View File

@@ -0,0 +1,115 @@
package com.rosetta.messenger.network
import android.util.Log
import org.webrtc.FrameDecryptor
import org.webrtc.FrameEncryptor
/**
* XChaCha20-based E2EE compatible with Rosetta Desktop.
*
* Desktop encrypts audio frames using XChaCha20 (libsodium) with a nonce
* derived from the RTP timestamp. The shared key is computed as
* nacl.box.before(peerPub, ownSecret) = HSalsa20(zeros, X25519(sk, pk)).
*
* This class provides:
* - [hsalsa20] — applies HSalsa20 to a raw X25519 shared secret,
* producing the same key as nacl.box.before().
* - [Encryptor] / [Decryptor] — WebRTC FrameEncryptor / FrameDecryptor
* that use XChaCha20 matching the Desktop implementation.
*/
object XChaCha20E2EE {
private const val TAG = "XChaCha20E2EE"
var nativeLoaded: Boolean = false
private set
private var crashFilePath: String? = null
fun initWithContext(context: android.content.Context) {
if (!nativeLoaded) return
try {
val dir = java.io.File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val path = java.io.File(dir, "native_crash.txt").absolutePath
crashFilePath = path
nativeInstallCrashHandler(path)
Log.i(TAG, "Native crash handler installed → $path")
} catch (e: Throwable) {
Log.e(TAG, "Failed to install native crash handler", e)
}
}
init {
try {
System.loadLibrary("rosetta_e2ee")
nativeLoaded = true
Log.i(TAG, "Native library loaded successfully")
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "Failed to load native library rosetta_e2ee", e)
}
}
/**
* HSalsa20(zeros_16, rawDhShared, sigma) — converts a raw X25519
* shared secret into the NaCl box-before shared key.
*/
fun hsalsa20(rawDhShared: ByteArray): ByteArray {
require(nativeLoaded) { "Native library not loaded" }
require(rawDhShared.size >= 32) { "Raw DH shared secret must be >= 32 bytes" }
return nativeHSalsa20(rawDhShared)
}
/** WebRTC [FrameEncryptor] backed by native XChaCha20. */
class Encryptor(key: ByteArray) : FrameEncryptor {
private val nativePtr: Long
init {
require(nativeLoaded) { "Native library not loaded" }
nativePtr = nativeCreateEncryptor(key)
Log.i(TAG, "Encryptor created, ptr=0x${nativePtr.toString(16)}")
}
override fun getNativeFrameEncryptor(): Long = nativePtr
fun frameCount(): Int = if (nativePtr != 0L) nativeGetEncryptorFrameCount(nativePtr) else -1
fun dispose() {
if (nativePtr != 0L) nativeReleaseEncryptor(nativePtr)
}
}
/** WebRTC [FrameDecryptor] backed by native XChaCha20. */
class Decryptor(key: ByteArray) : FrameDecryptor {
private val nativePtr: Long
init {
require(nativeLoaded) { "Native library not loaded" }
nativePtr = nativeCreateDecryptor(key)
Log.i(TAG, "Decryptor created, ptr=0x${nativePtr.toString(16)}")
}
override fun getNativeFrameDecryptor(): Long = nativePtr
fun frameCount(): Int = if (nativePtr != 0L) nativeGetDecryptorFrameCount(nativePtr) else -1
fun badStreak(): Int = if (nativePtr != 0L) nativeGetDecryptorBadStreak(nativePtr) else -1
fun dispose() {
if (nativePtr != 0L) nativeReleaseDecryptor(nativePtr)
}
}
/* ── JNI ─────────────────────────────────────────────────── */
@JvmStatic private external fun nativeHSalsa20(rawDh: ByteArray): ByteArray
@JvmStatic private external fun nativeCreateEncryptor(key: ByteArray): Long
@JvmStatic private external fun nativeReleaseEncryptor(ptr: Long)
@JvmStatic private external fun nativeGetEncryptorFrameCount(ptr: Long): Int
@JvmStatic private external fun nativeCreateDecryptor(key: ByteArray): Long
@JvmStatic private external fun nativeReleaseDecryptor(ptr: Long)
@JvmStatic private external fun nativeGetDecryptorFrameCount(ptr: Long): Int
@JvmStatic private external fun nativeGetDecryptorBadStreak(ptr: Long): Int
@JvmStatic private external fun nativeInstallCrashHandler(path: String)
@JvmStatic external fun nativeOpenDiagFile(path: String)
@JvmStatic external fun nativeCloseDiagFile()
}

View File

@@ -0,0 +1,72 @@
package com.rosetta.messenger.network.connection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicLong
class AuthBootstrapCoordinator(
private val scope: CoroutineScope,
private val addLog: (String) -> Unit
) {
private val sessionCounter = AtomicLong(0L)
private val mutex = Mutex()
@Volatile private var activeAuthenticatedSessionId = 0L
@Volatile private var lastBootstrappedSessionId = 0L
@Volatile private var deferredAuthBootstrap = false
fun onAuthenticatedSessionStarted(): Long {
val sessionId = sessionCounter.incrementAndGet()
activeAuthenticatedSessionId = sessionId
deferredAuthBootstrap = false
return sessionId
}
fun reset() {
deferredAuthBootstrap = false
activeAuthenticatedSessionId = 0L
lastBootstrappedSessionId = 0L
}
fun isBootstrapPending(): Boolean {
return activeAuthenticatedSessionId > 0L &&
lastBootstrappedSessionId != activeAuthenticatedSessionId
}
fun tryRun(
trigger: String,
canRun: () -> Boolean,
onDeferred: () -> Unit,
runBootstrap: suspend () -> Unit
) {
val sessionId = activeAuthenticatedSessionId
if (sessionId <= 0L) return
scope.launch {
mutex.withLock {
if (sessionId != activeAuthenticatedSessionId) return@withLock
if (sessionId == lastBootstrappedSessionId) return@withLock
if (!canRun()) {
deferredAuthBootstrap = true
onDeferred()
return@withLock
}
deferredAuthBootstrap = false
addLog("🚀 AUTH bootstrap start session=$sessionId trigger=$trigger")
runCatching { runBootstrap() }
.onSuccess {
lastBootstrappedSessionId = sessionId
addLog("✅ AUTH bootstrap complete session=$sessionId trigger=$trigger")
}
.onFailure { error ->
addLog(
"❌ AUTH bootstrap failed session=$sessionId trigger=$trigger: ${error.message}"
)
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.data.AccountManager
class AuthRestoreService(
private val getAccountManager: () -> AccountManager?,
private val addLog: (String) -> Unit,
private val shortKeyForLog: (String) -> String,
private val authenticate: (publicKey: String, privateHash: String) -> Unit
) {
fun restoreAuthFromStoredCredentials(
preferredPublicKey: String? = null,
reason: String = "background_restore"
): Boolean {
val accountManager = getAccountManager()
if (accountManager == null) {
addLog("⚠️ restoreAuthFromStoredCredentials skipped: AccountManager is not bound")
return false
}
val publicKey =
preferredPublicKey?.trim().orEmpty().ifBlank {
accountManager.getLastLoggedPublicKey().orEmpty()
}
val privateHash = accountManager.getLastLoggedPrivateKeyHash().orEmpty()
if (publicKey.isBlank() || privateHash.isBlank()) {
addLog(
"⚠️ restoreAuthFromStoredCredentials skipped (pk=${publicKey.isNotBlank()} hash=${privateHash.isNotBlank()} reason=$reason)"
)
return false
}
addLog("🔐 Restoring auth from cache reason=$reason pk=${shortKeyForLog(publicKey)}")
authenticate(publicKey, privateHash)
return true
}
}

View File

@@ -0,0 +1,92 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.*
class BootstrapCoordinator(
private val readyPacketGate: ReadyPacketGate,
private val addLog: (String) -> Unit,
private val shortKeyForLog: (String) -> String,
private val sendPacketDirect: (Packet) -> Unit
) {
fun protocolToLifecycleState(state: ProtocolState): ConnectionLifecycleState =
when (state) {
ProtocolState.DISCONNECTED -> ConnectionLifecycleState.DISCONNECTED
ProtocolState.CONNECTING -> ConnectionLifecycleState.CONNECTING
ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> ConnectionLifecycleState.HANDSHAKING
ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
ConnectionLifecycleState.DEVICE_VERIFICATION_REQUIRED
ProtocolState.AUTHENTICATED -> ConnectionLifecycleState.AUTHENTICATED
}
fun packetCanBypassReadyGate(packet: Packet): Boolean =
when (packet) {
is PacketHandshake,
is PacketSync,
is PacketSearch,
is PacketPushNotification,
is PacketRequestTransport,
is PacketRequestUpdate,
is PacketSignalPeer,
is PacketWebRTC,
is PacketIceServers,
is PacketDeviceResolve -> true
else -> false
}
fun recomputeLifecycleState(
context: ConnectionBootstrapContext,
currentState: ConnectionLifecycleState,
reason: String,
onStateChanged: (ConnectionLifecycleState, String) -> Unit
): ConnectionLifecycleState {
val nextState =
if (context.authenticated) {
if (context.accountInitialized && context.syncCompleted && context.ownProfileResolved) {
ConnectionLifecycleState.READY
} else {
ConnectionLifecycleState.BOOTSTRAPPING
}
} else {
protocolToLifecycleState(context.protocolState)
}
if (currentState != nextState) {
onStateChanged(nextState, reason)
}
if (nextState == ConnectionLifecycleState.READY) {
flushReadyPacketQueue(context.accountPublicKey, reason)
}
return nextState
}
fun clearReadyPacketQueue(reason: String) {
readyPacketGate.clear(reason = reason, addLog = addLog)
}
fun enqueueReadyPacket(
packet: Packet,
accountPublicKey: String,
state: ConnectionLifecycleState
) {
readyPacketGate.enqueue(
packet = packet,
accountPublicKey = accountPublicKey,
state = state,
shortKeyForLog = shortKeyForLog,
addLog = addLog
)
}
fun flushReadyPacketQueue(activeAccountKey: String, reason: String) {
val packetsToSend =
readyPacketGate.drainForAccount(
activeAccountKey = activeAccountKey,
reason = reason,
addLog = addLog
)
if (packetsToSend.isEmpty()) return
packetsToSend.forEach(sendPacketDirect)
}
}

View File

@@ -0,0 +1,117 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketIceServers
import com.rosetta.messenger.network.PacketSignalPeer
import com.rosetta.messenger.network.PacketWebRTC
import com.rosetta.messenger.network.SignalType
import com.rosetta.messenger.network.WebRTCSignalType
class CallSignalBridge(
private val sendPacket: (Packet) -> Unit,
private val waitPacket: (packetId: Int, callback: (Packet) -> Unit) -> Unit,
private val unwaitPacket: (packetId: Int, callback: (Packet) -> Unit) -> Unit,
private val addLog: (String) -> Unit,
private val shortKeyForLog: (String, Int) -> String,
private val shortTextForLog: (String, Int) -> String
) {
private companion object {
const val PACKET_SIGNAL_PEER = 0x1A
const val PACKET_WEB_RTC = 0x1B
const val PACKET_ICE_SERVERS = 0x1C
}
fun sendCallSignal(
signalType: SignalType,
src: String = "",
dst: String = "",
sharedPublic: String = "",
callId: String = "",
joinToken: String = ""
) {
addLog(
"📡 CALL TX type=$signalType src=${shortKeyForLog(src, 8)} dst=${shortKeyForLog(dst, 8)} " +
"sharedLen=${sharedPublic.length} callId=${shortKeyForLog(callId, 12)} join=${shortKeyForLog(joinToken, 12)}"
)
sendPacket(
PacketSignalPeer().apply {
this.signalType = signalType
this.src = src
this.dst = dst
this.sharedPublic = sharedPublic
this.callId = callId
this.joinToken = joinToken
}
)
}
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
addLog(
"📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " +
"preview='${shortTextForLog(sdpOrCandidate, 56)}'"
)
sendPacket(
PacketWebRTC().apply {
this.signalType = signalType
this.sdpOrCandidate = sdpOrCandidate
}
)
}
fun requestIceServers() {
addLog("📡 ICE TX request")
sendPacket(PacketIceServers())
}
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketSignalPeer)?.let {
addLog(
"📡 CALL RX type=${it.signalType} src=${shortKeyForLog(it.src, 8)} dst=${shortKeyForLog(it.dst, 8)} " +
"sharedLen=${it.sharedPublic.length} callId=${shortKeyForLog(it.callId, 12)} join=${shortKeyForLog(it.joinToken, 12)}"
)
callback(it)
}
}
waitPacket(PACKET_SIGNAL_PEER, wrapper)
return wrapper
}
fun unwaitCallSignal(callback: (Packet) -> Unit) {
unwaitPacket(PACKET_SIGNAL_PEER, callback)
}
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketWebRTC)?.let {
addLog(
"📡 WEBRTC RX type=${it.signalType} sdpLen=${it.sdpOrCandidate.length} " +
"preview='${shortTextForLog(it.sdpOrCandidate, 56)}'"
)
callback(it)
}
}
waitPacket(PACKET_WEB_RTC, wrapper)
return wrapper
}
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
unwaitPacket(PACKET_WEB_RTC, callback)
}
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketIceServers)?.let {
val firstUrl = it.iceServers.firstOrNull()?.url.orEmpty()
addLog("📡 ICE RX count=${it.iceServers.size} first='${shortTextForLog(firstUrl, 56)}'")
callback(it)
}
}
waitPacket(PACKET_ICE_SERVERS, wrapper)
return wrapper
}
fun unwaitIceServers(callback: (Packet) -> Unit) {
unwaitPacket(PACKET_ICE_SERVERS, callback)
}
}

View File

@@ -0,0 +1,53 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.ConnectionEvent
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.ProtocolState
class ConnectionEventRouter(
private val handleInitializeAccount: (publicKey: String, privateKey: String) -> Unit,
private val handleConnect: (reason: String) -> Unit,
private val handleFastReconnect: (reason: String) -> Unit,
private val handleDisconnect: (reason: String, clearCredentials: Boolean) -> Unit,
private val handleAuthenticate: (publicKey: String, privateHash: String) -> Unit,
private val handleProtocolStateChanged: (state: ProtocolState) -> Unit,
private val handleSendPacket: (packet: Packet) -> Unit,
private val handleSyncCompleted: (reason: String) -> Unit,
private val handleOwnProfileResolved: (publicKey: String) -> Unit,
private val handleOwnProfileFallbackTimeout: (sessionGeneration: Long) -> Unit
) {
suspend fun route(event: ConnectionEvent) {
when (event) {
is ConnectionEvent.InitializeAccount -> {
handleInitializeAccount(event.publicKey, event.privateKey)
}
is ConnectionEvent.Connect -> {
handleConnect(event.reason)
}
is ConnectionEvent.FastReconnect -> {
handleFastReconnect(event.reason)
}
is ConnectionEvent.Disconnect -> {
handleDisconnect(event.reason, event.clearCredentials)
}
is ConnectionEvent.Authenticate -> {
handleAuthenticate(event.publicKey, event.privateHash)
}
is ConnectionEvent.ProtocolStateChanged -> {
handleProtocolStateChanged(event.state)
}
is ConnectionEvent.SendPacket -> {
handleSendPacket(event.packet)
}
is ConnectionEvent.SyncCompleted -> {
handleSyncCompleted(event.reason)
}
is ConnectionEvent.OwnProfileResolved -> {
handleOwnProfileResolved(event.publicKey)
}
is ConnectionEvent.OwnProfileFallbackTimeout -> {
handleOwnProfileFallbackTimeout(event.sessionGeneration)
}
}
}
}

View File

@@ -0,0 +1,37 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.HandshakeDevice
import com.rosetta.messenger.network.Protocol
class ConnectionOrchestrator(
private val hasActiveInternet: () -> Boolean,
private val waitForNetworkAndReconnect: (String) -> Unit,
private val stopWaitingForNetwork: (String) -> Unit,
private val getProtocol: () -> Protocol,
private val persistHandshakeCredentials: (publicKey: String, privateHash: String) -> Unit,
private val buildHandshakeDevice: () -> HandshakeDevice
) {
fun handleConnect(reason: String) {
if (!hasActiveInternet()) {
waitForNetworkAndReconnect("connect:$reason")
return
}
stopWaitingForNetwork("connect:$reason")
getProtocol().connect()
}
fun handleFastReconnect(reason: String) {
if (!hasActiveInternet()) {
waitForNetworkAndReconnect("reconnect:$reason")
return
}
stopWaitingForNetwork("reconnect:$reason")
getProtocol().reconnectNowIfNeeded(reason)
}
fun handleAuthenticate(publicKey: String, privateHash: String) {
runCatching { persistHandshakeCredentials(publicKey, privateHash) }
val device = buildHandshakeDevice()
getProtocol().startHandshake(publicKey, privateHash, device)
}
}

View File

@@ -0,0 +1,96 @@
package com.rosetta.messenger.network.connection
import android.content.Context
import android.os.Build
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.DeviceResolveSolution
import com.rosetta.messenger.network.HandshakeDevice
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketDeviceList
import java.security.SecureRandom
import kotlinx.coroutines.flow.StateFlow
class DeviceRuntimeService(
private val getAppContext: () -> Context?,
private val sendPacket: (Packet) -> Unit,
private val devicePrefsName: String = "rosetta_protocol",
private val deviceIdKey: String = "device_id",
private val deviceIdLength: Int = 128
) {
private val verificationService = DeviceVerificationService()
val devices: StateFlow<List<DeviceEntry>> = verificationService.devices
val pendingDeviceVerification: StateFlow<DeviceEntry?> =
verificationService.pendingDeviceVerification
fun handleDeviceList(packet: PacketDeviceList) {
verificationService.handleDeviceList(packet)
}
fun acceptDevice(deviceId: String) {
sendPacket(
verificationService.buildResolvePacket(
deviceId = deviceId,
solution = DeviceResolveSolution.ACCEPT
)
)
}
fun declineDevice(deviceId: String) {
sendPacket(
verificationService.buildResolvePacket(
deviceId = deviceId,
solution = DeviceResolveSolution.DECLINE
)
)
}
fun resolvePushDeviceId(): String {
return getAppContext()?.let(::getOrCreateDeviceId).orEmpty()
}
fun buildHandshakeDevice(): HandshakeDevice {
val context = getAppContext()
val deviceId = if (context != null) getOrCreateDeviceId(context) else generateDeviceId()
val manufacturer = Build.MANUFACTURER.orEmpty().trim()
val model = Build.MODEL.orEmpty().trim()
val name =
listOf(manufacturer, model)
.filter { it.isNotBlank() }
.distinct()
.joinToString(" ")
.ifBlank { "Android Device" }
val os = "Android ${Build.VERSION.RELEASE ?: "Unknown"}"
return HandshakeDevice(
deviceId = deviceId,
deviceName = name,
deviceOs = os
)
}
fun clear() {
verificationService.clear()
}
private fun getOrCreateDeviceId(context: Context): String {
val prefs = context.getSharedPreferences(devicePrefsName, Context.MODE_PRIVATE)
val cached = prefs.getString(deviceIdKey, null)
if (!cached.isNullOrBlank()) {
return cached
}
val newId = generateDeviceId()
prefs.edit().putString(deviceIdKey, newId).apply()
return newId
}
private fun generateDeviceId(): String {
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
val random = SecureRandom()
return buildString(deviceIdLength) {
repeat(deviceIdLength) {
append(chars[random.nextInt(chars.length)])
}
}
}
}

View File

@@ -0,0 +1,42 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.DeviceResolveSolution
import com.rosetta.messenger.network.DeviceVerifyState
import com.rosetta.messenger.network.PacketDeviceList
import com.rosetta.messenger.network.PacketDeviceResolve
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class DeviceVerificationService {
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
val devices: StateFlow<List<DeviceEntry>> = _devices.asStateFlow()
private val _pendingDeviceVerification = MutableStateFlow<DeviceEntry?>(null)
val pendingDeviceVerification: StateFlow<DeviceEntry?> = _pendingDeviceVerification.asStateFlow()
fun handleDeviceList(packet: PacketDeviceList) {
val parsedDevices = packet.devices
_devices.value = parsedDevices
_pendingDeviceVerification.value =
parsedDevices.firstOrNull { device ->
device.deviceVerify == DeviceVerifyState.NOT_VERIFIED
}
}
fun buildResolvePacket(
deviceId: String,
solution: DeviceResolveSolution
): PacketDeviceResolve {
return PacketDeviceResolve().apply {
this.deviceId = deviceId
this.solution = solution
}
}
fun clear() {
_devices.value = emptyList()
_pendingDeviceVerification.value = null
}
}

View File

@@ -0,0 +1,279 @@
package com.rosetta.messenger.network.connection
import android.util.Log
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.OnlineState
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketDelivery
import com.rosetta.messenger.network.PacketDeviceList
import com.rosetta.messenger.network.PacketDeviceNew
import com.rosetta.messenger.network.PacketGroupJoin
import com.rosetta.messenger.network.PacketMessage
import com.rosetta.messenger.network.PacketOnlineState
import com.rosetta.messenger.network.PacketRead
import com.rosetta.messenger.network.PacketRequestTransport
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.PacketSync
import com.rosetta.messenger.network.PacketTyping
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class InboundPacketHandlerRegistrar(
private val tag: String,
private val scope: CoroutineScope,
private val syncCoordinator: SyncCoordinator,
private val presenceTypingService: PresenceTypingService,
private val deviceRuntimeService: DeviceRuntimeService,
private val packetRouter: PacketRouter,
private val ownProfileSyncService: OwnProfileSyncService,
private val waitPacket: (Int, (Packet) -> Unit) -> Unit,
private val launchInboundPacketTask: (suspend () -> Unit) -> Boolean,
private val getMessageRepository: () -> MessageRepository?,
private val getGroupRepository: () -> GroupRepository?,
private val getProtocolPublicKey: () -> String,
private val addLog: (String) -> Unit,
private val markInboundProcessingFailure: (String, Throwable?) -> Unit,
private val resolveOutgoingRetry: (String) -> Unit,
private val isGroupDialogKey: (String) -> Boolean,
private val onOwnProfileResolved: (String) -> Unit,
private val setTransportServer: (String) -> Unit
) {
fun register() {
registerIncomingMessageHandler()
registerDeliveryHandler()
registerReadHandler()
registerDeviceLoginHandler()
registerSyncHandler()
registerGroupSyncHandler()
registerOnlineStatusHandler()
registerTypingHandler()
registerDeviceListHandler()
registerSearchHandler()
registerTransportHandler()
}
private fun registerIncomingMessageHandler() {
waitPacket(0x06) { packet ->
val messagePacket = packet as PacketMessage
launchInboundPacketTask {
val repository = getMessageRepository()
if (repository == null || !repository.isInitialized()) {
syncCoordinator.requireResyncAfterAccountInit(
"⏳ Incoming message before account init, scheduling re-sync"
)
markInboundProcessingFailure("Incoming packet skipped before account init", null)
return@launchInboundPacketTask
}
val processed = repository.handleIncomingMessage(messagePacket)
if (!processed) {
markInboundProcessingFailure(
"Message processing failed for ${messagePacket.messageId.take(8)}",
null
)
return@launchInboundPacketTask
}
if (!syncCoordinator.isBatchInProgress()) {
repository.updateLastSyncTimestamp(messagePacket.timestamp)
}
}
}
}
private fun registerDeliveryHandler() {
waitPacket(0x08) { packet ->
val deliveryPacket = packet as PacketDelivery
launchInboundPacketTask {
val repository = getMessageRepository()
if (repository == null || !repository.isInitialized()) {
syncCoordinator.requireResyncAfterAccountInit(
"⏳ Delivery status before account init, scheduling re-sync"
)
markInboundProcessingFailure("Delivery packet skipped before account init", null)
return@launchInboundPacketTask
}
try {
repository.handleDelivery(deliveryPacket)
resolveOutgoingRetry(deliveryPacket.messageId)
} catch (e: Exception) {
markInboundProcessingFailure("Delivery processing failed", e)
return@launchInboundPacketTask
}
if (!syncCoordinator.isBatchInProgress()) {
repository.updateLastSyncTimestamp(System.currentTimeMillis())
}
}
}
}
private fun registerReadHandler() {
waitPacket(0x07) { packet ->
val readPacket = packet as PacketRead
launchInboundPacketTask {
val repository = getMessageRepository()
if (repository == null || !repository.isInitialized()) {
syncCoordinator.requireResyncAfterAccountInit(
"⏳ Read status before account init, scheduling re-sync"
)
markInboundProcessingFailure("Read packet skipped before account init", null)
return@launchInboundPacketTask
}
val ownKey = getProtocolPublicKey()
if (ownKey.isBlank()) {
syncCoordinator.requireResyncAfterAccountInit(
"⏳ Read status before protocol account init, scheduling re-sync"
)
markInboundProcessingFailure(
"Read packet skipped before protocol account init",
null
)
return@launchInboundPacketTask
}
try {
repository.handleRead(readPacket)
} catch (e: Exception) {
markInboundProcessingFailure("Read processing failed", e)
return@launchInboundPacketTask
}
if (!syncCoordinator.isBatchInProgress()) {
// Desktop parity:
// own direct read sync (from=me,to=peer) does not advance sync cursor.
val isOwnDirectReadSync =
readPacket.fromPublicKey.trim() == ownKey &&
!isGroupDialogKey(readPacket.toPublicKey)
if (!isOwnDirectReadSync) {
repository.updateLastSyncTimestamp(System.currentTimeMillis())
}
}
}
}
}
private fun registerDeviceLoginHandler() {
waitPacket(0x09) { packet ->
val devicePacket = packet as PacketDeviceNew
addLog(
"🔐 DEVICE LOGIN ATTEMPT: ip=${devicePacket.ipAddress}, device=${devicePacket.device.deviceName}, os=${devicePacket.device.deviceOs}"
)
launchInboundPacketTask {
getMessageRepository()?.addDeviceLoginSystemMessage(
ipAddress = devicePacket.ipAddress,
deviceId = devicePacket.device.deviceId,
deviceName = devicePacket.device.deviceName,
deviceOs = devicePacket.device.deviceOs
)
}
}
}
private fun registerSyncHandler() {
waitPacket(0x19) { packet ->
syncCoordinator.handleSyncPacket(packet as PacketSync)
}
}
private fun registerGroupSyncHandler() {
waitPacket(0x14) { packet ->
val joinPacket = packet as PacketGroupJoin
launchInboundPacketTask {
val repository = getMessageRepository()
val groups = getGroupRepository()
val account = repository?.getCurrentAccountKey()
val privateKey = repository?.getCurrentPrivateKey()
if (groups == null || account.isNullOrBlank() || privateKey.isNullOrBlank()) {
return@launchInboundPacketTask
}
try {
val result = groups.synchronizeJoinedGroup(
accountPublicKey = account,
accountPrivateKey = privateKey,
packet = joinPacket
)
if (result?.success == true) {
addLog("👥 GROUP synced: ${result.dialogPublicKey}")
}
} catch (e: Exception) {
Log.w(tag, "Failed to sync group packet", e)
}
}
}
}
private fun registerOnlineStatusHandler() {
waitPacket(0x05) { packet ->
val onlinePacket = packet as PacketOnlineState
scope.launch {
val repository = getMessageRepository()
if (repository != null) {
onlinePacket.publicKeysState.forEach { item ->
val isOnline = item.state == OnlineState.ONLINE
repository.updateOnlineStatus(item.publicKey, isOnline)
}
}
}
}
}
private fun registerTypingHandler() {
waitPacket(0x0B) { packet ->
presenceTypingService.handleTypingPacket(packet as PacketTyping) {
getProtocolPublicKey().ifBlank {
getMessageRepository()?.getCurrentAccountKey()?.trim().orEmpty()
}
}
}
}
private fun registerDeviceListHandler() {
waitPacket(0x17) { packet ->
deviceRuntimeService.handleDeviceList(packet as PacketDeviceList)
}
}
private fun registerSearchHandler() {
waitPacket(0x03) { packet ->
val searchPacket = packet as PacketSearch
scope.launch(Dispatchers.IO) {
val ownPublicKey =
getProtocolPublicKey().ifBlank {
getMessageRepository()?.getCurrentAccountKey()?.trim().orEmpty()
}
packetRouter.onSearchPacket(searchPacket) { user ->
val normalizedUserPublicKey = user.publicKey.trim()
getMessageRepository()?.updateDialogUserInfo(
normalizedUserPublicKey,
user.title,
user.username,
user.verified
)
val ownProfileResolved =
ownProfileSyncService.applyOwnProfileFromSearch(
ownPublicKey = ownPublicKey,
user = user
)
if (ownProfileResolved) {
onOwnProfileResolved(user.publicKey)
}
}
}
}
}
private fun registerTransportHandler() {
waitPacket(0x0F) { packet ->
val transportPacket = packet as PacketRequestTransport
setTransportServer(transportPacket.transportServer)
}
}
}

View File

@@ -0,0 +1,49 @@
package com.rosetta.messenger.network.connection
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
class InboundTaskQueueService(
private val scope: CoroutineScope,
private val onTaskQueued: () -> Unit,
private val onTaskFailure: (String, Throwable?) -> Unit
) {
private val inboundTaskChannel = Channel<suspend () -> Unit>(Channel.UNLIMITED)
@Volatile private var inboundQueueDrainJob: Job? = null
fun enqueue(block: suspend () -> Unit): Boolean {
ensureDrainRunning()
onTaskQueued()
val result = inboundTaskChannel.trySend(block)
if (result.isFailure) {
onTaskFailure("Failed to enqueue inbound task", result.exceptionOrNull())
return false
}
return true
}
suspend fun whenTasksFinish(): Boolean {
val done = CompletableDeferred<Unit>()
if (!enqueue { done.complete(Unit) }) {
return false
}
done.await()
return true
}
private fun ensureDrainRunning() {
if (inboundQueueDrainJob?.isActive == true) return
inboundQueueDrainJob = scope.launch {
for (task in inboundTaskChannel) {
try {
task()
} catch (t: Throwable) {
onTaskFailure("Dialog queue error", t)
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
package com.rosetta.messenger.network.connection
import android.content.Context
class NetworkConnectivityFacade(
private val networkReconnectWatcher: NetworkReconnectWatcher,
private val getAppContext: () -> Context?
) {
fun hasActiveInternet(): Boolean {
return networkReconnectWatcher.hasActiveInternet(getAppContext())
}
fun stopWaitingForNetwork(reason: String? = null) {
networkReconnectWatcher.stop(getAppContext(), reason)
}
fun waitForNetworkAndReconnect(reason: String) {
networkReconnectWatcher.waitForNetwork(getAppContext(), reason)
}
}

View File

@@ -0,0 +1,131 @@
package com.rosetta.messenger.network.connection
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class NetworkReconnectWatcher(
private val scope: CoroutineScope,
private val networkWaitTimeoutMs: Long,
private val addLog: (String) -> Unit,
private val onReconnectRequested: (String) -> Unit
) {
private val lock = Any()
@Volatile private var registered = false
@Volatile private var callback: ConnectivityManager.NetworkCallback? = null
@Volatile private var timeoutJob: Job? = null
fun hasActiveInternet(context: Context?): Boolean {
if (context == null) return true
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return true
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
fun stop(context: Context?, reason: String? = null) {
if (context == null) return
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return
val currentCallback = synchronized(lock) {
val current = callback
callback = null
registered = false
timeoutJob?.cancel()
timeoutJob = null
current
}
if (currentCallback != null) {
runCatching { cm.unregisterNetworkCallback(currentCallback) }
if (!reason.isNullOrBlank()) {
addLog("📡 NETWORK WATCH STOP: $reason")
}
}
}
fun waitForNetwork(context: Context?, reason: String) {
if (context == null) return
if (hasActiveInternet(context)) {
stop(context, "network already available")
return
}
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return
val alreadyRegistered = synchronized(lock) {
if (registered) {
true
} else {
val callback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (hasActiveInternet(context)) {
addLog("📡 NETWORK AVAILABLE → reconnect")
stop(context, "available")
onReconnectRequested("network_available")
}
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
addLog("📡 NETWORK CAPABILITIES READY → reconnect")
stop(context, "capabilities_changed")
onReconnectRequested("network_capabilities_changed")
}
}
}
this.callback = callback
registered = true
false
}
}
if (alreadyRegistered) {
addLog("📡 NETWORK WAIT already active (reason=$reason)")
return
}
addLog("📡 NETWORK WAIT start (reason=$reason)")
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
cm.registerDefaultNetworkCallback(callback!!)
} else {
val request =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, callback!!)
}
}.onFailure { error ->
addLog("⚠️ NETWORK WAIT register failed: ${error.message}")
stop(context, "register_failed")
onReconnectRequested("network_wait_register_failed")
}
timeoutJob?.cancel()
timeoutJob =
scope.launch {
delay(networkWaitTimeoutMs)
if (!hasActiveInternet(context)) {
addLog("⏱️ NETWORK WAIT timeout (${networkWaitTimeoutMs}ms), reconnect fallback")
stop(context, "timeout")
onReconnectRequested("network_wait_timeout")
}
}
}
}

View File

@@ -0,0 +1,48 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.DeliveryStatus
import com.rosetta.messenger.network.PacketMessage
import kotlinx.coroutines.CoroutineScope
class OutgoingMessagePipelineService(
scope: CoroutineScope,
private val getRepository: () -> MessageRepository?,
private val sendPacket: (PacketMessage) -> Unit,
isAuthenticated: () -> Boolean,
addLog: (String) -> Unit
) {
private val retryQueueService =
RetryQueueService(
scope = scope,
sendPacket = sendPacket,
isAuthenticated = isAuthenticated,
addLog = addLog,
markOutgoingAsError = ::markOutgoingAsError
)
fun sendWithRetry(packet: PacketMessage) {
sendPacket(packet)
retryQueueService.register(packet)
}
fun resolveOutgoingRetry(messageId: String) {
retryQueueService.resolve(messageId)
}
fun clearRetryQueue() {
retryQueueService.clear()
}
private suspend fun markOutgoingAsError(messageId: String, packet: PacketMessage) {
val repository = getRepository() ?: return
val opponentKey =
if (packet.fromPublicKey == repository.getCurrentAccountKey()) {
packet.toPublicKey
} else {
packet.fromPublicKey
}
val dialogKey = repository.getDialogKey(opponentKey)
repository.updateMessageDeliveryStatus(dialogKey, messageId, DeliveryStatus.ERROR)
}
}

View File

@@ -0,0 +1,28 @@
package com.rosetta.messenger.network.connection
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class OwnProfileFallbackTimerService(
private val scope: CoroutineScope,
private val fallbackTimeoutMs: Long,
private val onTimeout: (Long) -> Unit
) {
@Volatile private var timeoutJob: Job? = null
fun schedule(sessionGeneration: Long) {
cancel()
timeoutJob =
scope.launch {
delay(fallbackTimeoutMs)
onTimeout(sessionGeneration)
}
}
fun cancel() {
timeoutJob?.cancel()
timeoutJob = null
}
}

View File

@@ -0,0 +1,50 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.session.IdentityStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class OwnProfileSyncService(
private val isPlaceholderAccountName: (String?) -> Boolean,
private val updateAccountName: suspend (publicKey: String, name: String) -> Unit,
private val updateAccountUsername: suspend (publicKey: String, username: String) -> Unit
) {
private val _ownProfileUpdated = MutableStateFlow(0L)
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
fun notifyOwnProfileUpdated() {
_ownProfileUpdated.value = System.currentTimeMillis()
}
suspend fun applyOwnProfileFromSearch(
ownPublicKey: String,
user: SearchUser
): Boolean {
if (ownPublicKey.isBlank()) return false
if (!user.publicKey.equals(ownPublicKey, ignoreCase = true)) return false
if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) {
updateAccountName(ownPublicKey, user.title)
}
if (user.username.isNotBlank()) {
updateAccountUsername(ownPublicKey, user.username)
}
IdentityStore.updateOwnProfile(user, reason = "protocol_search_own_profile")
_ownProfileUpdated.value = System.currentTimeMillis()
return true
}
fun buildOwnProfilePacket(publicKey: String?, privateHash: String?): PacketSearch? {
val normalizedPublicKey = publicKey?.trim().orEmpty()
val normalizedPrivateHash = privateHash?.trim().orEmpty()
if (normalizedPublicKey.isEmpty() || normalizedPrivateHash.isEmpty()) return null
return PacketSearch().apply {
this.privateKey = normalizedPrivateHash
this.search = normalizedPublicKey
}
}
}

View File

@@ -0,0 +1,221 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import java.util.LinkedHashSet
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.resume
class PacketRouter(
private val sendSearchPacket: (PacketSearch) -> Unit,
private val privateHashProvider: () -> String?
) {
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
private val pendingResolves =
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
private val pendingSearchQueries =
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>>()
private fun normalizeSearchQuery(value: String): String =
value.trim().removePrefix("@").lowercase(Locale.ROOT)
suspend fun onSearchPacket(packet: PacketSearch, onUserDiscovered: suspend (SearchUser) -> Unit) {
if (packet.users.isNotEmpty()) {
packet.users.forEach { user ->
val normalizedUserPublicKey = user.publicKey.trim()
userInfoCache[normalizedUserPublicKey] = user
pendingResolves
.keys
.filter { it.equals(normalizedUserPublicKey, ignoreCase = true) }
.forEach { key ->
pendingResolves.remove(key)?.forEach { cont ->
try {
cont.resume(user)
} catch (_: Exception) {}
}
}
onUserDiscovered(user)
}
}
if (packet.search.isNotEmpty() && packet.users.none { it.publicKey == packet.search }) {
pendingResolves.remove(packet.search)?.forEach { cont ->
try {
cont.resume(null)
} catch (_: Exception) {}
}
}
if (packet.search.isNotEmpty()) {
val rawQuery = packet.search.trim()
val normalizedQuery = normalizeSearchQuery(rawQuery)
val continuations =
LinkedHashSet<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>()
fun collectByKey(key: String) {
if (key.isEmpty()) return
pendingSearchQueries.remove(key)?.let { continuations.addAll(it) }
}
collectByKey(rawQuery)
if (normalizedQuery.isNotEmpty() && normalizedQuery != rawQuery) {
collectByKey(normalizedQuery)
}
if (continuations.isEmpty()) {
val matchedByQuery =
pendingSearchQueries.keys.firstOrNull { pendingKey ->
pendingKey.equals(rawQuery, ignoreCase = true) ||
normalizeSearchQuery(pendingKey) == normalizedQuery
}
if (matchedByQuery != null) collectByKey(matchedByQuery)
}
if (continuations.isEmpty() && packet.users.isNotEmpty()) {
val responseUsernames =
packet.users
.map { normalizeSearchQuery(it.username) }
.filter { it.isNotEmpty() }
.toSet()
if (responseUsernames.isNotEmpty()) {
val matchedByUsers =
pendingSearchQueries.keys.firstOrNull { pendingKey ->
val normalizedPending = normalizeSearchQuery(pendingKey)
normalizedPending.isNotEmpty() &&
responseUsernames.contains(normalizedPending)
}
if (matchedByUsers != null) collectByKey(matchedByUsers)
}
}
continuations.forEach { cont ->
try {
cont.resume(packet.users)
} catch (_: Exception) {}
}
}
}
fun getCachedUserName(publicKey: String): String? {
val cached = userInfoCache[publicKey] ?: return null
return cached.title.ifEmpty { cached.username }.ifEmpty { null }
}
fun getCachedUserInfo(publicKey: String): SearchUser? = userInfoCache[publicKey]
fun getCachedUserByUsername(username: String): SearchUser? {
val normalizedUsername = normalizeSearchQuery(username)
if (normalizedUsername.isEmpty()) return null
return userInfoCache.values.firstOrNull { cached ->
normalizeSearchQuery(cached.username) == normalizedUsername
}
}
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
if (publicKey.isEmpty()) return null
userInfoCache[publicKey]?.let { cached ->
val name = cached.title.ifEmpty { cached.username }
if (name.isNotEmpty()) return name
}
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return null
return try {
withTimeout(timeoutMs) {
suspendCancellableCoroutine { cont ->
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
cont.invokeOnCancellation {
pendingResolves[publicKey]?.remove(cont)
if (pendingResolves[publicKey]?.isEmpty() == true) {
pendingResolves.remove(publicKey)
}
}
val packet = PacketSearch().apply {
this.privateKey = privateHash
this.search = publicKey
}
sendSearchPacket(packet)
}
}?.let { user -> user.title.ifEmpty { user.username }.ifEmpty { null } }
} catch (_: Exception) {
pendingResolves.remove(publicKey)
null
}
}
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
if (publicKey.isEmpty()) return null
userInfoCache[publicKey]?.let { return it }
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return null
return try {
withTimeout(timeoutMs) {
suspendCancellableCoroutine { cont ->
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
cont.invokeOnCancellation {
pendingResolves[publicKey]?.remove(cont)
if (pendingResolves[publicKey]?.isEmpty() == true) {
pendingResolves.remove(publicKey)
}
}
val packet = PacketSearch().apply {
this.privateKey = privateHash
this.search = publicKey
}
sendSearchPacket(packet)
}
}
} catch (_: Exception) {
pendingResolves.remove(publicKey)
null
}
}
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
val normalizedQuery = normalizeSearchQuery(query)
if (normalizedQuery.isEmpty()) return emptyList()
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return emptyList()
val cachedMatches =
userInfoCache.values.filter { cached ->
normalizeSearchQuery(cached.username) == normalizedQuery && cached.publicKey.isNotBlank()
}
if (cachedMatches.isNotEmpty()) {
return cachedMatches.distinctBy { it.publicKey }
}
return try {
withTimeout(timeoutMs) {
suspendCancellableCoroutine { cont ->
pendingSearchQueries.getOrPut(normalizedQuery) { mutableListOf() }.add(cont)
cont.invokeOnCancellation {
pendingSearchQueries[normalizedQuery]?.remove(cont)
if (pendingSearchQueries[normalizedQuery]?.isEmpty() == true) {
pendingSearchQueries.remove(normalizedQuery)
}
}
val packet = PacketSearch().apply {
this.privateKey = privateHash
this.search = normalizedQuery
}
sendSearchPacket(packet)
}
}
} catch (_: Exception) {
pendingSearchQueries.remove(normalizedQuery)
emptyList()
}
}
}

View File

@@ -0,0 +1,21 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketSubscriptionRegistry
import kotlinx.coroutines.flow.SharedFlow
class PacketSubscriptionFacade(
private val packetSubscriptionRegistry: PacketSubscriptionRegistry
) {
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetSubscriptionRegistry.addCallback(packetId, callback)
}
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetSubscriptionRegistry.removeCallback(packetId, callback)
}
fun packetFlow(packetId: Int): SharedFlow<Packet> {
return packetSubscriptionRegistry.flow(packetId)
}
}

View File

@@ -0,0 +1,142 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.PacketTyping
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class PresenceTypingService(
private val scope: CoroutineScope,
private val typingIndicatorTimeoutMs: Long
) {
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
private val _typingUsersByDialogSnapshot =
MutableStateFlow<Map<String, Set<String>>>(emptyMap())
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
_typingUsersByDialogSnapshot.asStateFlow()
private val typingStateLock = Any()
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
val normalizedDialogKey =
if (isGroupDialogKey(dialogKey)) {
normalizeGroupDialogKey(dialogKey)
} else {
dialogKey.trim()
}
if (normalizedDialogKey.isBlank()) return emptySet()
synchronized(typingStateLock) {
return typingUsersByDialog[normalizedDialogKey]?.toSet() ?: emptySet()
}
}
fun handleTypingPacket(
packet: PacketTyping,
ownPublicKeyProvider: () -> String
) {
val fromPublicKey = packet.fromPublicKey.trim()
val toPublicKey = packet.toPublicKey.trim()
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return
val ownPublicKey = ownPublicKeyProvider().trim()
if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) {
return
}
val dialogKey =
resolveTypingDialogKey(
fromPublicKey = fromPublicKey,
toPublicKey = toPublicKey,
ownPublicKey = ownPublicKey
) ?: return
rememberTypingEvent(dialogKey, fromPublicKey)
}
fun clear() {
typingTimeoutJobs.values.forEach { it.cancel() }
typingTimeoutJobs.clear()
synchronized(typingStateLock) {
typingUsersByDialog.clear()
_typingUsers.value = emptySet()
_typingUsersByDialogSnapshot.value = emptyMap()
}
}
private fun isGroupDialogKey(value: String): Boolean {
val normalized = value.trim().lowercase(Locale.ROOT)
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun normalizeGroupDialogKey(value: String): String {
val trimmed = value.trim()
val normalized = trimmed.lowercase(Locale.ROOT)
return when {
normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}"
normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}"
else -> trimmed
}
}
private fun resolveTypingDialogKey(
fromPublicKey: String,
toPublicKey: String,
ownPublicKey: String
): String? {
return when {
isGroupDialogKey(toPublicKey) -> normalizeGroupDialogKey(toPublicKey)
ownPublicKey.isNotBlank() && toPublicKey.equals(ownPublicKey, ignoreCase = true) ->
fromPublicKey.trim()
else -> null
}
}
private fun makeTypingTimeoutKey(dialogKey: String, fromPublicKey: String): String {
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
}
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
val normalizedDialogKey =
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
val normalizedFrom = fromPublicKey.trim()
if (normalizedDialogKey.isBlank() || normalizedFrom.isBlank()) return
synchronized(typingStateLock) {
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
users.add(normalizedFrom)
_typingUsers.value = typingUsersByDialog.keys.toSet()
_typingUsersByDialogSnapshot.value =
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
}
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
typingTimeoutJobs.remove(timeoutKey)?.cancel()
typingTimeoutJobs[timeoutKey] =
scope.launch {
delay(typingIndicatorTimeoutMs)
synchronized(typingStateLock) {
val users = typingUsersByDialog[normalizedDialogKey]
users?.remove(normalizedFrom)
if (users.isNullOrEmpty()) {
typingUsersByDialog.remove(normalizedDialogKey)
}
_typingUsers.value = typingUsersByDialog.keys.toSet()
_typingUsersByDialogSnapshot.value =
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
}
typingTimeoutJobs.remove(timeoutKey)
}
}
}

View File

@@ -0,0 +1,90 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.ConnectionBootstrapContext
import com.rosetta.messenger.network.ProtocolState
class ProtocolAccountSessionCoordinator(
private val stateStore: ProtocolLifecycleStateStore,
private val syncCoordinator: SyncCoordinator,
private val authBootstrapCoordinator: AuthBootstrapCoordinator,
private val presenceTypingService: PresenceTypingService,
private val deviceRuntimeService: DeviceRuntimeService,
private val getMessageRepository: () -> MessageRepository?,
private val getProtocolState: () -> ProtocolState,
private val isProtocolAuthenticated: () -> Boolean,
private val addLog: (String) -> Unit,
private val shortKeyForLog: (String) -> String,
private val clearReadyPacketQueue: (String) -> Unit,
private val recomputeConnectionLifecycleState: (String) -> Unit,
private val stopWaitingForNetwork: (String) -> Unit,
private val disconnectProtocol: (clearCredentials: Boolean) -> Unit,
private val tryRunPostAuthBootstrap: (String) -> Unit,
private val launchVersionUpdateCheck: () -> Unit
) {
fun handleInitializeAccount(publicKey: String, privateKey: String) {
val normalizedPublicKey = publicKey.trim()
val normalizedPrivateKey = privateKey.trim()
if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) {
addLog("⚠️ initializeAccount skipped: missing account credentials")
return
}
val protocolState = getProtocolState()
addLog(
"🔐 initializeAccount pk=${shortKeyForLog(normalizedPublicKey)} keyLen=${normalizedPrivateKey.length} state=$protocolState"
)
syncCoordinator.markSyncInProgress(false)
presenceTypingService.clear()
val repository = getMessageRepository()
if (repository == null) {
addLog("❌ initializeAccount aborted: MessageRepository is not bound")
return
}
repository.initialize(normalizedPublicKey, normalizedPrivateKey)
val context = stateStore.bootstrapContext
val sameAccount = context.accountPublicKey.equals(normalizedPublicKey, ignoreCase = true)
if (!sameAccount) {
clearReadyPacketQueue("account_switch")
}
stateStore.bootstrapContext =
context.copy(
accountPublicKey = normalizedPublicKey,
accountInitialized = true,
syncCompleted = if (sameAccount) context.syncCompleted else false,
ownProfileResolved = if (sameAccount) context.ownProfileResolved else false
)
recomputeConnectionLifecycleState("account_initialized")
val shouldResync = syncCoordinator.shouldResyncAfterAccountInit() || isProtocolAuthenticated()
if (shouldResync) {
syncCoordinator.clearResyncRequired()
syncCoordinator.clearRequestState()
addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync")
syncCoordinator.requestSynchronize()
}
if (isProtocolAuthenticated() && authBootstrapCoordinator.isBootstrapPending()) {
tryRunPostAuthBootstrap("account_initialized")
}
launchVersionUpdateCheck()
}
fun handleDisconnect(reason: String, clearCredentials: Boolean) {
stopWaitingForNetwork(reason)
disconnectProtocol(clearCredentials)
getMessageRepository()?.clearInitialization()
presenceTypingService.clear()
deviceRuntimeService.clear()
syncCoordinator.resetForDisconnect()
stateStore.clearLastSubscribedToken()
stateStore.cancelOwnProfileFallbackTimeout()
authBootstrapCoordinator.reset()
stateStore.bootstrapContext = ConnectionBootstrapContext()
clearReadyPacketQueue("disconnect:$reason")
recomputeConnectionLifecycleState("disconnect:$reason")
}
}

View File

@@ -0,0 +1,132 @@
package com.rosetta.messenger.network.connection
import android.content.Context
import java.io.File
import java.util.Date
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
internal class ProtocolDebugLogService(
private val scope: CoroutineScope,
private val maxDebugLogs: Int,
private val debugLogFlushDelayMs: Long,
private val heartbeatOkLogMinIntervalMs: Long,
private val protocolTraceFileName: String,
private val protocolTraceMaxBytes: Long,
private val protocolTraceKeepBytes: Int,
private val appContextProvider: () -> Context?
) {
private var uiLogsEnabled = false
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
private val debugLogsBuffer = ArrayDeque<String>(maxDebugLogs)
private val debugLogsLock = Any()
private val protocolTraceLock = Any()
@Volatile private var debugFlushJob: Job? = null
private val debugFlushPending = AtomicBoolean(false)
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
fun addLog(message: String) {
var normalizedMessage = message
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
if (isHeartbeatOk) {
val now = System.currentTimeMillis()
if (now - lastHeartbeatOkLogAtMs < heartbeatOkLogMinIntervalMs) {
suppressedHeartbeatOkLogs++
return
}
if (suppressedHeartbeatOkLogs > 0) {
normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)"
suppressedHeartbeatOkLogs = 0
}
lastHeartbeatOkLogAtMs = now
}
val timestamp =
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val line = "[$timestamp] $normalizedMessage"
if (shouldPersistProtocolTrace(normalizedMessage)) {
persistProtocolTraceLine(line)
}
if (!uiLogsEnabled) return
synchronized(debugLogsLock) {
if (debugLogsBuffer.size >= maxDebugLogs) {
debugLogsBuffer.removeFirst()
}
debugLogsBuffer.addLast(line)
}
flushDebugLogsThrottled()
}
fun enableUILogs(enabled: Boolean) {
uiLogsEnabled = enabled
if (enabled) {
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
_debugLogs.value = snapshot
} else {
_debugLogs.value = emptyList()
}
}
fun clearLogs() {
synchronized(debugLogsLock) {
debugLogsBuffer.clear()
}
suppressedHeartbeatOkLogs = 0
lastHeartbeatOkLogAtMs = 0L
_debugLogs.value = emptyList()
}
private fun shouldPersistProtocolTrace(message: String): Boolean {
if (uiLogsEnabled) return true
if (message.startsWith("") || message.startsWith("⚠️")) return true
if (message.contains("STATE CHANGE")) return true
if (message.contains("CONNECTION FULLY ESTABLISHED")) return true
if (message.contains("HANDSHAKE COMPLETE")) return true
if (message.contains("SYNC COMPLETE")) return true
if (message.startsWith("🔌 CONNECT CALLED") || message.startsWith("🔌 Connecting to")) return true
if (message.startsWith("✅ WebSocket OPEN")) return true
if (message.startsWith("📡 NETWORK")) return true
return false
}
private fun persistProtocolTraceLine(line: String) {
val context = appContextProvider() ?: return
runCatching {
val dir = File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val traceFile = File(dir, protocolTraceFileName)
synchronized(protocolTraceLock) {
if (traceFile.exists() && traceFile.length() > protocolTraceMaxBytes) {
val tail = runCatching {
traceFile.readText(Charsets.UTF_8).takeLast(protocolTraceKeepBytes)
}.getOrDefault("")
traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8)
}
traceFile.appendText("$line\n", Charsets.UTF_8)
}
}
}
private fun flushDebugLogsThrottled() {
debugFlushPending.set(true)
if (debugFlushJob?.isActive == true) return
debugFlushJob =
scope.launch {
while (debugFlushPending.getAndSet(false)) {
delay(debugLogFlushDelayMs)
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
_debugLogs.value = snapshot
}
}
}
}

View File

@@ -0,0 +1,58 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.Protocol
import com.rosetta.messenger.network.ProtocolState
import kotlinx.coroutines.flow.StateFlow
class ProtocolInstanceManager(
private val serverAddress: String,
private val addLog: (String) -> Unit,
private val isNetworkAvailable: () -> Boolean,
private val onNetworkUnavailable: () -> Unit
) {
@Volatile private var protocol: Protocol? = null
private val protocolInstanceLock = Any()
fun getOrCreateProtocol(): Protocol {
protocol?.let { return it }
synchronized(protocolInstanceLock) {
protocol?.let { return it }
val created =
Protocol(
serverAddress = serverAddress,
logger = { msg -> addLog(msg) },
isNetworkAvailable = isNetworkAvailable,
onNetworkUnavailable = onNetworkUnavailable
)
protocol = created
addLog("🧩 Protocol singleton created: id=${System.identityHashCode(created)}")
return created
}
}
val state: StateFlow<ProtocolState>
get() = getOrCreateProtocol().state
val lastError: StateFlow<String?>
get() = getOrCreateProtocol().lastError
fun disconnect(clearCredentials: Boolean) {
protocol?.disconnect()
if (clearCredentials) {
protocol?.clearCredentials()
}
}
fun destroy() {
synchronized(protocolInstanceLock) {
protocol?.destroy()
protocol = null
}
}
fun isAuthenticated(): Boolean = protocol?.isAuthenticated() ?: false
fun isConnected(): Boolean = protocol?.isConnected() ?: false
}

Some files were not shown because too many files have changed in this diff Show More