Compare commits

106 Commits

Author SHA1 Message Date
46d048a6f7 Релиз 1.5.5: обновить версию и Release Notes
All checks were successful
Android Kernel Build / build (push) Successful in 21m39s
2026-04-20 22:01:48 +05:00
2e5dcfc99d fix: Довести UI голосовых сообщений до Telegram: lock/blob, центр иконок и параллельная отправка 2026-04-20 21:56:41 +05:00
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
208 changed files with 27710 additions and 8111 deletions

View File

@@ -45,7 +45,7 @@ jobs:
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: ~/android-sdk path: ~/android-sdk
key: android-sdk-34 key: android-sdk-34-ndk26
- name: Install Android SDK - name: Install Android SDK
run: | run: |
@@ -67,9 +67,12 @@ jobs:
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platforms;android-34" \ "platforms;android-34" \
"build-tools;34.0.0" \ "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_HOME=$ANDROID_HOME" >> $GITHUB_ENV
echo "ANDROID_SDK_ROOT=$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 - name: Cache Gradle wrapper
uses: actions/cache@v3 uses: actions/cache@v3
@@ -110,8 +113,15 @@ jobs:
./gradlew --no-daemon --version ./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 - name: Build Release APK
run: ./gradlew --no-daemon assembleRelease run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx2g" assembleRelease
- name: Check if APK exists - name: Check if APK exists
run: | run: |

3
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Built application files # Built application files
*.apk *.apk
*.aar # *.aar — кастомный WebRTC разрешён в app/libs/
!app/libs/*.aar
*.ap_ *.ap_
*.aab *.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,26 @@
# Release Notes # 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 ## 1.3.4
### Звонки и UI ### Звонки и UI

View File

@@ -2,6 +2,7 @@ plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("kotlin-kapt") id("kotlin-kapt")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services") id("com.google.gms.google-services")
} }
@@ -23,8 +24,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.4.0" val rosettaVersionName = "1.5.5"
val rosettaVersionCode = 42 // Increment on each release val rosettaVersionCode = 57 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar") val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {
@@ -119,6 +120,10 @@ android {
} }
} }
kapt {
correctErrorTypes = true
}
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
@@ -182,6 +187,11 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler: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 // Biometric authentication
implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.biometric:biometric:1.1.0")
@@ -207,6 +217,10 @@ dependencies {
implementation("com.google.firebase:firebase-messaging-ktx") implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-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 // Testing dependencies
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8") testImplementation("io.mockk:mockk:1.13.8")

Binary file not shown.

View File

@@ -9,9 +9,13 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <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.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_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <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" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" 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.BLUETOOTH_CONNECT" />
@@ -23,10 +27,8 @@
<application <application
android:name=".RosettaApplication" android:name=".RosettaApplication"
android:allowBackup="true" android:allowBackup="false"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
@@ -44,13 +46,104 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait"> android:screenOrientation="portrait">
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.LAUNCHER" /> <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> </intent-filter>
</activity> </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 --> <!-- FileProvider for camera images -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
@@ -74,7 +167,7 @@
<service <service
android:name=".network.CallForegroundService" android:name=".network.CallForegroundService"
android:exported="false" android:exported="false"
android:foregroundServiceType="microphone|mediaPlayback" /> android:foregroundServiceType="microphone|mediaPlayback|phoneCall" />
<!-- Firebase notification icon (optional, for better looking notifications) --> <!-- Firebase notification icon (optional, for better looking notifications) -->
<meta-data <meta-data

View File

@@ -531,6 +531,7 @@ public:
} }
uint32_t KeyFingerprint() const { return key_fingerprint_; } uint32_t KeyFingerprint() const { return key_fingerprint_; }
int FrameCount() const { return diag_count_.load(std::memory_order_relaxed); }
/* ── RefCountInterface ─────────────────────────────────────── */ /* ── RefCountInterface ─────────────────────────────────────── */
void AddRef() const override { void AddRef() const override {
@@ -724,6 +725,8 @@ public:
} }
uint32_t KeyFingerprint() const { return key_fingerprint_; } uint32_t KeyFingerprint() const { return key_fingerprint_; }
int FrameCount() const { return diag_count_.load(std::memory_order_relaxed); }
uint32_t BadStreak() const { return bad_audio_streak_.load(std::memory_order_relaxed); }
/* ── RefCountInterface ─────────────────────────────────────── */ /* ── RefCountInterface ─────────────────────────────────────── */
void AddRef() const override { void AddRef() const override {
@@ -1121,4 +1124,33 @@ Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeCloseDiagFile(
} }
} }
/* ── Query frame counts for health checks ────────────────────── */
JNIEXPORT jint JNICALL
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeGetEncryptorFrameCount(
JNIEnv *, jclass, jlong ptr)
{
if (ptr == 0) return -1;
auto *enc = reinterpret_cast<XChaCha20Encryptor *>(ptr);
return enc->FrameCount();
}
JNIEXPORT jint JNICALL
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeGetDecryptorFrameCount(
JNIEnv *, jclass, jlong ptr)
{
if (ptr == 0) return -1;
auto *dec = reinterpret_cast<XChaCha20Decryptor *>(ptr);
return dec->FrameCount();
}
JNIEXPORT jint JNICALL
Java_com_rosetta_messenger_network_XChaCha20E2EE_nativeGetDecryptorBadStreak(
JNIEnv *, jclass, jlong ptr)
{
if (ptr == 0) return -1;
auto *dec = reinterpret_cast<XChaCha20Decryptor *>(ptr);
return dec->BadStreak();
}
} /* extern "C" */ } /* extern "C" */

View File

@@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition(
wasEmojiShown = true wasEmojiShown = true
} }
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) { if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
// Emoji закрылся после того как был открыт = переход emoji→keyboard // Keep reserved space only if keyboard is actually opening.
isTransitioningToKeyboard = true // 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 // 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji
@@ -63,6 +72,19 @@ fun AnimatedKeyboardTransition(
isTransitioningToKeyboard = false isTransitioningToKeyboard = false
wasEmojiShown = false 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 val targetAlpha = if (showEmojiPicker) 1f else 0f
@@ -109,4 +131,4 @@ fun AnimatedKeyboardTransition(
content() content()
} }
} }
} }

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

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ class AccountManager(private val context: Context) {
private val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in") private val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
private const val PREFS_NAME = "rosetta_account_prefs" private const val PREFS_NAME = "rosetta_account_prefs"
private const val KEY_LAST_LOGGED = "last_logged_public_key" 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 // Use SharedPreferences for last logged account - more reliable for immediate reads
@@ -43,13 +44,19 @@ class AccountManager(private val context: Context) {
val publicKey = sharedPrefs.getString(KEY_LAST_LOGGED, null) val publicKey = sharedPrefs.getString(KEY_LAST_LOGGED, null)
return publicKey return publicKey
} }
fun getLastLoggedPrivateKeyHash(): String? {
return sharedPrefs.getString(KEY_LAST_LOGGED_PRIVATE_HASH, null)
}
// Synchronous write to SharedPreferences // Synchronous write to SharedPreferences
fun setLastLoggedPublicKey(publicKey: String) { 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) { suspend fun saveAccount(account: EncryptedAccount) {
@@ -98,6 +105,7 @@ class AccountManager(private val context: Context) {
context.accountDataStore.edit { preferences -> context.accountDataStore.edit { preferences ->
preferences[IS_LOGGED_IN] = false 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 // Clear SharedPreferences if this was the last logged account
val lastLogged = sharedPrefs.getString(KEY_LAST_LOGGED, null) val lastLogged = sharedPrefs.getString(KEY_LAST_LOGGED, null)
if (lastLogged == publicKey) { 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() { suspend fun clearAll() {
context.accountDataStore.edit { it.clear() } 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 { private fun serializeAccounts(accounts: List<EncryptedAccount>): String {

View File

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

View File

@@ -30,7 +30,8 @@ object ForwardManager {
val senderPublicKey: String, // publicKey отправителя сообщения val senderPublicKey: String, // publicKey отправителя сообщения
val originalChatPublicKey: String, // publicKey чата откуда пересылается val originalChatPublicKey: String, // publicKey чата откуда пересылается
val senderName: String = "", // Имя отправителя для атрибуции 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.PacketGroupInviteInfo
import com.rosetta.messenger.network.PacketGroupJoin import com.rosetta.messenger.network.PacketGroupJoin
import com.rosetta.messenger.network.PacketGroupLeave 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.security.SecureRandom
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume 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 db = RosettaDatabase.getDatabase(context.applicationContext)
private val groupDao = db.groupDao() private val groupDao = db.groupDao()
private val messageDao = db.messageDao() 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_INVITE_PASSWORD = "rosetta_group"
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
private const val GROUP_CREATED_MARKER = "\$a=Group created" 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( data class ParsedGroupInvite(
@@ -155,7 +153,7 @@ class GroupRepository private constructor(context: Context) {
this.groupId = groupId this.groupId = groupId
this.members = emptyList() this.members = emptyList()
} }
ProtocolManager.send(packet) protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupInfo>( val response = awaitPacketOnce<PacketGroupInfo>(
packetId = 0x12, packetId = 0x12,
@@ -189,7 +187,7 @@ class GroupRepository private constructor(context: Context) {
this.membersCount = 0 this.membersCount = 0
this.groupStatus = GroupStatus.NOT_JOINED this.groupStatus = GroupStatus.NOT_JOINED
} }
ProtocolManager.send(packet) protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupInviteInfo>( val response = awaitPacketOnce<PacketGroupInviteInfo>(
packetId = 0x13, packetId = 0x13,
@@ -217,7 +215,7 @@ class GroupRepository private constructor(context: Context) {
} }
val createPacket = PacketCreateGroup() val createPacket = PacketCreateGroup()
ProtocolManager.send(createPacket) protocolClient.send(createPacket)
val response = awaitPacketOnce<PacketCreateGroup>( val response = awaitPacketOnce<PacketCreateGroup>(
packetId = 0x11, packetId = 0x11,
@@ -268,7 +266,7 @@ class GroupRepository private constructor(context: Context) {
groupString = encodedGroupStringForServer groupString = encodedGroupStringForServer
groupStatus = GroupStatus.NOT_JOINED groupStatus = GroupStatus.NOT_JOINED
} }
ProtocolManager.send(packet) protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupJoin>( val response = awaitPacketOnce<PacketGroupJoin>(
packetId = 0x14, packetId = 0x14,
@@ -376,7 +374,7 @@ class GroupRepository private constructor(context: Context) {
val packet = PacketGroupLeave().apply { val packet = PacketGroupLeave().apply {
this.groupId = groupId this.groupId = groupId
} }
ProtocolManager.send(packet) protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupLeave>( val response = awaitPacketOnce<PacketGroupLeave>(
packetId = 0x15, packetId = 0x15,
@@ -402,7 +400,7 @@ class GroupRepository private constructor(context: Context) {
this.groupId = groupId this.groupId = groupId
this.publicKey = targetPublicKey this.publicKey = targetPublicKey
} }
ProtocolManager.send(packet) protocolClient.send(packet)
val response = awaitPacketOnce<PacketGroupBan>( val response = awaitPacketOnce<PacketGroupBan>(
packetId = 0x16, packetId = 0x16,
@@ -479,9 +477,8 @@ class GroupRepository private constructor(context: Context) {
dialogPublicKey: String dialogPublicKey: String
) { ) {
try { try {
val messages = MessageRepository.getInstance(appContext) messageRepository.initialize(accountPublicKey, accountPrivateKey)
messages.initialize(accountPublicKey, accountPrivateKey) messageRepository.sendMessage(
messages.sendMessage(
toPublicKey = dialogPublicKey, toPublicKey = dialogPublicKey,
text = GROUP_CREATED_MARKER text = GROUP_CREATED_MARKER
) )
@@ -512,13 +509,13 @@ class GroupRepository private constructor(context: Context) {
callback = { packet -> callback = { packet ->
val typedPacket = packet as? T val typedPacket = packet as? T
if (typedPacket != null && predicate(typedPacket)) { if (typedPacket != null && predicate(typedPacket)) {
ProtocolManager.unwaitPacket(packetId, callback) protocolClient.unwaitPacket(packetId, callback)
continuation.resume(typedPacket) continuation.resume(typedPacket)
} }
} }
ProtocolManager.waitPacket(packetId, callback) protocolClient.waitPacket(packetId, callback)
continuation.invokeOnCancellation { continuation.invokeOnCancellation {
ProtocolManager.unwaitPacket(packetId, callback) protocolClient.unwaitPacket(packetId, callback)
} }
} }
} }

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.data package com.rosetta.messenger.data
import android.content.Context import android.content.Context
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.database.* import com.rosetta.messenger.database.*
@@ -8,8 +9,11 @@ import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger import com.rosetta.messenger.utils.MessageLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.json.JSONArray import org.json.JSONArray
@@ -30,7 +34,6 @@ data class Message(
val replyToMessageId: String? = null val replyToMessageId: String? = null
) )
/** UI модель диалога */
data class Dialog( data class Dialog(
val opponentKey: String, val opponentKey: String,
val opponentTitle: String, val opponentTitle: String,
@@ -44,7 +47,11 @@ data class Dialog(
) )
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */ /** 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 database = RosettaDatabase.getDatabase(context)
private val messageDao = database.messageDao() private val messageDao = database.messageDao()
@@ -97,8 +104,6 @@ class MessageRepository private constructor(private val context: Context) {
private var currentPrivateKey: String? = null private var currentPrivateKey: String? = null
companion object { companion object {
@Volatile private var INSTANCE: MessageRepository? = null
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */ /** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
@@ -136,16 +141,6 @@ class MessageRepository private constructor(private val context: Context) {
/** Очистка кэша (вызывается при logout) */ /** Очистка кэша (вызывается при logout) */
fun clearProcessedCache() = processedMessageIds.clear() fun clearProcessedCache() = processedMessageIds.clear()
fun getInstance(context: Context): MessageRepository {
return INSTANCE
?: synchronized(this) {
INSTANCE
?: MessageRepository(context.applicationContext).also {
INSTANCE = it
}
}
}
/** /**
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного * Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями) * хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
@@ -245,6 +240,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername = opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME } existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
?: 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, isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1), verified = maxOf(existing?.verified ?: 0, 1),
@@ -265,7 +267,7 @@ class MessageRepository private constructor(private val context: Context) {
try { try {
CryptoManager.encryptWithPassword(messageText, privateKey) CryptoManager.encryptWithPassword(messageText, privateKey)
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e) if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
return null return null
} }
@@ -324,6 +326,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername = opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME } existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
?: 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, isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0, lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1), verified = maxOf(existing?.verified ?: 0, 1),
@@ -343,12 +352,12 @@ class MessageRepository private constructor(private val context: Context) {
suspend fun checkAndSendVersionUpdateMessage() { suspend fun checkAndSendVersionUpdateMessage() {
val account = currentAccount val account = currentAccount
if (account == null) { 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 return
} }
val privateKey = currentPrivateKey val privateKey = currentPrivateKey
if (privateKey == null) { 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 return
} }
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
@@ -356,7 +365,7 @@ class MessageRepository private constructor(private val context: Context) {
val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}" 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) { if (lastNoticeKey != currentKey) {
// Delete the previous message for this version (if any) // Delete the previous message for this version (if any)
@@ -367,15 +376,15 @@ class MessageRepository private constructor(private val context: Context) {
} }
val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion)) 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) { if (messageId != null) {
prefs.edit() prefs.edit()
.putString("lastNoticeKey", currentKey) .putString("lastNoticeKey", currentKey)
.putString("lastNoticeMessageId_$currentVersion", messageId) .putString("lastNoticeMessageId_$currentVersion", messageId)
.apply() .apply()
android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully") if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
} else { } else {
android.util.Log.e("ReleaseNotes", "❌ Failed to create update message") if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
} }
} }
} }
@@ -448,6 +457,18 @@ class MessageRepository private constructor(private val context: Context) {
return if (raw < 1_000_000_000_000L) raw * 1000L else raw 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>> { fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
val dialogKey = getDialogKey(opponentKey) val dialogKey = getDialogKey(opponentKey)
@@ -587,6 +608,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages // 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
dialogDao.updateDialogFromMessages(account, toPublicKey) 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 // 📁 Для saved messages - гарантируем создание/обновление dialog
if (isSavedMessages) { if (isSavedMessages) {
val existing = dialogDao.getDialog(account, account) val existing = dialogDao.getDialog(account, account)
@@ -662,7 +689,7 @@ class MessageRepository private constructor(private val context: Context) {
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp) MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout) // iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
ProtocolManager.sendMessageWithRetry(packet) protocolClient.sendMessageWithRetry(packet)
// 📝 LOG: Успешная отправка // 📝 LOG: Успешная отправка
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime) MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
@@ -711,6 +738,13 @@ class MessageRepository private constructor(private val context: Context) {
val isOwnMessage = packet.fromPublicKey == account val isOwnMessage = packet.fromPublicKey == account
val isGroupMessage = isGroupDialogKey(packet.toPublicKey) 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)) { if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
@@ -795,11 +829,19 @@ class MessageRepository private constructor(private val context: Context) {
} }
if (isGroupMessage && groupKey.isNullOrBlank()) { if (isGroupMessage && groupKey.isNullOrBlank()) {
MessageLogger.debug( val requiresGroupKey =
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..." (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"
) )
processedMessageIds.remove(messageId)
return false
} }
val plainKeyAndNonce = val plainKeyAndNonce =
@@ -811,7 +853,7 @@ class MessageRepository private constructor(private val context: Context) {
} }
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) { if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
ProtocolManager.addLog( protocolClient.addLog(
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..." "⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
) )
} }
@@ -830,8 +872,9 @@ class MessageRepository private constructor(private val context: Context) {
if (isAttachmentOnly) { if (isAttachmentOnly) {
"" ""
} else if (isGroupMessage) { } else if (isGroupMessage) {
CryptoManager.decryptWithPassword(packet.content, groupKey!!) val decryptedGroupPayload =
?: throw IllegalStateException("Failed to decrypt group payload") groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
} else if (plainKeyAndNonce != null) { } else if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce) MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else { } else {
@@ -839,7 +882,7 @@ class MessageRepository private constructor(private val context: Context) {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey) MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
} catch (e: Exception) { } catch (e: Exception) {
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content) // Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}") if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
"" ""
} }
} }
@@ -911,7 +954,7 @@ class MessageRepository private constructor(private val context: Context) {
fromPublicKey = packet.fromPublicKey, fromPublicKey = packet.fromPublicKey,
toPublicKey = packet.toPublicKey, toPublicKey = packet.toPublicKey,
content = packet.content, content = packet.content,
timestamp = packet.timestamp, timestamp = normalizedPacketTimestamp,
chachaKey = storedChachaKey, chachaKey = storedChachaKey,
read = 0, read = 0,
fromMe = if (isOwnMessage) 1 else 0, fromMe = if (isOwnMessage) 1 else 0,
@@ -979,8 +1022,10 @@ class MessageRepository private constructor(private val context: Context) {
} catch (e: Exception) { } catch (e: Exception) {
// 📝 LOG: Ошибка обработки // 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e) MessageLogger.logDecryptionError(messageId, e)
ProtocolManager.addLog( protocolClient.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}" "❌ 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, если пакет не удалось сохранить. // Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
processedMessageIds.remove(messageId) processedMessageIds.remove(messageId)
@@ -993,15 +1038,12 @@ class MessageRepository private constructor(private val context: Context) {
suspend fun handleDelivery(packet: PacketDelivery) { suspend fun handleDelivery(packet: PacketDelivery) {
val account = currentAccount ?: return val account = currentAccount ?: return
// 📝 LOG: Получено подтверждение доставки
MessageLogger.logDeliveryStatus( MessageLogger.logDeliveryStatus(
messageId = packet.messageId, messageId = packet.messageId,
toPublicKey = packet.toPublicKey, toPublicKey = packet.toPublicKey,
status = "DELIVERED" 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() val deliveryTimestamp = System.currentTimeMillis()
messageDao.updateDeliveryStatusAndTimestamp( messageDao.updateDeliveryStatusAndTimestamp(
account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp
@@ -1026,6 +1068,50 @@ class MessageRepository private constructor(private val context: Context) {
dialogDao.updateDialogFromMessages(account, packet.toPublicKey) 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 сообщает что собеседник прочитал наши сообщения * Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
* fromPublicKey - кто прочитал (собеседник) * fromPublicKey - кто прочитал (собеседник)
@@ -1176,7 +1262,7 @@ class MessageRepository private constructor(private val context: Context) {
this.toPublicKey = toPublicKey this.toPublicKey = toPublicKey
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
} }
ProtocolManager.send(packet) protocolClient.send(packet)
} }
} }
@@ -1241,7 +1327,7 @@ class MessageRepository private constructor(private val context: Context) {
syncedOpponentsWithWrongStatus.forEach { opponentKey -> syncedOpponentsWithWrongStatus.forEach { opponentKey ->
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) } runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
} }
android.util.Log.i( if (BuildConfig.DEBUG) android.util.Log.i(
"MessageRepository", "MessageRepository",
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED" "✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
) )
@@ -1250,14 +1336,14 @@ class MessageRepository private constructor(private val context: Context) {
// Mark expired messages as ERROR (older than 80 seconds) // Mark expired messages as ERROR (older than 80 seconds)
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS) val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (expiredCount > 0) { 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) // Get remaining WAITING messages (younger than 80s)
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS) val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (waitingMessages.isEmpty()) return 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) { for (entity in waitingMessages) {
// Skip saved messages (should not happen, but guard) // Skip saved messages (should not happen, but guard)
@@ -1281,7 +1367,7 @@ class MessageRepository private constructor(private val context: Context) {
privateKey privateKey
) )
} catch (e: Exception) { } 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")
"" ""
} }
} }
@@ -1307,10 +1393,10 @@ class MessageRepository private constructor(private val context: Context) {
} }
// iOS parity: use retry mechanism for reconnect-resent messages too // iOS parity: use retry mechanism for reconnect-resent messages too
ProtocolManager.sendMessageWithRetry(packet) protocolClient.sendMessageWithRetry(packet)
android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}") if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
} catch (e: Exception) { } 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 // Mark as ERROR if retry fails
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value) messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
val dialogKey = getDialogKey(entity.toPublicKey) val dialogKey = getDialogKey(entity.toPublicKey)
@@ -1411,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) { suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
val account = currentAccount ?: return val account = currentAccount ?: return
@@ -1572,7 +1658,7 @@ class MessageRepository private constructor(private val context: Context) {
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
this.search = dialog.opponentKey this.search = dialog.opponentKey
} }
ProtocolManager.send(packet) protocolClient.send(packet)
// Small delay to avoid flooding the server with search requests // Small delay to avoid flooding the server with search requests
kotlinx.coroutines.delay(50) kotlinx.coroutines.delay(50)
} }
@@ -1609,7 +1695,7 @@ class MessageRepository private constructor(private val context: Context) {
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
this.search = publicKey this.search = publicKey
} }
ProtocolManager.send(packet) protocolClient.send(packet)
} }
} }
@@ -1695,6 +1781,7 @@ class MessageRepository private constructor(private val context: Context) {
put("preview", attachment.preview) put("preview", attachment.preview)
put("width", attachment.width) put("width", attachment.width)
put("height", attachment.height) put("height", attachment.height)
put("localUri", attachment.localUri)
put("transportTag", attachment.transportTag) put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer) put("transportServer", attachment.transportServer)
} }
@@ -1793,7 +1880,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения // 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) { if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey) decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else { } else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1850,7 +1937,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения // 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) { if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey) decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else { } else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1914,7 +2001,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем с ChaCha ключом сообщения // 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob = val decryptedBlob =
if (groupKey != null) { if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey) decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else { } else {
plainKeyAndNonce?.let { plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it) MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1939,6 +2026,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer) jsonObj.put("transportServer", attachment.transportServer)
} else { } else {
@@ -1949,6 +2037,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer) jsonObj.put("transportServer", attachment.transportServer)
} }
@@ -1960,6 +2049,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer) jsonObj.put("transportServer", attachment.transportServer)
} }
@@ -1971,6 +2061,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer) jsonObj.put("transportServer", attachment.transportServer)
} }
@@ -1979,4 +2070,26 @@ class MessageRepository private constructor(private val context: Context) {
} }
return jsonArray.toString() 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

@@ -36,6 +36,7 @@ class PreferencesManager(private val context: Context) {
val NOTIFICATION_SOUND_ENABLED = booleanPreferencesKey("notification_sound_enabled") val NOTIFICATION_SOUND_ENABLED = booleanPreferencesKey("notification_sound_enabled")
val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled") val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled")
val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled") val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled")
val NOTIFICATION_AVATAR_ENABLED = booleanPreferencesKey("notification_avatar_enabled")
// Chat Settings // Chat Settings
val MESSAGE_TEXT_SIZE = intPreferencesKey("message_text_size") // 0=small, 1=medium, 2=large val MESSAGE_TEXT_SIZE = intPreferencesKey("message_text_size") // 0=small, 1=medium, 2=large
@@ -57,6 +58,9 @@ class PreferencesManager(private val context: Context) {
val BACKGROUND_BLUR_COLOR_ID = val BACKGROUND_BLUR_COLOR_ID =
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets 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) // Pinned Chats (max 3)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
@@ -143,6 +147,11 @@ class PreferencesManager(private val context: Context) {
preferences[NOTIFICATION_PREVIEW_ENABLED] ?: true preferences[NOTIFICATION_PREVIEW_ENABLED] ?: true
} }
val notificationAvatarEnabled: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[NOTIFICATION_AVATAR_ENABLED] ?: true
}
suspend fun setNotificationsEnabled(value: Boolean) { suspend fun setNotificationsEnabled(value: Boolean) {
context.dataStore.edit { preferences -> preferences[NOTIFICATIONS_ENABLED] = value } context.dataStore.edit { preferences -> preferences[NOTIFICATIONS_ENABLED] = value }
} }
@@ -159,6 +168,10 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[NOTIFICATION_PREVIEW_ENABLED] = value } 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 // 💬 CHAT SETTINGS
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════
@@ -323,6 +336,19 @@ class PreferencesManager(private val context: Context) {
return wasPinned 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 // 🔕 MUTED CHATS
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════

View File

@@ -17,15 +17,14 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Протокол и вложения - Выполнен крупный рефакторинг runtime сети и сессии: SessionStore/Reducer, декомпозиция ProtocolManager и выделение профильных coordinator/service слоев
- Обновлен Stream под новый серверный формат сериализации - Стабилизированы первичное подключение, авторизация и восстановление после сбоев навигации в auth-flow
- Добавлена поддержка transportServer/transportTag во вложениях - Улучшены sync/send-потоки и retry-пайплайн: меньше регрессий при переподключениях и фоновых отправках
- Исправлена совместимость шифрования вложений Android -> Desktop - Текстовые сообщения теперь отправляются параллельно с загрузкой голосовых и вложений
- Улучшена обработка call-аттачментов и рендер карточек звонков - Панель записи ГС приведена к Telegram-поведению: lock flow, анимации, blob-эффекты в lock, корректные pause/play и центрирование иконок
- Исправлена анимация waveform после перемотки: прогресс продолжается с текущей позиции без отката к началу
Push-уведомления - Улучшены QR-сканер и in-app camera: более плавный выход и стабильнее обработка UI-состояний
- Пуши теперь учитывают mute-чаты корректно - Добавлен расширенный сетевой debug-канал rosettadev1 с выводом в crash logs для ускоренной диагностики
- Заголовок уведомления берет имя отправителя из payload сервера
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -238,7 +238,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC ORDER BY timestamp DESC, id DESC
LIMIT :limit OFFSET :offset LIMIT :limit OFFSET :offset
""" """
) )
@@ -260,7 +260,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_public_key = :account AND from_public_key = :account
AND to_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 LIMIT :limit OFFSET :offset
""" """
) )
@@ -286,7 +286,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey 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>> fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
@@ -319,7 +319,7 @@ interface MessageDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC ORDER BY timestamp DESC, id DESC
LIMIT :limit LIMIT :limit
""" """
) )
@@ -378,7 +378,7 @@ interface MessageDao {
AND dialog_key = :dialogKey AND dialog_key = :dialogKey
AND from_public_key = :fromPublicKey AND from_public_key = :fromPublicKey
AND timestamp BETWEEN :timestampFrom AND :timestampTo AND timestamp BETWEEN :timestampFrom AND :timestampTo
ORDER BY timestamp ASC, message_id ASC ORDER BY timestamp ASC, id ASC
LIMIT 1 LIMIT 1
""" """
) )
@@ -440,6 +440,10 @@ interface MessageDao {
) )
suspend fun messageExists(account: String, messageId: String): Boolean 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 от собеседника. * PacketRead от собеседника.
@@ -462,7 +466,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) 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? suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity?
@@ -477,7 +481,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) 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? suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus?
@@ -492,7 +496,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account) AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent)) 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? suspend fun getLastMessageAttachments(account: String, opponent: String): String?
@@ -508,6 +512,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_me = 1 AND from_me = 1
AND delivered = 0 AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp >= :minTimestamp AND timestamp >= :minTimestamp
ORDER BY timestamp ASC ORDER BY timestamp ASC
""" """
@@ -524,6 +529,7 @@ interface MessageDao {
WHERE account = :account WHERE account = :account
AND from_me = 1 AND from_me = 1
AND delivered = 0 AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp < :maxTimestamp AND timestamp < :maxTimestamp
""" """
) )
@@ -629,7 +635,7 @@ interface MessageDao {
END END
WHERE m.account = :account WHERE m.account = :account
AND m.primary_attachment_type = 4 AND m.primary_attachment_type = 4
ORDER BY m.timestamp DESC, m.message_id DESC ORDER BY m.timestamp DESC, m.id DESC
LIMIT :limit LIMIT :limit
""" """
) )
@@ -978,7 +984,7 @@ interface DialogDao {
""" """
SELECT * FROM messages SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey 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? suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity?

View File

@@ -332,7 +332,7 @@ abstract class RosettaDatabase : RoomDatabase() {
THEN dialogs.account || ':' || dialogs.opponent_key THEN dialogs.account || ':' || dialogs.opponent_key
ELSE dialogs.opponent_key || ':' || dialogs.account ELSE dialogs.opponent_key || ':' || dialogs.account
END END
ORDER BY m.timestamp DESC, m.message_id DESC ORDER BY m.timestamp DESC, m.id DESC
LIMIT 1 LIMIT 1
), ),
'' ''

View File

@@ -0,0 +1,200 @@
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>
fun setAppInForeground(foreground: Boolean)
}
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

@@ -9,6 +9,8 @@ enum class AttachmentType(val value: Int) {
FILE(2), // Файл FILE(2), // Файл
AVATAR(3), // Аватар пользователя AVATAR(3), // Аватар пользователя
CALL(4), // Событие звонка (пропущен/принят/завершен) CALL(4), // Событие звонка (пропущен/принят/завершен)
VOICE(5), // Голосовое сообщение
VIDEO_CIRCLE(6), // Видео-кружок (video note)
UNKNOWN(-1); // Неизвестный тип UNKNOWN(-1); // Неизвестный тип
companion object { companion object {

View File

@@ -9,25 +9,45 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo 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.Build
import android.os.IBinder import android.os.IBinder
import android.util.Base64
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import com.rosetta.messenger.MainActivity import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R 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. * Keeps call alive while app goes to background.
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs. * Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
*/ */
@AndroidEntryPoint
class CallForegroundService : Service() { class CallForegroundService : Service() {
@Inject lateinit var preferencesManager: PreferencesManager
private data class Snapshot( private data class Snapshot(
val phase: CallPhase, val phase: CallPhase,
val displayName: String, val displayName: String,
val statusText: String, val statusText: String,
val durationSec: Int val durationSec: Int,
val peerPublicKey: String = ""
) )
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -35,38 +55,73 @@ class CallForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_SYNC val action = intent?.action ?: ACTION_SYNC
CallManager.initialize(applicationContext) CallManager.initialize(applicationContext)
val phaseNow = CallManager.state.value.phase
notifLog("onStartCommand action=$action phase=$phaseNow")
when (action) { when (action) {
ACTION_STOP -> { ACTION_STOP -> {
stopForegroundCompat() if (phaseNow == CallPhase.IDLE) {
stopSelf() notifLog("ACTION_STOP → stopSelf")
return START_NOT_STICKY safeStopForeground()
return START_NOT_STICKY
}
// Может прилететь поздний STOP от прошлой сессии, не глушим живой звонок.
notifLog("ACTION_STOP ignored: phase=$phaseNow")
} }
ACTION_END -> { ACTION_END -> {
notifLog("ACTION_END → endCall")
CallManager.endCall() CallManager.endCall()
stopForegroundCompat() safeStopForeground()
stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_DECLINE -> { ACTION_DECLINE -> {
CallManager.declineIncomingCall() val phase = CallManager.state.value.phase
stopForegroundCompat() notifLog("ACTION_DECLINE phase=$phase")
stopSelf() if (phase == CallPhase.INCOMING) {
CallManager.declineIncomingCall()
} else {
CallManager.endCall()
}
safeStopForeground()
return START_NOT_STICKY return START_NOT_STICKY
} }
ACTION_ACCEPT -> { ACTION_ACCEPT -> {
val result = CallManager.acceptIncomingCall() notifLog("ACTION_ACCEPT → acceptIncomingCall phase=${CallManager.state.value.phase}")
if (result == CallActionResult.STARTED || CallManager.state.value.phase != CallPhase.IDLE) { // Если push пришёл раньше WebSocket — CallManager ещё в IDLE.
openCallUi() // Ждём до 5 сек пока реальный CALL сигнал придёт по WebSocket.
} else { CoroutineScope(Dispatchers.Main).launch {
Log.w(TAG, "Accept action ignored: $result") 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 else -> Unit
} }
val snapshot = extractSnapshot(intent) val snapshot = extractSnapshot(intent)
notifLog("snapshot: phase=${snapshot.phase} name=${snapshot.displayName} status=${snapshot.statusText}")
if (snapshot.phase == CallPhase.IDLE) { if (snapshot.phase == CallPhase.IDLE) {
notifLog("phase=IDLE → stopSelf")
stopForegroundCompat() stopForegroundCompat()
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
@@ -74,7 +129,18 @@ class CallForegroundService : Service() {
ensureNotificationChannel() ensureNotificationChannel()
val notification = buildNotification(snapshot) val notification = buildNotification(snapshot)
val hasFullScreen = snapshot.phase == CallPhase.INCOMING
notifLog("buildNotification OK, hasFullScreenIntent=$hasFullScreen, starting foreground")
startForegroundCompat(notification, snapshot.phase) 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 return START_STICKY
} }
@@ -86,7 +152,8 @@ class CallForegroundService : Service() {
phase = state.phase, phase = state.phase,
displayName = state.displayName, displayName = state.displayName,
statusText = state.statusText, statusText = state.statusText,
durationSec = state.durationSec durationSec = state.durationSec,
peerPublicKey = state.peerPublicKey
) )
} }
@@ -99,40 +166,55 @@ class CallForegroundService : Service() {
.ifBlank { "Unknown" } .ifBlank { "Unknown" }
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText } val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec) val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
val peerPublicKey = payloadIntent.getStringExtra(EXTRA_PEER_PUBLIC_KEY)
.orEmpty()
.ifBlank { state.peerPublicKey }
return Snapshot( return Snapshot(
phase = phase, phase = phase,
displayName = displayName, displayName = displayName,
statusText = statusText, statusText = statusText,
durationSec = durationSec.coerceAtLeast(0) durationSec = durationSec.coerceAtLeast(0),
peerPublicKey = peerPublicKey
) )
} }
private fun ensureNotificationChannel() { private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(CHANNEL_ID) if (manager.getNotificationChannel(CHANNEL_ID) != null) return
if (existing != null) return
val channel = val channel =
NotificationChannel( NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
"Calls", "Calls",
NotificationManager.IMPORTANCE_HIGH NotificationManager.IMPORTANCE_MAX
).apply { ).apply {
description = "Ongoing call controls" description = "Incoming and ongoing calls"
lockscreenVisibility = Notification.VISIBILITY_PUBLIC lockscreenVisibility = Notification.VISIBILITY_PUBLIC
setShowBadge(false) setShowBadge(false)
enableVibration(true)
vibrationPattern = longArrayOf(0, 1000, 500, 1000)
setBypassDnd(true)
} }
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel)
} }
private fun buildNotification(snapshot: Snapshot): Notification { 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( val openAppPendingIntent = PendingIntent.getActivity(
this, this,
REQUEST_OPEN_APP, REQUEST_OPEN_APP,
Intent(this, MainActivity::class.java).apply { Intent(this, contentActivity).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP 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) if (contentActivity == MainActivity::class.java) {
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
}
}, },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
@@ -155,6 +237,18 @@ class CallForegroundService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE 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 = val defaultStatus =
when (snapshot.phase) { when (snapshot.phase) {
CallPhase.INCOMING -> "Incoming call" CallPhase.INCOMING -> "Incoming call"
@@ -164,9 +258,14 @@ class CallForegroundService : Service() {
CallPhase.IDLE -> "Call ended" CallPhase.IDLE -> "Call ended"
} }
val contentText = snapshot.statusText.ifBlank { defaultStatus } val contentText = snapshot.statusText.ifBlank { defaultStatus }
val avatarBitmap = loadAvatarBitmap(snapshot.peerPublicKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val person = Person.Builder().setName(snapshot.displayName).setImportant(true).build() val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true)
if (avatarBitmap != null) {
personBuilder.setIcon(Icon.createWithBitmap(avatarBitmap))
}
val person = personBuilder.build()
val style = val style =
if (snapshot.phase == CallPhase.INCOMING) { if (snapshot.phase == CallPhase.INCOMING) {
Notification.CallStyle.forIncomingCall( Notification.CallStyle.forIncomingCall(
@@ -188,6 +287,11 @@ class CallForegroundService : Service() {
.setVisibility(Notification.VISIBILITY_PUBLIC) .setVisibility(Notification.VISIBILITY_PUBLIC)
.setStyle(style) .setStyle(style)
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) .setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
.apply {
if (fullScreenPendingIntent != null) {
setFullScreenIntent(fullScreenPendingIntent, true)
}
}
.apply { .apply {
if (snapshot.phase == CallPhase.ACTIVE) { if (snapshot.phase == CallPhase.ACTIVE) {
setUsesChronometer(true) setUsesChronometer(true)
@@ -200,6 +304,7 @@ class CallForegroundService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(snapshot.displayName) .setContentTitle(snapshot.displayName)
.apply { if (avatarBitmap != null) setLargeIcon(avatarBitmap) }
.setContentText(contentText) .setContentText(contentText)
.setContentIntent(openAppPendingIntent) .setContentIntent(openAppPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -207,6 +312,11 @@ class CallForegroundService : Service() {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setOngoing(true) .setOngoing(true)
.apply {
if (fullScreenPendingIntent != null) {
setFullScreenIntent(fullScreenPendingIntent, true)
}
}
.apply { .apply {
if (snapshot.phase == CallPhase.INCOMING) { if (snapshot.phase == CallPhase.INCOMING) {
addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent) addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent)
@@ -251,8 +361,10 @@ class CallForegroundService : Service() {
private fun startForegroundTyped(notification: Notification, type: Int): Boolean { private fun startForegroundTyped(notification: Notification, type: Int): Boolean {
return try { return try {
startForeground(NOTIFICATION_ID, notification, type) startForeground(NOTIFICATION_ID, notification, type)
notifLog("startForeground OK type=$type")
true true
} catch (error: Throwable) { } catch (error: Throwable) {
notifLog("startForeground FAILED type=$type: ${error.message}")
Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}") Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}")
false false
} }
@@ -261,8 +373,10 @@ class CallForegroundService : Service() {
private fun startForegroundUntyped(notification: Notification): Boolean { private fun startForegroundUntyped(notification: Notification): Boolean {
return try { return try {
startForeground(NOTIFICATION_ID, notification) startForeground(NOTIFICATION_ID, notification)
notifLog("startForeground (untyped) OK")
true true
} catch (error: Throwable) { } catch (error: Throwable) {
notifLog("startForeground (untyped) FAILED: ${error.message}")
Log.w(TAG, "Untyped startForeground failed: ${error.message}") Log.w(TAG, "Untyped startForeground failed: ${error.message}")
false false
} }
@@ -277,6 +391,26 @@ class CallForegroundService : Service() {
} }
} }
/** Безопасная остановка: 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 { companion object {
private const val TAG = "CallForegroundService" private const val TAG = "CallForegroundService"
private const val CHANNEL_ID = "rosetta_calls" private const val CHANNEL_ID = "rosetta_calls"
@@ -285,6 +419,8 @@ class CallForegroundService : Service() {
private const val REQUEST_END_CALL = 9012 private const val REQUEST_END_CALL = 9012
private const val REQUEST_DECLINE_CALL = 9013 private const val REQUEST_DECLINE_CALL = 9013
private const val REQUEST_ACCEPT_CALL = 9014 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_SYNC = "com.rosetta.messenger.call.ACTION_SYNC"
private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END" private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END"
@@ -296,12 +432,16 @@ class CallForegroundService : Service() {
private const val EXTRA_DISPLAY_NAME = "extra_display_name" private const val EXTRA_DISPLAY_NAME = "extra_display_name"
private const val EXTRA_STATUS_TEXT = "extra_status_text" private const val EXTRA_STATUS_TEXT = "extra_status_text"
private const val EXTRA_DURATION_SEC = "extra_duration_sec" 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" const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
fun syncWithCallState(context: Context, state: CallUiState) { fun syncWithCallState(context: Context, state: CallUiState) {
val appContext = context.applicationContext val appContext = context.applicationContext
if (state.phase == CallPhase.IDLE) { if (state.phase == CallPhase.IDLE) {
appContext.stopService(Intent(appContext, CallForegroundService::class.java)) // Используем 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 return
} }
@@ -312,6 +452,7 @@ class CallForegroundService : Service() {
.putExtra(EXTRA_DISPLAY_NAME, state.displayName) .putExtra(EXTRA_DISPLAY_NAME, state.displayName)
.putExtra(EXTRA_STATUS_TEXT, state.statusText) .putExtra(EXTRA_STATUS_TEXT, state.statusText)
.putExtra(EXTRA_DURATION_SEC, state.durationSec) .putExtra(EXTRA_DURATION_SEC, state.durationSec)
.putExtra(EXTRA_PEER_PUBLIC_KEY, state.peerPublicKey)
runCatching { ContextCompat.startForegroundService(appContext, intent) } runCatching { ContextCompat.startForegroundService(appContext, intent) }
.onFailure { error -> .onFailure { error ->
@@ -329,13 +470,82 @@ class CallForegroundService : Service() {
} }
} }
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() { private fun openCallUi() {
notifLog("openCallUi → MainActivity")
val intent = val intent =
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP 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) putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
} }
runCatching { startActivity(intent) } runCatching { startActivity(intent) }
.onFailure { error -> Log.w(TAG, "Failed to open call UI: ${error.message}") } .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) {}
} }
} }

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.media.AudioManager import android.media.AudioManager
import android.util.Log import android.util.Log
import com.rosetta.messenger.BuildConfig import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
@@ -95,7 +96,12 @@ object CallManager {
private const val TAIL_LINES = 300 private const val TAIL_LINES = 300
private const val PROTOCOL_LOG_TAIL_LINES = 180 private const val PROTOCOL_LOG_TAIL_LINES = 180
private const val MAX_LOG_PREFIX = 180 private const val MAX_LOG_PREFIX = 180
private const val INCOMING_RING_TIMEOUT_MS = 45_000L // Backend's CallManager.java uses RINGING_TIMEOUT = 30s. Local timeouts are
// slightly larger so the server's RINGING_TIMEOUT signal takes precedence when
// the network is healthy; local jobs are a fallback when the signal is lost.
private const val INCOMING_RING_TIMEOUT_MS = 35_000L
private const val OUTGOING_RING_TIMEOUT_MS = 35_000L
private const val CONNECTING_TIMEOUT_MS = 30_000L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val secureRandom = SecureRandom() private val secureRandom = SecureRandom()
@@ -106,16 +112,19 @@ object CallManager {
@Volatile @Volatile
private var initialized = false private var initialized = false
private var appContext: Context? = null private var appContext: Context? = null
private var messageRepository: MessageRepository? = null
private var accountManager: AccountManager? = null
private var ownPublicKey: String = "" private var ownPublicKey: String = ""
private var role: CallRole? = null private var role: CallRole? = null
private var roomId: String = "" private var serverCallId: String = ""
private var serverJoinToken: String = ""
private var offerSent = false private var offerSent = false
private var remoteDescriptionSet = false private var remoteDescriptionSet = false
private var callSessionId: String = "" private var callSessionId: String = ""
private var callStartedAtMs: Long = 0L private var callStartedAtMs: Long = 0L
private var keyExchangeSent = false private var keyExchangeSent = false
private var createRoomSent = false private var activeSignalSent = false
private var lastPeerSharedPublicHex: String = "" private var lastPeerSharedPublicHex: String = ""
private var localPrivateKey: ByteArray? = null private var localPrivateKey: ByteArray? = null
@@ -125,6 +134,8 @@ object CallManager {
private var protocolStateJob: Job? = null private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null private var disconnectResetJob: Job? = null
private var incomingRingTimeoutJob: Job? = null private var incomingRingTimeoutJob: Job? = null
private var outgoingRingTimeoutJob: Job? = null
private var connectingTimeoutJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null private var signalWaiter: ((Packet) -> Unit)? = null
private var webRtcWaiter: ((Packet) -> Unit)? = null private var webRtcWaiter: ((Packet) -> Unit)? = null
@@ -146,6 +157,7 @@ object CallManager {
private var lastLocalOfferFingerprint: String = "" private var lastLocalOfferFingerprint: String = ""
private var e2eeRebindJob: Job? = null private var e2eeRebindJob: Job? = null
@Volatile private var resetting = false
private var iceServers: List<PeerConnection.IceServer> = emptyList() private var iceServers: List<PeerConnection.IceServer> = emptyList()
fun initialize(context: Context) { fun initialize(context: Context) {
@@ -153,46 +165,128 @@ object CallManager {
initialized = true initialized = true
appContext = context.applicationContext appContext = context.applicationContext
CallSoundManager.initialize(context) CallSoundManager.initialize(context)
CallProximityManager.initialize(context)
XChaCha20E2EE.initWithContext(context) XChaCha20E2EE.initWithContext(context)
signalWaiter = ProtocolManager.waitCallSignal { packet -> signalWaiter = ProtocolRuntimeAccess.get().waitCallSignal { packet ->
scope.launch { handleSignalPacket(packet) } scope.launch { handleSignalPacket(packet) }
} }
webRtcWaiter = ProtocolManager.waitWebRtcSignal { packet -> webRtcWaiter = ProtocolRuntimeAccess.get().waitWebRtcSignal { packet ->
scope.launch { handleWebRtcPacket(packet) } scope.launch { handleWebRtcPacket(packet) }
} }
iceWaiter = ProtocolManager.waitIceServers { packet -> iceWaiter = ProtocolRuntimeAccess.get().waitIceServers { packet ->
handleIceServersPacket(packet) handleIceServersPacket(packet)
} }
protocolStateJob = protocolStateJob =
scope.launch { scope.launch {
ProtocolManager.state.collect { protocolState -> ProtocolRuntimeAccess.get().state.collect { protocolState ->
when (protocolState) { when (protocolState) {
ProtocolState.AUTHENTICATED -> { ProtocolState.AUTHENTICATED -> {
ProtocolManager.requestIceServers() ProtocolRuntimeAccess.get().requestIceServers()
} }
ProtocolState.DISCONNECTED -> { ProtocolState.DISCONNECTED -> {
resetSession(reason = "Disconnected", notifyPeer = false) // Не сбрасываем звонок при переподключении WebSocket —
// push мог разбудить процесс и вызвать reconnect,
// а звонок уже в INCOMING/CONNECTING
val phase = _state.value.phase
if (phase == CallPhase.IDLE) {
val hasResidualSession =
callSessionId.isNotBlank() ||
serverCallId.isNotBlank() ||
serverJoinToken.isNotBlank() ||
role != null ||
_state.value.peerPublicKey.isNotBlank() ||
sharedKeyBytes != null ||
peerConnection != null
if (hasResidualSession) {
resetSession(reason = "Disconnected", notifyPeer = false)
} else {
breadcrumb("DISCONNECTED in IDLE — skip reset (no active session)")
}
} else {
breadcrumb("DISCONNECTED but phase=$phase — keeping call alive")
}
} }
else -> Unit else -> Unit
} }
} }
} }
ProtocolManager.requestIceServers() ProtocolRuntimeAccess.get().requestIceServers()
}
fun bindDependencies(
messageRepository: MessageRepository,
accountManager: AccountManager
) {
this.messageRepository = messageRepository
this.accountManager = accountManager
} }
fun bindAccount(publicKey: String) { fun bindAccount(publicKey: String) {
ownPublicKey = publicKey.trim() ownPublicKey = publicKey.trim()
} }
/**
* Вызывается из FCM push когда приходит type=call.
* Ставит CallManager в INCOMING сразу, не дожидаясь WebSocket сигнала.
* Если WebSocket CALL придёт позже — дедупликация его отбросит.
*/
fun setIncomingFromPush(
peerPublicKey: String,
peerTitle: String,
callId: String = "",
joinToken: String = ""
) {
val peer = peerPublicKey.trim()
if (peer.isBlank()) return
// Уже в звонке — не перебиваем
if (_state.value.phase != CallPhase.IDLE) {
breadcrumb("setIncomingFromPush SKIP: phase=${_state.value.phase}")
return
}
serverCallId = callId.trim()
serverJoinToken = joinToken.trim()
breadcrumb(
"setIncomingFromPush peer=${peer.take(8)}… title=$peerTitle " +
"callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
)
beginCallSession("incoming-push:${peer.take(8)}")
role = CallRole.CALLEE
resetRtcObjects()
val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(peer)
val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() }
val username = cachedInfo?.username.orEmpty()
setPeer(peer, title, username)
updateState {
it.copy(
phase = CallPhase.INCOMING,
statusText = "Incoming call..."
)
}
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
appContext?.let { ctx ->
CallForegroundService.syncWithCallState(ctx, _state.value)
}
resolvePeerIdentity(peer)
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = scope.launch {
delay(INCOMING_RING_TIMEOUT_MS)
val pending = _state.value
if (pending.phase == CallPhase.INCOMING && pending.peerPublicKey == peer) {
breadcrumb("setIncomingFromPush: timeout → auto-decline")
declineIncomingCall()
}
}
}
fun startOutgoingCall(user: SearchUser): CallActionResult { fun startOutgoingCall(user: SearchUser): CallActionResult {
val targetKey = user.publicKey.trim() val targetKey = user.publicKey.trim()
if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL
if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED if (!ProtocolRuntimeAccess.get().isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
resetSession(reason = null, notifyPeer = false) resetSession(reason = null, notifyPeer = false)
beginCallSession("outgoing:${targetKey.take(8)}") beginCallSession("outgoing:${targetKey.take(8)}")
@@ -206,13 +300,25 @@ object CallManager {
) )
} }
ProtocolManager.sendCallSignal( ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.CALL, signalType = SignalType.CALL,
src = ownPublicKey, src = ownPublicKey,
dst = targetKey dst = targetKey
) )
breadcrumbState("startOutgoingCall") breadcrumbState("startOutgoingCall")
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
// Local fallback for caller: if RINGING_TIMEOUT signal from the server is lost,
// stop ringing after the same window the server uses (~30s + small buffer).
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = scope.launch {
delay(OUTGOING_RING_TIMEOUT_MS)
val snap = _state.value
if (snap.phase == CallPhase.OUTGOING && snap.peerPublicKey == targetKey) {
breadcrumb("startOutgoingCall: local ring timeout (${OUTGOING_RING_TIMEOUT_MS}ms) → reset")
resetSession(reason = "No answer", notifyPeer = true)
}
}
return CallActionResult.STARTED return CallActionResult.STARTED
} }
@@ -220,28 +326,75 @@ object CallManager {
val snapshot = _state.value val snapshot = _state.value
if (snapshot.phase != CallPhase.INCOMING) return CallActionResult.NOT_INCOMING if (snapshot.phase != CallPhase.INCOMING) return CallActionResult.NOT_INCOMING
if (snapshot.peerPublicKey.isBlank()) return CallActionResult.INVALID_TARGET if (snapshot.peerPublicKey.isBlank()) return CallActionResult.INVALID_TARGET
if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND
// Если ownPublicKey пустой (push разбудил но аккаунт не привязан) — попробуем привязать
if (ownPublicKey.isBlank()) {
val lastPk = accountManager?.getLastLoggedPublicKey().orEmpty()
if (lastPk.isNotBlank()) {
bindAccount(lastPk)
breadcrumb("acceptIncomingCall: auto-bind account pk=${lastPk.take(8)}")
} else {
return CallActionResult.ACCOUNT_NOT_BOUND
}
}
val restoredAuth = ProtocolRuntimeAccess.get().restoreAuthFromStoredCredentials(
preferredPublicKey = ownPublicKey,
reason = "accept_incoming_call"
)
if (restoredAuth) {
ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_incoming_call")
breadcrumb("acceptIncomingCall: auth restore requested")
}
role = CallRole.CALLEE role = CallRole.CALLEE
generateSessionKeys() generateSessionKeys()
val localPublic = localPublicKey ?: return CallActionResult.INVALID_TARGET
ProtocolManager.sendCallSignal(
signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey,
dst = snapshot.peerPublicKey,
sharedPublic = localPublic.toHex()
)
keyExchangeSent = true
incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null incomingRingTimeoutJob = null
updateState { updateState {
it.copy( it.copy(
phase = CallPhase.CONNECTING, phase = CallPhase.CONNECTING,
statusText = "Exchanging keys..." statusText = "Connecting..."
) )
} }
armConnectingTimeout("acceptIncomingCall")
// Отправляем ACCEPT с callId/joinToken. Если push пришел раньше WS CALL,
// подождем немного пока идентификаторы звонка подтянутся.
scope.launch {
var sent = false
for (attempt in 1..60) { // 60 * 200ms = 12 sec
val callIdNow = serverCallId.trim()
val joinTokenNow = serverJoinToken.trim()
if (callIdNow.isBlank() || joinTokenNow.isBlank()) {
breadcrumb("acceptIncomingCall: waiting callId/joinToken (attempt #$attempt)")
kotlinx.coroutines.delay(200)
continue
}
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.ACCEPT,
src = ownPublicKey,
dst = snapshot.peerPublicKey,
callId = callIdNow,
joinToken = joinTokenNow
)
// ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен
// сразу при открытии сокета (или останется в очереди до onOpen).
ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_send_$attempt")
breadcrumb(
"acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " +
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
)
sent = true
break
}
if (!sent) {
breadcrumb("acceptIncomingCall: FAILED to send ACCEPT after 12s — resetting")
resetSession(reason = "Failed to connect", notifyPeer = false)
}
}
breadcrumbState("acceptIncomingCall") breadcrumbState("acceptIncomingCall")
return CallActionResult.STARTED return CallActionResult.STARTED
} }
@@ -251,11 +404,20 @@ object CallManager {
if (snapshot.phase != CallPhase.INCOMING) return if (snapshot.phase != CallPhase.INCOMING) return
incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null incomingRingTimeoutJob = null
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank()) { val callIdNow = serverCallId.trim()
ProtocolManager.sendCallSignal( val joinTokenNow = serverJoinToken.trim()
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank() && callIdNow.isNotBlank() && joinTokenNow.isNotBlank()) {
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.END_CALL, signalType = SignalType.END_CALL,
src = ownPublicKey, src = ownPublicKey,
dst = snapshot.peerPublicKey dst = snapshot.peerPublicKey,
callId = callIdNow,
joinToken = joinTokenNow
)
} else {
breadcrumb(
"declineIncomingCall: skip END_CALL (missing ids) " +
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
) )
} }
resetSession(reason = null, notifyPeer = false) resetSession(reason = null, notifyPeer = false)
@@ -305,6 +467,11 @@ object CallManager {
resetSession(reason = "Peer disconnected", notifyPeer = false) resetSession(reason = "Peer disconnected", notifyPeer = false)
return return
} }
SignalType.RINGING_TIMEOUT -> {
breadcrumb("SIG: ringing timeout → reset")
resetSession(reason = "No answer", notifyPeer = false)
return
}
SignalType.END_CALL -> { SignalType.END_CALL -> {
breadcrumb("SIG: END_CALL → reset") breadcrumb("SIG: END_CALL → reset")
resetSession(reason = "Call ended", notifyPeer = false) resetSession(reason = "Call ended", notifyPeer = false)
@@ -322,25 +489,44 @@ object CallManager {
when (packet.signalType) { when (packet.signalType) {
SignalType.CALL -> { SignalType.CALL -> {
val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return
serverCallId = packet.callId.trim()
serverJoinToken = packet.joinToken.trim()
// Дедупликация: push уже поставил INCOMING для этого peer — обновляем только имя
if (_state.value.phase == CallPhase.INCOMING && _state.value.peerPublicKey == incomingPeer) {
breadcrumb(
"SIG: CALL from ${incomingPeer.take(8)}… but already INCOMING — dedup " +
"callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
)
resolvePeerIdentity(incomingPeer)
return
}
if (_state.value.phase != CallPhase.IDLE) { if (_state.value.phase != CallPhase.IDLE) {
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY") breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
val callerKey = packet.src.trim() if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) {
if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) { ProtocolRuntimeAccess.get().sendCallSignal(
ProtocolManager.sendCallSignal(
signalType = SignalType.END_CALL_BECAUSE_BUSY, signalType = SignalType.END_CALL_BECAUSE_BUSY,
src = ownPublicKey, src = ownPublicKey,
dst = callerKey dst = incomingPeer
) )
} }
return return
} }
val incomingPeer = packet.src.trim()
if (incomingPeer.isBlank()) return
beginCallSession("incoming:${incomingPeer.take(8)}") beginCallSession("incoming:${incomingPeer.take(8)}")
breadcrumb("SIG: CALL from ${incomingPeer.take(8)}… → INCOMING") breadcrumb(
"SIG: CALL from ${incomingPeer.take(8)}… → INCOMING " +
"callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)}"
)
role = CallRole.CALLEE role = CallRole.CALLEE
resetRtcObjects() resetRtcObjects()
setPeer(incomingPeer, "", "") // Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(incomingPeer)
val cachedTitle = cachedInfo?.title.orEmpty()
val cachedUsername = cachedInfo?.username.orEmpty()
setPeer(incomingPeer, cachedTitle, cachedUsername)
updateState { updateState {
it.copy( it.copy(
phase = CallPhase.INCOMING, phase = CallPhase.INCOMING,
@@ -348,6 +534,24 @@ object CallManager {
) )
} }
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.RINGTONE) }
// Запускаем ForegroundService + IncomingCallActivity
appContext?.let { ctx ->
CallForegroundService.syncWithCallState(ctx, _state.value)
// Пробуем запустить IncomingCallActivity напрямую
try {
val activityIntent = android.content.Intent(
ctx,
com.rosetta.messenger.IncomingCallActivity::class.java
).apply {
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
}
ctx.startActivity(activityIntent)
breadcrumb("IncomingCallActivity started from WebSocket OK")
} catch (e: Throwable) {
breadcrumb("IncomingCallActivity start FAILED: ${e.message} — relying on fullScreenIntent")
}
}
resolvePeerIdentity(incomingPeer) resolvePeerIdentity(incomingPeer)
incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = incomingRingTimeoutJob =
@@ -366,20 +570,54 @@ object CallManager {
breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange") breadcrumb("SIG: KEY_EXCHANGE → handleKeyExchange")
handleKeyExchange(packet) handleKeyExchange(packet)
} }
SignalType.CREATE_ROOM -> { SignalType.ACCEPT -> {
val incomingRoomId = packet.roomId.trim() breadcrumb(
breadcrumb("SIG: CREATE_ROOM roomId=${incomingRoomId.take(16)}") "SIG: ACCEPT callId=${packet.callId.take(12)} join=${packet.joinToken.take(12)}"
if (incomingRoomId.isBlank()) { )
breadcrumb("SIG: CREATE_ROOM IGNORED — empty roomId!") serverCallId = packet.callId.trim()
serverJoinToken = packet.joinToken.trim()
if (role != CallRole.CALLER) {
breadcrumb("SIG: ACCEPT ignored — role=$role")
return
}
// Callee answered before timeout — cancel outgoing ring timer
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
if (localPrivateKey == null || localPublicKey == null) {
breadcrumb("SIG: ACCEPT — generating local session keys")
generateSessionKeys()
}
val localPublic = localPublicKey ?: return
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey,
dst = _state.value.peerPublicKey,
sharedPublic = localPublic.toHex()
)
keyExchangeSent = true
updateState {
it.copy(
phase = CallPhase.CONNECTING,
statusText = "Exchanging keys..."
)
}
armConnectingTimeout("signal:accept")
}
SignalType.ACTIVE -> {
breadcrumb("SIG: ACTIVE")
if (sharedKeyBytes == null && localPrivateKey == null) {
breadcrumb("SIG: ACTIVE but no session keys — resetting")
CallSoundManager.stop()
resetSession(reason = null, notifyPeer = false)
return return
} }
roomId = incomingRoomId
updateState { updateState {
it.copy( it.copy(
phase = CallPhase.CONNECTING, phase = CallPhase.CONNECTING,
statusText = "Connecting..." statusText = "Connecting..."
) )
} }
armConnectingTimeout("signal:active")
ensurePeerConnectionAndOffer() ensurePeerConnectionAndOffer()
} }
SignalType.ACTIVE_CALL -> Unit SignalType.ACTIVE_CALL -> Unit
@@ -419,27 +657,18 @@ object CallManager {
return return
} }
setupE2EE(sharedKey) setupE2EE(sharedKey)
breadcrumb("KE: CALLER — E2EE ready, sending missing signaling packets") breadcrumb("KE: CALLER — E2EE ready, notifying ACTIVE")
updateState { it.copy(keyCast = sharedKey, statusText = "Creating room...") } updateState { it.copy(keyCast = sharedKey, statusText = "Connecting...") }
val localPublic = localPublicKey ?: return if (!activeSignalSent) {
if (!keyExchangeSent) { ProtocolRuntimeAccess.get().sendCallSignal(
ProtocolManager.sendCallSignal( signalType = SignalType.ACTIVE,
signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey,
dst = peerKey,
sharedPublic = localPublic.toHex()
)
keyExchangeSent = true
}
if (!createRoomSent) {
ProtocolManager.sendCallSignal(
signalType = SignalType.CREATE_ROOM,
src = ownPublicKey, src = ownPublicKey,
dst = peerKey dst = peerKey
) )
createRoomSent = true activeSignalSent = true
} }
updateState { it.copy(phase = CallPhase.CONNECTING) } updateState { it.copy(phase = CallPhase.CONNECTING) }
armConnectingTimeout("key_exchange:caller")
return return
} }
@@ -454,9 +683,23 @@ object CallManager {
return return
} }
setupE2EE(sharedKey) setupE2EE(sharedKey)
breadcrumb("KE: CALLEE — E2EE ready, waiting for CREATE_ROOM") if (!keyExchangeSent) {
val localPublic = localPublicKey ?: return
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey,
dst = peerKey,
sharedPublic = localPublic.toHex()
)
keyExchangeSent = true
}
breadcrumb("KE: CALLEE — E2EE ready, waiting for ACTIVE")
updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) } updateState { it.copy(keyCast = sharedKey, phase = CallPhase.CONNECTING) }
armConnectingTimeout("key_exchange:callee")
return
} }
breadcrumb("KE: ignored — unknown role")
} }
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) { private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
@@ -543,7 +786,7 @@ object CallManager {
val answer = pc.createAnswerAwait() val answer = pc.createAnswerAwait()
pc.setLocalDescriptionAwait(answer) pc.setLocalDescriptionAwait(answer)
ProtocolManager.sendWebRtcSignal( ProtocolRuntimeAccess.get().sendWebRtcSignal(
signalType = WebRTCSignalType.ANSWER, signalType = WebRTCSignalType.ANSWER,
sdpOrCandidate = serializeSessionDescription(answer) sdpOrCandidate = serializeSessionDescription(answer)
) )
@@ -575,8 +818,8 @@ object CallManager {
private suspend fun ensurePeerConnectionAndOffer() { private suspend fun ensurePeerConnectionAndOffer() {
val peerKey = _state.value.peerPublicKey val peerKey = _state.value.peerPublicKey
if (peerKey.isBlank() || roomId.isBlank()) { if (peerKey.isBlank()) {
breadcrumb("PC: ensurePCAndOffer SKIP — peer=${peerKey.take(8)} room=${roomId.take(8)}") breadcrumb("PC: ensurePCAndOffer SKIP — peer=${peerKey.take(8)}")
return return
} }
if (offerSent) { if (offerSent) {
@@ -584,7 +827,7 @@ object CallManager {
return return
} }
breadcrumb("PC: ensurePCAndOffer START role=$role room=${roomId.take(8)}") breadcrumb("PC: ensurePCAndOffer START role=$role")
ensurePeerFactory() ensurePeerFactory()
val factory = peerConnectionFactory val factory = peerConnectionFactory
if (factory == null) { if (factory == null) {
@@ -631,7 +874,7 @@ object CallManager {
pc.setLocalDescriptionAwait(offer) pc.setLocalDescriptionAwait(offer)
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10) lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint") breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
ProtocolManager.sendWebRtcSignal( ProtocolRuntimeAccess.get().sendWebRtcSignal(
signalType = WebRTCSignalType.OFFER, signalType = WebRTCSignalType.OFFER,
sdpOrCandidate = serializeSessionDescription(offer) sdpOrCandidate = serializeSessionDescription(offer)
) )
@@ -672,7 +915,7 @@ object CallManager {
override fun onIceCandidate(candidate: IceCandidate?) { override fun onIceCandidate(candidate: IceCandidate?) {
if (candidate == null) return if (candidate == null) return
breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}") breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}")
ProtocolManager.sendWebRtcSignal( ProtocolRuntimeAccess.get().sendWebRtcSignal(
signalType = WebRTCSignalType.ICE_CANDIDATE, signalType = WebRTCSignalType.ICE_CANDIDATE,
sdpOrCandidate = serializeIceCandidate(candidate) sdpOrCandidate = serializeIceCandidate(candidate)
) )
@@ -735,7 +978,11 @@ object CallManager {
} }
private fun onCallConnected() { private fun onCallConnected() {
disarmConnectingTimeout("connected")
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CONNECTED) }
val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
breadcrumb("CONNECTED: e2eeAvail=$e2eeAvailable keyFp=$keyFp sEnc=${senderEncryptors.size} rDec=${receiverDecryptors.size} nativeLoaded=${XChaCha20E2EE.nativeLoaded}")
breadcrumbState("onCallConnected")
updateState { it.copy(phase = CallPhase.ACTIVE, statusText = "Call active") } updateState { it.copy(phase = CallPhase.ACTIVE, statusText = "Call active") }
durationJob?.cancel() durationJob?.cancel()
durationJob = durationJob =
@@ -750,6 +997,31 @@ object CallManager {
} }
} }
private fun armConnectingTimeout(origin: String) {
connectingTimeoutJob?.cancel()
connectingTimeoutJob =
scope.launch {
delay(CONNECTING_TIMEOUT_MS)
val snapshot = _state.value
if (snapshot.phase != CallPhase.CONNECTING) return@launch
breadcrumb(
"CONNECTING TIMEOUT origin=$origin role=$role callId=${serverCallId.take(12)} join=${serverJoinToken.take(12)} " +
"keyExSent=$keyExchangeSent activeSent=$activeSignalSent offerSent=$offerSent " +
"remoteDesc=$remoteDescriptionSet peer=${snapshot.peerPublicKey.take(8)}"
)
resetSession(reason = "Connecting timeout", notifyPeer = false)
}
breadcrumb("CONNECTING watchdog armed origin=$origin timeoutMs=$CONNECTING_TIMEOUT_MS")
}
private fun disarmConnectingTimeout(origin: String) {
if (connectingTimeoutJob != null) {
connectingTimeoutJob?.cancel()
connectingTimeoutJob = null
breadcrumb("CONNECTING watchdog disarmed origin=$origin")
}
}
private fun setPeer(publicKey: String, title: String, username: String) { private fun setPeer(publicKey: String, title: String, username: String) {
updateState { updateState {
it.copy( it.copy(
@@ -762,7 +1034,7 @@ object CallManager {
private fun resolvePeerIdentity(publicKey: String) { private fun resolvePeerIdentity(publicKey: String) {
scope.launch { scope.launch {
val resolved = ProtocolManager.resolveUserInfo(publicKey) val resolved = ProtocolRuntimeAccess.get().resolveUserInfo(publicKey)
if (resolved != null && _state.value.peerPublicKey == publicKey) { if (resolved != null && _state.value.peerPublicKey == publicKey) {
setPeer(publicKey, resolved.title, resolved.username) setPeer(publicKey, resolved.title, resolved.username)
} }
@@ -780,9 +1052,7 @@ object CallManager {
} }
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) { private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
if (role != CallRole.CALLER) return
val peerPublicKey = snapshot.peerPublicKey.trim() val peerPublicKey = snapshot.peerPublicKey.trim()
val context = appContext ?: return
if (peerPublicKey.isBlank()) return if (peerPublicKey.isBlank()) return
val durationSec = snapshot.durationSec.coerceAtLeast(0) val durationSec = snapshot.durationSec.coerceAtLeast(0)
@@ -794,32 +1064,66 @@ object CallManager {
preview = durationSec.toString() preview = durationSec.toString()
) )
// Capture role synchronously before the coroutine launches, because
// resetSession() sets role = null right after calling this function —
// otherwise the async check below would fall through to the callee branch.
val capturedRole = role
scope.launch { scope.launch {
runCatching { runCatching {
MessageRepository.getInstance(context).sendMessage( val repository = messageRepository
toPublicKey = peerPublicKey, if (repository == null) {
text = "", breadcrumb("CALL ATTACHMENT: MessageRepository not bound")
attachments = listOf(callAttachment) return@runCatching
) }
if (capturedRole == CallRole.CALLER) {
// CALLER: send call attachment as a message (peer will receive it)
repository.sendMessage(
toPublicKey = peerPublicKey,
text = "",
attachments = listOf(callAttachment)
)
} else {
// CALLEE: do not create local fallback call message.
// Caller sends a single canonical CALL attachment; local fallback here
// caused duplicates (local + remote) in direct dialogs.
breadcrumb("CALL ATTACHMENT: CALLEE skip local fallback, waiting caller message")
}
}.onFailure { error -> }.onFailure { error ->
Log.w(TAG, "Failed to send call attachment", error) Log.w(TAG, "Failed to emit call attachment", error)
} }
} }
} }
private fun resetSession(reason: String?, notifyPeer: Boolean) { private fun resetSession(reason: String?, notifyPeer: Boolean) {
resetting = true
disarmConnectingTimeout("resetSession")
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}") breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
breadcrumbState("resetSession") breadcrumbState("resetSession")
appContext?.let { CallProximityManager.setEnabled(it, false) }
val snapshot = _state.value val snapshot = _state.value
val wasActive = snapshot.phase != CallPhase.IDLE val wasActive = snapshot.phase != CallPhase.IDLE
val peerToNotify = snapshot.peerPublicKey val peerToNotify = snapshot.peerPublicKey
if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) { if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
ProtocolManager.sendCallSignal( ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.END_CALL, signalType = SignalType.END_CALL,
src = ownPublicKey, src = ownPublicKey,
dst = peerToNotify dst = peerToNotify,
callId = serverCallId,
joinToken = serverJoinToken
) )
} }
// Отменяем все jobs ПЕРВЫМИ — чтобы они не вызвали updateState с пустым state
durationJob?.cancel()
durationJob = null
e2eeRebindJob?.cancel()
e2eeRebindJob = null
disconnectResetJob?.cancel()
disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
// Play end call sound, then stop all // Play end call sound, then stop all
if (wasActive) { if (wasActive) {
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
@@ -832,29 +1136,28 @@ object CallManager {
emitCallAttachmentIfNeeded(snapshot) emitCallAttachmentIfNeeded(snapshot)
resetRtcObjects() resetRtcObjects()
e2eeAvailable = true e2eeAvailable = true
lastScanLog = ""
lastHealthLog = ""
healthLogCount = 0
role = null role = null
roomId = "" serverCallId = ""
serverJoinToken = ""
offerSent = false offerSent = false
remoteDescriptionSet = false remoteDescriptionSet = false
keyExchangeSent = false keyExchangeSent = false
createRoomSent = false activeSignalSent = false
lastPeerSharedPublicHex = "" lastPeerSharedPublicHex = ""
lastRemoteOfferFingerprint = "" lastRemoteOfferFingerprint = ""
lastLocalOfferFingerprint = "" lastLocalOfferFingerprint = ""
e2eeRebindJob?.cancel()
e2eeRebindJob = null
localPrivateKey = null localPrivateKey = null
localPublicKey = null localPublicKey = null
callSessionId = "" callSessionId = ""
callStartedAtMs = 0L callStartedAtMs = 0L
durationJob?.cancel()
durationJob = null
disconnectResetJob?.cancel()
disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
setSpeakerphone(false) setSpeakerphone(false)
// Останавливаем ForegroundService и сбрасываем state
appContext?.let { CallForegroundService.stop(it) }
_state.value = CallUiState() _state.value = CallUiState()
resetting = false
} }
private fun resetRtcObjects() { private fun resetRtcObjects() {
@@ -865,7 +1168,11 @@ object CallManager {
runCatching { localAudioTrack?.setEnabled(false) } runCatching { localAudioTrack?.setEnabled(false) }
runCatching { localAudioTrack?.dispose() } runCatching { localAudioTrack?.dispose() }
runCatching { audioSource?.dispose() } runCatching { audioSource?.dispose() }
// close() stops media but does NOT release native resources (ICE agent,
// ports, OWTF threads). Without dispose() the old PC keeps ports bound
// for ~30 s, blocking the next call from connecting.
runCatching { peerConnection?.close() } runCatching { peerConnection?.close() }
runCatching { peerConnection?.dispose() }
localAudioTrack = null localAudioTrack = null
audioSource = null audioSource = null
peerConnection = null peerConnection = null
@@ -893,14 +1200,15 @@ object CallManager {
} }
sharedKeyBytes = keyBytes.copyOf(32) sharedKeyBytes = keyBytes.copyOf(32)
breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}") breadcrumb("KE: shared key fp=${sharedKeyBytes?.fingerprintHex(8)}")
// Frame-level diagnostics are enabled only for debug builds. // Enable frame-level diagnostics for all builds (needed for debugging E2EE issues).
if (BuildConfig.DEBUG) { try {
try { val dir = java.io.File(appContext!!.filesDir, "crash_reports")
val dir = java.io.File(appContext!!.filesDir, "crash_reports") if (!dir.exists()) dir.mkdirs()
if (!dir.exists()) dir.mkdirs() val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath
val diagPath = java.io.File(dir, "e2ee_diag.txt").absolutePath XChaCha20E2EE.nativeOpenDiagFile(diagPath)
XChaCha20E2EE.nativeOpenDiagFile(diagPath) breadcrumb("E2EE: diag file opened at $diagPath")
} catch (_: Throwable) {} } catch (t: Throwable) {
breadcrumb("E2EE: diag file open FAILED: ${t.message}")
} }
// If sender track already exists, bind encryptor now. // If sender track already exists, bind encryptor now.
val existingSender = val existingSender =
@@ -916,8 +1224,13 @@ object CallManager {
Log.i(TAG, "E2EE key ready (XChaCha20)") Log.i(TAG, "E2EE key ready (XChaCha20)")
} }
private var lastHealthLog = ""
private var healthLogCount = 0
private fun startE2EERebindLoopIfNeeded() { private fun startE2EERebindLoopIfNeeded() {
if (e2eeRebindJob?.isActive == true) return if (e2eeRebindJob?.isActive == true) return
healthLogCount = 0
lastHealthLog = ""
e2eeRebindJob = e2eeRebindJob =
scope.launch { scope.launch {
while (true) { while (true) {
@@ -934,10 +1247,30 @@ object CallManager {
attachSenderE2EE(sender) attachSenderE2EE(sender)
} }
attachReceiverE2EEFromPeerConnection() attachReceiverE2EEFromPeerConnection()
// Periodic health check: log frame counts from native
healthLogCount++
if (healthLogCount % 4 == 0) { // every ~6s
logE2EEHealth()
}
} }
} }
} }
private fun logE2EEHealth() {
val encFrames = senderEncryptors.values.firstOrNull()?.frameCount() ?: -1
val decFrames = receiverDecryptors.values.firstOrNull()?.frameCount() ?: -1
val badStreak = receiverDecryptors.values.firstOrNull()?.badStreak() ?: -1
val keyFp = sharedKeyBytes?.fingerprintHex(8) ?: "null"
val health = "enc=$encFrames dec=$decFrames bad=$badStreak keyFp=$keyFp sEnc=${senderEncryptors.size} rDec=${receiverDecryptors.size}"
// Only log if state changed or every 5th iteration to avoid spam
if (health != lastHealthLog || healthLogCount % 20 == 0) {
breadcrumb("E2EE-HEALTH: $health")
lastHealthLog = health
}
}
private var lastScanLog = ""
private fun attachReceiverE2EEFromPeerConnection() { private fun attachReceiverE2EEFromPeerConnection() {
val pc = peerConnection ?: return val pc = peerConnection ?: return
runCatching { runCatching {
@@ -956,7 +1289,12 @@ object CallManager {
fromTransceivers++ fromTransceivers++
} }
} }
breadcrumb("E2EE: scan receivers attached recv=$fromReceivers tx=$fromTransceivers totalMap=${receiverDecryptors.size}") val hasKey = sharedKeyBytes != null
val scanLog = "recv=$fromReceivers tx=$fromTransceivers totalMap=${receiverDecryptors.size} hasKey=$hasKey e2eeAvail=$e2eeAvailable"
if (scanLog != lastScanLog) {
breadcrumb("E2EE: scan receivers attached $scanLog")
lastScanLog = scanLog
}
}.onFailure { }.onFailure {
breadcrumb("E2EE: attachReceiverE2EEFromPeerConnection failed: ${it.message}") breadcrumb("E2EE: attachReceiverE2EEFromPeerConnection failed: ${it.message}")
} }
@@ -990,7 +1328,7 @@ object CallManager {
val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES) val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES)
val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES) val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES)
val protocolTail = val protocolTail =
ProtocolManager.debugLogs.value ProtocolRuntimeAccess.get().debugLogs.value
.takeLast(PROTOCOL_LOG_TAIL_LINES) .takeLast(PROTOCOL_LOG_TAIL_LINES)
.joinToString("\n") .joinToString("\n")
f.writeText( f.writeText(
@@ -1065,7 +1403,8 @@ object CallManager {
append(" phase=").append(st.phase) append(" phase=").append(st.phase)
append(" role=").append(role) append(" role=").append(role)
append(" peer=").append(st.peerPublicKey.take(12)) append(" peer=").append(st.peerPublicKey.take(12))
append(" room=").append(roomId.take(16)) append(" callId=").append(serverCallId.take(16))
append(" join=").append(serverJoinToken.take(16))
append(" offerSent=").append(offerSent) append(" offerSent=").append(offerSent)
append(" remoteDescSet=").append(remoteDescriptionSet) append(" remoteDescSet=").append(remoteDescriptionSet)
append(" e2eeAvail=").append(e2eeAvailable) append(" e2eeAvail=").append(e2eeAvailable)
@@ -1096,8 +1435,15 @@ object CallManager {
} }
private fun attachSenderE2EE(sender: RtpSender?) { private fun attachSenderE2EE(sender: RtpSender?) {
if (!e2eeAvailable) return if (!e2eeAvailable) {
val key = sharedKeyBytes ?: return breadcrumb("E2EE: attachSender SKIP — e2eeAvailable=false")
return
}
val key = sharedKeyBytes
if (key == null) {
breadcrumb("E2EE: attachSender SKIP — sharedKeyBytes=null")
return
}
if (sender == null) return if (sender == null) return
val mapKey = senderMapKey(sender) val mapKey = senderMapKey(sender)
val existing = senderEncryptors[mapKey] val existing = senderEncryptors[mapKey]
@@ -1134,8 +1480,15 @@ object CallManager {
} }
private fun attachReceiverE2EE(receiver: RtpReceiver?) { private fun attachReceiverE2EE(receiver: RtpReceiver?) {
if (!e2eeAvailable) return if (!e2eeAvailable) {
val key = sharedKeyBytes ?: return breadcrumb("E2EE: attachReceiver SKIP — e2eeAvailable=false")
return
}
val key = sharedKeyBytes
if (key == null) {
breadcrumb("E2EE: attachReceiver SKIP — sharedKeyBytes=null")
return
}
if (receiver == null) return if (receiver == null) return
val mapKey = receiverMapKey(receiver) val mapKey = receiverMapKey(receiver)
val existing = receiverDecryptors[mapKey] val existing = receiverDecryptors[mapKey]
@@ -1274,7 +1627,24 @@ object CallManager {
} }
private fun updateState(reducer: (CallUiState) -> CallUiState) { private fun updateState(reducer: (CallUiState) -> CallUiState) {
if (resetting) return // Не синхронизируем во время resetSession — иначе "Unknown" мелькает
val old = _state.value
_state.update(reducer) _state.update(reducer)
val newState = _state.value
// Proximity is needed only while call is connecting/active and speaker is off.
appContext?.let { context ->
val shouldEnableProximity =
(newState.phase == CallPhase.CONNECTING || newState.phase == CallPhase.ACTIVE) &&
!newState.isSpeakerOn
CallProximityManager.setEnabled(context, shouldEnableProximity)
}
// Синхронизируем ForegroundService при смене фазы или имени
if (newState.phase != CallPhase.IDLE &&
(newState.phase != old.phase || newState.displayName != old.displayName)) {
appContext?.let { ctx ->
CallForegroundService.syncWithCallState(ctx, newState)
}
}
} }
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }

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

@@ -48,6 +48,22 @@ object CallSoundManager {
stop() stop()
currentSound = sound 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) { val resId = when (sound) {
CallSound.RINGTONE -> R.raw.call_ringtone CallSound.RINGTONE -> R.raw.call_ringtone
CallSound.CALLING -> R.raw.call_calling CallSound.CALLING -> R.raw.call_calling
@@ -86,7 +102,7 @@ object CallSoundManager {
mediaPlayer = player mediaPlayer = player
// Vibrate for incoming calls // Vibrate for incoming calls
if (sound == CallSound.RINGTONE) { if (allowVibration) {
startVibration() startVibration()
} }

View File

@@ -5,9 +5,11 @@ enum class SignalType(val value: Int) {
KEY_EXCHANGE(1), KEY_EXCHANGE(1),
ACTIVE_CALL(2), ACTIVE_CALL(2),
END_CALL(3), END_CALL(3),
CREATE_ROOM(4), ACTIVE(4),
END_CALL_BECAUSE_PEER_DISCONNECTED(5), END_CALL_BECAUSE_PEER_DISCONNECTED(5),
END_CALL_BECAUSE_BUSY(6); END_CALL_BECAUSE_BUSY(6),
ACCEPT(7),
RINGING_TIMEOUT(8);
companion object { companion object {
fun fromValue(value: Int): SignalType = fun fromValue(value: Int): SignalType =
@@ -25,7 +27,8 @@ class PacketSignalPeer : Packet() {
var dst: String = "" var dst: String = ""
var sharedPublic: String = "" var sharedPublic: String = ""
var signalType: SignalType = SignalType.CALL var signalType: SignalType = SignalType.CALL
var roomId: String = "" var callId: String = ""
var joinToken: String = ""
override fun getPacketId(): Int = 0x1A override fun getPacketId(): Int = 0x1A
@@ -33,7 +36,8 @@ class PacketSignalPeer : Packet() {
signalType = SignalType.fromValue(stream.readInt8()) signalType = SignalType.fromValue(stream.readInt8())
if ( if (
signalType == SignalType.END_CALL_BECAUSE_BUSY || signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED ||
signalType == SignalType.RINGING_TIMEOUT
) { ) {
return return
} }
@@ -42,8 +46,13 @@ class PacketSignalPeer : Packet() {
if (signalType == SignalType.KEY_EXCHANGE) { if (signalType == SignalType.KEY_EXCHANGE) {
sharedPublic = stream.readString() sharedPublic = stream.readString()
} }
if (signalType == SignalType.CREATE_ROOM) { if (
roomId = stream.readString() signalType == SignalType.CALL ||
signalType == SignalType.ACCEPT ||
signalType == SignalType.END_CALL
) {
callId = stream.readString()
joinToken = stream.readString()
} }
} }
@@ -53,7 +62,8 @@ class PacketSignalPeer : Packet() {
stream.writeInt8(signalType.value) stream.writeInt8(signalType.value)
if ( if (
signalType == SignalType.END_CALL_BECAUSE_BUSY || signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED ||
signalType == SignalType.RINGING_TIMEOUT
) { ) {
return stream return stream
} }
@@ -62,8 +72,13 @@ class PacketSignalPeer : Packet() {
if (signalType == SignalType.KEY_EXCHANGE) { if (signalType == SignalType.KEY_EXCHANGE) {
stream.writeString(sharedPublic) stream.writeString(sharedPublic)
} }
if (signalType == SignalType.CREATE_ROOM) { if (
stream.writeString(roomId) signalType == SignalType.CALL ||
signalType == SignalType.ACCEPT ||
signalType == SignalType.END_CALL
) {
stream.writeString(callId)
stream.writeString(joinToken)
} }
return stream 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()
}
}

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,159 @@
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 setAppInForeground(foreground: Boolean) =
connectionControlApi.setAppInForeground(foreground)
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,90 @@
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 setAppInForeground(foreground: Boolean) {
runCatching { protocolInstanceManager.getOrCreateProtocol().setAppInForeground(foreground) }
}
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

@@ -10,16 +10,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.rosetta.messenger.utils.RosettaDev1Log
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.SocketTimeoutException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
/** /**
@@ -38,12 +40,14 @@ data class TransportState(
object TransportManager { object TransportManager {
private const val MAX_RETRIES = 3 private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L private const val INITIAL_BACKOFF_MS = 1000L
private const val UPLOAD_ATTEMPT_TIMEOUT_MS = 45_000L
private var transportServer: String? = null private var transportServer: String? = null
private var appContext: Context? = null private var appContext: Context? = null
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList()) private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow() val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
private val activeUploadCalls = ConcurrentHashMap<String, Call>()
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList()) private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow() val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
@@ -68,6 +72,7 @@ object TransportManager {
fun setTransportServer(server: String) { fun setTransportServer(server: String) {
val normalized = server.trim().trimEnd('/') val normalized = server.trim().trimEnd('/')
transportServer = normalized.ifBlank { null } transportServer = normalized.ifBlank { null }
RosettaDev1Log.d("net/transport-server set=${transportServer.orEmpty()}")
} }
/** /**
@@ -99,15 +104,37 @@ object TransportManager {
/** /**
* Retry с exponential backoff: 1с, 2с, 4с * Retry с exponential backoff: 1с, 2с, 4с
*/ */
private suspend fun <T> withRetry(block: suspend () -> T): T { private suspend fun <T> withRetry(
operation: String = "transport",
id: String = "-",
block: suspend () -> T
): T {
var lastException: Exception? = null var lastException: Exception? = null
repeat(MAX_RETRIES) { attempt -> repeat(MAX_RETRIES) { attempt ->
try { try {
return block() return block()
} catch (e: CancellationException) { } catch (e: CancellationException) {
RosettaDev1Log.w(
"net/$operation cancelled id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES",
e
)
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e) lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
val shouldRetry = attempt < MAX_RETRIES - 1
if (shouldRetry) {
val backoffMs = INITIAL_BACKOFF_MS shl attempt
RosettaDev1Log.w(
"net/$operation retry id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
"backoff=${backoffMs}ms reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}"
)
} else {
RosettaDev1Log.e(
"net/$operation failed id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
"reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
e
)
}
if (attempt < MAX_RETRIES - 1) { if (attempt < MAX_RETRIES - 1) {
delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s
} }
@@ -121,7 +148,8 @@ object TransportManager {
*/ */
fun requestTransportServer() { fun requestTransportServer() {
val packet = PacketRequestTransport() val packet = PacketRequestTransport()
ProtocolManager.sendPacket(packet) RosettaDev1Log.d("net/transport-server request packet=0x0F")
ProtocolRuntimeAccess.get().sendPacket(packet)
} }
/** /**
@@ -133,6 +161,14 @@ object TransportManager {
_downloading.value = _downloading.value.filter { it.id != id } _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 = private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
val call = client.newCall(request) val call = client.newCall(request)
@@ -164,6 +200,37 @@ object TransportManager {
}) })
} }
private suspend fun awaitUploadResponse(id: String, request: Request): Response =
suspendCancellableCoroutine { 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)
}
})
}
private fun parseContentRangeTotal(value: String?): Long? { private fun parseContentRangeTotal(value: String?): Long? {
if (value.isNullOrBlank()) return null if (value.isNullOrBlank()) return null
// Example: "bytes 100-999/12345" // Example: "bytes 100-999/12345"
@@ -180,13 +247,16 @@ object TransportManager {
*/ */
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) { suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer() val server = getActiveServer()
ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server") RosettaDev1Log.i(
"net/upload start id=${id.take(12)} server=$server bytes=${content.length}"
)
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
// Добавляем в список загрузок // Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0) _uploading.value = _uploading.value + TransportState(id, 0)
try { try {
withRetry { withRetry(operation = "upload", id = id) {
val contentBytes = content.toByteArray(Charsets.UTF_8) val contentBytes = content.toByteArray(Charsets.UTF_8)
val totalSize = contentBytes.size.toLong() val totalSize = contentBytes.size.toLong()
@@ -198,6 +268,7 @@ object TransportManager {
val source = okio.Buffer().write(contentBytes) val source = okio.Buffer().write(contentBytes)
var uploaded = 0L var uploaded = 0L
val bufferSize = 8 * 1024L val bufferSize = 8 * 1024L
var lastProgressUpdateMs = 0L
while (true) { while (true) {
val read = source.read(sink.buffer, bufferSize) val read = source.read(sink.buffer, bufferSize)
@@ -206,9 +277,14 @@ object TransportManager {
uploaded += read uploaded += read
sink.flush() sink.flush()
val progress = ((uploaded * 100) / totalSize).toInt() val now = System.currentTimeMillis()
_uploading.value = _uploading.value.map { val isLast = uploaded >= totalSize
if (it.id == id) it.copy(progress = progress) else it if (isLast || now - lastProgressUpdateMs >= 200) {
lastProgressUpdateMs = now
val progress = ((uploaded * 100) / totalSize).toInt()
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = progress) else it
}
} }
} }
} }
@@ -223,42 +299,64 @@ object TransportManager {
.url("$server/u") .url("$server/u")
.post(requestBody) .post(requestBody)
.build() .build()
val response =
try {
withTimeout(UPLOAD_ATTEMPT_TIMEOUT_MS) {
awaitUploadResponse(id, request)
}
} catch (timeout: CancellationException) {
if (timeout is kotlinx.coroutines.TimeoutCancellationException) {
activeUploadCalls.remove(id)?.cancel()
RosettaDev1Log.w(
"net/upload attempt-timeout id=${id.take(12)} timeoutMs=$UPLOAD_ATTEMPT_TIMEOUT_MS"
)
throw SocketTimeoutException(
"Upload timeout after ${UPLOAD_ATTEMPT_TIMEOUT_MS}ms"
)
}
throw timeout
}
val response = suspendCoroutine<Response> { cont -> val tag =
client.newCall(request).enqueue(object : Callback { response.use { uploadResponse ->
override fun onFailure(call: Call, e: IOException) { if (!uploadResponse.isSuccessful) {
cont.resumeWithException(e) val errorBody = uploadResponse.body?.string()?.take(240).orEmpty()
RosettaDev1Log.e(
"net/upload http-fail id=${id.take(12)} code=${uploadResponse.code} body=$errorBody"
)
throw IOException("Upload failed: ${uploadResponse.code}")
} }
override fun onResponse(call: Call, response: Response) { val responseBody = uploadResponse.body?.string()
cont.resume(response) ?: throw IOException("Empty response")
} org.json.JSONObject(responseBody).getString("t")
}) }
}
if (!response.isSuccessful) {
throw IOException("Upload failed: ${response.code}")
}
val responseBody = response.body?.string()
?: throw IOException("Empty response")
val tag = org.json.JSONObject(responseBody).getString("t")
// Обновляем прогресс до 100% // Обновляем прогресс до 100%
_uploading.value = _uploading.value.map { _uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = 100) else it 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)}")
RosettaDev1Log.i("net/upload success id=${id.take(12)} tag=${tag.take(16)}")
tag tag
} }
} catch (e: CancellationException) {
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
RosettaDev1Log.w("net/upload cancelled id=${id.take(12)}", e)
throw e
} catch (e: Exception) { } catch (e: Exception) {
ProtocolManager.addLog( ProtocolRuntimeAccess.get().addLog(
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" "❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
) )
RosettaDev1Log.e(
"net/upload failed id=${id.take(12)} reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
e
)
throw e throw e
} finally { } finally {
activeUploadCalls.remove(id)?.cancel()
// Удаляем из списка загрузок // Удаляем из списка загрузок
_uploading.value = _uploading.value.filter { it.id != id } _uploading.value = _uploading.value.filter { it.id != id }
} }
@@ -279,13 +377,13 @@ object TransportManager {
transportServer: String? = null transportServer: String? = null
): String = withContext(Dispatchers.IO) { ): String = withContext(Dispatchers.IO) {
val server = getActiveServer(transportServer) val server = getActiveServer(transportServer)
ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server") ProtocolRuntimeAccess.get().addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
// Добавляем в список скачиваний // Добавляем в список скачиваний
_downloading.value = _downloading.value + TransportState(id, 0) _downloading.value = _downloading.value + TransportState(id, 0)
try { try {
withRetry { withRetry(operation = "download", id = id) {
val request = Request.Builder() val request = Request.Builder()
.url("$server/d/$tag") .url("$server/d/$tag")
.get() .get()
@@ -306,7 +404,7 @@ object TransportManager {
_downloading.value = _downloading.value.map { _downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it 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 return@withRetry content
} }
@@ -353,14 +451,14 @@ object TransportManager {
if (it.id == id) it.copy(progress = 100) else it 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 content
} finally { } finally {
tempFile.delete() tempFile.delete()
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
ProtocolManager.addLog( ProtocolRuntimeAccess.get().addLog(
"❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" "❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
) )
throw e throw e
@@ -427,14 +525,14 @@ object TransportManager {
transportServer: String? = null transportServer: String? = null
): File = withContext(Dispatchers.IO) { ): File = withContext(Dispatchers.IO) {
val server = getActiveServer(transportServer) val server = getActiveServer(transportServer)
ProtocolManager.addLog( ProtocolRuntimeAccess.get().addLog(
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes" "📥 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) _downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
try { try {
withRetry { withRetry(operation = "download-raw-resume", id = id) {
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L)) val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
.coerceAtMost(existingBytes) .coerceAtMost(existingBytes)
@@ -511,13 +609,13 @@ object TransportManager {
_downloading.value = _downloading.value.map { _downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it if (it.id == id) it.copy(progress = 100) else it
} }
ProtocolManager.addLog( ProtocolRuntimeAccess.get().addLog(
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead" "✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
) )
targetFile targetFile
} }
} catch (e: Exception) { } catch (e: Exception) {
ProtocolManager.addLog( ProtocolRuntimeAccess.get().addLog(
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" "❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
) )
throw e throw e

View File

@@ -72,6 +72,8 @@ object XChaCha20E2EE {
override fun getNativeFrameEncryptor(): Long = nativePtr override fun getNativeFrameEncryptor(): Long = nativePtr
fun frameCount(): Int = if (nativePtr != 0L) nativeGetEncryptorFrameCount(nativePtr) else -1
fun dispose() { fun dispose() {
if (nativePtr != 0L) nativeReleaseEncryptor(nativePtr) if (nativePtr != 0L) nativeReleaseEncryptor(nativePtr)
} }
@@ -89,6 +91,9 @@ object XChaCha20E2EE {
override fun getNativeFrameDecryptor(): Long = nativePtr 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() { fun dispose() {
if (nativePtr != 0L) nativeReleaseDecryptor(nativePtr) if (nativePtr != 0L) nativeReleaseDecryptor(nativePtr)
} }
@@ -99,8 +104,11 @@ object XChaCha20E2EE {
@JvmStatic private external fun nativeHSalsa20(rawDh: ByteArray): ByteArray @JvmStatic private external fun nativeHSalsa20(rawDh: ByteArray): ByteArray
@JvmStatic private external fun nativeCreateEncryptor(key: ByteArray): Long @JvmStatic private external fun nativeCreateEncryptor(key: ByteArray): Long
@JvmStatic private external fun nativeReleaseEncryptor(ptr: 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 nativeCreateDecryptor(key: ByteArray): Long
@JvmStatic private external fun nativeReleaseDecryptor(ptr: 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 private external fun nativeInstallCrashHandler(path: String)
@JvmStatic external fun nativeOpenDiagFile(path: String) @JvmStatic external fun nativeOpenDiagFile(path: String)
@JvmStatic external fun nativeCloseDiagFile() @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,56 @@
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 com.rosetta.messenger.utils.RosettaDev1Log
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) {
RosettaDev1Log.d(
"net/pipeline sendWithRetry msg=${packet.messageId.take(8)} " +
"to=${packet.toPublicKey.take(12)} from=${packet.fromPublicKey.take(12)}"
)
sendPacket(packet)
retryQueueService.register(packet)
}
fun resolveOutgoingRetry(messageId: String) {
RosettaDev1Log.d("net/pipeline resolveRetry msg=${messageId.take(8)}")
retryQueueService.resolve(messageId)
}
fun clearRetryQueue() {
RosettaDev1Log.d("net/pipeline 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)
RosettaDev1Log.w("net/pipeline markError msg=${messageId.take(8)} dialog=${dialogKey.take(16)}")
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,133 @@
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.contains("rosettadev1", ignoreCase = true)) 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
}

View File

@@ -0,0 +1,138 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.ConnectionBootstrapContext
import com.rosetta.messenger.network.ProtocolState
import java.util.Locale
interface ProtocolLifecycleStateStore {
var bootstrapContext: ConnectionBootstrapContext
fun clearLastSubscribedToken()
fun cancelOwnProfileFallbackTimeout()
fun scheduleOwnProfileFallbackTimeout(sessionGeneration: Long)
fun nextSessionGeneration(): Long
fun currentSessionGeneration(): Long
}
class ProtocolLifecycleCoordinator(
private val stateStore: ProtocolLifecycleStateStore,
private val syncCoordinator: SyncCoordinator,
private val authBootstrapCoordinator: AuthBootstrapCoordinator,
private val addLog: (String) -> Unit,
private val shortKeyForLog: (String) -> String,
private val stopWaitingForNetwork: (String) -> Unit,
private val cancelAllOutgoingRetries: () -> Unit,
private val recomputeConnectionLifecycleState: (String) -> Unit,
private val onAuthenticated: () -> Unit,
private val onSyncCompletedSideEffects: () -> Unit,
private val updateOwnProfileResolved: (publicKey: String, reason: String) -> Unit,
) {
fun handleProtocolStateChanged(newProtocolState: ProtocolState) {
val context = stateStore.bootstrapContext
val previousProtocolState = context.protocolState
if (
newProtocolState == ProtocolState.AUTHENTICATED &&
previousProtocolState != ProtocolState.AUTHENTICATED
) {
stateStore.clearLastSubscribedToken()
stopWaitingForNetwork("authenticated")
stateStore.cancelOwnProfileFallbackTimeout()
val generation = stateStore.nextSessionGeneration()
authBootstrapCoordinator.onAuthenticatedSessionStarted()
stateStore.bootstrapContext =
context.copy(
protocolState = newProtocolState,
authenticated = true,
syncCompleted = false,
ownProfileResolved = false
)
recomputeConnectionLifecycleState("protocol_authenticated")
stateStore.scheduleOwnProfileFallbackTimeout(generation)
onAuthenticated()
return
}
if (
newProtocolState != ProtocolState.AUTHENTICATED &&
newProtocolState != ProtocolState.HANDSHAKING
) {
syncCoordinator.clearRequestState()
syncCoordinator.markSyncInProgress(false)
stateStore.clearLastSubscribedToken()
cancelAllOutgoingRetries()
stateStore.cancelOwnProfileFallbackTimeout()
authBootstrapCoordinator.reset()
stateStore.bootstrapContext =
context.copy(
protocolState = newProtocolState,
authenticated = false,
syncCompleted = false,
ownProfileResolved = false
)
recomputeConnectionLifecycleState(
"protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}"
)
return
}
if (newProtocolState == ProtocolState.HANDSHAKING && context.authenticated) {
stateStore.cancelOwnProfileFallbackTimeout()
authBootstrapCoordinator.reset()
stateStore.bootstrapContext =
context.copy(
protocolState = newProtocolState,
authenticated = false,
syncCompleted = false,
ownProfileResolved = false
)
recomputeConnectionLifecycleState("protocol_re_handshaking")
return
}
stateStore.bootstrapContext = context.copy(protocolState = newProtocolState)
recomputeConnectionLifecycleState(
"protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}"
)
}
fun handleSyncCompleted(reason: String) {
syncCoordinator.onSyncCompletedStateApplied()
addLog(reason)
onSyncCompletedSideEffects()
stateStore.bootstrapContext = stateStore.bootstrapContext.copy(syncCompleted = true)
recomputeConnectionLifecycleState("sync_completed")
}
fun handleOwnProfileResolved(publicKey: String) {
val accountPublicKey = stateStore.bootstrapContext.accountPublicKey
val matchesAccount =
accountPublicKey.isBlank() || publicKey.equals(accountPublicKey, ignoreCase = true)
if (!matchesAccount) return
stateStore.cancelOwnProfileFallbackTimeout()
stateStore.bootstrapContext = stateStore.bootstrapContext.copy(ownProfileResolved = true)
updateOwnProfileResolved(publicKey, "protocol_own_profile_resolved")
recomputeConnectionLifecycleState("own_profile_resolved")
}
fun handleOwnProfileFallbackTimeout(sessionGeneration: Long) {
if (stateStore.currentSessionGeneration() != sessionGeneration) return
val context = stateStore.bootstrapContext
if (!context.authenticated || context.ownProfileResolved) return
addLog(
"⏱️ Own profile fetch timeout — continuing bootstrap for ${shortKeyForLog(context.accountPublicKey)}"
)
val updatedContext = context.copy(ownProfileResolved = true)
stateStore.bootstrapContext = updatedContext
val accountPublicKey = updatedContext.accountPublicKey
if (accountPublicKey.isNotBlank()) {
updateOwnProfileResolved(accountPublicKey, "protocol_own_profile_fallback_timeout")
}
recomputeConnectionLifecycleState("own_profile_fallback_timeout")
}
}

View File

@@ -0,0 +1,34 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.ConnectionBootstrapContext
import java.util.concurrent.atomic.AtomicLong
class ProtocolLifecycleStateStoreImpl(
private val lifecycleStateMachine: RuntimeLifecycleStateMachine,
private val ownProfileFallbackTimerService: OwnProfileFallbackTimerService,
private val clearLastSubscribedTokenValue: () -> Unit
) : ProtocolLifecycleStateStore {
private val sessionGeneration = AtomicLong(0L)
override var bootstrapContext: ConnectionBootstrapContext
get() = lifecycleStateMachine.bootstrapContext
set(value) {
lifecycleStateMachine.bootstrapContext = value
}
override fun clearLastSubscribedToken() {
clearLastSubscribedTokenValue()
}
override fun cancelOwnProfileFallbackTimeout() {
ownProfileFallbackTimerService.cancel()
}
override fun scheduleOwnProfileFallbackTimeout(sessionGeneration: Long) {
ownProfileFallbackTimerService.schedule(sessionGeneration)
}
override fun nextSessionGeneration(): Long = sessionGeneration.incrementAndGet()
override fun currentSessionGeneration(): Long = sessionGeneration.get()
}

View File

@@ -0,0 +1,146 @@
package com.rosetta.messenger.network.connection
import android.content.Context
import android.util.Log
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketPushNotification
import com.rosetta.messenger.network.PushNotificationAction
import com.rosetta.messenger.network.PushTokenType
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ProtocolPostAuthBootstrapCoordinator(
private val tag: String,
private val scope: CoroutineScope,
private val authBootstrapCoordinator: AuthBootstrapCoordinator,
private val syncCoordinator: SyncCoordinator,
private val ownProfileSyncService: OwnProfileSyncService,
private val deviceRuntimeService: DeviceRuntimeService,
private val getMessageRepository: () -> MessageRepository?,
private val getAppContext: () -> Context?,
private val getProtocolPublicKey: () -> String?,
private val getProtocolPrivateHash: () -> String?,
private val sendPacket: (Packet) -> Unit,
private val requestTransportServer: () -> Unit,
private val requestUpdateServer: () -> Unit,
private val addLog: (String) -> Unit,
private val shortKeyForLog: (String) -> String,
private val getLastSubscribedToken: () -> String?,
private val setLastSubscribedToken: (String?) -> Unit
) {
fun runPostAuthBootstrap(trigger: String) {
authBootstrapCoordinator.tryRun(
trigger = trigger,
canRun = ::canRunPostAuthBootstrap,
onDeferred = {
val repositoryAccount =
getMessageRepository()?.getCurrentAccountKey()?.let { shortKeyForLog(it) }
?: "<none>"
val protocolAccount = getProtocolPublicKey()?.let { shortKeyForLog(it) } ?: "<none>"
addLog(
"⏳ AUTH bootstrap deferred trigger=$trigger repo=$repositoryAccount proto=$protocolAccount"
)
}
) {
syncCoordinator.markSyncInProgress(false)
requestTransportServer()
requestUpdateServer()
fetchOwnProfile()
syncCoordinator.requestSynchronize()
subscribePushTokenIfAvailable()
}
}
fun handleSyncCompletedSideEffects() {
retryWaitingMessages()
requestMissingUserInfo()
}
fun subscribePushTokenIfAvailable(forceToken: String? = null) {
val context = getAppContext() ?: return
val token =
(forceToken
?: context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
.getString("fcm_token", null))
?.trim()
.orEmpty()
if (token.isEmpty()) return
if (token == getLastSubscribedToken()) {
addLog("🔔 Push token already subscribed this session — skipped")
return
}
val deviceId = deviceRuntimeService.resolvePushDeviceId()
val subPacket =
PacketPushNotification().apply {
notificationsToken = token
action = PushNotificationAction.SUBSCRIBE
tokenType = PushTokenType.FCM
this.deviceId = deviceId
}
sendPacket(subPacket)
setLastSubscribedToken(token)
addLog("🔔 Push token SUBSCRIBE sent")
try {
val dir = File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val file = File(dir, "fcm_token.txt")
val ts =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
file.writeText(
"=== FCM TOKEN ===\n\nTimestamp: $ts\nDeviceId: $deviceId\n\nToken:\n$token\n"
)
} catch (_: Throwable) {}
}
private fun canRunPostAuthBootstrap(): Boolean {
val repository = getMessageRepository() ?: return false
if (!repository.isInitialized()) return false
val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty()
if (repositoryAccount.isBlank()) return false
val protocolAccount = getProtocolPublicKey()?.trim().orEmpty()
if (protocolAccount.isBlank()) return true
return repositoryAccount.equals(protocolAccount, ignoreCase = true)
}
private fun fetchOwnProfile() {
val packet =
ownProfileSyncService.buildOwnProfilePacket(
publicKey = getProtocolPublicKey(),
privateHash = getProtocolPrivateHash()
) ?: return
sendPacket(packet)
}
private fun retryWaitingMessages() {
scope.launch {
val repository = getMessageRepository()
if (repository == null || !repository.isInitialized()) return@launch
try {
repository.retryWaitingMessages()
} catch (e: Exception) {
Log.e(tag, "retryWaitingMessages failed", e)
}
}
}
private fun requestMissingUserInfo() {
scope.launch {
val repository = getMessageRepository()
if (repository == null || !repository.isInitialized()) return@launch
try {
repository.clearUserInfoRequestCache()
repository.requestMissingUserInfo()
} catch (e: Exception) {
Log.e(tag, "requestMissingUserInfo failed", e)
}
}
}
}

View File

@@ -0,0 +1,44 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.ConnectionLifecycleState
import com.rosetta.messenger.network.Packet
class ReadyPacketDispatchCoordinator(
private val bootstrapCoordinator: BootstrapCoordinator,
private val getConnectionLifecycleState: () -> ConnectionLifecycleState,
private val resolveAccountPublicKey: () -> String,
private val sendPacketDirect: (Packet) -> Unit,
private val isAuthenticated: () -> Boolean,
private val hasActiveInternet: () -> Boolean,
private val waitForNetworkAndReconnect: (String) -> Unit,
private val reconnectNowIfNeeded: (String) -> Unit
) {
fun clearReadyPacketQueue(reason: String) {
bootstrapCoordinator.clearReadyPacketQueue(reason)
}
fun handleSendPacket(packet: Packet) {
val lifecycle = getConnectionLifecycleState()
if (
bootstrapCoordinator.packetCanBypassReadyGate(packet) ||
lifecycle == ConnectionLifecycleState.READY
) {
sendPacketDirect(packet)
return
}
bootstrapCoordinator.enqueueReadyPacket(
packet = packet,
accountPublicKey = resolveAccountPublicKey(),
state = lifecycle
)
if (!isAuthenticated()) {
if (!hasActiveInternet()) {
waitForNetworkAndReconnect("ready_gate_send")
} else {
reconnectNowIfNeeded("ready_gate_send")
}
}
}
}

View File

@@ -0,0 +1,110 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.PacketMessage
import com.rosetta.messenger.utils.RosettaDev1Log
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Outgoing retry queue for PacketMessage delivery.
*
* Mirrors iOS behavior:
* - retry every 4s,
* - max 3 attempts,
* - max 80s lifetime.
*/
class RetryQueueService(
private val scope: CoroutineScope,
private val sendPacket: (PacketMessage) -> Unit,
private val isAuthenticated: () -> Boolean,
private val addLog: (String) -> Unit,
private val markOutgoingAsError: suspend (messageId: String, packet: PacketMessage) -> Unit,
private val retryIntervalMs: Long = 4_000L,
private val maxRetryAttempts: Int = 3,
private val maxLifetimeMs: Long = 80_000L
) {
private val pendingOutgoingPackets = ConcurrentHashMap<String, PacketMessage>()
private val pendingOutgoingAttempts = ConcurrentHashMap<String, Int>()
private val pendingOutgoingRetryJobs = ConcurrentHashMap<String, Job>()
fun register(packet: PacketMessage) {
val messageId = packet.messageId
RosettaDev1Log.d("net/retry register msg=${messageId.take(8)}")
pendingOutgoingRetryJobs[messageId]?.cancel()
pendingOutgoingPackets[messageId] = packet
pendingOutgoingAttempts[messageId] = 0
schedule(messageId)
}
fun resolve(messageId: String) {
RosettaDev1Log.d("net/retry resolve msg=${messageId.take(8)}")
pendingOutgoingRetryJobs[messageId]?.cancel()
pendingOutgoingRetryJobs.remove(messageId)
pendingOutgoingPackets.remove(messageId)
pendingOutgoingAttempts.remove(messageId)
}
fun clear() {
RosettaDev1Log.d("net/retry clear size=${pendingOutgoingRetryJobs.size}")
pendingOutgoingRetryJobs.values.forEach { it.cancel() }
pendingOutgoingRetryJobs.clear()
pendingOutgoingPackets.clear()
pendingOutgoingAttempts.clear()
}
private fun schedule(messageId: String) {
pendingOutgoingRetryJobs[messageId]?.cancel()
pendingOutgoingRetryJobs[messageId] =
scope.launch {
delay(retryIntervalMs)
val packet = pendingOutgoingPackets[messageId] ?: return@launch
val attempts = pendingOutgoingAttempts[messageId] ?: 0
val nowMs = System.currentTimeMillis()
val ageMs = nowMs - packet.timestamp
if (ageMs >= maxLifetimeMs) {
RosettaDev1Log.w(
"net/retry expired msg=${messageId.take(8)} age=${ageMs}ms"
)
addLog(
"⚠️ Message ${messageId.take(8)} expired after ${ageMs}ms — marking as error"
)
scope.launch { markOutgoingAsError(messageId, packet) }
resolve(messageId)
return@launch
}
if (attempts >= maxRetryAttempts) {
RosettaDev1Log.w(
"net/retry exhausted msg=${messageId.take(8)} attempts=$attempts"
)
addLog(
"⚠️ Message ${messageId.take(8)} exhausted $attempts retries — marking as error"
)
scope.launch { markOutgoingAsError(messageId, packet) }
resolve(messageId)
return@launch
}
if (!isAuthenticated()) {
RosettaDev1Log.w("net/retry deferred-not-auth msg=${messageId.take(8)}")
addLog("⏳ Message ${messageId.take(8)} retry deferred — not authenticated")
resolve(messageId)
return@launch
}
val nextAttempt = attempts + 1
pendingOutgoingAttempts[messageId] = nextAttempt
RosettaDev1Log.i(
"net/retry resend msg=${messageId.take(8)} attempt=$nextAttempt/$maxRetryAttempts"
)
addLog("🔄 Retrying message ${messageId.take(8)}, attempt $nextAttempt")
sendPacket(packet)
schedule(messageId)
}
}
}

View File

@@ -0,0 +1,32 @@
package com.rosetta.messenger.network.connection
import android.content.Context
class RuntimeInitializationCoordinator(
private val ensureConnectionSupervisor: () -> Unit,
private val setupPacketHandlers: () -> Unit,
private val setupStateMonitoring: () -> Unit,
private val setAppContext: (Context) -> Unit,
private val hasBoundDependencies: () -> Boolean,
private val addLog: (String) -> Unit
) {
@Volatile private var packetHandlersRegistered = false
@Volatile private var stateMonitoringStarted = false
fun initialize(context: Context) {
setAppContext(context.applicationContext)
if (!hasBoundDependencies()) {
addLog("⚠️ initialize called before dependencies were bound via DI")
}
ensureConnectionSupervisor()
if (!packetHandlersRegistered) {
setupPacketHandlers()
packetHandlersRegistered = true
}
if (!stateMonitoringStarted) {
setupStateMonitoring()
stateMonitoringStarted = true
}
}
}

View File

@@ -0,0 +1,37 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.ConnectionBootstrapContext
import com.rosetta.messenger.network.ConnectionLifecycleState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class RuntimeLifecycleStateMachine(
private val bootstrapCoordinator: BootstrapCoordinator,
private val addLog: (String) -> Unit
) {
private val _connectionLifecycleState =
MutableStateFlow(ConnectionLifecycleState.DISCONNECTED)
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
_connectionLifecycleState.asStateFlow()
var bootstrapContext: ConnectionBootstrapContext = ConnectionBootstrapContext()
fun currentState(): ConnectionLifecycleState = _connectionLifecycleState.value
fun recompute(reason: String) {
val nextState =
bootstrapCoordinator.recomputeLifecycleState(
context = bootstrapContext,
currentState = _connectionLifecycleState.value,
reason = reason
) { state, updateReason ->
if (_connectionLifecycleState.value == state) return@recomputeLifecycleState
addLog(
"🧭 CONNECTION STATE: ${_connectionLifecycleState.value} -> $state ($updateReason)"
)
_connectionLifecycleState.value = state
}
_connectionLifecycleState.value = nextState
}
}

View File

@@ -0,0 +1,25 @@
package com.rosetta.messenger.network.connection
class RuntimeShutdownCoordinator(
private val stopWaitingForNetwork: (String) -> Unit,
private val destroyPacketSubscriptionRegistry: () -> Unit,
private val destroyProtocolInstance: () -> Unit,
private val clearMessageRepositoryInitialization: () -> Unit,
private val clearPresenceTyping: () -> Unit,
private val clearDeviceRuntime: () -> Unit,
private val resetSyncCoordinator: () -> Unit,
private val resetAuthBootstrap: () -> Unit,
private val cancelRuntimeScope: () -> Unit
) {
fun destroy() {
stopWaitingForNetwork("destroy")
destroyPacketSubscriptionRegistry()
destroyProtocolInstance()
clearMessageRepositoryInitialization()
clearPresenceTyping()
clearDeviceRuntime()
resetSyncCoordinator()
resetAuthBootstrap()
cancelRuntimeScope()
}
}

View File

@@ -0,0 +1,257 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.PacketSync
import com.rosetta.messenger.network.SyncStatus
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.launch
class SyncCoordinator(
private val scope: CoroutineScope,
private val syncRequestTimeoutMs: Long,
private val manualSyncBacktrackMs: Long,
private val addLog: (String) -> Unit,
private val isAuthenticated: () -> Boolean,
private val getRepository: () -> MessageRepository?,
private val getProtocolPublicKey: () -> String,
private val sendPacket: (PacketSync) -> Unit,
private val onSyncCompleted: (String) -> Unit,
private val whenInboundTasksFinish: suspend () -> Boolean
) {
private val _syncInProgress = MutableStateFlow(false)
val syncInProgress: StateFlow<Boolean> = _syncInProgress.asStateFlow()
@Volatile private var syncBatchInProgress = false
@Volatile private var syncRequestInFlight = false
@Volatile private var resyncRequiredAfterAccountInit = false
@Volatile private var lastForegroundSyncTime = 0L
@Volatile private var syncRequestTimeoutJob: Job? = null
private val inboundProcessingFailures = AtomicInteger(0)
private val inboundTasksInCurrentBatch = AtomicInteger(0)
private val fullFailureBatchStreak = AtomicInteger(0)
private val syncBatchEndMutex = Mutex()
fun isBatchInProgress(): Boolean = syncBatchInProgress
fun isRequestInFlight(): Boolean = syncRequestInFlight
fun markSyncInProgress(value: Boolean) {
syncBatchInProgress = value
if (_syncInProgress.value != value) {
_syncInProgress.value = value
}
}
fun clearRequestState() {
syncRequestInFlight = false
clearSyncRequestTimeout()
}
fun clearResyncRequired() {
resyncRequiredAfterAccountInit = false
}
fun shouldResyncAfterAccountInit(): Boolean = resyncRequiredAfterAccountInit
fun requireResyncAfterAccountInit(reason: String) {
if (!resyncRequiredAfterAccountInit) {
addLog(reason)
}
resyncRequiredAfterAccountInit = true
}
fun markInboundProcessingFailure() {
inboundProcessingFailures.incrementAndGet()
}
fun trackInboundTaskQueued() {
if (syncBatchInProgress) {
inboundTasksInCurrentBatch.incrementAndGet()
}
}
fun requestSynchronize() {
if (syncBatchInProgress) {
addLog("⚠️ SYNC request skipped: sync already in progress")
return
}
if (syncRequestInFlight) {
addLog("⚠️ SYNC request skipped: previous request still in flight")
return
}
syncRequestInFlight = true
addLog("🔄 SYNC requested — fetching last sync timestamp...")
scope.launch {
val repository = getRepository()
if (repository == null || !repository.isInitialized()) {
syncRequestInFlight = false
clearSyncRequestTimeout()
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
return@launch
}
val protocolAccount = getProtocolPublicKey().trim()
val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty()
if (
protocolAccount.isNotBlank() &&
repositoryAccount.isNotBlank() &&
!repositoryAccount.equals(protocolAccount, ignoreCase = true)
) {
syncRequestInFlight = false
clearSyncRequestTimeout()
requireResyncAfterAccountInit(
"⏳ Sync postponed: repository bound to another account"
)
return@launch
}
val lastSync = repository.getLastSyncTimestamp()
addLog("🔄 SYNC sending request with lastSync=$lastSync")
sendSynchronize(lastSync)
}
}
fun handleSyncPacket(packet: PacketSync) {
syncRequestInFlight = false
clearSyncRequestTimeout()
when (packet.status) {
SyncStatus.BATCH_START -> {
addLog("🔄 SYNC BATCH_START — incoming message batch")
markSyncInProgress(true)
inboundProcessingFailures.set(0)
inboundTasksInCurrentBatch.set(0)
}
SyncStatus.BATCH_END -> {
addLog("🔄 SYNC BATCH_END — waiting for tasks to finish (ts=${packet.timestamp})")
scope.launch {
syncBatchEndMutex.withLock {
val tasksFinished = whenInboundTasksFinish()
if (!tasksFinished) {
val fallbackCursor = getRepository()?.getLastSyncTimestamp() ?: 0L
sendSynchronize(fallbackCursor)
return@launch
}
val failuresInBatch = inboundProcessingFailures.getAndSet(0)
val tasksInBatch = inboundTasksInCurrentBatch.getAndSet(0)
val fullBatchFailure = tasksInBatch > 0 && failuresInBatch >= tasksInBatch
if (failuresInBatch > 0) {
addLog(
"⚠️ SYNC batch had $failuresInBatch processing error(s) out of $tasksInBatch task(s)"
)
if (fullBatchFailure) {
val streak = fullFailureBatchStreak.incrementAndGet()
val fallbackCursor = getRepository()?.getLastSyncTimestamp() ?: 0L
if (streak <= 2) {
addLog(
"🛟 SYNC full-batch failure ($failuresInBatch/$tasksInBatch), keeping cursor=$fallbackCursor and retrying batch (streak=$streak)"
)
sendSynchronize(fallbackCursor)
return@launch
}
addLog(
"⚠️ SYNC full-batch failure streak=$streak, advancing cursor to avoid deadlock"
)
} else {
fullFailureBatchStreak.set(0)
}
} else {
fullFailureBatchStreak.set(0)
}
getRepository()?.updateLastSyncTimestamp(packet.timestamp)
addLog("🔄 SYNC tasks done — cursor=${packet.timestamp}, requesting next batch")
sendSynchronize(packet.timestamp)
}
}
}
SyncStatus.NOT_NEEDED -> {
onSyncCompleted("✅ SYNC COMPLETE — no more messages to sync")
}
}
}
fun syncOnForeground() {
if (!isAuthenticated()) return
if (syncBatchInProgress) return
if (syncRequestInFlight) return
val now = System.currentTimeMillis()
if (now - lastForegroundSyncTime < 5_000L) return
lastForegroundSyncTime = now
addLog("🔄 SYNC on foreground resume")
requestSynchronize()
}
fun forceSynchronize(backtrackMs: Long = manualSyncBacktrackMs) {
if (!isAuthenticated()) return
if (syncBatchInProgress) return
if (syncRequestInFlight) return
scope.launch {
val repository = getRepository()
if (repository == null || !repository.isInitialized()) {
requireResyncAfterAccountInit("⏳ Manual sync postponed until account is initialized")
return@launch
}
val currentSync = repository.getLastSyncTimestamp()
val rewindTo = (currentSync - backtrackMs.coerceAtLeast(0L)).coerceAtLeast(0L)
syncRequestInFlight = true
addLog("🔄 MANUAL SYNC requested: lastSync=$currentSync -> rewind=$rewindTo")
sendSynchronize(rewindTo)
}
}
fun onSyncCompletedStateApplied() {
clearRequestState()
inboundProcessingFailures.set(0)
inboundTasksInCurrentBatch.set(0)
fullFailureBatchStreak.set(0)
markSyncInProgress(false)
}
fun resetForDisconnect() {
clearRequestState()
markSyncInProgress(false)
clearResyncRequired()
inboundProcessingFailures.set(0)
inboundTasksInCurrentBatch.set(0)
fullFailureBatchStreak.set(0)
}
private fun sendSynchronize(timestamp: Long) {
syncRequestInFlight = true
scheduleSyncRequestTimeout(timestamp)
sendPacket(
PacketSync().apply {
status = SyncStatus.NOT_NEEDED
this.timestamp = timestamp
}
)
}
private fun scheduleSyncRequestTimeout(cursor: Long) {
syncRequestTimeoutJob?.cancel()
syncRequestTimeoutJob =
scope.launch {
delay(syncRequestTimeoutMs)
if (!syncRequestInFlight || !isAuthenticated()) return@launch
syncRequestInFlight = false
addLog("⏱️ SYNC response timeout for cursor=$cursor, retrying request")
requestSynchronize()
}
}
private fun clearSyncRequestTimeout() {
syncRequestTimeoutJob?.cancel()
syncRequestTimeoutJob = null
}
}

View File

@@ -5,19 +5,29 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build import android.os.Build
import android.util.Base64
import android.util.Log import android.util.Log
import com.rosetta.messenger.BuildConfig
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import com.rosetta.messenger.MainActivity import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.CallForegroundService import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.utils.AvatarFileManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -33,16 +43,19 @@ import java.util.Locale
* - Получение push-уведомлений о новых сообщениях * - Получение push-уведомлений о новых сообщениях
* - Отображение уведомлений * - Отображение уведомлений
*/ */
@AndroidEntryPoint
class RosettaFirebaseMessagingService : FirebaseMessagingService() { class RosettaFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var accountManager: AccountManager
@Inject lateinit var preferencesManager: PreferencesManager
@Inject lateinit var protocolGateway: ProtocolGateway
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
companion object { companion object {
private const val TAG = "RosettaFCM" private const val TAG = "RosettaFCM"
private const val CHANNEL_ID = "rosetta_messages" private const val CHANNEL_ID = "rosetta_messages"
private const val CHANNEL_NAME = "Messages" private const val CHANNEL_NAME = "Messages"
private const val CALL_CHANNEL_ID = "rosetta_calls_push"
private const val CALL_CHANNEL_NAME = "Calls"
private const val PUSH_TYPE_PERSONAL_MESSAGE = "personal_message" private const val PUSH_TYPE_PERSONAL_MESSAGE = "personal_message"
private const val PUSH_TYPE_GROUP_MESSAGE = "group_message" private const val PUSH_TYPE_GROUP_MESSAGE = "group_message"
private const val PUSH_TYPE_CALL = "call" private const val PUSH_TYPE_CALL = "call"
@@ -68,10 +81,13 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val variants = buildDialogKeyVariants(senderPublicKey) val variants = buildDialogKeyVariants(senderPublicKey)
for (key in variants) { for (key in variants) {
notificationManager.cancel(getNotificationIdForChat(key)) notificationManager.cancel(getNotificationIdForChat(key))
lastNotifTimestamps.remove(key)
} }
// Fallback: некоторые серверные payload могут прийти без sender key. // Fallback: некоторые серверные payload могут прийти без sender key.
// Для них используется ID от пустой строки — тоже очищаем при входе в диалог. // Для них используется ID от пустой строки — тоже очищаем при входе в диалог.
notificationManager.cancel(getNotificationIdForChat("")) notificationManager.cancel(getNotificationIdForChat(""))
lastNotifTimestamps.remove("__no_sender__")
lastNotifTimestamps.remove("__simple__")
} }
private fun buildDialogKeyVariants(rawKey: String): Set<String> { private fun buildDialogKeyVariants(rawKey: String): Set<String> {
@@ -112,22 +128,40 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
saveFcmToken(token) saveFcmToken(token)
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push. // Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
// Используем единую точку отправки в ProtocolManager (с дедупликацией). // Используем единую runtime-точку отправки (с дедупликацией).
if (ProtocolManager.isAuthenticated()) { if (protocolGateway.isAuthenticated()) {
runCatching { ProtocolManager.subscribePushTokenIfAvailable(forceToken = token) } runCatching { protocolGateway.subscribePushTokenIfAvailable(forceToken = token) }
} }
} }
/** Вызывается когда получено push-уведомление */ /** Вызывается когда получено push-уведомление */
override fun onMessageReceived(remoteMessage: RemoteMessage) { override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage) super.onMessageReceived(remoteMessage)
Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}") if (BuildConfig.DEBUG) Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
var handledByData = false
val data = remoteMessage.data val data = remoteMessage.data
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty() val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
val notificationBody = remoteMessage.notification?.body?.trim().orEmpty() val notificationBody = remoteMessage.notification?.body?.trim().orEmpty()
// Filter out empty/silent pushes (iOS wake-up pushes with mutable-content, empty alerts, etc.)
val hasDataContent = data.isNotEmpty() && data.any { (key, value) ->
key !in setOf("google.delivered_priority", "google.sent_time", "google.ttl",
"google.original_priority", "gcm.notification.e", "gcm.notification.tag",
"google.c.a.e", "google.c.sender.id", "google.c.fid",
"mutable-content", "mutable_content", "content-available", "content_available") &&
value.isNotBlank()
}
val hasNotificationContent = notificationTitle.isNotBlank() || notificationBody.isNotBlank()
if (!hasDataContent && !hasNotificationContent) {
if (BuildConfig.DEBUG) Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)")
// Still trigger reconnect if WebSocket is disconnected
protocolGateway.reconnectNowIfNeeded("silent_push")
return
}
var handledByData = false
// Обрабатываем data payload (новый server формат + legacy fallback) // Обрабатываем data payload (новый server формат + legacy fallback)
if (data.isNotEmpty()) { if (data.isNotEmpty()) {
val type = val type =
@@ -155,6 +189,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
"public_key", "public_key",
"publicKey" "publicKey"
) )
val callId = firstNonBlank(data, "callId", "call_id")
val joinToken = firstNonBlank(data, "joinToken", "join_token")
val senderName = val senderName =
firstNonBlank( firstNonBlank(
data, data,
@@ -189,8 +225,19 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
when { when {
isReadEvent -> { isReadEvent -> {
if (!dialogKey.isNullOrBlank()) { val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey)
cancelNotificationForChat(applicationContext, dialogKey) if (keysToClear.isEmpty()) {
if (BuildConfig.DEBUG) Log.d(TAG, "READ push received but no dialog key in payload: $data")
} else {
keysToClear.forEach { key ->
cancelNotificationForChat(applicationContext, key)
}
val titleHints = collectReadTitleHints(data, keysToClear)
cancelMatchingActiveNotifications(keysToClear, titleHints)
if (BuildConfig.DEBUG) Log.d(
TAG,
"READ push cleared notifications for keys=$keysToClear titles=$titleHints"
)
} }
handledByData = true handledByData = true
} }
@@ -198,7 +245,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
handleIncomingCallPush( handleIncomingCallPush(
dialogKey = dialogKey ?: senderPublicKey.orEmpty(), dialogKey = dialogKey ?: senderPublicKey.orEmpty(),
title = senderName, title = senderName,
body = messagePreview callId = callId.orEmpty(),
joinToken = joinToken.orEmpty()
) )
handledByData = true handledByData = true
} }
@@ -270,11 +318,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey] val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) { if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms") if (BuildConfig.DEBUG) Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms")
return // duplicate push — skip return // duplicate push — skip
} }
lastNotifTimestamps[dedupKey] = now lastNotifTimestamps[dedupKey] = now
Log.d(TAG, "\u2705 Showing notification for key=$dedupKey") if (BuildConfig.DEBUG) Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
val senderKey = senderPublicKey?.trim().orEmpty() val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) { if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return return
@@ -282,6 +330,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel() createNotificationChannel()
// Резолвим имя и аватарку по publicKey
val resolvedName = resolveNameForKey(senderPublicKey) ?: senderName
val avatarBitmap = loadAvatarBitmap(senderPublicKey)
val notifId = getNotificationIdForChat(senderPublicKey ?: "") val notifId = getNotificationIdForChat(senderPublicKey ?: "")
// Intent для открытия чата // Intent для открытия чата
@@ -302,12 +354,17 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notification = val notification =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(senderName) .setContentTitle(resolvedName)
.setContentText(messagePreview) .setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.apply {
if (avatarBitmap != null) {
setLargeIcon(avatarBitmap)
}
}
.build() .build()
val notificationManager = val notificationManager =
@@ -336,6 +393,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
createNotificationChannel() createNotificationChannel()
// Резолвим имя и аватарку по publicKey
val resolvedTitle = if (senderKey.isNotEmpty()) resolveNameForKey(senderKey) ?: title else title
val avatarBitmap = if (senderKey.isNotEmpty()) loadAvatarBitmap(senderKey) else null
// Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление // Используем sender-based ID если известен ключ — чтобы cancelNotificationForChat мог убрать уведомление
val notifId = if (senderKey.isNotEmpty()) { val notifId = if (senderKey.isNotEmpty()) {
getNotificationIdForChat(senderKey) getNotificationIdForChat(senderKey)
@@ -359,11 +420,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val notification = val notification =
NotificationCompat.Builder(this, CHANNEL_ID) NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification) .setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title) .setContentTitle(resolvedTitle)
.setContentText(body) .setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.apply {
if (avatarBitmap != null) {
setLargeIcon(avatarBitmap)
}
}
.build() .build()
val notificationManager = val notificationManager =
@@ -371,75 +437,105 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
notificationManager.notify(notifId, notification) notificationManager.notify(notifId, notification)
} }
/** Супер push входящего звонка: пробуждаем протокол и показываем call notification */ /** Супер push входящего звонка: пробуждаем протокол и запускаем ForegroundService с incoming call UI */
private fun handleIncomingCallPush(dialogKey: String, title: String, body: String) { private fun handleIncomingCallPush(
dialogKey: String,
title: String,
callId: String,
joinToken: String
) {
pushCallLog("handleIncomingCallPush dialog=$dialogKey title=$title")
wakeProtocolFromPush("call") wakeProtocolFromPush("call")
if (isAppInForeground || !areNotificationsEnabled()) return if (!areNotificationsEnabled()) {
pushCallLog("SKIP: notifications disabled")
return
}
val normalizedDialog = dialogKey.trim() val normalizedDialog = dialogKey.trim()
if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) return if (normalizedDialog.isNotEmpty() && isDialogMuted(normalizedDialog)) {
if (CallManager.state.value.phase != CallPhase.IDLE) return pushCallLog("SKIP: dialog muted")
return
}
val currentPhase = CallManager.state.value.phase
if (currentPhase != CallPhase.IDLE) {
pushCallLog("SKIP: phase=$currentPhase (not IDLE)")
return
}
val dedupKey = "call:${normalizedDialog.ifEmpty { "__no_dialog__" }}" val normalizedCallId = callId.trim()
val normalizedJoinToken = joinToken.trim()
val dedupKey =
if (normalizedCallId.isNotBlank()) {
"call:$normalizedCallId"
} else {
"call:${normalizedDialog.ifEmpty { "__no_dialog__" }}"
}
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey] val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) return if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
pushCallLog("SKIP: dedup blocked (delta=${now - lastTs}ms)")
return
}
lastNotifTimestamps[dedupKey] = now lastNotifTimestamps[dedupKey] = now
createCallNotificationChannel() val resolvedName = resolveNameForKey(normalizedDialog) ?: title
pushCallLog("resolvedName=$resolvedName, calling setIncomingFromPush")
val notifId = // Сразу ставим CallManager в INCOMING — не ждём WebSocket
if (normalizedDialog.isNotEmpty()) { CallManager.setIncomingFromPush(
getNotificationIdForChat(normalizedDialog) peerPublicKey = normalizedDialog,
} else { peerTitle = resolvedName,
("call:$title:$body").hashCode() and 0x7FFFFFFF callId = normalizedCallId,
} joinToken = normalizedJoinToken
)
pushCallLog("setIncomingFromPush done, phase=${CallManager.state.value.phase}")
val openIntent = // Пробуем запустить IncomingCallActivity напрямую из FCM
Intent(this, MainActivity::class.java).apply { // На Android 10+ может быть заблокировано — тогда fullScreenIntent на нотификации сработает
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP try {
putExtra("open_chat", normalizedDialog) val activityIntent = android.content.Intent(
putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true) applicationContext,
} com.rosetta.messenger.IncomingCallActivity::class.java
val pendingIntent = ).apply {
PendingIntent.getActivity( flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
this, android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP
notifId, }
openIntent, applicationContext.startActivity(activityIntent)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE pushCallLog("IncomingCallActivity started from FCM OK")
) } catch (e: Throwable) {
pushCallLog("IncomingCallActivity start from FCM FAILED: ${e.message} — relying on fullScreenIntent")
}
}
val notification = private fun pushCallLog(msg: String) {
NotificationCompat.Builder(this, CALL_CHANNEL_ID) if (BuildConfig.DEBUG) Log.d(TAG, msg)
.setSmallIcon(R.drawable.ic_notification) try {
.setContentTitle(title.ifBlank { "Incoming call" }) val dir = java.io.File(applicationContext.filesDir, "crash_reports")
.setContentText(body.ifBlank { "Incoming call" }) if (!dir.exists()) dir.mkdirs()
.setPriority(NotificationCompat.PRIORITY_MAX) val f = java.io.File(dir, "call_notification_log.txt")
.setCategory(NotificationCompat.CATEGORY_CALL) val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) f.appendText("$ts [FCM] $msg\n")
.setAutoCancel(true) } catch (_: Throwable) {}
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.build()
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(notifId, notification)
} }
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */ /** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
private fun wakeProtocolFromPush(reason: String) { private fun wakeProtocolFromPush(reason: String) {
runCatching { runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty() val account = accountManager.getLastLoggedPublicKey().orEmpty()
ProtocolManager.initialize(applicationContext) protocolGateway.initialize(applicationContext)
CallManager.initialize(applicationContext) CallManager.initialize(applicationContext)
if (account.isNotBlank()) { if (account.isNotBlank()) {
CallManager.bindAccount(account) CallManager.bindAccount(account)
} }
ProtocolManager.reconnectNowIfNeeded("push_$reason") val restored = protocolGateway.restoreAuthFromStoredCredentials(
preferredPublicKey = account,
reason = "push_$reason"
)
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}")
protocolGateway.reconnectNowIfNeeded("push_$reason")
}.onFailure { error -> }.onFailure { error ->
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}") if (BuildConfig.DEBUG) Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
} }
} }
@@ -463,26 +559,6 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
} }
} }
/** Отдельный канал для входящих звонков */
private fun createCallNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel =
NotificationChannel(
CALL_CHANNEL_ID,
CALL_CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = "Incoming call notifications"
enableVibration(true)
}
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
/** Сохранить FCM токен в SharedPreferences */ /** Сохранить FCM токен в SharedPreferences */
private fun saveFcmToken(token: String) { private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
@@ -492,7 +568,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun areNotificationsEnabled(): Boolean { private fun areNotificationsEnabled(): Boolean {
return runCatching { return runCatching {
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationsEnabled.first() preferencesManager.notificationsEnabled.first()
} }
}.getOrDefault(true) }.getOrDefault(true)
} }
@@ -505,18 +581,227 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
return null return null
} }
/**
* Builds a robust list of dialog keys for silent READ pushes.
* Server payloads may evolve (dialog/from/to/peer/group_* aliases), so we parse
* all known aliases and exclude current account public key.
*/
private fun collectReadDialogKeys(
data: Map<String, String>,
parsedDialogKey: String?,
parsedSenderKey: String?
): Set<String> {
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty().trim()
val candidates = linkedSetOf<String>()
fun addCandidate(raw: String?) {
val value = raw?.trim().orEmpty()
if (value.isBlank()) return
if (currentAccount.isNotBlank() && value == currentAccount) return
candidates.add(value)
}
addCandidate(parsedDialogKey)
addCandidate(parsedSenderKey)
addCandidate(firstNonBlank(data, "dialog", "dialog_key", "dialogPublicKey", "dialog_public_key"))
addCandidate(firstNonBlank(data, "peer", "peer_key", "peerPublicKey", "peer_public_key", "chat", "chat_key"))
addCandidate(firstNonBlank(data, "to", "toPublicKey", "to_public_key", "dst", "dst_public_key"))
addCandidate(firstNonBlank(data, "from", "fromPublicKey", "from_public_key", "src", "src_public_key"))
// Group aliases from some server payloads
val groupId = firstNonBlank(data, "group", "group_id", "groupId")
if (!groupId.isNullOrBlank()) {
addCandidate(groupId)
addCandidate("group:$groupId")
addCandidate("#group:$groupId")
}
return candidates
}
private fun collectReadTitleHints(
data: Map<String, String>,
dialogKeys: Set<String>
): Set<String> {
val hints = linkedSetOf<String>()
fun add(raw: String?) {
val value = raw?.trim().orEmpty()
if (value.isNotBlank()) hints.add(value)
}
add(firstNonBlank(data, "title", "sender_name", "sender_title", "from_title", "name"))
dialogKeys.forEach { key ->
add(resolveNameForKey(key))
}
return hints
}
/**
* Fallback for system-shown notifications with unknown IDs (FCM notification payload path).
* Matches active notifications by:
* 1) deterministic dialog hash id
* 2) dialog key/group key in notification extras/text
* 3) known dialog title hints
*/
private fun cancelMatchingActiveNotifications(
dialogKeys: Set<String>,
titleHints: Set<String>
) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val normalizedDialogKeys =
dialogKeys
.flatMap { key ->
buildDialogKeyVariants(key).toList() + key.trim()
}
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
val normalizedHints = titleHints.map { it.trim() }.filter { it.isNotEmpty() }.toSet()
if (normalizedDialogKeys.isEmpty() && normalizedHints.isEmpty()) return
runCatching {
manager.activeNotifications.forEach { sbn ->
val notification = sbn.notification ?: return@forEach
if (notification.channelId == "rosetta_calls") return@forEach
val title =
notification.extras
?.getCharSequence(android.app.Notification.EXTRA_TITLE)
?.toString()
?.trim()
.orEmpty()
val text =
notification.extras
?.getCharSequence(android.app.Notification.EXTRA_TEXT)
?.toString()
?.trim()
.orEmpty()
val bigText =
notification.extras
?.getCharSequence(android.app.Notification.EXTRA_BIG_TEXT)
?.toString()
?.trim()
.orEmpty()
val bag = mutableSetOf<String>()
if (title.isNotEmpty()) bag.add(title)
if (text.isNotEmpty()) bag.add(text)
if (bigText.isNotEmpty()) bag.add(bigText)
notification.extras?.keySet()?.forEach { extraKey ->
bag.add(extraKey)
val value = notification.extras?.get(extraKey)
when (value) {
is CharSequence -> bag.add(value.toString())
is String -> bag.add(value)
is Array<*> -> value.filterIsInstance<CharSequence>().forEach { bag.add(it.toString()) }
}
}
val bagLower = bag.map { it.lowercase(Locale.ROOT) }
val matchesDialogKey =
normalizedDialogKeys.any { key ->
val lowerKey = key.lowercase(Locale.ROOT)
bagLower.any { it.contains(lowerKey) }
}
val matchesHint =
normalizedHints.any { hint ->
title.equals(hint, ignoreCase = true) ||
text.contains(hint, ignoreCase = true) ||
bigText.contains(hint, ignoreCase = true)
}
val matchesDeterministicId =
normalizedDialogKeys.any { key ->
getNotificationIdForChat(key) == sbn.id
}
if (matchesDeterministicId || matchesDialogKey || matchesHint) {
manager.cancel(sbn.tag, sbn.id)
if (BuildConfig.DEBUG) Log.d(
TAG,
"READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " +
"channel=${notification.channelId} title='$title' " +
"matchId=$matchesDeterministicId matchKey=$matchesDialogKey matchHint=$matchesHint"
)
}
}
}.onFailure { error ->
if (BuildConfig.DEBUG) Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
}
}
private fun isAvatarInNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
preferencesManager.notificationAvatarEnabled.first()
}
}.getOrDefault(true)
}
/** Проверка: замьючен ли диалог для текущего аккаунта */ /** Проверка: замьючен ли диалог для текущего аккаунта */
private fun isDialogMuted(senderPublicKey: String): Boolean { private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false if (senderPublicKey.isBlank()) return false
return runCatching { return runCatching {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty() val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
val preferences = PreferencesManager(applicationContext)
buildDialogKeyVariants(senderPublicKey).any { key -> buildDialogKeyVariants(senderPublicKey).any { key ->
preferences.isChatMuted(currentAccount, key) preferencesManager.isChatMuted(currentAccount, key)
} }
} }
}.getOrDefault(false) }.getOrDefault(false)
} }
/** Получить имя пользователя по publicKey (runtime-кэш → БД dialogs) */
private fun resolveNameForKey(publicKey: String?): String? {
if (publicKey.isNullOrBlank()) return null
// 1. In-memory cache
protocolGateway.getCachedUserName(publicKey)?.let { return it }
// 2. DB dialogs table
return runCatching {
val account = accountManager.getLastLoggedPublicKey().orEmpty()
if (account.isBlank()) return null
val db = RosettaDatabase.getDatabase(applicationContext)
val dialog = runBlocking(Dispatchers.IO) {
db.dialogDao().getDialog(account, publicKey)
}
dialog?.opponentTitle?.takeIf { it.isNotBlank() }
?: dialog?.opponentUsername?.takeIf { it.isNotBlank() }
}.getOrNull()
}
/** Получить аватарку как круглый Bitmap для notification по publicKey */
private fun loadAvatarBitmap(publicKey: String?): Bitmap? {
if (publicKey.isNullOrBlank()) return null
// Проверяем настройку
if (!isAvatarInNotificationsEnabled()) 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
// Делаем круглый bitmap для notification
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
}
} }

View File

@@ -0,0 +1,49 @@
package com.rosetta.messenger.session
import com.rosetta.messenger.data.DecryptedAccount
import kotlinx.coroutines.flow.StateFlow
sealed interface SessionState {
data object LoggedOut : SessionState
data class AuthInProgress(
val publicKey: String? = null,
val reason: String = ""
) : SessionState
data class Ready(
val account: DecryptedAccount,
val reason: String = ""
) : SessionState
}
/**
* Single source of truth for app-level auth/session lifecycle.
* UI should rely on this state instead of scattering account checks.
*/
object AppSessionCoordinator {
val sessionState: StateFlow<SessionState> = SessionStore.state
fun dispatch(action: SessionAction) {
SessionStore.dispatch(action)
}
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))
}
}

View File

@@ -0,0 +1,125 @@
package com.rosetta.messenger.session
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.network.SearchUser
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class IdentityProfile(
val publicKey: String,
val displayName: String = "",
val username: String = "",
val verified: Int = 0,
val resolved: Boolean = false,
val updatedAtMs: Long = System.currentTimeMillis()
)
data class IdentityStateSnapshot(
val account: DecryptedAccount? = null,
val profile: IdentityProfile? = null,
val authInProgress: Boolean = false,
val pendingPublicKey: String? = null,
val reason: String = ""
) {
val ownProfileResolved: Boolean
get() {
val activeAccount = account ?: return false
val ownProfile = profile ?: return false
return ownProfile.resolved && ownProfile.publicKey.equals(activeAccount.publicKey, ignoreCase = true)
}
}
/**
* Runtime identity source of truth for account/profile resolution.
*/
object IdentityStore {
private val _state = MutableStateFlow(IdentityStateSnapshot())
val state: StateFlow<IdentityStateSnapshot> = _state.asStateFlow()
fun markLoggedOut(reason: String = "") {
_state.value =
IdentityStateSnapshot(
account = null,
profile = null,
authInProgress = false,
pendingPublicKey = null,
reason = reason
)
}
fun markAuthInProgress(publicKey: String? = null, reason: String = "") {
_state.value =
_state.value.copy(
authInProgress = true,
pendingPublicKey = publicKey?.trim().orEmpty().ifBlank { null },
reason = reason
)
}
fun setAccount(account: DecryptedAccount, reason: String = "") {
val current = _state.value
val existingProfile = current.profile
val nextProfile =
if (
existingProfile != null &&
existingProfile.publicKey.equals(account.publicKey, ignoreCase = true)
) {
existingProfile
} else {
null
}
_state.value =
current.copy(
account = account,
profile = nextProfile,
authInProgress = false,
pendingPublicKey = null,
reason = reason
)
}
fun updateOwnProfile(
publicKey: String,
displayName: String? = null,
username: String? = null,
verified: Int? = null,
resolved: Boolean = true,
reason: String = ""
) {
val normalizedPublicKey = publicKey.trim()
if (normalizedPublicKey.isBlank()) return
val current = _state.value
val base =
current.profile?.takeIf { it.publicKey.equals(normalizedPublicKey, ignoreCase = true) }
?: IdentityProfile(publicKey = normalizedPublicKey)
val nextProfile =
base.copy(
displayName = displayName?.takeIf { it.isNotBlank() } ?: base.displayName,
username = username?.takeIf { it.isNotBlank() } ?: base.username,
verified = verified ?: base.verified,
resolved = base.resolved || resolved,
updatedAtMs = System.currentTimeMillis()
)
_state.value =
current.copy(
profile = nextProfile,
reason = reason
)
}
fun updateOwnProfile(user: SearchUser, reason: String = "") {
updateOwnProfile(
publicKey = user.publicKey,
displayName = user.title,
username = user.username,
verified = user.verified,
resolved = true,
reason = reason
)
}
}

View File

@@ -0,0 +1,19 @@
package com.rosetta.messenger.session
import com.rosetta.messenger.data.DecryptedAccount
sealed interface SessionAction {
data class LoggedOut(val reason: String = "") : SessionAction
data class AuthInProgress(
val publicKey: String? = null,
val reason: String = ""
) : SessionAction
data class Ready(
val account: DecryptedAccount,
val reason: String = ""
) : SessionAction
data class SyncFromCachedAccount(val account: DecryptedAccount?) : SessionAction
}

View File

@@ -0,0 +1,27 @@
package com.rosetta.messenger.session
object SessionReducer {
fun reduce(current: SessionState, action: SessionAction): SessionState {
return when (action) {
is SessionAction.LoggedOut -> SessionState.LoggedOut
is SessionAction.AuthInProgress ->
SessionState.AuthInProgress(
publicKey = action.publicKey?.trim().orEmpty().ifBlank { null },
reason = action.reason
)
is SessionAction.Ready ->
SessionState.Ready(
account = action.account,
reason = action.reason
)
is SessionAction.SyncFromCachedAccount -> {
val account = action.account
if (account == null) {
if (current is SessionState.Ready) SessionState.LoggedOut else current
} else {
SessionState.Ready(account = account, reason = "cached")
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
package com.rosetta.messenger.session
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Single runtime source of truth for session lifecycle state.
* State transitions are produced only by SessionReducer.
*/
object SessionStore {
private val _state = MutableStateFlow<SessionState>(SessionState.LoggedOut)
val state: StateFlow<SessionState> = _state.asStateFlow()
private val lock = Any()
fun dispatch(action: SessionAction) {
synchronized(lock) {
_state.value = SessionReducer.reduce(_state.value, action)
}
syncIdentity(action)
}
private fun syncIdentity(action: SessionAction) {
when (action) {
is SessionAction.LoggedOut -> {
IdentityStore.markLoggedOut(reason = action.reason)
}
is SessionAction.AuthInProgress -> {
IdentityStore.markAuthInProgress(
publicKey = action.publicKey,
reason = action.reason
)
}
is SessionAction.Ready -> {
IdentityStore.setAccount(
account = action.account,
reason = action.reason
)
}
is SessionAction.SyncFromCachedAccount -> {
val account = action.account
if (account == null) {
IdentityStore.markLoggedOut(reason = "cached_account_cleared")
} else {
IdentityStore.setAccount(account = account, reason = "cached")
}
}
}
}
}

View File

@@ -8,6 +8,8 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.SessionCoordinator
enum class AuthScreen { enum class AuthScreen {
SELECT_ACCOUNT, SELECT_ACCOUNT,
@@ -15,6 +17,8 @@ enum class AuthScreen {
SEED_PHRASE, SEED_PHRASE,
CONFIRM_SEED, CONFIRM_SEED,
SET_PASSWORD, SET_PASSWORD,
SET_BIOMETRIC,
SET_PROFILE,
IMPORT_SEED, IMPORT_SEED,
UNLOCK UNLOCK
} }
@@ -25,6 +29,8 @@ fun AuthFlow(
hasExistingAccount: Boolean, hasExistingAccount: Boolean,
accounts: List<AccountInfo> = emptyList(), accounts: List<AccountInfo> = emptyList(),
accountManager: AccountManager, accountManager: AccountManager,
protocolGateway: ProtocolGateway,
sessionCoordinator: SessionCoordinator,
startInCreateMode: Boolean = false, startInCreateMode: Boolean = false,
onAuthComplete: (DecryptedAccount?) -> Unit, onAuthComplete: (DecryptedAccount?) -> Unit,
onLogout: () -> Unit = {} onLogout: () -> Unit = {}
@@ -50,6 +56,7 @@ fun AuthFlow(
) )
} }
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) } var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var createdAccount by remember { mutableStateOf<DecryptedAccount?>(null) }
// Use last logged account or fallback to first account // Use last logged account or fallback to first account
var selectedAccountId by remember { var selectedAccountId by remember {
mutableStateOf<String?>( mutableStateOf<String?>(
@@ -59,6 +66,13 @@ fun AuthFlow(
var showCreateModal by remember { mutableStateOf(false) } var showCreateModal by remember { mutableStateOf(false) }
var isImportMode by remember { mutableStateOf(false) } var isImportMode by remember { mutableStateOf(false) }
LaunchedEffect(currentScreen, selectedAccountId) {
sessionCoordinator.markAuthInProgress(
publicKey = selectedAccountId,
reason = "auth_flow_${currentScreen.name.lowercase()}"
)
}
// If parent requests create mode while AuthFlow is alive, jump to Welcome/Create path. // If parent requests create mode while AuthFlow is alive, jump to Welcome/Create path.
LaunchedEffect(startInCreateMode) { LaunchedEffect(startInCreateMode) {
if (startInCreateMode && currentScreen == AuthScreen.UNLOCK) { if (startInCreateMode && currentScreen == AuthScreen.UNLOCK) {
@@ -82,12 +96,17 @@ fun AuthFlow(
} else if (hasExistingAccount) { } else if (hasExistingAccount) {
currentScreen = AuthScreen.UNLOCK currentScreen = AuthScreen.UNLOCK
} else { } else {
currentScreen = AuthScreen.CONFIRM_SEED currentScreen = AuthScreen.SEED_PHRASE
} }
} }
AuthScreen.SET_BIOMETRIC -> {
currentScreen = AuthScreen.SET_PROFILE
}
AuthScreen.SET_PROFILE -> {
onAuthComplete(createdAccount)
}
AuthScreen.IMPORT_SEED -> { AuthScreen.IMPORT_SEED -> {
if (isImportMode && hasExistingAccount) { if (isImportMode && hasExistingAccount) {
// Came from UnlockScreen recover — go back to unlock
currentScreen = AuthScreen.UNLOCK currentScreen = AuthScreen.UNLOCK
isImportMode = false isImportMode = false
} else { } else {
@@ -146,18 +165,14 @@ fun AuthFlow(
onBack = { currentScreen = AuthScreen.WELCOME }, onBack = { currentScreen = AuthScreen.WELCOME },
onConfirm = { words -> onConfirm = { words ->
seedPhrase = words seedPhrase = words
currentScreen = AuthScreen.CONFIRM_SEED currentScreen = AuthScreen.SET_PASSWORD
} }
) )
} }
AuthScreen.CONFIRM_SEED -> { AuthScreen.CONFIRM_SEED -> {
ConfirmSeedPhraseScreen( // Skipped — go directly from SEED_PHRASE to SET_PASSWORD
seedPhrase = seedPhrase, LaunchedEffect(Unit) { currentScreen = AuthScreen.SET_PASSWORD }
isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.SEED_PHRASE },
onConfirmed = { currentScreen = AuthScreen.SET_PASSWORD }
)
} }
AuthScreen.SET_PASSWORD -> { AuthScreen.SET_PASSWORD -> {
@@ -165,19 +180,47 @@ fun AuthFlow(
seedPhrase = seedPhrase, seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isImportMode = isImportMode, isImportMode = isImportMode,
onBack = { accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onBack = {
if (isImportMode) { if (isImportMode) {
currentScreen = AuthScreen.IMPORT_SEED currentScreen = AuthScreen.IMPORT_SEED
} else if (hasExistingAccount) { } else if (hasExistingAccount) {
currentScreen = AuthScreen.UNLOCK currentScreen = AuthScreen.UNLOCK
} else { } else {
currentScreen = AuthScreen.CONFIRM_SEED currentScreen = AuthScreen.SEED_PHRASE
} }
}, },
onAccountCreated = { account -> onAuthComplete(account) } onAccountCreated = { account ->
if (isImportMode) {
onAuthComplete(account)
} else {
createdAccount = account
currentScreen = AuthScreen.SET_BIOMETRIC
}
}
)
}
AuthScreen.SET_BIOMETRIC -> {
SetBiometricScreen(
isDarkTheme = isDarkTheme,
account = createdAccount,
onContinue = { currentScreen = AuthScreen.SET_PROFILE }
) )
} }
AuthScreen.SET_PROFILE -> {
SetProfileScreen(
isDarkTheme = isDarkTheme,
account = createdAccount,
protocolGateway = protocolGateway,
accountManager = accountManager,
onComplete = { onAuthComplete(createdAccount) },
onSkip = { onAuthComplete(createdAccount) }
)
}
AuthScreen.IMPORT_SEED -> { AuthScreen.IMPORT_SEED -> {
ImportSeedPhraseScreen( ImportSeedPhraseScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -200,6 +243,8 @@ fun AuthFlow(
UnlockScreen( UnlockScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
selectedAccountId = selectedAccountId, selectedAccountId = selectedAccountId,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocked = { account -> onAuthComplete(account) }, onUnlocked = { account -> onAuthComplete(account) },
onSwitchAccount = { onSwitchAccount = {
// Navigate to create new account screen // Navigate to create new account screen

View File

@@ -1,28 +1,33 @@
package com.rosetta.messenger.ui.auth package com.rosetta.messenger.ui.auth
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
internal fun startAuthHandshakeFast(publicKey: String, privateKeyHash: String) { internal fun startAuthHandshakeFast(
protocolGateway: ProtocolGateway,
publicKey: String,
privateKeyHash: String
) {
// Desktop parity: start connection+handshake immediately, without artificial waits. // Desktop parity: start connection+handshake immediately, without artificial waits.
ProtocolManager.connect() protocolGateway.connect()
ProtocolManager.authenticate(publicKey, privateKeyHash) protocolGateway.authenticate(publicKey, privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("auth_fast_start") protocolGateway.reconnectNowIfNeeded("auth_fast_start")
} }
internal suspend fun awaitAuthHandshakeState( internal suspend fun awaitAuthHandshakeState(
protocolGateway: ProtocolGateway,
publicKey: String, publicKey: String,
privateKeyHash: String, privateKeyHash: String,
attempts: Int = 2, attempts: Int = 2,
timeoutMs: Long = 25_000L timeoutMs: Long = 25_000L
): ProtocolState? { ): ProtocolState? {
repeat(attempts) { attempt -> repeat(attempts) { attempt ->
startAuthHandshakeFast(publicKey, privateKeyHash) startAuthHandshakeFast(protocolGateway, publicKey, privateKeyHash)
val state = withTimeoutOrNull(timeoutMs) { val state = withTimeoutOrNull(timeoutMs) {
ProtocolManager.state.first { protocolGateway.state.first {
it == ProtocolState.AUTHENTICATED || it == ProtocolState.AUTHENTICATED ||
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
} }
@@ -30,7 +35,7 @@ internal suspend fun awaitAuthHandshakeState(
if (state != null) { if (state != null) {
return state return state
} }
ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}") protocolGateway.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
} }
return null return null
} }

View File

@@ -10,6 +10,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -144,7 +146,7 @@ fun ConfirmSeedPhraseScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = textColor) Icon(TablerIcons.ChevronLeft, "Back", tint = textColor)
} }
} }

View File

@@ -33,10 +33,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -52,10 +54,10 @@ import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.DeviceResolveSolution import com.rosetta.messenger.network.DeviceResolveSolution
import com.rosetta.messenger.network.Packet import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketDeviceResolve import com.rosetta.messenger.network.PacketDeviceResolve
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.DeviceMobile import compose.icons.tablericons.DeviceMobile
@@ -64,6 +66,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun DeviceConfirmScreen( fun DeviceConfirmScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
protocolGateway: ProtocolGateway,
onExit: () -> Unit onExit: () -> Unit
) { ) {
val view = LocalView.current val view = LocalView.current
@@ -110,10 +113,31 @@ fun DeviceConfirmScreen(
val onExitState by rememberUpdatedState(onExit) val onExitState by rememberUpdatedState(onExit)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
var isResumed by remember(lifecycleOwner) {
mutableStateOf(
lifecycleOwner.lifecycle.currentState.isAtLeast(
androidx.lifecycle.Lifecycle.State.RESUMED
)
)
}
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, _ ->
isResumed = lifecycleOwner.lifecycle.currentState.isAtLeast(
androidx.lifecycle.Lifecycle.State.RESUMED
)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm)) val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm))
val progress by animateLottieCompositionAsState( val progress by animateLottieCompositionAsState(
composition = composition, composition = composition,
iterations = LottieConstants.IterateForever iterations = LottieConstants.IterateForever,
isPlaying = isResumed
) )
val localDeviceName = remember { val localDeviceName = remember {
@@ -131,9 +155,9 @@ fun DeviceConfirmScreen(
scope.launch { onExitState() } scope.launch { onExitState() }
} }
} }
ProtocolManager.waitPacket(0x18, callback) protocolGateway.waitPacket(0x18, callback)
onDispose { onDispose {
ProtocolManager.unwaitPacket(0x18, callback) protocolGateway.unwaitPacket(0x18, callback)
} }
} }

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -64,7 +66,7 @@ fun ImportSeedPhraseScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = textColor) Icon(TablerIcons.ChevronLeft, "Back", tint = textColor)
} }
} }

View File

@@ -7,12 +7,13 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -35,19 +36,18 @@ fun SeedPhraseScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) } var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var isGenerating by remember { mutableStateOf(true) }
var hasCopied by remember { mutableStateOf(false) } var hasCopied by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(100) // Генерируем фразу сразу, без задержек
seedPhrase = CryptoManager.generateSeedPhrase() seedPhrase = CryptoManager.generateSeedPhrase()
isGenerating = false // Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию
delay(50)
visible = true visible = true
} }
@@ -59,7 +59,7 @@ fun SeedPhraseScreen(
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Simple top bar // Top bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -67,7 +67,7 @@ fun SeedPhraseScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = textColor) Icon(TablerIcons.ChevronLeft, "Back", tint = textColor)
} }
} }
@@ -108,126 +108,110 @@ fun SeedPhraseScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Two column layout // Сетка со словами (без Crossfade и лоадера)
if (isGenerating) { if (seedPhrase.isNotEmpty()) {
Box( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(12.dp)
.height(300.dp),
contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator( // Левая колонка (1-6)
color = PrimaryBlue, Column(
strokeWidth = 2.dp, modifier = Modifier.weight(1f),
modifier = Modifier.size(40.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
)
}
} else {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 200))
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Left column (words 1-6) for (i in 0..5) {
Column( AnimatedWordItem(
modifier = Modifier.weight(1f), number = i + 1,
verticalArrangement = Arrangement.spacedBy(12.dp) word = seedPhrase[i],
) { isDarkTheme = isDarkTheme,
for (i in 0..5) { visible = visible,
AnimatedWordItem( delay = i * 60
number = i + 1, )
word = seedPhrase[i],
isDarkTheme = isDarkTheme,
visible = visible,
delay = 300 + (i * 50)
)
}
} }
}
// Right column (words 7-12)
Column( // Правая колонка (7-12)
modifier = Modifier.weight(1f), Column(
verticalArrangement = Arrangement.spacedBy(12.dp) modifier = Modifier.weight(1f),
) { verticalArrangement = Arrangement.spacedBy(12.dp)
for (i in 6..11) { ) {
AnimatedWordItem( for (i in 6..11) {
number = i + 1, AnimatedWordItem(
word = seedPhrase[i], number = i + 1,
isDarkTheme = isDarkTheme, word = seedPhrase[i],
visible = visible, isDarkTheme = isDarkTheme,
delay = 300 + (i * 50) visible = visible,
) delay = i * 60
} )
} }
} }
} }
} }
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(24.dp))
// Copy button // Кнопка Copy
if (!isGenerating) { AnimatedVisibility(
AnimatedVisibility( visible = visible,
visible = visible, enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn(
enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn( initialScale = 0.8f,
initialScale = 0.8f, animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing)
animationSpec = tween(500, delayMillis = 600) )
) ) {
) { TextButton(
TextButton( onClick = {
onClick = { clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" ")))
clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" "))) hasCopied = true
hasCopied = true scope.launch {
scope.launch { delay(2000)
delay(2000) hasCopied = false
hasCopied = false }
} scope.launch {
delay(30_000)
clipboardManager.setText(AnnotatedString(""))
} }
) {
Icon(
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = null,
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (hasCopied) "Copied" else "Copy to clipboard",
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
fontSize = 15.sp
)
} }
) {
Icon(
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = null,
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (hasCopied) "Copied" else "Copy to clipboard",
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
fontSize = 15.sp
)
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Continue button // Кнопка Continue
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = 700)) enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically(
initialOffsetY = { 20 },
animationSpec = tween(400, delayMillis = 900)
)
) { ) {
Button( Button(
onClick = { onConfirm(seedPhrase) }, onClick = { onConfirm(seedPhrase) },
enabled = !isGenerating,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(50.dp), .height(52.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue, containerColor = PrimaryBlue,
contentColor = Color.White, contentColor = Color.White
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
), ),
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Text( Text(
text = "Continue", text = "I Saved It",
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.SemiBold
) )
} }
} }
@@ -246,21 +230,11 @@ private fun WordItem(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
// Beautiful solid colors that fit the theme
val wordColors = listOf( val wordColors = listOf(
Color(0xFF5E9FFF), // Soft blue Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE),
Color(0xFFFF7EB3), // Soft pink Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4),
Color(0xFF7B68EE), // Medium purple Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB),
Color(0xFF50C878), // Emerald green Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F)
Color(0xFFFF6B6B), // Coral red
Color(0xFF4ECDC4), // Teal
Color(0xFFFFB347), // Pastel orange
Color(0xFFBA55D3), // Medium orchid
Color(0xFF87CEEB), // Sky blue
Color(0xFFDDA0DD), // Plum
Color(0xFF98D8C8), // Mint
Color(0xFFF7DC6F) // Soft yellow
) )
val wordColor = wordColors[(number - 1) % wordColors.size] val wordColor = wordColors[(number - 1) % wordColors.size]
@@ -271,21 +245,18 @@ private fun WordItem(
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(bgColor) .background(bgColor)
.padding(horizontal = 16.dp, vertical = 14.dp) .padding(horizontal = 14.dp, vertical = 12.dp)
) { ) {
Row( Row(verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text( Text(
text = "$number.", text = "$number.",
fontSize = 15.sp, fontSize = 13.sp,
color = numberColor, color = numberColor,
modifier = Modifier.width(28.dp) modifier = Modifier.width(26.dp)
) )
Text( Text(
text = word, text = word,
fontSize = 17.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = wordColor, color = wordColor,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace
@@ -303,15 +274,21 @@ private fun AnimatedWordItem(
delay: Int, delay: Int,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) }
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = delay)) enter = fadeIn(animationSpec = tween(300, delayMillis = delay)) +
slideInVertically(
initialOffsetY = { 30 },
animationSpec = tween(400, delayMillis = delay, easing = FastOutSlowInEasing)
) +
scaleIn(
initialScale = 0.85f,
animationSpec = tween(400, delayMillis = delay, easing = overshootEasing)
),
modifier = modifier
) { ) {
WordItem( WordItem(number, word, isDarkTheme)
number = number,
word = word,
isDarkTheme = isDarkTheme,
modifier = modifier
)
} }
} }

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