Compare commits

81 Commits

Author SHA1 Message Date
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
176 changed files with 24044 additions and 7468 deletions

576
Architecture.md Normal file
View File

@@ -0,0 +1,576 @@
# 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"]
D1A["ProtocolRuntime"]
D2["SessionCoordinator"]
D3["IdentityGateway"]
D4["AccountManager / PreferencesManager"]
D5["MessageRepository / GroupRepository"]
end
subgraph CHAT_UI["Chat UI Orchestration"]
C1["ChatDetailScreen / ChatsListScreen"]
C2["ChatViewModel (host)"]
C3["Messages/Voice/Attachments/Typing ViewModel"]
C4["Messages/Forward/Attachments Coordinator"]
end
subgraph CHAT_DOMAIN["Chat Domain UseCases"]
U1["SendText / SendMedia / SendForward"]
U2["SendVoice / SendTyping / SendReadReceipt"]
U3["CreateAttachment / EncryptAndUpload"]
end
subgraph SESSION["Session / Identity Runtime"]
S1["SessionStore"]
S2["SessionReducer"]
S3["IdentityStore"]
S4["AppSessionCoordinator"]
end
subgraph NET["Network Runtime"]
N0["ProtocolRuntime"]
N1C["RuntimeComposition"]
N1A["ProtocolManager (compat facade)"]
N2["Protocol"]
N3["PacketSubscriptionRegistry"]
N4["ReadyPacketGate"]
end
subgraph DATA["Data + Persistence"]
R1["MessageRepository"]
R2["GroupRepository"]
R3["Room: RosettaDatabase"]
end
ENTRY --> DI
DI --> SESSION
DI --> NET
DI --> DATA
DI --> CHAT_UI
CHAT_UI --> CHAT_DOMAIN
CHAT_UI --> DATA
CHAT_DOMAIN --> D1
D1 --> D1A
D1A --> N1C
N1A --> N1C
SESSION --> NET
DATA --> NET
DATA --> R3
N1C --> N2
```
---
## 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 --> CO["ConnectionOrchestrator"]
RC --> PIM["ProtocolInstanceManager"]
RC --> RLSM["RuntimeLifecycleStateMachine"]
RC --> RIC["RuntimeInitializationCoordinator"]
RC --> PLSS["ProtocolLifecycleStateStoreImpl"]
RC --> OPFT["OwnProfileFallbackTimerService"]
RC --> ARS["AuthRestoreService"]
RC --> RSC["RuntimeShutdownCoordinator"]
RC --> CER["ConnectionEventRouter"]
RC --> NCF["NetworkConnectivityFacade"]
RC --> PLC["ProtocolLifecycleCoordinator"]
RC --> PAC["ProtocolAccountSessionCoordinator"]
RC --> RPDC["ReadyPacketDispatchCoordinator"]
RC --> PABC["ProtocolPostAuthBootstrapCoordinator"]
RC --> BC["BootstrapCoordinator"]
RC --> SC["SyncCoordinator"]
RC --> PT["PresenceTypingService"]
RC --> PR["PacketRouter"]
RC --> OPS["OwnProfileSyncService"]
RC --> RQ["RetryQueueService"]
RC --> ABC["AuthBootstrapCoordinator"]
RC --> NRW["NetworkReconnectWatcher"]
RC --> DVS["DeviceVerificationService"]
RC --> CSB["CallSignalBridge"]
RC --> PSF["PacketSubscriptionFacade"]
RC --> PSR["PacketSubscriptionRegistry"]
RC --> IPR["InboundPacketHandlerRegistrar"]
RC --> IQ["InboundTaskQueueService"]
RC --> SUP["ProtocolConnectionSupervisor"]
RC --> RPG["ReadyPacketGate"]
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 PM as Runtime API (Core/Facade)
participant REG as PacketSubscriptionRegistry
participant P as Protocol
Feature->>PM: waitPacket(0x03, callback)
PM->>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.sendMessageWithRetry"]
GW --> PR["ProtocolRuntime"]
PR --> RC["RuntimeComposition"]
RC --> RQ["RetryQueueService"]
RC --> RG["ReadyPacketGate"]
RC --> 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"]
TVM --> CVM
VVM --> CVM
AVM --> CVM
CVM --> MCO["MessagesCoordinator"]
CVM --> FCO["ForwardCoordinator"]
CVM --> ACO["AttachmentsCoordinator"]
CVM --> U["domain/chats/usecase/*"]
MCO --> U
FCO --> U
ACO --> 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 RC as RuntimeComposition
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)
RC-->>RC: HANDSHAKE -> AUTHENTICATED -> BOOTSTRAPPING
RC-->>RC: SyncCompleted + OwnProfileResolved
RC-->>RC: connectionLifecycleState = 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 --> 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 строки) после выноса `RuntimeTransportAssembly`, `RuntimeMessagingAssembly`, `RuntimeStateAssembly`, `RuntimeRoutingAssembly` и удаления публичных proxy-методов; следующий шаг — перенос части lifecycle/orchestration helper-кода в отдельные domain-oriented service/adapters.
- `ProtocolRuntime` и `ProtocolRuntimePort` все еще имеют широкий proxy-surface; нужен audit методов и дальнейшее сужение публичного API по use-case группам.
- `ChatViewModel` остается крупным host-классом (state + bridge/proxy API к feature/coordinator/use-case слоям).
- High-level media сценарии теперь в `AttachmentsFeatureCoordinator`, но остаются крупными и требуют дальнейшей декомпозиции на более узкие coordinator/service/use-case блоки.
- Тестовое покрытие архитектурных слоев все еще недостаточно:
- сейчас в `app/src/test` всего 7 unit-тестов (в основном crypto/data/helpers), в `app/src/androidTest` — 1 тест;
- не покрыты network runtime/coordinator слои (`RuntimeComposition`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat orchestration (`Messages/Forward/Attachments*`).
Уже закрыто и больше не считается техдолгом:
- `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

@@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
}
@@ -23,8 +24,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.4.8"
val rosettaVersionCode = 50 // Increment on each release
val rosettaVersionName = "1.5.4"
val rosettaVersionCode = 56 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {
@@ -119,6 +120,10 @@ android {
}
}
kapt {
correctErrorTypes = true
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
@@ -182,6 +187,11 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// Hilt DI
implementation("com.google.dagger:hilt-android:2.51.1")
kapt("com.google.dagger:hilt-compiler:2.51.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")
@@ -207,6 +217,10 @@ dependencies {
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
// QR Code generation (ZXing) + scanning (ML Kit)
implementation("com.google.zxing:core:3.5.3")
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// Testing dependencies
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")

View File

@@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
@@ -47,13 +48,93 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait">
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="rosetta" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="rosetta.im" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<!-- App Icon Aliases: only one enabled at a time -->
<activity-alias
android:name=".MainActivityDefault"
android:targetActivity=".MainActivity"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityCalculator"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_calc"
android:roundIcon="@mipmap/ic_launcher_calc"
android:label="Calculator">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityWeather"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_weather"
android:roundIcon="@mipmap/ic_launcher_weather"
android:label="Weather">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityNotes"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_notes"
android:roundIcon="@mipmap/ic_launcher_notes"
android:label="Notes">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".IncomingCallActivity"
android:exported="false"

View File

@@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition(
wasEmojiShown = true
}
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
// Emoji закрылся после того как был открыт = переход emoji→keyboard
isTransitioningToKeyboard = true
// Keep reserved space only if keyboard is actually opening.
// For back-swipe/back-press close there is no keyboard open request,
// so we must drop the emoji box immediately to avoid an empty gap.
val keyboardIsComing =
coordinator.currentState == KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD ||
coordinator.isKeyboardVisible ||
coordinator.keyboardHeight > 0.dp
isTransitioningToKeyboard = keyboardIsComing
if (!keyboardIsComing) {
wasEmojiShown = false
}
}
// 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji
@@ -63,6 +72,19 @@ fun AnimatedKeyboardTransition(
isTransitioningToKeyboard = 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
@@ -109,4 +131,4 @@ fun AnimatedKeyboardTransition(
content()
}
}
}
}

View File

@@ -8,19 +8,27 @@ 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"
}
@@ -115,10 +123,23 @@ class IncomingCallActivity : ComponentActivity() {
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}")

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,11 @@ import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.json.JSONArray
@@ -30,7 +33,6 @@ data class Message(
val replyToMessageId: String? = null
)
/** UI модель диалога */
data class Dialog(
val opponentKey: String,
val opponentTitle: String,
@@ -44,7 +46,11 @@ data class Dialog(
)
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */
class MessageRepository private constructor(private val context: Context) {
@Singleton
class MessageRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val protocolClient: ProtocolClient
) {
private val database = RosettaDatabase.getDatabase(context)
private val messageDao = database.messageDao()
@@ -97,8 +103,6 @@ class MessageRepository private constructor(private val context: Context) {
private var currentPrivateKey: String? = null
companion object {
@Volatile private var INSTANCE: MessageRepository? = null
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
@@ -136,16 +140,6 @@ class MessageRepository private constructor(private val context: Context) {
/** Очистка кэша (вызывается при logout) */
fun clearProcessedCache() = processedMessageIds.clear()
fun getInstance(context: Context): MessageRepository {
return INSTANCE
?: synchronized(this) {
INSTANCE
?: MessageRepository(context.applicationContext).also {
INSTANCE = it
}
}
}
/**
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
@@ -245,6 +239,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
?: SYSTEM_SAFE_USERNAME,
lastMessage = encryptedPlainMessage,
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
hasContent = 1,
lastMessageFromMe = 0,
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
lastMessageRead = 0,
lastMessageAttachments = "[]",
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
@@ -324,6 +325,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
?: SYSTEM_UPDATES_USERNAME,
lastMessage = encryptedPlainMessage,
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
hasContent = 1,
lastMessageFromMe = 0,
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
lastMessageRead = 0,
lastMessageAttachments = "[]",
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
@@ -599,6 +607,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
dialogDao.updateDialogFromMessages(account, toPublicKey)
// Notify listeners (ChatViewModel) that a new message was persisted
// so the chat UI reloads from DB. Without this, messages produced by
// non-input flows (e.g. CallManager's missed-call attachment) only
// appear after the user re-enters the chat.
_newMessageEvents.tryEmit(dialogKey)
// 📁 Для saved messages - гарантируем создание/обновление dialog
if (isSavedMessages) {
val existing = dialogDao.getDialog(account, account)
@@ -674,7 +688,7 @@ class MessageRepository private constructor(private val context: Context) {
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
ProtocolManager.sendMessageWithRetry(packet)
protocolClient.sendMessageWithRetry(packet)
// 📝 LOG: Успешная отправка
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
@@ -814,11 +828,19 @@ class MessageRepository private constructor(private val context: Context) {
}
if (isGroupMessage && groupKey.isNullOrBlank()) {
MessageLogger.debug(
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
val requiresGroupKey =
(packet.content.isNotBlank() && isProbablyEncryptedPayload(packet.content)) ||
packet.attachments.any { it.blob.isNotBlank() }
if (requiresGroupKey) {
MessageLogger.debug(
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
)
processedMessageIds.remove(messageId)
return false
}
protocolClient.addLog(
"⚠️ GROUP fallback without key: ${messageId.take(8)}..., contentLikelyPlain=true"
)
processedMessageIds.remove(messageId)
return false
}
val plainKeyAndNonce =
@@ -830,7 +852,7 @@ class MessageRepository private constructor(private val context: Context) {
}
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
ProtocolManager.addLog(
protocolClient.addLog(
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
)
}
@@ -849,8 +871,9 @@ class MessageRepository private constructor(private val context: Context) {
if (isAttachmentOnly) {
""
} else if (isGroupMessage) {
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
?: throw IllegalStateException("Failed to decrypt group payload")
val decryptedGroupPayload =
groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
} else if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else {
@@ -998,8 +1021,10 @@ class MessageRepository private constructor(private val context: Context) {
} catch (e: Exception) {
// 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e)
ProtocolManager.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
protocolClient.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " +
"group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " +
"aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: "<no-message>"}"
)
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
processedMessageIds.remove(messageId)
@@ -1012,15 +1037,12 @@ class MessageRepository private constructor(private val context: Context) {
suspend fun handleDelivery(packet: PacketDelivery) {
val account = currentAccount ?: return
// 📝 LOG: Получено подтверждение доставки
MessageLogger.logDeliveryStatus(
messageId = packet.messageId,
toPublicKey = packet.toPublicKey,
status = "DELIVERED"
)
// Desktop parity: update both delivery status AND timestamp on delivery confirmation.
// Desktop sets timestamp = Date.now() when PacketDelivery arrives (useSynchronize.ts).
val deliveryTimestamp = System.currentTimeMillis()
messageDao.updateDeliveryStatusAndTimestamp(
account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp
@@ -1045,6 +1067,50 @@ class MessageRepository private constructor(private val context: Context) {
dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
}
/**
* Save an incoming call event locally (for CALLEE side).
* Creates a message as if received from the peer, with CALL attachment.
*/
suspend fun saveIncomingCallEvent(fromPublicKey: String, durationSec: Int) {
val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return
val messageId = java.util.UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val dialogKey = getDialogKey(fromPublicKey)
val attId = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
val attachmentsJson = org.json.JSONArray().apply {
put(org.json.JSONObject().apply {
put("id", attId)
put("type", com.rosetta.messenger.network.AttachmentType.CALL.value)
put("preview", durationSec.toString())
put("blob", "")
})
}.toString()
val encryptedPlainMessage = com.rosetta.messenger.crypto.CryptoManager.encryptWithPassword("", privateKey)
val entity = com.rosetta.messenger.database.MessageEntity(
account = account,
fromPublicKey = fromPublicKey,
toPublicKey = account,
content = "",
timestamp = timestamp,
chachaKey = "",
read = 1,
fromMe = 0,
delivered = com.rosetta.messenger.network.DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = attachmentsJson,
primaryAttachmentType = com.rosetta.messenger.network.AttachmentType.CALL.value,
dialogKey = dialogKey
)
messageDao.insertMessage(entity)
dialogDao.updateDialogFromMessages(account, fromPublicKey)
_newMessageEvents.tryEmit(dialogKey)
}
/**
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
* fromPublicKey - кто прочитал (собеседник)
@@ -1195,7 +1261,7 @@ class MessageRepository private constructor(private val context: Context) {
this.toPublicKey = toPublicKey
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
}
ProtocolManager.send(packet)
protocolClient.send(packet)
}
}
@@ -1326,7 +1392,7 @@ class MessageRepository private constructor(private val context: Context) {
}
// 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)}")
} catch (e: Exception) {
android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
@@ -1430,7 +1496,7 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* Public API for ProtocolManager to update delivery status (e.g., marking as ERROR on retry timeout).
* Runtime API to update delivery status (e.g., marking as ERROR on retry timeout).
*/
suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
val account = currentAccount ?: return
@@ -1591,7 +1657,7 @@ class MessageRepository private constructor(private val context: Context) {
this.privateKey = privateKeyHash
this.search = dialog.opponentKey
}
ProtocolManager.send(packet)
protocolClient.send(packet)
// Small delay to avoid flooding the server with search requests
kotlinx.coroutines.delay(50)
}
@@ -1628,7 +1694,7 @@ class MessageRepository private constructor(private val context: Context) {
this.privateKey = privateKeyHash
this.search = publicKey
}
ProtocolManager.send(packet)
protocolClient.send(packet)
}
}
@@ -1714,6 +1780,7 @@ class MessageRepository private constructor(private val context: Context) {
put("preview", attachment.preview)
put("width", attachment.width)
put("height", attachment.height)
put("localUri", attachment.localUri)
put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer)
}
@@ -1812,7 +1879,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1869,7 +1936,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем blob с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1933,7 +2000,7 @@ class MessageRepository private constructor(private val context: Context) {
// 1. Расшифровываем с ChaCha ключом сообщения
val decryptedBlob =
if (groupKey != null) {
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
decryptWithGroupKeyCompat(attachment.blob, groupKey)
} else {
plainKeyAndNonce?.let {
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
@@ -1958,6 +2025,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} else {
@@ -1968,6 +2036,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
@@ -1979,6 +2048,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
@@ -1990,6 +2060,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
@@ -1998,4 +2069,26 @@ class MessageRepository private constructor(private val context: Context) {
}
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

@@ -58,6 +58,9 @@ class PreferencesManager(private val context: Context) {
val BACKGROUND_BLUR_COLOR_ID =
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
// App Icon disguise: "default", "calculator", "weather", "notes"
val APP_ICON = stringPreferencesKey("app_icon")
// Pinned Chats (max 3)
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
@@ -333,6 +336,19 @@ class PreferencesManager(private val context: Context) {
return wasPinned
}
// ═════════════════════════════════════════════════════════════
// 🎨 APP ICON
// ═════════════════════════════════════════════════════════════
val appIcon: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[APP_ICON] ?: "default"
}
suspend fun setAppIcon(value: String) {
context.dataStore.edit { preferences -> preferences[APP_ICON] = value }
}
// ═════════════════════════════════════════════════════════════
// 🔕 MUTED CHATS
// ═════════════════════════════════════════════════════════════

View File

@@ -17,19 +17,11 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Синхронизация (как на Desktop)
- Во время sync экран чатов показывает "Updating..." и скрывает шумящие промежуточные индикаторы
- На период синхронизации скрываются badge'ы непрочитанного и requests, чтобы список не "прыгал"
Медиа и вложения
- Исправлен кейс, когда фото уже отправлено, но локально оставалось в ERROR с красным индикатором
- Для исходящих медиа стабилизирован переход статусов: после успешной отправки фиксируется SENT без ложного timeout->ERROR
- Таймаут/ретрай WAITING из БД больше не портит медиа-вложения (применяется только к обычным текстовым ожиданиям)
- Для legacy/неподдерживаемых attachment добавлен desktop-style fallback:
"This attachment is no longer available because it was sent for a previous version of the app."
Группы и UI
- Исправлена геометрия входящих фото в группах: пузырь больше не прилипает к аватарке
- Исправлена перемотка голосовых: waveform продолжается с текущей позиции после seek, без перерисовки с нуля
- Стабилизирован вход и переподключение после подтверждения или отклонения верификации на другом устройстве
- Исправлена отправка сообщений и синхронизация после повторного запроса входа
- Восстановлена совместимость старых вложений и голосовых между версиями приложения
- Улучшен запрос Full Screen Intent для звонков на Android 14+
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -440,6 +440,10 @@ interface MessageDao {
)
suspend fun messageExists(account: String, messageId: String): Boolean
/** Найти сообщение по ID */
@Query("SELECT * FROM messages WHERE account = :account AND message_id = :messageId LIMIT 1")
suspend fun findMessageById(account: String, messageId: String): MessageEntity?
/**
* Отметить все исходящие сообщения к собеседнику как прочитанные Используется когда приходит
* PacketRead от собеседника.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,8 +21,11 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.utils.AvatarFileManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -34,13 +37,17 @@ import kotlinx.coroutines.runBlocking
* Keeps call alive while app goes to background.
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
*/
@AndroidEntryPoint
class CallForegroundService : Service() {
@Inject lateinit var preferencesManager: PreferencesManager
private data class Snapshot(
val phase: CallPhase,
val displayName: String,
val statusText: String,
val durationSec: Int
val durationSec: Int,
val peerPublicKey: String = ""
)
override fun onBind(intent: Intent?): IBinder? = null
@@ -145,7 +152,8 @@ class CallForegroundService : Service() {
phase = state.phase,
displayName = state.displayName,
statusText = state.statusText,
durationSec = state.durationSec
durationSec = state.durationSec,
peerPublicKey = state.peerPublicKey
)
}
@@ -158,11 +166,15 @@ class CallForegroundService : Service() {
.ifBlank { "Unknown" }
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
val peerPublicKey = payloadIntent.getStringExtra(EXTRA_PEER_PUBLIC_KEY)
.orEmpty()
.ifBlank { state.peerPublicKey }
return Snapshot(
phase = phase,
displayName = displayName,
statusText = statusText,
durationSec = durationSec.coerceAtLeast(0)
durationSec = durationSec.coerceAtLeast(0),
peerPublicKey = peerPublicKey
)
}
@@ -246,7 +258,7 @@ class CallForegroundService : Service() {
CallPhase.IDLE -> "Call ended"
}
val contentText = snapshot.statusText.ifBlank { defaultStatus }
val avatarBitmap = loadAvatarBitmap(CallManager.state.value.peerPublicKey)
val avatarBitmap = loadAvatarBitmap(snapshot.peerPublicKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true)
@@ -420,6 +432,7 @@ class CallForegroundService : Service() {
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
private const val EXTRA_STATUS_TEXT = "extra_status_text"
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
private const val EXTRA_PEER_PUBLIC_KEY = "extra_peer_public_key"
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
fun syncWithCallState(context: Context, state: CallUiState) {
@@ -439,6 +452,7 @@ class CallForegroundService : Service() {
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
.putExtra(EXTRA_PEER_PUBLIC_KEY, state.peerPublicKey)
runCatching { ContextCompat.startForegroundService(appContext, intent) }
.onFailure { error ->
@@ -461,8 +475,7 @@ class CallForegroundService : Service() {
// Проверяем настройку
val avatarEnabled = runCatching {
runBlocking(Dispatchers.IO) {
com.rosetta.messenger.data.PreferencesManager(applicationContext)
.notificationAvatarEnabled.first()
preferencesManager.notificationAvatarEnabled.first()
}
}.getOrDefault(true)
if (!avatarEnabled) return null
@@ -471,8 +484,9 @@ class CallForegroundService : Service() {
val entity = runBlocking(Dispatchers.IO) {
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
} ?: return null
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
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)

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.media.AudioManager
import android.util.Log
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest
import java.security.SecureRandom
@@ -95,7 +96,11 @@ object CallManager {
private const val TAIL_LINES = 300
private const val PROTOCOL_LOG_TAIL_LINES = 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())
@@ -107,6 +112,8 @@ object CallManager {
@Volatile
private var initialized = false
private var appContext: Context? = null
private var messageRepository: MessageRepository? = null
private var accountManager: AccountManager? = null
private var ownPublicKey: String = ""
private var role: CallRole? = null
@@ -127,6 +134,7 @@ object CallManager {
private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null
private var incomingRingTimeoutJob: Job? = null
private var outgoingRingTimeoutJob: Job? = null
private var connectingTimeoutJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null
@@ -157,24 +165,25 @@ object CallManager {
initialized = true
appContext = context.applicationContext
CallSoundManager.initialize(context)
CallProximityManager.initialize(context)
XChaCha20E2EE.initWithContext(context)
signalWaiter = ProtocolManager.waitCallSignal { packet ->
signalWaiter = ProtocolRuntimeAccess.get().waitCallSignal { packet ->
scope.launch { handleSignalPacket(packet) }
}
webRtcWaiter = ProtocolManager.waitWebRtcSignal { packet ->
webRtcWaiter = ProtocolRuntimeAccess.get().waitWebRtcSignal { packet ->
scope.launch { handleWebRtcPacket(packet) }
}
iceWaiter = ProtocolManager.waitIceServers { packet ->
iceWaiter = ProtocolRuntimeAccess.get().waitIceServers { packet ->
handleIceServersPacket(packet)
}
protocolStateJob =
scope.launch {
ProtocolManager.state.collect { protocolState ->
ProtocolRuntimeAccess.get().state.collect { protocolState ->
when (protocolState) {
ProtocolState.AUTHENTICATED -> {
ProtocolManager.requestIceServers()
ProtocolRuntimeAccess.get().requestIceServers()
}
ProtocolState.DISCONNECTED -> {
// Не сбрасываем звонок при переподключении WebSocket —
@@ -204,7 +213,15 @@ object CallManager {
}
}
ProtocolManager.requestIceServers()
ProtocolRuntimeAccess.get().requestIceServers()
}
fun bindDependencies(
messageRepository: MessageRepository,
accountManager: AccountManager
) {
this.messageRepository = messageRepository
this.accountManager = accountManager
}
fun bindAccount(publicKey: String) {
@@ -238,7 +255,7 @@ object CallManager {
beginCallSession("incoming-push:${peer.take(8)}")
role = CallRole.CALLEE
resetRtcObjects()
val cachedInfo = ProtocolManager.getCachedUserInfo(peer)
val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(peer)
val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() }
val username = cachedInfo?.username.orEmpty()
setPeer(peer, title, username)
@@ -269,7 +286,7 @@ object CallManager {
if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL
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)
beginCallSession("outgoing:${targetKey.take(8)}")
@@ -283,13 +300,25 @@ object CallManager {
)
}
ProtocolManager.sendCallSignal(
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.CALL,
src = ownPublicKey,
dst = targetKey
)
breadcrumbState("startOutgoingCall")
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
}
@@ -300,7 +329,7 @@ object CallManager {
// Если ownPublicKey пустой (push разбудил но аккаунт не привязан) — попробуем привязать
if (ownPublicKey.isBlank()) {
val lastPk = appContext?.let { com.rosetta.messenger.data.AccountManager(it).getLastLoggedPublicKey() }.orEmpty()
val lastPk = accountManager?.getLastLoggedPublicKey().orEmpty()
if (lastPk.isNotBlank()) {
bindAccount(lastPk)
breadcrumb("acceptIncomingCall: auto-bind account pk=${lastPk.take(8)}")
@@ -308,12 +337,12 @@ object CallManager {
return CallActionResult.ACCOUNT_NOT_BOUND
}
}
val restoredAuth = ProtocolManager.restoreAuthFromStoredCredentials(
val restoredAuth = ProtocolRuntimeAccess.get().restoreAuthFromStoredCredentials(
preferredPublicKey = ownPublicKey,
reason = "accept_incoming_call"
)
if (restoredAuth) {
ProtocolManager.reconnectNowIfNeeded("accept_incoming_call")
ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_incoming_call")
breadcrumb("acceptIncomingCall: auth restore requested")
}
@@ -343,7 +372,7 @@ object CallManager {
kotlinx.coroutines.delay(200)
continue
}
ProtocolManager.sendCallSignal(
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.ACCEPT,
src = ownPublicKey,
dst = snapshot.peerPublicKey,
@@ -352,7 +381,7 @@ object CallManager {
)
// ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен
// сразу при открытии сокета (или останется в очереди до onOpen).
ProtocolManager.reconnectNowIfNeeded("accept_send_$attempt")
ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_send_$attempt")
breadcrumb(
"acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " +
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
@@ -378,7 +407,7 @@ object CallManager {
val callIdNow = serverCallId.trim()
val joinTokenNow = serverJoinToken.trim()
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank() && callIdNow.isNotBlank() && joinTokenNow.isNotBlank()) {
ProtocolManager.sendCallSignal(
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.END_CALL,
src = ownPublicKey,
dst = snapshot.peerPublicKey,
@@ -478,7 +507,7 @@ object CallManager {
if (_state.value.phase != CallPhase.IDLE) {
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) {
ProtocolManager.sendCallSignal(
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.END_CALL_BECAUSE_BUSY,
src = ownPublicKey,
dst = incomingPeer
@@ -494,7 +523,7 @@ object CallManager {
role = CallRole.CALLEE
resetRtcObjects()
// Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
val cachedInfo = ProtocolManager.getCachedUserInfo(incomingPeer)
val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(incomingPeer)
val cachedTitle = cachedInfo?.title.orEmpty()
val cachedUsername = cachedInfo?.username.orEmpty()
setPeer(incomingPeer, cachedTitle, cachedUsername)
@@ -551,12 +580,15 @@ object CallManager {
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
ProtocolManager.sendCallSignal(
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey,
dst = _state.value.peerPublicKey,
@@ -628,7 +660,7 @@ object CallManager {
breadcrumb("KE: CALLER — E2EE ready, notifying ACTIVE")
updateState { it.copy(keyCast = sharedKey, statusText = "Connecting...") }
if (!activeSignalSent) {
ProtocolManager.sendCallSignal(
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.ACTIVE,
src = ownPublicKey,
dst = peerKey
@@ -653,7 +685,7 @@ object CallManager {
setupE2EE(sharedKey)
if (!keyExchangeSent) {
val localPublic = localPublicKey ?: return
ProtocolManager.sendCallSignal(
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.KEY_EXCHANGE,
src = ownPublicKey,
dst = peerKey,
@@ -754,7 +786,7 @@ object CallManager {
val answer = pc.createAnswerAwait()
pc.setLocalDescriptionAwait(answer)
ProtocolManager.sendWebRtcSignal(
ProtocolRuntimeAccess.get().sendWebRtcSignal(
signalType = WebRTCSignalType.ANSWER,
sdpOrCandidate = serializeSessionDescription(answer)
)
@@ -842,7 +874,7 @@ object CallManager {
pc.setLocalDescriptionAwait(offer)
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
ProtocolManager.sendWebRtcSignal(
ProtocolRuntimeAccess.get().sendWebRtcSignal(
signalType = WebRTCSignalType.OFFER,
sdpOrCandidate = serializeSessionDescription(offer)
)
@@ -883,7 +915,7 @@ object CallManager {
override fun onIceCandidate(candidate: IceCandidate?) {
if (candidate == null) return
breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}")
ProtocolManager.sendWebRtcSignal(
ProtocolRuntimeAccess.get().sendWebRtcSignal(
signalType = WebRTCSignalType.ICE_CANDIDATE,
sdpOrCandidate = serializeIceCandidate(candidate)
)
@@ -1002,7 +1034,7 @@ object CallManager {
private fun resolvePeerIdentity(publicKey: String) {
scope.launch {
val resolved = ProtocolManager.resolveUserInfo(publicKey)
val resolved = ProtocolRuntimeAccess.get().resolveUserInfo(publicKey)
if (resolved != null && _state.value.peerPublicKey == publicKey) {
setPeer(publicKey, resolved.title, resolved.username)
}
@@ -1020,9 +1052,7 @@ object CallManager {
}
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
if (role != CallRole.CALLER) return
val peerPublicKey = snapshot.peerPublicKey.trim()
val context = appContext ?: return
if (peerPublicKey.isBlank()) return
val durationSec = snapshot.durationSec.coerceAtLeast(0)
@@ -1034,15 +1064,33 @@ object CallManager {
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 {
runCatching {
MessageRepository.getInstance(context).sendMessage(
toPublicKey = peerPublicKey,
text = "",
attachments = listOf(callAttachment)
)
val repository = messageRepository
if (repository == null) {
breadcrumb("CALL ATTACHMENT: MessageRepository not bound")
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 ->
Log.w(TAG, "Failed to send call attachment", error)
Log.w(TAG, "Failed to emit call attachment", error)
}
}
}
@@ -1052,11 +1100,12 @@ object CallManager {
disarmConnectingTimeout("resetSession")
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
breadcrumbState("resetSession")
appContext?.let { CallProximityManager.setEnabled(it, false) }
val snapshot = _state.value
val wasActive = snapshot.phase != CallPhase.IDLE
val peerToNotify = snapshot.peerPublicKey
if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
ProtocolManager.sendCallSignal(
ProtocolRuntimeAccess.get().sendCallSignal(
signalType = SignalType.END_CALL,
src = ownPublicKey,
dst = peerToNotify,
@@ -1073,6 +1122,8 @@ object CallManager {
disconnectResetJob = null
incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
// Play end call sound, then stop all
if (wasActive) {
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
@@ -1277,7 +1328,7 @@ object CallManager {
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 protocolTail =
ProtocolManager.debugLogs.value
ProtocolRuntimeAccess.get().debugLogs.value
.takeLast(PROTOCOL_LOG_TAIL_LINES)
.joinToString("\n")
f.writeText(
@@ -1580,6 +1631,13 @@ object CallManager {
val old = _state.value
_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)) {

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()
currentSound = sound
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
val ringerMode = audioManager?.ringerMode ?: AudioManager.RINGER_MODE_NORMAL
val allowAudible = ringerMode == AudioManager.RINGER_MODE_NORMAL
val allowVibration =
sound == CallSound.RINGTONE &&
(ringerMode == AudioManager.RINGER_MODE_NORMAL ||
ringerMode == AudioManager.RINGER_MODE_VIBRATE)
if (!allowAudible) {
if (allowVibration) {
startVibration()
}
Log.i(TAG, "Skip audible $sound due to ringerMode=$ringerMode")
return
}
val resId = when (sound) {
CallSound.RINGTONE -> R.raw.call_ringtone
CallSound.CALLING -> R.raw.call_calling
@@ -86,7 +102,7 @@ object CallSoundManager {
mediaPlayer = player
// Vibrate for incoming calls
if (sound == CallSound.RINGTONE) {
if (allowVibration) {
startVibration()
}

View File

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

View File

@@ -4,10 +4,14 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.channels.Channel
import okhttp3.*
import okio.ByteString
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* Protocol connection states
@@ -35,12 +39,14 @@ class Protocol(
private const val TAG = "RosettaProtocol"
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
private const val CONNECTING_STUCK_TIMEOUT_MS = 15_000L
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
private const val HEX_PREVIEW_BYTES = 64
private const val TEXT_PREVIEW_CHARS = 80
private val INSTANCE_COUNTER = AtomicInteger(0)
}
private fun log(message: String) {
@@ -181,9 +187,103 @@ class Protocol(
private var lastStateChangeTime = System.currentTimeMillis()
private var lastSuccessfulConnection = 0L
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
private var connectingTimeoutJob: Job? = null
private var isConnecting = false // Флаг для защиты от одновременных подключений
private var connectingSinceMs = 0L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val connectionGeneration = AtomicLong(0L)
@Volatile private var activeConnectionGeneration: Long = 0L
private val instanceId = INSTANCE_COUNTER.incrementAndGet()
/**
* Single-writer session loop for all lifecycle mutations.
* Replaces ad-hoc Mutex locking and guarantees strict FIFO ordering.
*/
private sealed interface SessionEvent {
data class Connect(val trigger: String = "api_connect") : SessionEvent
data class HandleDisconnect(val source: String) : SessionEvent
data class Disconnect(val manual: Boolean, val reason: String) : SessionEvent
data class FastReconnect(val reason: String) : SessionEvent
data class AccountSwitchReconnect(val reason: String = "Account switch reconnect") : SessionEvent
data class HandshakeResponse(val packet: PacketHandshake) : SessionEvent
data class DeviceVerificationAccepted(val deviceId: String) : SessionEvent
data class DeviceVerificationDeclined(
val deviceId: String,
val observedState: ProtocolState
) : SessionEvent
data class SocketOpened(
val generation: Long,
val socket: WebSocket,
val responseCode: Int
) : SessionEvent
data class SocketClosed(
val generation: Long,
val socket: WebSocket,
val code: Int,
val reason: String
) : SessionEvent
data class SocketFailure(
val generation: Long,
val socket: WebSocket,
val throwable: Throwable,
val responseCode: Int?,
val responseMessage: String?
) : SessionEvent
data class ConnectingTimeout(val generation: Long) : SessionEvent
}
private val sessionEvents = Channel<SessionEvent>(Channel.UNLIMITED)
private val sessionLoopJob =
scope.launch {
for (event in sessionEvents) {
try {
when (event) {
is SessionEvent.Connect -> connectLocked()
is SessionEvent.HandleDisconnect -> handleDisconnectLocked(event.source)
is SessionEvent.Disconnect ->
disconnectLocked(manual = event.manual, reason = event.reason)
is SessionEvent.FastReconnect -> reconnectNowIfNeededLocked(event.reason)
is SessionEvent.AccountSwitchReconnect -> {
disconnectLocked(manual = false, reason = event.reason)
connectLocked()
}
is SessionEvent.HandshakeResponse -> handleHandshakeResponse(event.packet)
is SessionEvent.DeviceVerificationAccepted ->
handleDeviceVerificationAccepted(event.deviceId)
is SessionEvent.DeviceVerificationDeclined -> {
handshakeComplete = false
handshakeJob?.cancel()
packetQueue.clear()
if (webSocket != null) {
setState(
ProtocolState.CONNECTED,
"Device verification declined, waiting for retry"
)
} else {
setState(
ProtocolState.DISCONNECTED,
"Device verification declined without active socket"
)
}
log(
"⛔ DEVICE DECLINE APPLIED: deviceId=${shortKey(event.deviceId, 12)} " +
"observed=${event.observedState} current=${_state.value}"
)
}
is SessionEvent.SocketOpened -> handleSocketOpened(event)
is SessionEvent.SocketClosed -> handleSocketClosed(event)
is SessionEvent.SocketFailure -> handleSocketFailure(event)
is SessionEvent.ConnectingTimeout -> handleConnectingTimeout(event.generation)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log("❌ Session event failed: ${event::class.java.simpleName} ${e.message}")
e.printStackTrace()
}
}
}
private val _state = MutableStateFlow(ProtocolState.DISCONNECTED)
val state: StateFlow<ProtocolState> = _state.asStateFlow()
@@ -215,12 +315,209 @@ class Protocol(
}
}
}
private fun enqueueSessionEvent(event: SessionEvent) {
val result = sessionEvents.trySend(event)
if (result.isFailure) {
log(
"⚠️ Session event dropped: ${event::class.java.simpleName} " +
"reason=${result.exceptionOrNull()?.message ?: "channel_closed"}"
)
}
}
private fun cancelConnectingTimeout(reason: String) {
if (connectingTimeoutJob != null) {
log("⏱️ CONNECTING watchdog disarmed ($reason)")
}
connectingTimeoutJob?.cancel()
connectingTimeoutJob = null
}
private fun armConnectingTimeout(generation: Long) {
cancelConnectingTimeout(reason = "re-arm")
connectingTimeoutJob = scope.launch {
delay(CONNECTING_STUCK_TIMEOUT_MS)
enqueueSessionEvent(SessionEvent.ConnectingTimeout(generation))
}
log("⏱️ CONNECTING watchdog armed gen=$generation timeout=${CONNECTING_STUCK_TIMEOUT_MS}ms")
}
private fun handleSocketOpened(event: SessionEvent.SocketOpened) {
if (isStaleSocketEvent("onOpen", event.generation, event.socket)) return
log(
"✅ WebSocket OPEN: response=${event.responseCode}, " +
"hasCredentials=${lastPublicKey != null}, gen=${event.generation}"
)
cancelConnectingTimeout(reason = "socket_opened")
isConnecting = false
connectingSinceMs = 0L
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
// Flush queue as soon as socket is open.
// Auth-required packets will remain queued until handshake completes.
flushPacketQueue()
if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) {
lastPublicKey?.let { publicKey ->
lastPrivateHash?.let { privateHash ->
log("🤝 Auto-starting handshake with saved credentials")
startHandshake(publicKey, privateHash, lastDevice)
}
} ?: log("⚠️ No saved credentials, waiting for manual handshake")
} else {
log("⚠️ Skipping auto-handshake: already in state ${_state.value}")
}
}
private fun handleSocketClosed(event: SessionEvent.SocketClosed) {
if (isStaleSocketEvent("onClosed", event.generation, event.socket)) return
log(
"❌ WebSocket CLOSED: code=${event.code} reason='${event.reason}' state=${_state.value} " +
"manuallyClosed=$isManuallyClosed gen=${event.generation}"
)
cancelConnectingTimeout(reason = "socket_closed")
isConnecting = false
connectingSinceMs = 0L
handleDisconnectLocked("onClosed")
}
private fun handleSocketFailure(event: SessionEvent.SocketFailure) {
if (isStaleSocketEvent("onFailure", event.generation, event.socket)) return
log("❌ WebSocket FAILURE: ${event.throwable.message}")
log(" Response: ${event.responseCode} ${event.responseMessage}")
log(" State: ${_state.value}")
log(" Manually closed: $isManuallyClosed")
log(" Reconnect attempts: $reconnectAttempts")
log(" Generation: ${event.generation}")
event.throwable.printStackTrace()
cancelConnectingTimeout(reason = "socket_failure")
isConnecting = false
connectingSinceMs = 0L
_lastError.value = event.throwable.message
handleDisconnectLocked("onFailure")
}
private fun handleConnectingTimeout(generation: Long) {
val currentState = _state.value
if (generation != activeConnectionGeneration) {
log(
"⏱️ CONNECTING watchdog ignored for stale generation " +
"(event=$generation active=$activeConnectionGeneration)"
)
return
}
if (!isConnecting || currentState != ProtocolState.CONNECTING) {
return
}
val elapsed = if (connectingSinceMs > 0L) {
System.currentTimeMillis() - connectingSinceMs
} else {
CONNECTING_STUCK_TIMEOUT_MS
}
log("🧯 CONNECTING TIMEOUT fired (elapsed=${elapsed}ms) -> forcing disconnect/reconnect")
cancelConnectingTimeout(reason = "timeout_fired")
isConnecting = false
connectingSinceMs = 0L
runCatching { webSocket?.cancel() }
webSocket = null
handleDisconnectLocked("connecting_timeout")
}
private fun handleHandshakeResponse(packet: PacketHandshake) {
handshakeJob?.cancel()
when (packet.handshakeState) {
HandshakeState.COMPLETED -> {
log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
handshakeComplete = true
setState(ProtocolState.AUTHENTICATED, "Handshake completed")
flushPacketQueue()
}
HandshakeState.NEED_DEVICE_VERIFICATION -> {
log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION")
handshakeComplete = false
setState(
ProtocolState.DEVICE_VERIFICATION_REQUIRED,
"Handshake requires device verification"
)
packetQueue.clear()
}
}
// Keep heartbeat in both handshake states to maintain server session.
startHeartbeat(packet.heartbeatInterval)
}
private fun handleDeviceVerificationAccepted(deviceId: String) {
log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(deviceId, 12)})")
val stateAtAccept = _state.value
if (stateAtAccept == ProtocolState.AUTHENTICATED) {
log("✅ ACCEPT ignored: already authenticated")
return
}
if (stateAtAccept == ProtocolState.DEVICE_VERIFICATION_REQUIRED) {
setState(ProtocolState.CONNECTED, "Device verification accepted")
}
val publicKey = lastPublicKey
val privateHash = lastPrivateHash
if (publicKey.isNullOrBlank() || privateHash.isNullOrBlank()) {
log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect")
return
}
when (_state.value) {
ProtocolState.DISCONNECTED -> {
log("🔄 ACCEPT while disconnected -> reconnecting")
connectLocked()
}
ProtocolState.CONNECTING -> {
log("⏳ ACCEPT while connecting -> waiting for onOpen auto-handshake")
}
else -> {
startHandshake(publicKey, privateHash, lastDevice)
}
}
}
private fun rotateConnectionGeneration(reason: String): Long {
val generation = connectionGeneration.incrementAndGet()
activeConnectionGeneration = generation
log("🧬 CONNECTION GENERATION: #$generation ($reason, instance=$instanceId)")
return generation
}
private fun isStaleSocketEvent(event: String, generation: Long, socket: WebSocket): Boolean {
val currentGeneration = activeConnectionGeneration
val activeSocket = webSocket
val staleByGeneration = generation != currentGeneration
val staleBySocket = activeSocket != null && activeSocket !== socket
if (!staleByGeneration && !staleBySocket) {
return false
}
log(
"🧊 STALE SOCKET EVENT ignored: event=$event gen=$generation activeGen=$currentGeneration " +
"sameSocket=${activeSocket === socket} instance=$instanceId"
)
runCatching { socket.close(1000, "Stale socket event") }
return true
}
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
// Packet waiters - callbacks for specific packet types (thread-safe)
private val packetWaiters = java.util.concurrent.ConcurrentHashMap<Int, MutableList<(Packet) -> Unit>>()
private val packetWaiters =
java.util.concurrent.ConcurrentHashMap<Int, CopyOnWriteArrayList<(Packet) -> Unit>>()
// Packet queue for packets sent before handshake complete (thread-safe)
private val packetQueue = java.util.Collections.synchronizedList(mutableListOf<Packet>())
@@ -230,7 +527,7 @@ class Protocol(
private var lastPrivateHash: String? = null
private var lastDevice: HandshakeDevice = HandshakeDevice()
// Getters for ProtocolManager to fetch own profile
// Getters for runtime layers to fetch own profile
fun getPublicKey(): String? = lastPublicKey
fun getPrivateHash(): String? = lastPrivateHash
@@ -271,32 +568,48 @@ class Protocol(
)
init {
log("🧩 Protocol init: instance=$instanceId")
// Register handshake response handler
waitPacket(0x00) { packet ->
if (packet is PacketHandshake) {
handshakeJob?.cancel()
enqueueSessionEvent(SessionEvent.HandshakeResponse(packet))
}
}
when (packet.handshakeState) {
HandshakeState.COMPLETED -> {
log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
handshakeComplete = true
setState(ProtocolState.AUTHENTICATED, "Handshake completed")
flushPacketQueue()
}
// Device verification resolution from primary device.
// Desktop typically continues after next handshake response; here we also
// add a safety re-handshake trigger on ACCEPT to avoid being stuck in
// DEVICE_VERIFICATION_REQUIRED if server doesn't immediately push 0x00.
waitPacket(0x18) { packet ->
val resolve = packet as? PacketDeviceResolve ?: return@waitPacket
when (resolve.solution) {
DeviceResolveSolution.ACCEPT -> {
enqueueSessionEvent(
SessionEvent.DeviceVerificationAccepted(deviceId = resolve.deviceId)
)
}
DeviceResolveSolution.DECLINE -> {
val stateAtDecline = _state.value
log(
"⛔ DEVICE VERIFICATION DECLINED (deviceId=${shortKey(resolve.deviceId, 12)}, state=$stateAtDecline)"
)
HandshakeState.NEED_DEVICE_VERIFICATION -> {
log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION")
handshakeComplete = false
setState(
ProtocolState.DEVICE_VERIFICATION_REQUIRED,
"Handshake requires device verification"
// Critical recovery: after DECLINE user may retry login without app restart.
// Keep socket session alive when possible, but leave DEVICE_VERIFICATION_REQUIRED
// state so next authenticate() is not ignored by startHandshake guards.
if (
stateAtDecline == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
stateAtDecline == ProtocolState.HANDSHAKING
) {
enqueueSessionEvent(
SessionEvent.DeviceVerificationDeclined(
deviceId = resolve.deviceId,
observedState = stateAtDecline
)
)
packetQueue.clear()
}
}
// Keep heartbeat in both handshake states to maintain server session.
startHeartbeat(packet.heartbeatInterval)
}
}
}
@@ -366,7 +679,7 @@ class Protocol(
// Триггерим reconnect если heartbeat не прошёл
if (!isManuallyClosed) {
log("🔄 TRIGGERING RECONNECT due to failed heartbeat")
handleDisconnect()
handleDisconnect("heartbeat_failed")
}
}
} else {
@@ -384,8 +697,13 @@ class Protocol(
* Initialize connection to server
*/
fun connect() {
enqueueSessionEvent(SessionEvent.Connect())
}
private fun connectLocked() {
val currentState = _state.value
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
val now = System.currentTimeMillis()
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting, instance=$instanceId")
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
if (
@@ -403,10 +721,21 @@ class Protocol(
return
}
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
if (isConnecting || currentState == ProtocolState.CONNECTING) {
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
return
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
log("⚠️ Already connecting, skipping... (elapsed=${elapsed}ms)")
return
}
log("🧯 CONNECTING STUCK detected (elapsed=${elapsed}ms) -> forcing reconnect reset")
cancelConnectingTimeout(reason = "connect_stuck_reset")
isConnecting = false
connectingSinceMs = 0L
runCatching { webSocket?.cancel() }
webSocket = null
setState(ProtocolState.DISCONNECTED, "Reset stuck CONNECTING (${elapsed}ms)")
}
val networkReady = isNetworkAvailable?.invoke() ?: true
@@ -424,9 +753,11 @@ class Protocol(
// Устанавливаем флаг ПЕРЕД любыми операциями
isConnecting = true
connectingSinceMs = now
reconnectAttempts++
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
val generation = rotateConnectionGeneration("connect_attempt_$reconnectAttempts")
// Закрываем старый сокет если есть (как в Архиве)
webSocket?.let { oldSocket ->
@@ -442,6 +773,7 @@ class Protocol(
isManuallyClosed = false
setState(ProtocolState.CONNECTING, "Starting new connection attempt #$reconnectAttempts")
_lastError.value = null
armConnectingTimeout(generation)
log("🔌 Connecting to: $serverAddress (attempt #$reconnectAttempts)")
@@ -451,40 +783,28 @@ class Protocol(
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
log("✅ WebSocket OPEN: response=${response.code}, hasCredentials=${lastPublicKey != null}")
// Сбрасываем флаг подключения
isConnecting = false
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
// Flush queue as soon as socket is open.
// Auth-required packets will remain queued until handshake completes.
flushPacketQueue()
// КРИТИЧНО: проверяем что не идет уже handshake
if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) {
// If we have saved credentials, start handshake automatically
lastPublicKey?.let { publicKey ->
lastPrivateHash?.let { privateHash ->
log("🤝 Auto-starting handshake with saved credentials")
startHandshake(publicKey, privateHash, lastDevice)
}
} ?: log("⚠️ No saved credentials, waiting for manual handshake")
} else {
log("⚠️ Skipping auto-handshake: already in state ${_state.value}")
}
enqueueSessionEvent(
SessionEvent.SocketOpened(
generation = generation,
socket = webSocket,
responseCode = response.code
)
)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
if (isStaleSocketEvent("onMessage(bytes)", generation, webSocket)) return
log("📥 onMessage called - ${bytes.size} bytes")
handleMessage(bytes.toByteArray())
}
override fun onMessage(webSocket: WebSocket, text: String) {
if (isStaleSocketEvent("onMessage(text)", generation, webSocket)) return
log("Received text message (unexpected): $text")
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
if (isStaleSocketEvent("onClosing", generation, webSocket)) return
log("⚠️ WebSocket CLOSING: code=$code reason='$reason' state=${_state.value}")
// Must respond with close() so OkHttp transitions to onClosed.
// Without this, the socket stays in a half-closed "zombie" state —
@@ -498,21 +818,26 @@ class Protocol(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
isConnecting = false // Сбрасываем флаг
handleDisconnect()
enqueueSessionEvent(
SessionEvent.SocketClosed(
generation = generation,
socket = webSocket,
code = code,
reason = reason
)
)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
log("❌ WebSocket FAILURE: ${t.message}")
log(" Response: ${response?.code} ${response?.message}")
log(" State: ${_state.value}")
log(" Manually closed: $isManuallyClosed")
log(" Reconnect attempts: $reconnectAttempts")
t.printStackTrace()
isConnecting = false // Сбрасываем флаг
_lastError.value = t.message
handleDisconnect()
enqueueSessionEvent(
SessionEvent.SocketFailure(
generation = generation,
socket = webSocket,
throwable = t,
responseCode = response?.code,
responseMessage = response?.message
)
)
}
})
}
@@ -542,8 +867,9 @@ class Protocol(
// If switching accounts, force disconnect and reconnect with new credentials
if (switchingAccount) {
log("🔄 Account switch detected, forcing reconnect with new credentials")
disconnect()
connect() // Will auto-handshake with saved credentials (publicKey, privateHash) on connect
enqueueSessionEvent(
SessionEvent.AccountSwitchReconnect(reason = "Account switch reconnect")
)
return
}
@@ -601,7 +927,14 @@ class Protocol(
val currentState = _state.value
val socket = webSocket
val socketReady = socket != null
val authReady = handshakeComplete && currentState == ProtocolState.AUTHENTICATED
val authReady = currentState == ProtocolState.AUTHENTICATED
if (authReady && !handshakeComplete) {
// Defensive self-heal:
// AUTHENTICATED state must imply completed handshake.
// If these flags diverge, message sending can be stuck in queue forever.
log("⚠️ AUTHENTICATED with handshakeComplete=false -> self-heal handshakeComplete=true")
handshakeComplete = true
}
val preAuthAllowedPacket =
packet is PacketSignalPeer || packet is PacketWebRTC || packet is PacketIceServers
val preAuthReady =
@@ -726,15 +1059,32 @@ class Protocol(
}
}
private fun handleDisconnect() {
private fun handleDisconnect(source: String = "unknown") {
enqueueSessionEvent(SessionEvent.HandleDisconnect(source))
}
private fun handleDisconnectLocked(source: String) {
val previousState = _state.value
log("🔌 DISCONNECT HANDLER: previousState=$previousState, manuallyClosed=$isManuallyClosed, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
log(
"🔌 DISCONNECT HANDLER: source=$source previousState=$previousState, manuallyClosed=$isManuallyClosed, " +
"reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting, instance=$instanceId"
)
cancelConnectingTimeout(reason = "handle_disconnect:$source")
// Duplicate callbacks are possible (e.g. heartbeat failure + onFailure/onClosed).
// If we are already disconnected and a reconnect is pending, avoid scheduling another one.
if (previousState == ProtocolState.DISCONNECTED && reconnectJob?.isActive == true) {
log("⚠️ DISCONNECT DUPLICATE: reconnect already scheduled, skipping")
return
}
// КРИТИЧНО: если уже идет подключение, не делаем ничего
if (isConnecting) {
log("⚠️ DISCONNECT IGNORED: connection already in progress")
return
}
rotateConnectionGeneration("disconnect:$source")
setState(ProtocolState.DISCONNECTED, "Disconnect handler called from $previousState")
handshakeComplete = false
@@ -756,12 +1106,16 @@ class Protocol(
// КРИТИЧНО: отменяем предыдущий reconnect job если есть
reconnectJob?.cancel()
// Экспоненциальная задержка: 1s, 2s, 4s, 8s, максимум 30s
val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempts - 1, 4)), 30000L)
log("🔄 SCHEDULING RECONNECT: attempt #$reconnectAttempts, delay=${delayMs}ms")
// Экспоненциальная задержка: 1s, 2s, 4s, 8s, 16s, максимум 30s.
// IMPORTANT: reconnectAttempts may be 0 right after AUTHENTICATED reset.
// Using (1 shl -1) causes overflow (seen in logs as -2147483648000ms).
val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1)
val exponent = (nextAttemptNumber - 1).coerceIn(0, 4)
val delayMs = minOf(1000L * (1L shl exponent), 30000L)
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber, delay=${delayMs}ms")
if (reconnectAttempts > 20) {
log("⚠️ WARNING: Too many reconnect attempts ($reconnectAttempts), may be stuck in loop")
if (nextAttemptNumber > 20) {
log("⚠️ WARNING: Too many reconnect attempts ($nextAttemptNumber), may be stuck in loop")
}
reconnectJob = scope.launch {
@@ -782,33 +1136,58 @@ class Protocol(
* Register callback for specific packet type
*/
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetWaiters.getOrPut(packetId) { mutableListOf() }.add(callback)
val count = packetWaiters[packetId]?.size ?: 0
log("📝 waitPacket(0x${Integer.toHexString(packetId)}) registered. Total handlers for 0x${Integer.toHexString(packetId)}: $count")
val waiters = packetWaiters.computeIfAbsent(packetId) { CopyOnWriteArrayList() }
if (waiters.contains(callback)) {
log(
"📝 waitPacket(0x${Integer.toHexString(packetId)}) skipped duplicate callback. " +
"Total handlers for 0x${Integer.toHexString(packetId)}: ${waiters.size}"
)
return
}
waiters.add(callback)
log(
"📝 waitPacket(0x${Integer.toHexString(packetId)}) registered. " +
"Total handlers for 0x${Integer.toHexString(packetId)}: ${waiters.size}"
)
}
/**
* Unregister callback for specific packet type
*/
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
packetWaiters[packetId]?.remove(callback)
val waiters = packetWaiters[packetId] ?: return
waiters.remove(callback)
if (waiters.isEmpty()) {
packetWaiters.remove(packetId, waiters)
}
}
/**
* Disconnect from server
*/
fun disconnect() {
log("🔌 Manual disconnect requested")
isManuallyClosed = true
enqueueSessionEvent(
SessionEvent.Disconnect(manual = true, reason = "User disconnected")
)
}
private fun disconnectLocked(manual: Boolean, reason: String) {
log("🔌 Disconnect requested: manual=$manual reason='$reason' instance=$instanceId")
isManuallyClosed = manual
cancelConnectingTimeout(reason = "disconnect_locked")
isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
reconnectJob?.cancel() // Отменяем запланированные переподключения
reconnectJob = null
handshakeJob?.cancel()
heartbeatJob?.cancel()
heartbeatPeriodMs = 0L
webSocket?.close(1000, "User disconnected")
rotateConnectionGeneration("disconnect_locked:${if (manual) "manual" else "internal"}")
val socket = webSocket
webSocket = null
_state.value = ProtocolState.DISCONNECTED
runCatching { socket?.close(1000, reason) }
setState(ProtocolState.DISCONNECTED, "disconnectLocked(manual=$manual, reason=$reason)")
}
/**
@@ -821,21 +1200,43 @@ class Protocol(
* on app resume we should not wait scheduled exponential backoff.
*/
fun reconnectNowIfNeeded(reason: String = "foreground") {
enqueueSessionEvent(SessionEvent.FastReconnect(reason))
}
private fun reconnectNowIfNeededLocked(reason: String) {
val currentState = _state.value
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
val now = System.currentTimeMillis()
log(
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
)
if (isManuallyClosed) {
log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason")
return
}
if (!hasCredentials) return
if (
if (currentState == ProtocolState.CONNECTING && isConnecting) {
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
return
}
log("🧯 FAST RECONNECT: stuck CONNECTING (${elapsed}ms) -> reset and reconnect")
cancelConnectingTimeout(reason = "fast_reconnect_reset")
isConnecting = false
connectingSinceMs = 0L
runCatching { webSocket?.cancel() }
webSocket = null
rotateConnectionGeneration("fast_reconnect_reset:$reason")
setState(ProtocolState.DISCONNECTED, "Fast reconnect reset stuck CONNECTING")
} else if (
currentState == ProtocolState.AUTHENTICATED ||
currentState == ProtocolState.HANDSHAKING ||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
currentState == ProtocolState.CONNECTED ||
(currentState == ProtocolState.CONNECTING && isConnecting)
currentState == ProtocolState.CONNECTED
) {
return
}
@@ -844,7 +1245,7 @@ class Protocol(
reconnectAttempts = 0
reconnectJob?.cancel()
reconnectJob = null
connect()
connectLocked()
}
/**
@@ -867,7 +1268,20 @@ class Protocol(
* Release resources
*/
fun destroy() {
disconnect()
enqueueSessionEvent(
SessionEvent.Disconnect(manual = true, reason = "Destroy protocol")
)
runCatching { sessionEvents.close() }
runBlocking {
val drained = withTimeoutOrNull(2_000L) {
sessionLoopJob.join()
true
} ?: false
if (!drained) {
sessionLoopJob.cancelAndJoin()
}
}
connectingTimeoutJob?.cancel()
heartbeatJob?.cancel()
scope.cancel()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,7 +121,7 @@ object TransportManager {
*/
fun requestTransportServer() {
val packet = PacketRequestTransport()
ProtocolManager.sendPacket(packet)
ProtocolRuntimeAccess.get().sendPacket(packet)
}
/**
@@ -188,7 +188,7 @@ object TransportManager {
*/
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
val server = getActiveServer()
ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server")
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
// Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0)
@@ -275,15 +275,15 @@ object TransportManager {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
ProtocolRuntimeAccess.get().addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
tag
}
} catch (e: CancellationException) {
ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}")
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
throw e
} catch (e: Exception) {
ProtocolManager.addLog(
ProtocolRuntimeAccess.get().addLog(
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
@@ -309,7 +309,7 @@ object TransportManager {
transportServer: String? = null
): String = withContext(Dispatchers.IO) {
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)
@@ -336,7 +336,7 @@ object TransportManager {
_downloading.value = _downloading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}")
ProtocolRuntimeAccess.get().addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}")
return@withRetry content
}
@@ -383,14 +383,14 @@ object TransportManager {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
ProtocolRuntimeAccess.get().addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
content
} finally {
tempFile.delete()
}
}
} catch (e: Exception) {
ProtocolManager.addLog(
ProtocolRuntimeAccess.get().addLog(
"❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
@@ -457,7 +457,7 @@ object TransportManager {
transportServer: String? = null
): File = withContext(Dispatchers.IO) {
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"
)
@@ -541,13 +541,13 @@ object TransportManager {
_downloading.value = _downloading.value.map {
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"
)
targetFile
}
} catch (e: Exception) {
ProtocolManager.addLog(
ProtocolRuntimeAccess.get().addLog(
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,96 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.network.PacketMessage
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
pendingOutgoingRetryJobs[messageId]?.cancel()
pendingOutgoingPackets[messageId] = packet
pendingOutgoingAttempts[messageId] = 0
schedule(messageId)
}
fun resolve(messageId: String) {
pendingOutgoingRetryJobs[messageId]?.cancel()
pendingOutgoingRetryJobs.remove(messageId)
pendingOutgoingPackets.remove(messageId)
pendingOutgoingAttempts.remove(messageId)
}
fun clear() {
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) {
addLog(
"⚠️ Message ${messageId.take(8)} expired after ${ageMs}ms — marking as error"
)
scope.launch { markOutgoingAsError(messageId, packet) }
resolve(messageId)
return@launch
}
if (attempts >= maxRetryAttempts) {
addLog(
"⚠️ Message ${messageId.take(8)} exhausted $attempts retries — marking as error"
)
scope.launch { markOutgoingAsError(messageId, packet) }
resolve(messageId)
return@launch
}
if (!isAuthenticated()) {
addLog("⏳ Message ${messageId.take(8)} retry deferred — not authenticated")
resolve(messageId)
return@launch
}
val nextAttempt = attempts + 1
pendingOutgoingAttempts[messageId] = nextAttempt
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

@@ -19,12 +19,14 @@ import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
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.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.ProtocolManager
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.SupervisorJob
@@ -40,8 +42,13 @@ import java.util.Locale
* - Получение push-уведомлений о новых сообщениях
* - Отображение уведомлений
*/
@AndroidEntryPoint
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)
companion object {
@@ -120,9 +127,9 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
saveFcmToken(token)
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
// Используем единую точку отправки в ProtocolManager (с дедупликацией).
if (ProtocolManager.isAuthenticated()) {
runCatching { ProtocolManager.subscribePushTokenIfAvailable(forceToken = token) }
// Используем единую runtime-точку отправки (с дедупликацией).
if (protocolGateway.isAuthenticated()) {
runCatching { protocolGateway.subscribePushTokenIfAvailable(forceToken = token) }
}
}
@@ -131,11 +138,29 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
super.onMessageReceived(remoteMessage)
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 notificationTitle = remoteMessage.notification?.title?.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) {
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)
if (data.isNotEmpty()) {
val type =
@@ -496,18 +521,18 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
private fun wakeProtocolFromPush(reason: String) {
runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
ProtocolManager.initialize(applicationContext)
val account = accountManager.getLastLoggedPublicKey().orEmpty()
protocolGateway.initialize(applicationContext)
CallManager.initialize(applicationContext)
if (account.isNotBlank()) {
CallManager.bindAccount(account)
}
val restored = ProtocolManager.restoreAuthFromStoredCredentials(
val restored = protocolGateway.restoreAuthFromStoredCredentials(
preferredPublicKey = account,
reason = "push_$reason"
)
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}")
ProtocolManager.reconnectNowIfNeeded("push_$reason")
protocolGateway.reconnectNowIfNeeded("push_$reason")
}.onFailure { error ->
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
}
@@ -542,7 +567,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun areNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationsEnabled.first()
preferencesManager.notificationsEnabled.first()
}
}.getOrDefault(true)
}
@@ -565,7 +590,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
parsedDialogKey: String?,
parsedSenderKey: String?
): Set<String> {
val currentAccount = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty().trim()
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty().trim()
val candidates = linkedSetOf<String>()
fun addCandidate(raw: String?) {
@@ -708,7 +733,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun isAvatarInNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationAvatarEnabled.first()
preferencesManager.notificationAvatarEnabled.first()
}
}.getOrDefault(true)
}
@@ -717,25 +742,23 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false
return runCatching {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking(Dispatchers.IO) {
val preferences = PreferencesManager(applicationContext)
buildDialogKeyVariants(senderPublicKey).any { key ->
preferences.isChatMuted(currentAccount, key)
preferencesManager.isChatMuted(currentAccount, key)
}
}
}.getOrDefault(false)
}
/** Получить имя пользователя по publicKey (кэш ProtocolManager → БД dialogs) */
/** Получить имя пользователя по publicKey (runtime-кэш → БД dialogs) */
private fun resolveNameForKey(publicKey: String?): String? {
if (publicKey.isNullOrBlank()) return null
// 1. In-memory cache
ProtocolManager.getCachedUserName(publicKey)?.let { return it }
protocolGateway.getCachedUserName(publicKey)?.let { return it }
// 2. DB dialogs table
return runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
val account = accountManager.getLastLoggedPublicKey().orEmpty()
if (account.isBlank()) return null
val db = RosettaDatabase.getDatabase(applicationContext)
val dialog = runBlocking(Dispatchers.IO) {
@@ -756,8 +779,9 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val entity = runBlocking(Dispatchers.IO) {
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
} ?: return null
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
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

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 com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.SessionCoordinator
enum class AuthScreen {
SELECT_ACCOUNT,
@@ -15,6 +17,8 @@ enum class AuthScreen {
SEED_PHRASE,
CONFIRM_SEED,
SET_PASSWORD,
SET_BIOMETRIC,
SET_PROFILE,
IMPORT_SEED,
UNLOCK
}
@@ -25,6 +29,8 @@ fun AuthFlow(
hasExistingAccount: Boolean,
accounts: List<AccountInfo> = emptyList(),
accountManager: AccountManager,
protocolGateway: ProtocolGateway,
sessionCoordinator: SessionCoordinator,
startInCreateMode: Boolean = false,
onAuthComplete: (DecryptedAccount?) -> Unit,
onLogout: () -> Unit = {}
@@ -50,6 +56,7 @@ fun AuthFlow(
)
}
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var createdAccount by remember { mutableStateOf<DecryptedAccount?>(null) }
// Use last logged account or fallback to first account
var selectedAccountId by remember {
mutableStateOf<String?>(
@@ -59,6 +66,13 @@ fun AuthFlow(
var showCreateModal 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.
LaunchedEffect(startInCreateMode) {
if (startInCreateMode && currentScreen == AuthScreen.UNLOCK) {
@@ -82,12 +96,17 @@ fun AuthFlow(
} else if (hasExistingAccount) {
currentScreen = AuthScreen.UNLOCK
} else {
currentScreen = AuthScreen.CONFIRM_SEED
currentScreen = AuthScreen.SEED_PHRASE
}
}
AuthScreen.SET_BIOMETRIC -> {
currentScreen = AuthScreen.SET_PROFILE
}
AuthScreen.SET_PROFILE -> {
onAuthComplete(createdAccount)
}
AuthScreen.IMPORT_SEED -> {
if (isImportMode && hasExistingAccount) {
// Came from UnlockScreen recover — go back to unlock
currentScreen = AuthScreen.UNLOCK
isImportMode = false
} else {
@@ -146,18 +165,14 @@ fun AuthFlow(
onBack = { currentScreen = AuthScreen.WELCOME },
onConfirm = { words ->
seedPhrase = words
currentScreen = AuthScreen.CONFIRM_SEED
currentScreen = AuthScreen.SET_PASSWORD
}
)
}
AuthScreen.CONFIRM_SEED -> {
ConfirmSeedPhraseScreen(
seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.SEED_PHRASE },
onConfirmed = { currentScreen = AuthScreen.SET_PASSWORD }
)
// Skipped — go directly from SEED_PHRASE to SET_PASSWORD
LaunchedEffect(Unit) { currentScreen = AuthScreen.SET_PASSWORD }
}
AuthScreen.SET_PASSWORD -> {
@@ -165,19 +180,47 @@ fun AuthFlow(
seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme,
isImportMode = isImportMode,
onBack = {
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onBack = {
if (isImportMode) {
currentScreen = AuthScreen.IMPORT_SEED
} else if (hasExistingAccount) {
currentScreen = AuthScreen.UNLOCK
} 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 -> {
ImportSeedPhraseScreen(
isDarkTheme = isDarkTheme,
@@ -200,6 +243,8 @@ fun AuthFlow(
UnlockScreen(
isDarkTheme = isDarkTheme,
selectedAccountId = selectedAccountId,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocked = { account -> onAuthComplete(account) },
onSwitchAccount = {
// Navigate to create new account screen

View File

@@ -1,28 +1,33 @@
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 kotlinx.coroutines.flow.first
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.
ProtocolManager.connect()
ProtocolManager.authenticate(publicKey, privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("auth_fast_start")
protocolGateway.connect()
protocolGateway.authenticate(publicKey, privateKeyHash)
protocolGateway.reconnectNowIfNeeded("auth_fast_start")
}
internal suspend fun awaitAuthHandshakeState(
protocolGateway: ProtocolGateway,
publicKey: String,
privateKeyHash: String,
attempts: Int = 2,
timeoutMs: Long = 25_000L
): ProtocolState? {
repeat(attempts) { attempt ->
startAuthHandshakeFast(publicKey, privateKeyHash)
startAuthHandshakeFast(protocolGateway, publicKey, privateKeyHash)
val state = withTimeoutOrNull(timeoutMs) {
ProtocolManager.state.first {
protocolGateway.state.first {
it == ProtocolState.AUTHENTICATED ||
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
}
@@ -30,7 +35,7 @@ internal suspend fun awaitAuthHandshakeState(
if (state != null) {
return state
}
ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
protocolGateway.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
}
return null
}

View File

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

View File

@@ -52,10 +52,10 @@ import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.DeviceResolveSolution
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketDeviceResolve
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.DeviceMobile
@@ -64,6 +64,7 @@ import kotlinx.coroutines.launch
@Composable
fun DeviceConfirmScreen(
isDarkTheme: Boolean,
protocolGateway: ProtocolGateway,
onExit: () -> Unit
) {
val view = LocalView.current
@@ -131,9 +132,9 @@ fun DeviceConfirmScreen(
scope.launch { onExitState() }
}
}
ProtocolManager.waitPacket(0x18, callback)
protocolGateway.waitPacket(0x18, callback)
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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -64,7 +66,7 @@ fun ImportSeedPhraseScreen(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = textColor)
Icon(TablerIcons.ChevronLeft, "Back", tint = textColor)
}
}

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -65,7 +67,7 @@ fun SeedPhraseScreen(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = textColor)
Icon(TablerIcons.ChevronLeft, "Back", tint = textColor)
}
}
@@ -203,7 +205,7 @@ fun SeedPhraseScreen(
shape = RoundedCornerShape(14.dp)
) {
Text(
text = "Continue",
text = "I Saved It",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)

View File

@@ -0,0 +1,254 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.biometric.BiometricAuthManager
import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
private val PrimaryBlue = Color(0xFF228BE6)
private val PrimaryBlueDark = Color(0xFF5AA5FF)
@Composable
fun SetBiometricScreen(
isDarkTheme: Boolean,
account: DecryptedAccount?,
onContinue: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) }
val biometricAvailable = remember { biometricManager.isBiometricAvailable() is BiometricAvailability.Available }
var biometricEnabled by remember { mutableStateOf(false) }
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF8F8FF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.statusBarsPadding()
.navigationBarsPadding()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Skip button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onContinue) {
Text(
text = "Skip",
color = accentColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Lock illustration
Box(
modifier = Modifier.size(120.dp),
contentAlignment = Alignment.Center
) {
// Background circle
Box(
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = 0.15f))
)
// Lock icon
Icon(
imageVector = TablerIcons.ShieldLock,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(56.dp)
)
}
Spacer(modifier = Modifier.height(28.dp))
Text(
text = "Protect Your Account",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Adding biometric protection ensures\nthat only you can access your account.",
fontSize = 15.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(36.dp))
// Biometric toggle card
if (biometricAvailable) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(cardColor)
.clickable { biometricEnabled = !biometricEnabled }
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Biometrics",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
Text(
text = "Use biometric authentication to unlock",
fontSize = 13.sp,
color = secondaryTextColor,
maxLines = 1
)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = biometricEnabled,
onCheckedChange = { biometricEnabled = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = accentColor,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = secondaryTextColor.copy(alpha = 0.3f)
)
)
}
} else {
// Device doesn't support biometrics
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(cardColor)
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Biometrics",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = secondaryTextColor
)
Text(
text = "Not available on this device",
fontSize = 13.sp,
color = secondaryTextColor.copy(alpha = 0.7f)
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Continue button
Button(
onClick = {
scope.launch {
if (biometricEnabled && account != null) {
try {
biometricPrefs.enableBiometric(account.publicKey)
// Save encrypted password for biometric unlock
biometricPrefs.saveEncryptedPassword(
account.publicKey,
CryptoManager.encryptWithPassword(
account.privateKey.take(16),
account.publicKey
)
)
} catch (_: Exception) {}
}
onContinue()
}
},
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor,
contentColor = Color.White
),
shape = RoundedCornerShape(14.dp)
) {
Text(
text = "Continue",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}

View File

@@ -15,7 +15,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -29,6 +28,7 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.di.SessionCoordinator
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
@@ -38,34 +38,16 @@ fun SetPasswordScreen(
seedPhrase: List<String>,
isDarkTheme: Boolean,
isImportMode: Boolean = false,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onBack: () -> Unit,
onAccountCreated: (DecryptedAccount) -> Unit
) {
val themeAnimSpec =
tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val backgroundColor by
animateColorAsState(
if (isDarkTheme) AuthBackground else AuthBackgroundLight,
animationSpec = themeAnimSpec
)
val textColor by
animateColorAsState(
if (isDarkTheme) Color.White else Color.Black,
animationSpec = themeAnimSpec
)
val secondaryTextColor by
animateColorAsState(
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666),
animationSpec = themeAnimSpec
)
val cardColor by
animateColorAsState(
if (isDarkTheme) AuthSurface else AuthSurfaceLight,
animationSpec = themeAnimSpec
)
val backgroundColor = if (isDarkTheme) AuthBackground else AuthBackgroundLight
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
val scope = rememberCoroutineScope()
var password by remember { mutableStateOf("") }
@@ -74,511 +56,302 @@ fun SetPasswordScreen(
var confirmPasswordVisible by remember { mutableStateOf(false) }
var isCreating by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var visible by remember { mutableStateOf(false) }
// Track keyboard visibility
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
// Auth screens should always keep white status bar icons.
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}
var isKeyboardVisible by remember { mutableStateOf(false) }
DisposableEffect(view) {
val listener =
android.view.ViewTreeObserver.OnGlobalLayoutListener {
val rect = android.graphics.Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
isKeyboardVisible = keypadHeight > screenHeight * 0.15
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) }
}
LaunchedEffect(Unit) { visible = true }
val passwordsMatch = password == confirmPassword && password.isNotEmpty()
val isPasswordWeak = password.isNotEmpty() && password.length < 6
val canContinue = passwordsMatch && !isCreating
Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) {
Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
// Top Bar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack, enabled = !isCreating) {
Icon(
Scaffold(
containerColor = backgroundColor,
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack, enabled = !isCreating) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Set Password",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
tint = textColor
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(12.dp))
// Lock icon
Box(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(16.dp))
.background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
TablerIcons.Lock,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(48.dp))
}
Column(
modifier =
Modifier.fillMaxSize()
.imePadding()
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp))
Spacer(modifier = Modifier.height(16.dp))
// Lock Icon - smaller when keyboard is visible
val iconSize by
animateDpAsState(
targetValue = if (isKeyboardVisible) 48.dp else 80.dp,
animationSpec = tween(300, easing = FastOutSlowInEasing)
)
val iconInnerSize by
animateDpAsState(
targetValue = if (isKeyboardVisible) 24.dp else 40.dp,
animationSpec = tween(300, easing = FastOutSlowInEasing)
)
Text(
text = if (isImportMode) "Recover Account" else "Protect Your Account",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(250)) +
scaleIn(
initialScale = 0.5f,
animationSpec =
tween(250, easing = FastOutSlowInEasing)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
fontSize = 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(28.dp))
// Password field — clean Telegram style
TextField(
value = password,
onValueChange = { password = it; error = null },
placeholder = { Text("Password", color = secondaryTextColor) },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(20.dp)
)
}
},
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
colors = TextFieldDefaults.colors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
focusedContainerColor = fieldBackground,
unfocusedContainerColor = fieldBackground,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = PrimaryBlue
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
)
)
// Strength bar
if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
val strength = when {
password.length < 6 -> 0.25f
password.length < 10 -> 0.6f
else -> 1f
}
val strengthColor = when {
password.length < 6 -> Color(0xFFE53935)
password.length < 10 -> Color(0xFFFFA726)
else -> Color(0xFF4CAF50)
}
val strengthLabel = when {
password.length < 6 -> "Weak"
password.length < 10 -> "Medium"
else -> "Strong"
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier =
Modifier.size(iconSize)
.clip(
RoundedCornerShape(
if (isKeyboardVisible) 12.dp else 20.dp
)
)
.background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
modifier = Modifier
.weight(1f)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(fieldBackground)
) {
Icon(
TablerIcons.Lock,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(iconInnerSize)
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(strength)
.clip(RoundedCornerShape(2.dp))
.background(strengthColor)
)
}
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 100))
) {
Text(
text = if (isImportMode) "Recover Account" else "Protect Your Account",
fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
fontWeight = FontWeight.Bold,
color = textColor
text = strengthLabel,
fontSize = 12.sp,
color = strengthColor
)
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 200))
) {
Text(
text = if (isImportMode)
"Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
else
"This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
fontSize = if (isKeyboardVisible) 12.sp else 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp
)
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp))
// Password Field
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 300))
) {
OutlinedTextField(
value = password,
onValueChange = {
password = it
error = null
},
label = { Text("Password") },
placeholder = { Text("Enter password") },
singleLine = true,
visualTransformation =
if (passwordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) TablerIcons.EyeOff
else TablerIcons.Eye,
contentDescription =
if (passwordVisible) "Hide" else "Show"
)
}
},
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor =
if (isDarkTheme) Color(0xFF4A4A4A)
else Color(0xFFD0D0D0),
focusedLabelColor = PrimaryBlue,
cursorColor = PrimaryBlue,
focusedTextColor = textColor,
unfocusedTextColor = textColor
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
)
)
}
// Password strength indicator
if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 350))
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val strength =
when {
password.length < 6 -> "Weak"
password.length < 10 -> "Medium"
else -> "Strong"
}
val strengthColor =
when {
password.length < 6 -> Color(0xFFE53935)
password.length < 10 -> Color(0xFFFFA726)
else -> Color(0xFF4CAF50)
}
Icon(
painter = TelegramIcons.Secret,
contentDescription = null,
tint = strengthColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Password strength: $strength",
fontSize = 12.sp,
color = strengthColor
)
}
// Warning for weak passwords
if (isPasswordWeak) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier =
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(
Color(0xFFE53935).copy(alpha = 0.1f)
)
.padding(8.dp),
verticalAlignment = Alignment.Top
) {
Icon(
painter = TelegramIcons.Warning,
contentDescription = null,
tint = Color(0xFFE53935),
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
"Your password is too weak. Consider using at least 6 characters for better security.",
fontSize = 11.sp,
color = Color(0xFFE53935),
lineHeight = 14.sp
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Confirm Password Field
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 400))
) {
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
error = null
},
label = { Text("Confirm Password") },
placeholder = { Text("Re-enter password") },
singleLine = true,
visualTransformation =
if (confirmPasswordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
trailingIcon = {
IconButton(
onClick = {
confirmPasswordVisible = !confirmPasswordVisible
}
) {
Icon(
imageVector =
if (confirmPasswordVisible)
TablerIcons.EyeOff
else TablerIcons.Eye,
contentDescription =
if (confirmPasswordVisible) "Hide" else "Show"
)
}
},
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor =
if (isDarkTheme) Color(0xFF4A4A4A)
else Color(0xFFD0D0D0),
focusedLabelColor = PrimaryBlue,
cursorColor = PrimaryBlue,
focusedTextColor = textColor,
unfocusedTextColor = textColor
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
)
}
// Match indicator
if (confirmPassword.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 450))
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val matchIcon =
if (passwordsMatch) TablerIcons.Check else TablerIcons.X
val matchColor =
if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
val matchText =
if (passwordsMatch) "Passwords match"
else "Passwords don't match"
Icon(
imageVector = matchIcon,
contentDescription = null,
tint = matchColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = matchText, fontSize = 12.sp, color = matchColor)
}
}
}
// Error message
error?.let { errorMsg ->
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f)
) {
Text(
text = errorMsg,
fontSize = 14.sp,
color = Color(0xFFE53935),
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Info - hide when keyboard is visible
AnimatedVisibility(
visible = visible && !isKeyboardVisible,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200))
) {
Row(
modifier =
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(cardColor)
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
Icon(
painter = TelegramIcons.Info,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text =
"Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.",
fontSize = 13.sp,
color = secondaryTextColor,
lineHeight = 18.sp
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Create Account Button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 600))
) {
Button(
onClick = {
if (!passwordsMatch) {
error = "Passwords don't match"
return@Button
}
isCreating = true
scope.launch {
try {
// Generate keys from seed phrase
val keyPair =
CryptoManager.generateKeyPairFromSeed(seedPhrase)
// Encrypt private key and seed phrase
val encryptedPrivateKey =
CryptoManager.encryptWithPassword(
keyPair.privateKey,
password
)
val encryptedSeedPhrase =
CryptoManager.encryptWithPassword(
seedPhrase.joinToString(" "),
password
)
// Save account with truncated public key as name
val truncatedKey =
"${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}"
val account =
EncryptedAccount(
publicKey = keyPair.publicKey,
encryptedPrivateKey = encryptedPrivateKey,
encryptedSeedPhrase = encryptedSeedPhrase,
name = truncatedKey
)
accountManager.saveAccount(account)
// 🔌 Connect to server and authenticate
val privateKeyHash =
CryptoManager.generatePrivateKeyHash(
keyPair.privateKey
)
startAuthHandshakeFast(
keyPair.publicKey,
privateKeyHash
)
accountManager.setCurrentAccount(keyPair.publicKey)
// Create DecryptedAccount to pass to callback
val decryptedAccount =
DecryptedAccount(
publicKey = keyPair.publicKey,
privateKey = keyPair.privateKey,
seedPhrase = seedPhrase,
privateKeyHash = privateKeyHash,
name = truncatedKey
)
onAccountCreated(decryptedAccount)
} catch (e: Exception) {
error = "Failed to create account: ${e.message}"
isCreating = false
}
}
},
enabled = canContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = if (isImportMode) "Recover Account" else "Create Account",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
}
Spacer(modifier = Modifier.height(12.dp))
// Confirm password field
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it; error = null },
placeholder = { Text("Confirm password", color = secondaryTextColor) },
singleLine = true,
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
Icon(
imageVector = if (confirmPasswordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(20.dp)
)
}
},
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
colors = TextFieldDefaults.colors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
focusedContainerColor = fieldBackground,
unfocusedContainerColor = fieldBackground,
errorContainerColor = fieldBackground,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = PrimaryBlue
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
)
// Match status
if (confirmPassword.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
val matchColor = if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
Icon(
imageVector = if (passwordsMatch) TablerIcons.Check else TablerIcons.X,
contentDescription = null,
tint = matchColor,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (passwordsMatch) "Passwords match" else "Passwords don't match",
fontSize = 12.sp,
color = matchColor
)
}
}
// Error
error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, fontSize = 13.sp, color = Color(0xFFE53935), textAlign = TextAlign.Center)
}
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.height(16.dp))
// Create button
Button(
onClick = {
if (!passwordsMatch) {
error = "Passwords don't match"
return@Button
}
isCreating = true
scope.launch {
try {
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
val encryptedPrivateKey = CryptoManager.encryptWithPassword(keyPair.privateKey, password)
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(seedPhrase.joinToString(" "), password)
val truncatedKey = "${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}"
val account = EncryptedAccount(
publicKey = keyPair.publicKey,
encryptedPrivateKey = encryptedPrivateKey,
encryptedSeedPhrase = encryptedSeedPhrase,
name = truncatedKey
)
accountManager.saveAccount(account)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
val decryptedAccount = DecryptedAccount(
publicKey = keyPair.publicKey,
privateKey = keyPair.privateKey,
seedPhrase = seedPhrase,
privateKeyHash = privateKeyHash,
name = truncatedKey
)
sessionCoordinator.bootstrapAuthenticatedSession(
account = decryptedAccount,
reason = "set_password"
)
onAccountCreated(decryptedAccount)
} catch (e: Exception) {
error = "Failed to create account: ${e.message}"
isCreating = false
}
}
},
enabled = canContinue,
modifier = Modifier.fillMaxWidth().height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.4f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = if (isImportMode) "Recover Account" else "Create Account",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}

View File

@@ -0,0 +1,542 @@
package com.rosetta.messenger.ui.auth
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.PacketUserInfo
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.database.RosettaDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
private val PrimaryBlue = Color(0xFF248AE6)
private val ErrorRed = Color(0xFFFF3B30)
private const val NAME_MAX_LENGTH = 40
private const val USERNAME_MIN_LENGTH = 5
private const val USERNAME_MAX_LENGTH = 32
private val NAME_ALLOWED_REGEX = Regex("^[\\p{L}\\p{N} ._'-]+$")
private val USERNAME_ALLOWED_REGEX = Regex("^[A-Za-z0-9_]+$")
private fun validateName(name: String): String? {
if (name.isBlank()) return "Name can't be empty"
if (name.length > NAME_MAX_LENGTH) return "Name is too long (max $NAME_MAX_LENGTH)"
if (!NAME_ALLOWED_REGEX.matches(name)) return "Only letters, numbers, spaces, . _ - ' are allowed"
return null
}
private fun validateUsername(username: String): String? {
if (username.isBlank()) return null // optional
if (username.length < USERNAME_MIN_LENGTH) return "Username must be at least $USERNAME_MIN_LENGTH characters"
if (username.length > USERNAME_MAX_LENGTH) return "Username is too long (max $USERNAME_MAX_LENGTH)"
if (!USERNAME_ALLOWED_REGEX.matches(username)) return "Use only letters, numbers, and underscore"
return null
}
@Composable
fun SetProfileScreen(
isDarkTheme: Boolean,
account: DecryptedAccount?,
protocolGateway: ProtocolGateway,
accountManager: AccountManager,
onComplete: () -> Unit,
onSkip: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var nameTouched by remember { mutableStateOf(false) }
var usernameTouched by remember { mutableStateOf(false) }
var avatarUri by remember { mutableStateOf<String?>(null) }
var showPhotoPicker by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
// Username availability check
var usernameAvailable by remember { mutableStateOf<Boolean?>(null) }
var isCheckingUsername by remember { mutableStateOf(false) }
LaunchedEffect(username) {
val trimmed = username.trim()
if (trimmed.length < USERNAME_MIN_LENGTH) {
usernameAvailable = null
return@LaunchedEffect
}
if (validateUsername(trimmed) != null) {
usernameAvailable = null
return@LaunchedEffect
}
usernameAvailable = null
isCheckingUsername = true
delay(600) // debounce
try {
val results = protocolGateway.searchUsers(trimmed, 3000)
val taken = results.any { it.username.equals(trimmed, ignoreCase = true) }
usernameAvailable = !taken
} catch (_: Exception) {
usernameAvailable = null
}
isCheckingUsername = false
}
val nameError = if (nameTouched) validateName(name.trim()) else null
val localUsernameError = if (usernameTouched) validateUsername(username.trim()) else null
val usernameError = localUsernameError
?: if (usernameTouched && usernameAvailable == false) "Username is already taken" else null
val isFormValid = validateName(name.trim()) == null
&& validateUsername(username.trim()) == null
&& usernameAvailable != false
LaunchedEffect(Unit) { visible = true }
val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
if (croppedUri != null) {
avatarUri = croppedUri.toString()
}
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF8F8FF)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color(0xFF1A1A1A)
val secondaryText = Color(0xFF8E8E93)
val avatarBg = if (isDarkTheme) Color(0xFF333336) else PrimaryBlue
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.statusBarsPadding()
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
// Skip button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onSkip) {
Text(
text = "Skip",
color = PrimaryBlue,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Avatar
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400)) + scaleIn(tween(400), initialScale = 0.8f)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(avatarBg)
.clickable { showPhotoPicker = true }
) {
if (avatarUri != null) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(Uri.parse(avatarUri))
.crossfade(true)
.build(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = TelegramIcons.Camera,
contentDescription = "Set photo",
tint = Color.White,
modifier = Modifier.size(36.dp)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { showPhotoPicker = true }) {
Text(
text = "Set Photo",
color = PrimaryBlue,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(24.dp))
// Title
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 150))
) {
Text(
text = "What's your name?",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 250))
) {
Text(
text = "Your friends can find you by this name. It will be displayed in chats, groups, and your profile.",
fontSize = 15.sp,
color = secondaryText,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
}
Spacer(modifier = Modifier.height(28.dp))
// Name & Username inputs
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 350))
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Name field
Column {
TextField(
value = name,
onValueChange = {
name = it.take(NAME_MAX_LENGTH)
nameTouched = true
},
placeholder = {
Text("Your name", color = secondaryText)
},
isError = nameError != null,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp)),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
focusedContainerColor = cardColor,
unfocusedContainerColor = cardColor,
errorContainerColor = cardColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = PrimaryBlue
)
)
if (nameError != null) {
Text(
text = nameError,
fontSize = 12.sp,
color = ErrorRed,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Username field
Column {
TextField(
value = username,
onValueChange = { raw ->
username = raw.take(USERNAME_MAX_LENGTH)
.lowercase()
.filter { it.isLetterOrDigit() || it == '_' }
usernameTouched = true
},
placeholder = {
Text("Username", color = secondaryText)
},
prefix = {
Text("@", color = secondaryText, fontSize = 16.sp)
},
trailingIcon = {
when {
isCheckingUsername && username.trim().length >= USERNAME_MIN_LENGTH -> {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = secondaryText
)
}
usernameAvailable == true && usernameError == null -> {
Icon(
imageVector = TablerIcons.Check,
contentDescription = "Available",
tint = Color(0xFF4CAF50),
modifier = Modifier.size(20.dp)
)
}
usernameAvailable == false -> {
Icon(
imageVector = TablerIcons.X,
contentDescription = "Taken",
tint = ErrorRed,
modifier = Modifier.size(20.dp)
)
}
}
},
isError = usernameError != null,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp)),
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
capitalization = androidx.compose.ui.text.input.KeyboardCapitalization.None,
autoCorrect = false
),
colors = TextFieldDefaults.colors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
focusedContainerColor = cardColor,
unfocusedContainerColor = cardColor,
errorContainerColor = cardColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = PrimaryBlue
)
)
if (usernameError != null) {
Text(
text = usernameError,
fontSize = 12.sp,
color = ErrorRed,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
} else if (usernameAvailable == true) {
Text(
text = "Username is available!",
fontSize = 12.sp,
color = Color(0xFF4CAF50),
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
} else {
Text(
text = "Username is optional. People can use it to find you without sharing your key.",
fontSize = 12.sp,
color = secondaryText.copy(alpha = 0.7f),
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
lineHeight = 16.sp
)
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Continue button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 450))
) {
Button(
onClick = {
if (account == null) {
onComplete()
return@Button
}
isSaving = true
scope.launch {
try {
// Wait for server connection (up to 8s)
val connected = withTimeoutOrNull(8000) {
while (!protocolGateway.isAuthenticated()) {
delay(300)
}
true
} ?: false
// Save name and username locally first
if (name.trim().isNotEmpty()) {
accountManager.updateAccountName(account.publicKey, name.trim())
}
if (username.trim().isNotEmpty()) {
accountManager.updateAccountUsername(account.publicKey, username.trim())
}
// Trigger UI refresh in MainActivity
protocolGateway.notifyOwnProfileUpdated()
// Send name and username to server
if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) {
val packet = PacketUserInfo()
packet.title = name.trim()
packet.username = username.trim()
packet.privateKey = account.privateKeyHash
protocolGateway.send(packet)
delay(1500)
// Повторяем для надёжности
if (protocolGateway.isAuthenticated()) {
val packet2 = PacketUserInfo()
packet2.title = name.trim()
packet2.username = username.trim()
packet2.privateKey = account.privateKeyHash
protocolGateway.send(packet2)
delay(500)
}
}
// Save avatar
if (avatarUri != null) {
withContext(Dispatchers.IO) {
val uri = Uri.parse(avatarUri)
val rawBytes = context.contentResolver
.openInputStream(uri)?.use { it.readBytes() }
if (rawBytes != null && rawBytes.isNotEmpty()) {
val preparedBase64 = AvatarFileManager
.imagePrepareForNetworkTransfer(context, rawBytes)
if (preparedBase64.isNotBlank()) {
val db = RosettaDatabase.getDatabase(context)
val avatarRepo = AvatarRepository(
context = context,
avatarDao = db.avatarDao(),
currentPublicKey = account.publicKey
)
avatarRepo.saveAvatar(
fromPublicKey = account.publicKey,
base64Image = "data:image/png;base64,$preparedBase64"
)
}
}
}
}
onComplete()
} catch (_: Exception) {
onComplete()
}
}
},
enabled = isFormValid && !isSaving,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(14.dp)
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = "Continue",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Examples of where name appears
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 550))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Your name will appear in:",
fontSize = 13.sp,
color = secondaryText,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Chat list • Messages • Groups • Profile",
fontSize = 13.sp,
color = secondaryText.copy(alpha = 0.7f)
)
}
}
}
}
ProfilePhotoPicker(
isVisible = showPhotoPicker,
onDismiss = { showPhotoPicker = false },
onPhotoSelected = { uri ->
showPhotoPicker = false
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
cropLauncher.launch(cropIntent)
},
isDarkTheme = isDarkTheme
)
}

View File

@@ -45,6 +45,7 @@ import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.di.SessionCoordinator
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.getAvatarColor
@@ -68,6 +69,7 @@ private suspend fun performUnlock(
selectedAccount: AccountItem?,
password: String,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onUnlocking: (Boolean) -> Unit,
onError: (String) -> Unit,
onSuccess: (DecryptedAccount) -> Unit
@@ -116,9 +118,10 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
name = selectedAccount.name
)
startAuthHandshakeFast(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(account.publicKey)
sessionCoordinator.bootstrapAuthenticatedSession(
account = decryptedAccount,
reason = "unlock"
)
onSuccess(decryptedAccount)
} catch (e: Exception) {
onError("Failed to unlock: ${e.message}")
@@ -131,6 +134,8 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
fun UnlockScreen(
isDarkTheme: Boolean,
selectedAccountId: String? = null,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onUnlocked: (DecryptedAccount) -> Unit,
onSwitchAccount: () -> Unit = {},
onRecover: () -> Unit = {}
@@ -160,7 +165,6 @@ fun UnlockScreen(
val context = LocalContext.current
val activity = context as? FragmentActivity
val accountManager = remember { AccountManager(context) }
val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) }
val scope = rememberCoroutineScope()
@@ -217,6 +221,10 @@ fun UnlockScreen(
// Проверяем доступность биометрии
biometricAvailable = biometricManager.isBiometricAvailable()
val accountKey = targetAccount?.publicKey ?: accounts.firstOrNull()?.publicKey ?: ""
if (accountKey.isNotEmpty()) {
biometricPrefs.loadForAccount(accountKey)
}
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
// Загружаем сохранённые пароли для всех аккаунтов
@@ -255,6 +263,7 @@ fun UnlockScreen(
selectedAccount = selectedAccount,
password = decryptedPassword,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->
@@ -441,6 +450,8 @@ fun UnlockScreen(
isDropdownExpanded = false
password = ""
error = null
biometricPrefs.loadForAccount(account.publicKey)
isBiometricEnabled = biometricPrefs.isBiometricEnabledForAccount(account.publicKey)
}
.background(
if (isSelected) PrimaryBlue.copy(alpha = 0.1f)
@@ -598,6 +609,7 @@ fun UnlockScreen(
selectedAccount = selectedAccount,
password = password,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->

View File

@@ -8,6 +8,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -81,7 +83,7 @@ fun WelcomeScreen(
if (hasExistingAccount) {
IconButton(onClick = onBack, modifier = Modifier.statusBarsPadding().padding(4.dp)) {
Icon(
Icons.Default.ArrowBack,
TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor.copy(alpha = 0.6f)
)

View File

@@ -0,0 +1,380 @@
package com.rosetta.messenger.ui.chats
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.utils.AttachmentFileManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
internal class AttachmentsCoordinator(
private val chatViewModel: ChatViewModel
) {
fun updateOptimisticImageMessage(
messageId: String,
base64: String,
blurhash: String,
width: Int,
height: Int
) {
val currentMessages = chatViewModel.currentMessagesForAttachments().toMutableList()
val index = currentMessages.indexOfFirst { it.id == messageId }
if (index == -1) return
val message = currentMessages[index]
val updatedAttachments =
message.attachments.map { attachment ->
if (attachment.type == AttachmentType.IMAGE) {
attachment.copy(
preview = blurhash,
blob = base64,
width = width,
height = height
)
} else {
attachment
}
}
currentMessages[index] = message.copy(attachments = updatedAttachments)
chatViewModel.replaceMessagesForAttachments(
messages = currentMessages,
syncCache = false
)
}
suspend fun sendImageMessageInternal(
messageId: String,
imageBase64: String,
blurhash: String,
caption: String,
width: Int,
height: Int,
timestamp: Long,
recipient: String,
sender: String,
privateKey: String
) {
var packetSentToProtocol = false
try {
val context = chatViewModel.appContext()
val pipelineStartedAt = System.currentTimeMillis()
chatViewModel.logPhotoEvent(
messageId,
"internal send start: base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}, captionLen=${caption.length}"
)
val encryptStartedAt = System.currentTimeMillis()
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = caption,
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
chatViewModel.logPhotoEvent(
messageId,
"text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms"
)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val blobEncryptStartedAt = System.currentTimeMillis()
val attachmentId = "img_$timestamp"
chatViewModel.logPhotoEvent(
messageId,
"attachment prepared: id=${chatViewModel.shortPhotoLogId(attachmentId, 12)}, size=${width}x$height"
)
val isSavedMessages = (sender == recipient)
if (!isSavedMessages) {
chatViewModel.logPhotoEvent(
messageId,
"upload start: attachment=${chatViewModel.shortPhotoLogId(attachmentId, 12)}"
)
}
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
payload = imageBase64,
attachmentPassword = encryptionContext.attachmentPassword,
attachmentId = attachmentId,
isSavedMessages = isSavedMessages
)
)
val uploadTag = uploadResult.transportTag
val attachmentTransportServer = uploadResult.transportServer
chatViewModel.logPhotoEvent(
messageId,
"blob encrypted: len=${uploadResult.encryptedBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms"
)
if (!isSavedMessages) {
chatViewModel.logPhotoEvent(
messageId,
"upload done: tag=${chatViewModel.shortPhotoLogId(uploadTag, 12)}"
)
} else {
chatViewModel.logPhotoEvent(messageId, "saved-messages mode: upload skipped")
}
val imageAttachment =
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.IMAGE,
preview = blurhash,
width = width,
height = height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
chatViewModel.sendMediaMessage(
SendMediaMessageCommand(
fromPublicKey = sender,
toPublicKey = recipient,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
aesChachaKey = aesChachaKey,
privateKeyHash = privateKeyHash,
messageId = messageId,
timestamp = timestamp,
mediaAttachments = listOf(imageAttachment),
isSavedMessages = isSavedMessages
)
)
if (!isSavedMessages) {
packetSentToProtocol = true
chatViewModel.logPhotoEvent(messageId, "packet sent to protocol")
} else {
chatViewModel.logPhotoEvent(messageId, "saved-messages mode: packet send skipped")
}
val savedLocally =
AttachmentFileManager.saveAttachment(
context = context,
blob = imageBase64,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
chatViewModel.logPhotoEvent(messageId, "local file cache saved=$savedLocally")
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", blurhash)
put("blob", "")
put("width", width)
put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
.toString()
val deliveryStatus = if (isSavedMessages) 1 else 0
chatViewModel.updateMessageStatusAndAttachmentsDb(
messageId = messageId,
delivered = deliveryStatus,
attachmentsJson = attachmentsJson
)
chatViewModel.logPhotoEvent(messageId, "db status+attachments updated")
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
clearAttachmentLocalUri(messageId)
}
chatViewModel.logPhotoEvent(messageId, "ui status switched to SENT")
chatViewModel.saveOutgoingDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo",
timestamp = timestamp,
accountPublicKey = sender,
accountPrivateKey = privateKey,
opponentPublicKey = recipient
)
chatViewModel.logPhotoEvent(
messageId,
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
)
} catch (e: CancellationException) {
chatViewModel.logPhotoEvent(messageId, "internal-send cancelled")
throw e
} catch (e: Exception) {
chatViewModel.logPhotoErrorEvent(messageId, "internal-send", e)
if (packetSentToProtocol) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
chatViewModel.logPhotoEvent(messageId, "post-send non-fatal error: status kept as SENT")
} else {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
}
}
}
suspend fun sendVideoCircleMessageInternal(
messageId: String,
attachmentId: String,
timestamp: Long,
videoHex: String,
preview: String,
width: Int,
height: Int,
recipient: String,
sender: String,
privateKey: String
) {
var packetSentToProtocol = false
try {
val application = chatViewModel.appContext()
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = "",
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val isSavedMessages = (sender == recipient)
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
payload = videoHex,
attachmentPassword = encryptionContext.attachmentPassword,
attachmentId = attachmentId,
isSavedMessages = isSavedMessages
)
)
val uploadTag = uploadResult.transportTag
val attachmentTransportServer = uploadResult.transportServer
val videoAttachment =
chatViewModel.createVideoCircleAttachment(
CreateVideoCircleAttachmentCommand(
attachmentId = attachmentId,
preview = preview,
width = width,
height = height,
blob = "",
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
)
chatViewModel.sendMediaMessage(
SendMediaMessageCommand(
fromPublicKey = sender,
toPublicKey = recipient,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
aesChachaKey = aesChachaKey,
privateKeyHash = privateKeyHash,
messageId = messageId,
timestamp = timestamp,
mediaAttachments = listOf(videoAttachment),
isSavedMessages = isSavedMessages
)
)
if (!isSavedMessages) {
packetSentToProtocol = true
}
runCatching {
AttachmentFileManager.saveAttachment(
context = application,
blob = videoHex,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
}
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VIDEO_CIRCLE.value)
put("preview", preview)
put("blob", "")
put("width", width)
put("height", height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
.toString()
chatViewModel.updateMessageStatusAndAttachmentsDb(
messageId = messageId,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson
)
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
clearAttachmentLocalUri(messageId)
}
chatViewModel.saveOutgoingDialog(
lastMessage = "Video message",
timestamp = timestamp,
accountPublicKey = sender,
accountPrivateKey = privateKey,
opponentPublicKey = recipient
)
} catch (_: Exception) {
if (packetSentToProtocol) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
} else {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
}
}
}
private fun clearAttachmentLocalUri(messageId: String) {
val updatedMessages =
chatViewModel.currentMessagesForAttachments().map { message ->
if (message.id == messageId) {
val updatedAttachments =
message.attachments.map { attachment ->
attachment.copy(localUri = "")
}
message.copy(attachments = updatedAttachments)
} else {
message
}
}
chatViewModel.replaceMessagesForAttachments(
messages = updatedMessages,
syncCache = true
)
}
}

View File

@@ -0,0 +1,761 @@
package com.rosetta.messenger.ui.chats
import android.graphics.BitmapFactory
import android.net.Uri
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.EncodeVideoUriToHexCommand
import com.rosetta.messenger.domain.chats.usecase.EncodeVideoUriToHexUseCase
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.ResolveVideoCircleMetaCommand
import com.rosetta.messenger.domain.chats.usecase.ResolveVideoCircleMetaUseCase
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.DeliveryStatus
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.ui.chats.models.ChatMessage
import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MediaUtils
import java.util.Date
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
internal class AttachmentsFeatureCoordinator(
private val chatViewModel: ChatViewModel,
private val resolveVideoCircleMetaUseCase: ResolveVideoCircleMetaUseCase,
private val encodeVideoUriToHexUseCase: EncodeVideoUriToHexUseCase
) {
fun sendImageGroupFromUris(imageUris: List<Uri>, caption: String = "") {
if (imageUris.isEmpty()) return
if (imageUris.size == 1) {
chatViewModel.attachmentsViewModel.sendImageFromUri(imageUris.first(), caption)
return
}
val context = chatViewModel.appContext()
chatViewModel.launchBackgroundUpload {
val prepared = mutableListOf<ChatViewModel.ImageData>()
for ((index, uri) in imageUris.withIndex()) {
val (width, height) = MediaUtils.getImageDimensions(context, uri)
val imageBase64 = MediaUtils.uriToBase64Image(context, uri) ?: continue
val blurhash = MediaUtils.generateBlurhash(context, uri)
chatViewModel.addProtocolLog(
"📸 IMG-GROUP convert item#$index: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
)
prepared.add(
ChatViewModel.ImageData(
base64 = imageBase64,
blurhash = blurhash,
width = width,
height = height
)
)
}
if (prepared.isEmpty()) return@launchBackgroundUpload
withContext(Dispatchers.Main) {
sendImageGroup(prepared, caption)
}
}
}
fun sendImageGroup(images: List<ChatViewModel.ImageData>, caption: String = "") {
if (images.isEmpty()) return
if (images.size == 1) {
val image = images.first()
chatViewModel.attachmentsViewModel.sendImageMessage(
imageBase64 = image.base64,
blurhash = image.blurhash,
caption = caption,
width = image.width,
height = image.height
)
return
}
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
if (!chatViewModel.tryAcquireSendSlot()) {
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val text = caption.trim()
chatViewModel.logPhotoEvent(
messageId,
"group start: count=${images.size}, captionLen=${text.length}"
)
val attachmentsList =
images.mapIndexed { index, imageData ->
MessageAttachment(
id = "img_${timestamp}_$index",
type = AttachmentType.IMAGE,
preview = imageData.blurhash,
blob = imageData.base64,
width = imageData.width,
height = imageData.height
)
}
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments = attachmentsList
)
)
chatViewModel.clearInputText()
chatViewModel.launchBackgroundUpload {
try {
val groupStartedAt = System.currentTimeMillis()
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = text,
recipient = sendContext.recipient,
privateKey = sendContext.privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
val isSavedMessages = sendContext.sender == sendContext.recipient
val networkAttachments = mutableListOf<MessageAttachment>()
val attachmentsJsonArray = JSONArray()
for ((index, imageData) in images.withIndex()) {
val attachmentId = "img_${timestamp}_$index"
chatViewModel.logPhotoEvent(
messageId,
"group item#$index start: id=${chatViewModel.shortPhotoLogId(attachmentId)}, size=${imageData.width}x${imageData.height}"
)
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
payload = imageData.base64,
attachmentPassword = encryptionContext.attachmentPassword,
attachmentId = attachmentId,
isSavedMessages = isSavedMessages
)
)
val uploadTag = uploadResult.transportTag
val attachmentTransportServer = uploadResult.transportServer
val previewValue = imageData.blurhash
chatViewModel.logPhotoEvent(
messageId,
"group item#$index upload done: tag=${chatViewModel.shortPhotoLogId(uploadTag)}"
)
AttachmentFileManager.saveAttachment(
context = chatViewModel.appContext(),
blob = imageData.base64,
attachmentId = attachmentId,
publicKey = sendContext.sender,
privateKey = sendContext.privateKey
)
networkAttachments.add(
MessageAttachment(
id = attachmentId,
blob = if (uploadTag.isNotEmpty()) "" else uploadResult.encryptedBlob,
type = AttachmentType.IMAGE,
preview = previewValue,
width = imageData.width,
height = imageData.height,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
)
attachmentsJsonArray.put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", previewValue)
put("blob", "")
put("width", imageData.width)
put("height", imageData.height)
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
chatViewModel.sendMediaMessage(
SendMediaMessageCommand(
fromPublicKey = sendContext.sender,
toPublicKey = sendContext.recipient,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
aesChachaKey = aesChachaKey,
privateKeyHash = privateKeyHash,
messageId = messageId,
timestamp = timestamp,
mediaAttachments = networkAttachments,
isSavedMessages = isSavedMessages
)
)
val storedEncryptedKey =
if (encryptionContext.isGroup) {
chatViewModel.buildStoredGroupEncryptedKey(
encryptionContext.attachmentPassword,
sendContext.privateKey
)
} else {
encryptedKey
}
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey = storedEncryptedKey,
timestamp = timestamp,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJsonArray.toString(),
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
chatViewModel.saveOutgoingDialog(
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
chatViewModel.logPhotoEvent(
messageId,
"group completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
)
} catch (e: Exception) {
chatViewModel.logPhotoErrorEvent(messageId, "group-send", e)
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
} finally {
chatViewModel.releaseSendSlot()
}
}
}
fun sendFileMessage(
fileBase64: String,
fileName: String,
fileSize: Long,
caption: String = ""
) {
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
if (!chatViewModel.tryAcquireSendSlot()) {
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val text = caption.trim()
val preview = "$fileSize::$fileName"
val attachmentId = "file_$timestamp"
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
chatViewModel.createFileAttachment(
CreateFileAttachmentCommand(
attachmentId = attachmentId,
preview = preview,
blob = fileBase64
)
)
)
)
)
chatViewModel.clearInputText()
chatViewModel.launchOnIo {
try {
runCatching {
val appContext = chatViewModel.appContext()
val downloadsDir =
java.io.File(appContext.filesDir, "rosetta_downloads").apply { mkdirs() }
val localFile = java.io.File(downloadsDir, fileName)
if (!localFile.exists()) {
val base64Data =
if (fileBase64.contains(",")) fileBase64.substringAfter(",")
else fileBase64
val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
localFile.writeBytes(bytes)
}
}
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = text,
recipient = sendContext.recipient,
privateKey = sendContext.privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
val isSavedMessages = (sendContext.sender == sendContext.recipient)
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
payload = fileBase64,
attachmentPassword = encryptionContext.attachmentPassword,
attachmentId = attachmentId,
isSavedMessages = isSavedMessages
)
)
val fileAttachment =
chatViewModel.createFileAttachment(
CreateFileAttachmentCommand(
attachmentId = attachmentId,
preview = preview,
blob = "",
transportTag = uploadResult.transportTag,
transportServer = uploadResult.transportServer
)
)
chatViewModel.sendMediaMessage(
SendMediaMessageCommand(
fromPublicKey = sendContext.sender,
toPublicKey = sendContext.recipient,
encryptedContent = encryptionContext.encryptedContent,
encryptedKey = encryptionContext.encryptedKey,
aesChachaKey = encryptionContext.aesChachaKey,
privateKeyHash = privateKeyHash,
messageId = messageId,
timestamp = timestamp,
mediaAttachments = listOf(fileAttachment),
isSavedMessages = isSavedMessages
)
)
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.FILE.value)
put("preview", preview)
put("blob", "")
put("transportTag", uploadResult.transportTag)
put("transportServer", uploadResult.transportServer)
}
)
}
.toString()
val storedEncryptedKey =
if (encryptionContext.isGroup) {
chatViewModel.buildStoredGroupEncryptedKey(
encryptionContext.attachmentPassword,
sendContext.privateKey
)
} else {
encryptionContext.encryptedKey
}
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = text,
encryptedContent = encryptionContext.encryptedContent,
encryptedKey = storedEncryptedKey,
timestamp = timestamp,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
withContext(Dispatchers.Main) {
if (isSavedMessages) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
}
chatViewModel.saveOutgoingDialog(
lastMessage = if (text.isNotEmpty()) text else "file",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} catch (_: Exception) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
chatViewModel.saveOutgoingDialog(
lastMessage = if (text.isNotEmpty()) text else "file",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} finally {
chatViewModel.releaseSendSlot()
}
}
}
fun sendVideoCircleFromUri(videoUri: Uri) {
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
if (!chatViewModel.tryAcquireSendSlot()) {
return
}
val fileSize = chatViewModel.resolveFileSizeForUri(videoUri)
if (fileSize > 0L && fileSize > chatViewModel.maxMediaBytes()) {
chatViewModel.releaseSendSlot()
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val attachmentId = "video_circle_$timestamp"
val meta =
resolveVideoCircleMetaUseCase(
ResolveVideoCircleMetaCommand(
context = chatViewModel.appContext(),
videoUri = videoUri
)
)
val preview = "${meta.durationSec}::${meta.mimeType}"
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
chatViewModel.createVideoCircleAttachment(
CreateVideoCircleAttachmentCommand(
attachmentId = attachmentId,
preview = preview,
width = meta.width,
height = meta.height,
blob = "",
localUri = videoUri.toString()
)
)
)
)
)
chatViewModel.clearInputText()
chatViewModel.launchBackgroundUpload upload@{
try {
val optimisticAttachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VIDEO_CIRCLE.value)
put("preview", preview)
put("blob", "")
put("width", meta.width)
put("height", meta.height)
put("localUri", videoUri.toString())
}
)
}
.toString()
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = "",
encryptedContent = "",
encryptedKey = "",
timestamp = timestamp,
delivered = 0,
attachmentsJson = optimisticAttachmentsJson,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
chatViewModel.saveOutgoingDialog(
lastMessage = "Video message",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} catch (_: Exception) {
}
try {
val videoHex =
encodeVideoUriToHexUseCase(
EncodeVideoUriToHexCommand(
context = chatViewModel.appContext(),
videoUri = videoUri
)
)
if (videoHex.isNullOrBlank()) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
return@upload
}
chatViewModel.sendVideoCircleMessageInternal(
messageId = messageId,
attachmentId = attachmentId,
timestamp = timestamp,
videoHex = videoHex,
preview = preview,
width = meta.width,
height = meta.height,
recipient = sendContext.recipient,
sender = sendContext.sender,
privateKey = sendContext.privateKey
)
} catch (_: Exception) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
} finally {
chatViewModel.releaseSendSlot()
}
}
}
fun sendAvatarMessage() {
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
if (!chatViewModel.tryAcquireSendSlot()) {
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val avatarAttachmentId = "avatar_$timestamp"
chatViewModel.launchOnIo sendAvatar@{
try {
val avatarDao = RosettaDatabase.getDatabase(chatViewModel.appContext()).avatarDao()
val myAvatar = avatarDao.getLatestAvatar(sendContext.sender)
if (myAvatar == null) {
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
chatViewModel.appContext(),
"No avatar to send",
android.widget.Toast.LENGTH_SHORT
).show()
}
return@sendAvatar
}
val avatarBlob = AvatarFileManager.readAvatar(chatViewModel.appContext(), myAvatar.avatar)
if (avatarBlob.isNullOrEmpty()) {
withContext(Dispatchers.Main) {
android.widget.Toast.makeText(
chatViewModel.appContext(),
"Failed to read avatar",
android.widget.Toast.LENGTH_SHORT
).show()
}
return@sendAvatar
}
val avatarDataUrl =
if (avatarBlob.startsWith("data:image")) {
avatarBlob
} else {
"data:image/png;base64,$avatarBlob"
}
val avatarBlurhash =
runCatching {
val cleanBase64 =
if (avatarBlob.contains(",")) avatarBlob.substringAfter(",") else avatarBlob
val bytes = android.util.Base64.decode(cleanBase64, android.util.Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (bitmap != null) {
MediaUtils.generateBlurhashFromBitmap(bitmap)
} else {
""
}
}.getOrDefault("")
withContext(Dispatchers.Main) {
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
chatViewModel.createAvatarAttachment(
CreateAvatarAttachmentCommand(
attachmentId = avatarAttachmentId,
preview = avatarBlurhash,
blob = avatarBlob
)
)
)
)
)
}
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = "",
recipient = sendContext.recipient,
privateKey = sendContext.privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
val isSavedMessages = (sendContext.sender == sendContext.recipient)
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
payload = avatarDataUrl,
attachmentPassword = encryptionContext.attachmentPassword,
attachmentId = avatarAttachmentId,
isSavedMessages = isSavedMessages
)
)
val avatarAttachment =
chatViewModel.createAvatarAttachment(
CreateAvatarAttachmentCommand(
attachmentId = avatarAttachmentId,
preview = avatarBlurhash,
blob = "",
transportTag = uploadResult.transportTag,
transportServer = uploadResult.transportServer
)
)
chatViewModel.sendMediaMessage(
SendMediaMessageCommand(
fromPublicKey = sendContext.sender,
toPublicKey = sendContext.recipient,
encryptedContent = encryptionContext.encryptedContent,
encryptedKey = encryptionContext.encryptedKey,
aesChachaKey = encryptionContext.aesChachaKey,
privateKeyHash = privateKeyHash,
messageId = messageId,
timestamp = timestamp,
mediaAttachments = listOf(avatarAttachment),
isSavedMessages = isSavedMessages
)
)
AttachmentFileManager.saveAttachment(
context = chatViewModel.appContext(),
blob = avatarBlob,
attachmentId = avatarAttachmentId,
publicKey = sendContext.sender,
privateKey = sendContext.privateKey
)
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", avatarAttachmentId)
put("type", AttachmentType.AVATAR.value)
put("preview", avatarBlurhash)
put("blob", "")
put("transportTag", uploadResult.transportTag)
put("transportServer", uploadResult.transportServer)
}
)
}
.toString()
val storedEncryptedKey =
if (encryptionContext.isGroup) {
chatViewModel.buildStoredGroupEncryptedKey(
encryptionContext.attachmentPassword,
sendContext.privateKey
)
} else {
encryptionContext.encryptedKey
}
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = "",
encryptedContent = encryptionContext.encryptedContent,
encryptedKey = storedEncryptedKey,
timestamp = timestamp,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
withContext(Dispatchers.Main) {
if (isSavedMessages) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
}
chatViewModel.saveOutgoingDialog(
lastMessage = "\$a=Avatar",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
android.widget.Toast.makeText(
chatViewModel.appContext(),
"Failed to send avatar: ${e.message}",
android.widget.Toast.LENGTH_SHORT
).show()
}
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
chatViewModel.saveOutgoingDialog(
lastMessage = "\$a=Avatar",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} finally {
chatViewModel.releaseSendSlot()
}
}
}
}

View File

@@ -0,0 +1,684 @@
package com.rosetta.messenger.ui.chats
import android.graphics.BitmapFactory
import android.net.Uri
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand
import com.rosetta.messenger.domain.chats.usecase.SendTypingIndicatorCommand
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.DeliveryStatus
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.chats.models.ChatMessage
import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MediaUtils
import java.util.Date
import java.util.UUID
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
class MessagesViewModel internal constructor(
private val chatViewModel: ChatViewModel
) {
val messages: StateFlow<List<ChatMessage>> = chatViewModel.messages
val messagesWithDates: StateFlow<List<Pair<ChatMessage, Boolean>>> = chatViewModel.messagesWithDates
val isLoading: StateFlow<Boolean> = chatViewModel.isLoading
val isLoadingMore: StateFlow<Boolean> = chatViewModel.isLoadingMore
val groupRequiresRejoin: StateFlow<Boolean> = chatViewModel.groupRequiresRejoin
val inputText: StateFlow<String> = chatViewModel.inputText
val replyMessages: StateFlow<List<ChatViewModel.ReplyMessage>> = chatViewModel.replyMessages
val isForwardMode: StateFlow<Boolean> = chatViewModel.isForwardMode
val pendingDeleteIds: StateFlow<Set<String>> = chatViewModel.pendingDeleteIds
val pinnedMessages = chatViewModel.pinnedMessages
val pinnedMessagePreviews = chatViewModel.pinnedMessagePreviews
val currentPinnedIndex = chatViewModel.currentPinnedIndex
val chatOpenMetrics = chatViewModel.chatOpenMetrics
val myPublicKey: String? get() = chatViewModel.myPublicKey
fun setUserKeys(publicKey: String, privateKey: String) = chatViewModel.setUserKeys(publicKey, privateKey)
fun ensureSendContext(
publicKey: String,
title: String = "",
username: String = "",
verified: Int = 0
) = chatViewModel.ensureSendContext(publicKey, title, username, verified)
fun openDialog(
publicKey: String,
title: String = "",
username: String = "",
verified: Int = 0
) = chatViewModel.openDialog(publicKey, title, username, verified)
fun closeDialog() = chatViewModel.closeDialog()
fun setDialogActive(active: Boolean) = chatViewModel.setDialogActive(active)
fun loadMoreMessages() = chatViewModel.loadMoreMessages()
fun updateInputText(text: String) = chatViewModel.updateInputText(text)
fun setReplyMessages(messages: List<ChatMessage>) = chatViewModel.setReplyMessages(messages)
fun setForwardMessages(messages: List<ChatMessage>) = chatViewModel.setForwardMessages(messages)
fun clearReplyMessages() = chatViewModel.clearReplyMessages()
suspend fun ensureMessageLoaded(messageId: String): Boolean = chatViewModel.ensureMessageLoaded(messageId)
fun pinMessage(messageId: String) = chatViewModel.pinMessage(messageId)
fun unpinMessage(messageId: String) = chatViewModel.unpinMessage(messageId)
suspend fun isMessagePinned(messageId: String): Boolean = chatViewModel.isMessagePinned(messageId)
fun navigateToNextPinned(): String? = chatViewModel.navigateToNextPinned()
fun unpinAllMessages() = chatViewModel.unpinAllMessages()
fun deleteMessage(messageId: String) = chatViewModel.deleteMessage(messageId)
suspend fun resolveUserForProfile(publicKey: String): SearchUser? =
chatViewModel.resolveUserForProfile(publicKey)
suspend fun resolveUserByUsername(username: String, timeoutMs: Long = 3000): SearchUser? =
chatViewModel.resolveUserByUsername(username, timeoutMs)
fun retryMessage(message: ChatMessage) {
deleteMessage(message.id)
updateInputText(message.text)
chatViewModel.launchInViewModel {
delay(100)
sendMessage()
}
}
fun sendMessage() {
val hasPayload = inputText.value.trim().isNotEmpty() || replyMessages.value.isNotEmpty()
if (!hasPayload) return
chatViewModel.trySendTextMessage(allowPendingRecovery = true)
}
fun sendForwardDirectly(
targetPublicKey: String,
forwardedMessages: List<ForwardManager.ForwardMessage>
) = chatViewModel.sendForwardDirectly(targetPublicKey, forwardedMessages)
fun markVisibleMessagesAsRead() = chatViewModel.markVisibleMessagesAsRead()
fun subscribeToOnlineStatus() = chatViewModel.subscribeToOnlineStatus()
fun markFirstListLayoutReady() = chatViewModel.markFirstListLayoutReady()
fun addChatOpenTraceEvent(event: String, details: String = "") =
chatViewModel.addChatOpenTraceEvent(event, details)
}
class VoiceRecordingViewModel internal constructor(
private val chatViewModel: ChatViewModel
) {
fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) {
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
if (!chatViewModel.tryAcquireSendSlot()) {
return
}
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
chatViewModel.releaseSendSlot()
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val attachmentId = "voice_$timestamp"
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
MessageAttachment(
id = attachmentId,
type = AttachmentType.VOICE,
preview = voicePayload.preview,
blob = voicePayload.normalizedVoiceHex
)
)
)
)
chatViewModel.clearInputText()
chatViewModel.launchOnIo {
try {
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = "",
recipient = sendContext.recipient,
privateKey = sendContext.privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
val isSavedMessages = (sendContext.sender == sendContext.recipient)
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
payload = voicePayload.normalizedVoiceHex,
attachmentPassword = encryptionContext.attachmentPassword,
attachmentId = attachmentId,
isSavedMessages = isSavedMessages
)
)
val voiceAttachment =
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.VOICE,
preview = voicePayload.preview,
transportTag = uploadResult.transportTag,
transportServer = uploadResult.transportServer
)
chatViewModel.sendMediaMessage(
SendMediaMessageCommand(
fromPublicKey = sendContext.sender,
toPublicKey = sendContext.recipient,
encryptedContent = encryptionContext.encryptedContent,
encryptedKey = encryptionContext.encryptedKey,
aesChachaKey = encryptionContext.aesChachaKey,
privateKeyHash = privateKeyHash,
messageId = messageId,
timestamp = timestamp,
mediaAttachments = listOf(voiceAttachment),
isSavedMessages = isSavedMessages
)
)
runCatching {
AttachmentFileManager.saveAttachment(
context = chatViewModel.appContext(),
blob = voicePayload.normalizedVoiceHex,
attachmentId = attachmentId,
publicKey = sendContext.sender,
privateKey = sendContext.privateKey
)
}
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VOICE.value)
put("preview", voicePayload.preview)
put("blob", "")
put("transportTag", uploadResult.transportTag)
put("transportServer", uploadResult.transportServer)
}
)
}
.toString()
val storedEncryptedKey =
if (encryptionContext.isGroup) {
chatViewModel.buildStoredGroupEncryptedKey(
encryptionContext.attachmentPassword,
sendContext.privateKey
)
} else {
encryptionContext.encryptedKey
}
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = "",
encryptedContent = encryptionContext.encryptedContent,
encryptedKey = storedEncryptedKey,
timestamp = timestamp,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
withContext(Dispatchers.Main) {
if (isSavedMessages) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
}
chatViewModel.saveOutgoingDialog(
lastMessage = "Voice message",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} catch (_: Exception) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
chatViewModel.saveOutgoingDialog(
lastMessage = "Voice message",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} finally {
chatViewModel.releaseSendSlot()
}
}
}
}
class AttachmentsViewModel internal constructor(
private val chatViewModel: ChatViewModel
) {
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) =
chatViewModel.cancelOutgoingImageUpload(messageId, attachmentId)
fun sendImageFromUri(imageUri: Uri, caption: String = "") {
val sendContext = chatViewModel.resolveOutgoingSendContext()
if (sendContext == null) {
chatViewModel.addProtocolLog("❌ IMG send aborted: missing keys or dialog")
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val text = caption.trim()
val attachmentId = "img_$timestamp"
val context = chatViewModel.appContext()
chatViewModel.logPhotoEvent(
messageId,
"start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${chatViewModel.shortPhotoLogId(attachmentId)}"
)
val (imageWidth, imageHeight) = MediaUtils.getImageDimensions(context, imageUri)
chatViewModel.logPhotoEvent(messageId, "dimensions: ${imageWidth}x$imageHeight")
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.IMAGE,
preview = "",
width = imageWidth,
height = imageHeight,
localUri = imageUri.toString()
)
)
)
)
chatViewModel.clearInputText()
chatViewModel.logPhotoEvent(messageId, "optimistic UI added")
val uploadJob =
chatViewModel.launchBackgroundUpload imageUpload@{
try {
chatViewModel.logPhotoEvent(messageId, "persist optimistic message in DB")
val optimisticAttachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", "")
put("blob", "")
put("width", imageWidth)
put("height", imageHeight)
put("localUri", imageUri.toString())
}
)
}
.toString()
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = text,
encryptedContent = "",
encryptedKey = "",
timestamp = timestamp,
delivered = 0,
attachmentsJson = optimisticAttachmentsJson,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
chatViewModel.saveOutgoingDialog(
lastMessage = if (text.isNotEmpty()) text else "photo",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
chatViewModel.logPhotoEvent(messageId, "optimistic dialog updated")
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
chatViewModel.logPhotoEvent(messageId, "optimistic DB save skipped (non-fatal)")
}
try {
val convertStartedAt = System.currentTimeMillis()
val (width, height) = MediaUtils.getImageDimensions(context, imageUri)
val imageBase64 = MediaUtils.uriToBase64Image(context, imageUri)
if (imageBase64 == null) {
chatViewModel.logPhotoEvent(messageId, "base64 conversion returned null")
if (!chatViewModel.isViewModelCleared()) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
}
return@imageUpload
}
chatViewModel.logPhotoEvent(
messageId,
"base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms"
)
val blurhash = MediaUtils.generateBlurhash(context, imageUri)
chatViewModel.logPhotoEvent(messageId, "blurhash ready: len=${blurhash.length}")
if (!chatViewModel.isViewModelCleared()) {
withContext(Dispatchers.Main) {
chatViewModel.updateOptimisticImageMessage(
messageId = messageId,
base64 = imageBase64,
blurhash = blurhash,
width = width,
height = height
)
}
chatViewModel.logPhotoEvent(messageId, "optimistic payload updated in UI")
}
chatViewModel.sendImageMessageInternal(
messageId = messageId,
imageBase64 = imageBase64,
blurhash = blurhash,
caption = text,
width = width,
height = height,
timestamp = timestamp,
recipient = sendContext.recipient,
sender = sendContext.sender,
privateKey = sendContext.privateKey
)
chatViewModel.logPhotoEvent(messageId, "pipeline completed")
} catch (e: CancellationException) {
chatViewModel.logPhotoEvent(messageId, "pipeline cancelled by user")
throw e
} catch (e: Exception) {
chatViewModel.logPhotoErrorEvent(messageId, "prepare+convert", e)
if (!chatViewModel.isViewModelCleared()) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
}
}
}
chatViewModel.registerOutgoingImageUploadJob(messageId, uploadJob)
}
fun sendImageMessage(
imageBase64: String,
blurhash: String,
caption: String = "",
width: Int = 0,
height: Int = 0
) {
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
if (!chatViewModel.tryAcquireSendSlot()) {
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val text = caption.trim()
val attachmentId = "img_$timestamp"
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
MessageAttachment(
id = attachmentId,
type = AttachmentType.IMAGE,
preview = blurhash,
blob = imageBase64,
width = width,
height = height
)
)
)
)
chatViewModel.clearInputText()
chatViewModel.launchBackgroundUpload prepareGroup@{
try {
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = text,
recipient = sendContext.recipient,
privateKey = sendContext.privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
val isSavedMessages = sendContext.sender == sendContext.recipient
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
payload = imageBase64,
attachmentPassword = encryptionContext.attachmentPassword,
attachmentId = attachmentId,
isSavedMessages = isSavedMessages
)
)
val imageAttachment =
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.IMAGE,
preview = blurhash,
width = width,
height = height,
transportTag = uploadResult.transportTag,
transportServer = uploadResult.transportServer
)
chatViewModel.sendMediaMessage(
SendMediaMessageCommand(
fromPublicKey = sendContext.sender,
toPublicKey = sendContext.recipient,
encryptedContent = encryptionContext.encryptedContent,
encryptedKey = encryptionContext.encryptedKey,
aesChachaKey = encryptionContext.aesChachaKey,
privateKeyHash = privateKeyHash,
messageId = messageId,
timestamp = timestamp,
mediaAttachments = listOf(imageAttachment),
isSavedMessages = isSavedMessages
)
)
AttachmentFileManager.saveAttachment(
context = chatViewModel.appContext(),
blob = imageBase64,
attachmentId = attachmentId,
publicKey = sendContext.sender,
privateKey = sendContext.privateKey
)
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.IMAGE.value)
put("preview", blurhash)
put("blob", "")
put("width", width)
put("height", height)
put("transportTag", uploadResult.transportTag)
put("transportServer", uploadResult.transportServer)
}
)
}
.toString()
val storedEncryptedKey =
if (encryptionContext.isGroup) {
chatViewModel.buildStoredGroupEncryptedKey(
encryptionContext.attachmentPassword,
sendContext.privateKey
)
} else {
encryptionContext.encryptedKey
}
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = text,
encryptedContent = encryptionContext.encryptedContent,
encryptedKey = storedEncryptedKey,
timestamp = timestamp,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
chatViewModel.saveOutgoingDialog(
lastMessage = if (text.isNotEmpty()) text else "photo",
timestamp = timestamp,
accountPublicKey = sendContext.sender,
accountPrivateKey = sendContext.privateKey,
opponentPublicKey = sendContext.recipient
)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
} finally {
chatViewModel.releaseSendSlot()
}
}
}
fun sendImageGroupFromUris(imageUris: List<Uri>, caption: String = "") {
chatViewModel.attachmentsFeatureCoordinator.sendImageGroupFromUris(
imageUris = imageUris,
caption = caption
)
}
fun sendImageGroup(images: List<ChatViewModel.ImageData>, caption: String = "") {
chatViewModel.attachmentsFeatureCoordinator.sendImageGroup(
images = images,
caption = caption
)
}
fun sendFileMessage(
fileBase64: String,
fileName: String,
fileSize: Long,
caption: String = ""
) {
chatViewModel.attachmentsFeatureCoordinator.sendFileMessage(
fileBase64 = fileBase64,
fileName = fileName,
fileSize = fileSize,
caption = caption
)
}
fun sendVideoCircleFromUri(videoUri: Uri) {
chatViewModel.attachmentsFeatureCoordinator.sendVideoCircleFromUri(videoUri)
}
fun sendAvatarMessage() {
chatViewModel.attachmentsFeatureCoordinator.sendAvatarMessage()
}
}
class TypingViewModel internal constructor(
private val chatViewModel: ChatViewModel
) {
val opponentTyping: StateFlow<Boolean> = chatViewModel.opponentTyping
val typingDisplayName: StateFlow<String> = chatViewModel.typingDisplayName
val typingDisplayPublicKey: StateFlow<String> = chatViewModel.typingDisplayPublicKey
val opponentOnline: StateFlow<Boolean> = chatViewModel.opponentOnline
fun sendTypingIndicator() {
val now = System.currentTimeMillis()
val context = chatViewModel.resolveTypingSendContext() ?: return
val decision =
chatViewModel.decideTypingSend(
SendTypingIndicatorCommand(
nowMs = now,
lastSentMs = chatViewModel.lastTypingSentTimeMs(),
throttleMs = chatViewModel.typingThrottleMs(),
opponentPublicKey = context.opponent,
senderPublicKey = context.sender,
isGroupDialog = context.isGroupDialog,
isOpponentOnline = context.isOpponentOnline
)
)
if (!decision.shouldSend) return
chatViewModel.setLastTypingSentTimeMs(decision.nextLastSentMs)
chatViewModel.sendTypingPacket(
privateKey = context.privateKey,
sender = context.sender,
opponent = context.opponent
)
}
}

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