Compare commits

34 Commits

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

593
Architecture.md Normal file
View File

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

View File

@@ -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.5.0"
val rosettaVersionCode = 52 // Increment on each release
val rosettaVersionName = "1.5.5"
val rosettaVersionCode = 57 // 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")

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" />
@@ -26,10 +27,8 @@
<application
android:name=".RosettaApplication"
android:allowBackup="true"
android:allowBackup="false"
android:enableOnBackInvokedCallback="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -47,10 +46,7 @@
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -63,8 +59,80 @@
<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

@@ -16,14 +16,19 @@ 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"
}
@@ -119,7 +124,7 @@ class IncomingCallActivity : ComponentActivity() {
}
val avatarRepository = remember {
val accountKey = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
val accountKey = accountManager.getLastLoggedPublicKey().orEmpty()
if (accountKey.isNotBlank()) {
val db = RosettaDatabase.getDatabase(applicationContext)
AvatarRepository(

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

@@ -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

@@ -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

@@ -1,6 +1,7 @@
package com.rosetta.messenger.data
import android.content.Context
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.database.*
@@ -8,8 +9,11 @@ import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.json.JSONArray
@@ -30,7 +34,6 @@ data class Message(
val replyToMessageId: String? = null
)
/** UI модель диалога */
data class Dialog(
val opponentKey: String,
val opponentTitle: String,
@@ -44,7 +47,11 @@ data class Dialog(
)
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */
class MessageRepository private constructor(private val context: Context) {
@Singleton
class MessageRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val protocolClient: ProtocolClient
) {
private val database = RosettaDatabase.getDatabase(context)
private val messageDao = database.messageDao()
@@ -97,8 +104,6 @@ class MessageRepository private constructor(private val context: Context) {
private var currentPrivateKey: String? = null
companion object {
@Volatile private var INSTANCE: MessageRepository? = null
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
@@ -136,16 +141,6 @@ class MessageRepository private constructor(private val context: Context) {
/** Очистка кэша (вызывается при logout) */
fun clearProcessedCache() = processedMessageIds.clear()
fun getInstance(context: Context): MessageRepository {
return INSTANCE
?: synchronized(this) {
INSTANCE
?: MessageRepository(context.applicationContext).also {
INSTANCE = it
}
}
}
/**
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
@@ -245,6 +240,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
?: SYSTEM_SAFE_USERNAME,
lastMessage = encryptedPlainMessage,
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
hasContent = 1,
lastMessageFromMe = 0,
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
lastMessageRead = 0,
lastMessageAttachments = "[]",
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
@@ -265,7 +267,7 @@ class MessageRepository private constructor(private val context: Context) {
try {
CryptoManager.encryptWithPassword(messageText, privateKey)
} catch (e: Exception) {
android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
return null
}
@@ -324,6 +326,13 @@ class MessageRepository private constructor(private val context: Context) {
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
?: SYSTEM_UPDATES_USERNAME,
lastMessage = encryptedPlainMessage,
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
hasContent = 1,
lastMessageFromMe = 0,
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
lastMessageRead = 0,
lastMessageAttachments = "[]",
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
@@ -343,12 +352,12 @@ class MessageRepository private constructor(private val context: Context) {
suspend fun checkAndSendVersionUpdateMessage() {
val account = currentAccount
if (account == null) {
android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
return
}
val privateKey = currentPrivateKey
if (privateKey == null) {
android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
return
}
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
@@ -356,7 +365,7 @@ class MessageRepository private constructor(private val context: Context) {
val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}"
android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
if (lastNoticeKey != currentKey) {
// Delete the previous message for this version (if any)
@@ -367,15 +376,15 @@ class MessageRepository private constructor(private val context: Context) {
}
val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion))
android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
if (messageId != null) {
prefs.edit()
.putString("lastNoticeKey", currentKey)
.putString("lastNoticeMessageId_$currentVersion", messageId)
.apply()
android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
} else {
android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
}
}
}
@@ -599,6 +608,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
dialogDao.updateDialogFromMessages(account, toPublicKey)
// Notify listeners (ChatViewModel) that a new message was persisted
// so the chat UI reloads from DB. Without this, messages produced by
// non-input flows (e.g. CallManager's missed-call attachment) only
// appear after the user re-enters the chat.
_newMessageEvents.tryEmit(dialogKey)
// 📁 Для saved messages - гарантируем создание/обновление dialog
if (isSavedMessages) {
val existing = dialogDao.getDialog(account, account)
@@ -674,7 +689,7 @@ class MessageRepository private constructor(private val context: Context) {
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
ProtocolManager.sendMessageWithRetry(packet)
protocolClient.sendMessageWithRetry(packet)
// 📝 LOG: Успешная отправка
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
@@ -814,11 +829,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 +853,7 @@ class MessageRepository private constructor(private val context: Context) {
}
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
ProtocolManager.addLog(
protocolClient.addLog(
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
)
}
@@ -849,8 +872,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 {
@@ -858,7 +882,7 @@ class MessageRepository private constructor(private val context: Context) {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
} catch (e: Exception) {
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
""
}
}
@@ -998,8 +1022,10 @@ class MessageRepository private constructor(private val context: Context) {
} catch (e: Exception) {
// 📝 LOG: Ошибка обработки
MessageLogger.logDecryptionError(messageId, e)
ProtocolManager.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
protocolClient.addLog(
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " +
"group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " +
"aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: "<no-message>"}"
)
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
processedMessageIds.remove(messageId)
@@ -1236,7 +1262,7 @@ class MessageRepository private constructor(private val context: Context) {
this.toPublicKey = toPublicKey
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
}
ProtocolManager.send(packet)
protocolClient.send(packet)
}
}
@@ -1301,7 +1327,7 @@ class MessageRepository private constructor(private val context: Context) {
syncedOpponentsWithWrongStatus.forEach { opponentKey ->
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
}
android.util.Log.i(
if (BuildConfig.DEBUG) android.util.Log.i(
"MessageRepository",
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
)
@@ -1310,14 +1336,14 @@ class MessageRepository private constructor(private val context: Context) {
// Mark expired messages as ERROR (older than 80 seconds)
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (expiredCount > 0) {
android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
}
// Get remaining WAITING messages (younger than 80s)
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
if (waitingMessages.isEmpty()) return
android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
if (BuildConfig.DEBUG) android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
for (entity in waitingMessages) {
// Skip saved messages (should not happen, but guard)
@@ -1341,7 +1367,7 @@ class MessageRepository private constructor(private val context: Context) {
privateKey
)
} catch (e: Exception) {
android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
""
}
}
@@ -1367,10 +1393,10 @@ class MessageRepository private constructor(private val context: Context) {
}
// iOS parity: use retry mechanism for reconnect-resent messages too
ProtocolManager.sendMessageWithRetry(packet)
android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
protocolClient.sendMessageWithRetry(packet)
if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
} catch (e: Exception) {
android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
if (BuildConfig.DEBUG) android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
// Mark as ERROR if retry fails
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
val dialogKey = getDialogKey(entity.toPublicKey)
@@ -1471,7 +1497,7 @@ class MessageRepository private constructor(private val context: Context) {
}
/**
* Public API for ProtocolManager to update delivery status (e.g., marking as ERROR on retry timeout).
* Runtime API to update delivery status (e.g., marking as ERROR on retry timeout).
*/
suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
val account = currentAccount ?: return
@@ -1632,7 +1658,7 @@ class MessageRepository private constructor(private val context: Context) {
this.privateKey = privateKeyHash
this.search = dialog.opponentKey
}
ProtocolManager.send(packet)
protocolClient.send(packet)
// Small delay to avoid flooding the server with search requests
kotlinx.coroutines.delay(50)
}
@@ -1669,7 +1695,7 @@ class MessageRepository private constructor(private val context: Context) {
this.privateKey = privateKeyHash
this.search = publicKey
}
ProtocolManager.send(packet)
protocolClient.send(packet)
}
}
@@ -1755,6 +1781,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)
}
@@ -1999,6 +2026,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 {
@@ -2009,6 +2037,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)
}
@@ -2020,6 +2049,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)
}
@@ -2031,6 +2061,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)
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,8 +37,11 @@ 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,
@@ -469,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

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)
}
@@ -1021,7 +1053,6 @@ object CallManager {
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
val peerPublicKey = snapshot.peerPublicKey.trim()
val context = appContext ?: return
if (peerPublicKey.isBlank()) return
val durationSec = snapshot.durationSec.coerceAtLeast(0)
@@ -1033,22 +1064,30 @@ 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 {
if (role == CallRole.CALLER) {
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)
MessageRepository.getInstance(context).sendMessage(
repository.sendMessage(
toPublicKey = peerPublicKey,
text = "",
attachments = listOf(callAttachment)
)
} else {
// CALLEE: save call event locally (incoming from peer)
// CALLER will send their own message which may arrive later
MessageRepository.getInstance(context).saveIncomingCallEvent(
fromPublicKey = peerPublicKey,
durationSec = durationSec
)
// 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 emit call attachment", error)
@@ -1061,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,
@@ -1082,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) }
@@ -1286,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(
@@ -1589,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,16 @@ 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 BACKGROUND_HEARTBEAT_INTERVAL_MS = 30_000L
private const val MAX_RECONNECT_ATTEMPTS = 10
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 +189,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 +317,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,13 +529,15 @@ 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
// Heartbeat
private var heartbeatJob: Job? = null
@Volatile private var heartbeatPeriodMs: Long = 0L
@Volatile private var isAppInForeground: Boolean = true
private var serverHeartbeatIntervalSec: Int = DEFAULT_HEARTBEAT_INTERVAL_SECONDS
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@Volatile private var heartbeatOkSuppressedCount: Int = 0
@@ -271,69 +572,96 @@ 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)
}
}
}
/**
* Start heartbeat to keep connection alive
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
* Start adaptive heartbeat to keep connection alive.
* Foreground: serverInterval / 2 (like desktop).
* Background: 30s to save battery.
*/
private fun startHeartbeat(intervalSeconds: Int) {
val normalizedServerIntervalSec =
serverHeartbeatIntervalSec =
if (intervalSeconds > 0) intervalSeconds else DEFAULT_HEARTBEAT_INTERVAL_SECONDS
// Отправляем чаще - каждые 1/3 интервала, но с нижним лимитом чтобы исключить tight-loop.
val intervalMs =
((normalizedServerIntervalSec * 1000L) / 3).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
if (heartbeatJob?.isActive == true && heartbeatPeriodMs == intervalMs) {
return
}
heartbeatJob?.cancel()
heartbeatPeriodMs = intervalMs
lastHeartbeatOkLogAtMs = 0L
heartbeatOkSuppressedCount = 0
log(
"💓 HEARTBEAT START: server=${intervalSeconds}s(normalized=${normalizedServerIntervalSec}s), " +
"sending=${intervalMs / 1000}s, state=${_state.value}"
)
heartbeatJob = scope.launch {
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
sendHeartbeat()
while (isActive) {
val intervalMs = if (isAppInForeground) {
((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
} else {
BACKGROUND_HEARTBEAT_INTERVAL_MS
}
heartbeatPeriodMs = intervalMs
delay(intervalMs)
sendHeartbeat()
}
}
val fgMs = ((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
log(
"💓 HEARTBEAT START: server=${intervalSeconds}s, " +
"foreground=${fgMs / 1000}s, background=${BACKGROUND_HEARTBEAT_INTERVAL_MS / 1000}s, " +
"appForeground=$isAppInForeground, state=${_state.value}"
)
}
/**
* Notify protocol about app foreground/background state.
* Adjusts heartbeat interval to save battery in background.
*/
fun setAppInForeground(foreground: Boolean) {
if (isAppInForeground == foreground) return
isAppInForeground = foreground
log("💓 App foreground=$foreground, heartbeat will adjust on next tick")
}
/**
@@ -366,7 +694,7 @@ class Protocol(
// Триггерим reconnect если heartbeat не прошёл
if (!isManuallyClosed) {
log("🔄 TRIGGERING RECONNECT due to failed heartbeat")
handleDisconnect()
handleDisconnect("heartbeat_failed")
}
}
} else {
@@ -384,8 +712,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 +736,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 +768,11 @@ class Protocol(
// Устанавливаем флаг ПЕРЕД любыми операциями
isConnecting = true
connectingSinceMs = now
reconnectAttempts++
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
val generation = rotateConnectionGeneration("connect_attempt_$reconnectAttempts")
// Закрываем старый сокет если есть (как в Архиве)
webSocket?.let { oldSocket ->
@@ -442,6 +788,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 +798,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 +833,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 +882,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 +942,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 +1074,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
@@ -751,18 +1116,22 @@ class Protocol(
}
}
// Автоматический reconnect с защитой от бесконечных попыток
// Автоматический reconnect с лимитом попыток
if (!isManuallyClosed) {
// КРИТИЧНО: отменяем предыдущий 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")
if (reconnectAttempts > 20) {
log("⚠️ WARNING: Too many reconnect attempts ($reconnectAttempts), may be stuck in loop")
val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1)
// После 10 попыток — останавливаемся, ждём NetworkCallback или foreground resume
if (nextAttemptNumber > MAX_RECONNECT_ATTEMPTS) {
log("🛑 RECONNECT STOPPED: $nextAttemptNumber attempts exhausted, waiting for network change or foreground resume")
onNetworkUnavailable?.invoke()
return
}
val exponent = (nextAttemptNumber - 1).coerceIn(0, 4)
val delayMs = minOf(1000L * (1L shl exponent), 30000L)
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber/$MAX_RECONNECT_ATTEMPTS, delay=${delayMs}ms")
reconnectJob = scope.launch {
delay(delayMs)
@@ -782,33 +1151,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 +1215,46 @@ 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"
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, attempts=$reconnectAttempts, reason=$reason"
)
// Reset attempt counter — network changed or user returned to app
reconnectAttempts = 0
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 +1263,7 @@ class Protocol(
reconnectAttempts = 0
reconnectJob?.cancel()
reconnectJob = null
connect()
connectLocked()
}
/**
@@ -867,7 +1286,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,159 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.ProtocolGateway
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ProtocolRuntime @Inject constructor(
private val runtimeComposition: RuntimeComposition,
private val messageRepository: MessageRepository,
private val groupRepository: GroupRepository,
private val accountManager: AccountManager
) : ProtocolRuntimePort, ProtocolGateway {
init {
bindDependencies()
}
private val connectionControlApi by lazy { runtimeComposition.connectionControlApi() }
private val directoryApi by lazy { runtimeComposition.directoryApi() }
private val packetIoApi by lazy { runtimeComposition.packetIoApi() }
override val state: StateFlow<ProtocolState> get() = runtimeComposition.state
override val syncInProgress: StateFlow<Boolean> get() = runtimeComposition.syncInProgress
override val pendingDeviceVerification: StateFlow<DeviceEntry?> get() =
runtimeComposition.pendingDeviceVerification
override val typingUsers: StateFlow<Set<String>> get() = runtimeComposition.typingUsers
override val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> get() =
runtimeComposition.typingUsersByDialogSnapshot
override val debugLogs: StateFlow<List<String>> get() = runtimeComposition.debugLogs
override val ownProfileUpdated: StateFlow<Long> get() = runtimeComposition.ownProfileUpdated
override fun initialize(context: Context) {
bindDependencies()
connectionControlApi.initialize(context)
}
override fun initializeAccount(publicKey: String, privateKey: String) =
connectionControlApi.initializeAccount(publicKey, privateKey)
override fun connect() = connectionControlApi.connect()
override fun authenticate(publicKey: String, privateHash: String) =
connectionControlApi.authenticate(publicKey, privateHash)
override fun reconnectNowIfNeeded(reason: String) =
connectionControlApi.reconnectNowIfNeeded(reason)
override fun disconnect() = connectionControlApi.disconnect()
override fun setAppInForeground(foreground: Boolean) =
connectionControlApi.setAppInForeground(foreground)
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()
override fun subscribePushTokenIfAvailable(forceToken: String?) =
connectionControlApi.subscribePushToken(forceToken)
override fun addLog(message: String) = runtimeComposition.addLog(message)
override fun enableUILogs(enabled: Boolean) = runtimeComposition.enableUILogs(enabled)
override fun clearLogs() = runtimeComposition.clearLogs()
override fun resolveOutgoingRetry(messageId: String) =
packetIoApi.resolveOutgoingRetry(messageId)
override fun getCachedUserByUsername(username: String): SearchUser? =
directoryApi.getCachedUserByUsername(username)
override fun getCachedUserName(publicKey: String): String? =
directoryApi.getCachedUserName(publicKey)
override fun getCachedUserInfo(publicKey: String): SearchUser? =
directoryApi.getCachedUserInfo(publicKey)
override fun acceptDevice(deviceId: String) = directoryApi.acceptDevice(deviceId)
override fun declineDevice(deviceId: String) = directoryApi.declineDevice(deviceId)
override fun send(packet: Packet) = packetIoApi.send(packet)
override fun sendPacket(packet: Packet) = packetIoApi.sendPacket(packet)
override fun sendMessageWithRetry(packet: PacketMessage) =
packetIoApi.sendMessageWithRetry(packet)
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
packetIoApi.waitPacket(packetId, callback)
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
packetIoApi.unwaitPacket(packetId, callback)
override fun packetFlow(packetId: Int): SharedFlow<Packet> =
packetIoApi.packetFlow(packetId)
override fun notifyOwnProfileUpdated() = directoryApi.notifyOwnProfileUpdated()
override fun restoreAuthFromStoredCredentials(
preferredPublicKey: String?,
reason: String
): Boolean = connectionControlApi.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
directoryApi.resolveUserName(publicKey, timeoutMs)
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
directoryApi.resolveUserInfo(publicKey, timeoutMs)
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
directoryApi.searchUsers(query, timeoutMs)
override fun requestIceServers() = packetIoApi.requestIceServers()
override fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit =
packetIoApi.waitCallSignal(callback)
override fun unwaitCallSignal(callback: (Packet) -> Unit) =
packetIoApi.unwaitCallSignal(callback)
override fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit =
packetIoApi.waitWebRtcSignal(callback)
override fun unwaitWebRtcSignal(callback: (Packet) -> Unit) =
packetIoApi.unwaitWebRtcSignal(callback)
override fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit =
packetIoApi.waitIceServers(callback)
override fun unwaitIceServers(callback: (Packet) -> Unit) =
packetIoApi.unwaitIceServers(callback)
override fun sendCallSignal(
signalType: SignalType,
src: String,
dst: String,
sharedPublic: String,
callId: String,
joinToken: String
) = packetIoApi.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
override fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) =
packetIoApi.sendWebRtcSignal(signalType, sdpOrCandidate)
private fun bindDependencies() {
runtimeComposition.bindDependencies(
messageRepository = messageRepository,
groupRepository = groupRepository,
accountManager = accountManager
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,11 +10,14 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
import com.rosetta.messenger.utils.RosettaDev1Log
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import java.io.File
import java.io.IOException
import java.net.SocketTimeoutException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
@@ -37,6 +40,7 @@ data class TransportState(
object TransportManager {
private const val MAX_RETRIES = 3
private const val INITIAL_BACKOFF_MS = 1000L
private const val UPLOAD_ATTEMPT_TIMEOUT_MS = 45_000L
private var transportServer: String? = null
private var appContext: Context? = null
@@ -68,6 +72,7 @@ object TransportManager {
fun setTransportServer(server: String) {
val normalized = server.trim().trimEnd('/')
transportServer = normalized.ifBlank { null }
RosettaDev1Log.d("net/transport-server set=${transportServer.orEmpty()}")
}
/**
@@ -99,15 +104,37 @@ object TransportManager {
/**
* Retry с exponential backoff: 1с, 2с, 4с
*/
private suspend fun <T> withRetry(block: suspend () -> T): T {
private suspend fun <T> withRetry(
operation: String = "transport",
id: String = "-",
block: suspend () -> T
): T {
var lastException: Exception? = null
repeat(MAX_RETRIES) { attempt ->
try {
return block()
} catch (e: CancellationException) {
RosettaDev1Log.w(
"net/$operation cancelled id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES",
e
)
throw e
} catch (e: Exception) {
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
val shouldRetry = attempt < MAX_RETRIES - 1
if (shouldRetry) {
val backoffMs = INITIAL_BACKOFF_MS shl attempt
RosettaDev1Log.w(
"net/$operation retry id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
"backoff=${backoffMs}ms reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}"
)
} else {
RosettaDev1Log.e(
"net/$operation failed id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
"reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
e
)
}
if (attempt < MAX_RETRIES - 1) {
delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s
}
@@ -121,7 +148,8 @@ object TransportManager {
*/
fun requestTransportServer() {
val packet = PacketRequestTransport()
ProtocolManager.sendPacket(packet)
RosettaDev1Log.d("net/transport-server request packet=0x0F")
ProtocolRuntimeAccess.get().sendPacket(packet)
}
/**
@@ -172,6 +200,37 @@ object TransportManager {
})
}
private suspend fun awaitUploadResponse(id: String, request: Request): Response =
suspendCancellableCoroutine { cont ->
val call = client.newCall(request)
activeUploadCalls[id] = call
cont.invokeOnCancellation {
activeUploadCalls.remove(id, call)
call.cancel()
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activeUploadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Upload cancelled"))
} else {
cont.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
activeUploadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response)
}
})
}
private fun parseContentRangeTotal(value: String?): Long? {
if (value.isNullOrBlank()) return null
// Example: "bytes 100-999/12345"
@@ -188,13 +247,16 @@ 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")
RosettaDev1Log.i(
"net/upload start id=${id.take(12)} server=$server bytes=${content.length}"
)
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
// Добавляем в список загрузок
_uploading.value = _uploading.value + TransportState(id, 0)
try {
withRetry {
withRetry(operation = "upload", id = id) {
val contentBytes = content.toByteArray(Charsets.UTF_8)
val totalSize = contentBytes.size.toLong()
@@ -206,6 +268,7 @@ object TransportManager {
val source = okio.Buffer().write(contentBytes)
var uploaded = 0L
val bufferSize = 8 * 1024L
var lastProgressUpdateMs = 0L
while (true) {
val read = source.read(sink.buffer, bufferSize)
@@ -214,9 +277,14 @@ object TransportManager {
uploaded += read
sink.flush()
val progress = ((uploaded * 100) / totalSize).toInt()
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = progress) else it
val now = System.currentTimeMillis()
val isLast = uploaded >= totalSize
if (isLast || now - lastProgressUpdateMs >= 200) {
lastProgressUpdateMs = now
val progress = ((uploaded * 100) / totalSize).toInt()
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = progress) else it
}
}
}
}
@@ -231,61 +299,61 @@ object TransportManager {
.url("$server/u")
.post(requestBody)
.build()
val response = suspendCancellableCoroutine<Response> { cont ->
val call = client.newCall(request)
activeUploadCalls[id] = call
cont.invokeOnCancellation {
activeUploadCalls.remove(id, call)
call.cancel()
val response =
try {
withTimeout(UPLOAD_ATTEMPT_TIMEOUT_MS) {
awaitUploadResponse(id, request)
}
} catch (timeout: CancellationException) {
if (timeout is kotlinx.coroutines.TimeoutCancellationException) {
activeUploadCalls.remove(id)?.cancel()
RosettaDev1Log.w(
"net/upload attempt-timeout id=${id.take(12)} timeoutMs=$UPLOAD_ATTEMPT_TIMEOUT_MS"
)
throw SocketTimeoutException(
"Upload timeout after ${UPLOAD_ATTEMPT_TIMEOUT_MS}ms"
)
}
throw timeout
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
activeUploadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Upload cancelled"))
} else {
cont.resumeWithException(e)
}
val tag =
response.use { uploadResponse ->
if (!uploadResponse.isSuccessful) {
val errorBody = uploadResponse.body?.string()?.take(240).orEmpty()
RosettaDev1Log.e(
"net/upload http-fail id=${id.take(12)} code=${uploadResponse.code} body=$errorBody"
)
throw IOException("Upload failed: ${uploadResponse.code}")
}
override fun onResponse(call: Call, response: Response) {
activeUploadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response)
}
})
}
if (!response.isSuccessful) {
throw IOException("Upload failed: ${response.code}")
}
val responseBody = response.body?.string()
?: throw IOException("Empty response")
val tag = org.json.JSONObject(responseBody).getString("t")
val responseBody = uploadResponse.body?.string()
?: throw IOException("Empty response")
org.json.JSONObject(responseBody).getString("t")
}
// Обновляем прогресс до 100%
_uploading.value = _uploading.value.map {
if (it.id == id) it.copy(progress = 100) else it
}
ProtocolManager.addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
ProtocolRuntimeAccess.get().addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
RosettaDev1Log.i("net/upload success id=${id.take(12)} tag=${tag.take(16)}")
tag
}
} catch (e: CancellationException) {
ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}")
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
RosettaDev1Log.w("net/upload cancelled id=${id.take(12)}", e)
throw e
} catch (e: Exception) {
ProtocolManager.addLog(
ProtocolRuntimeAccess.get().addLog(
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
RosettaDev1Log.e(
"net/upload failed id=${id.take(12)} reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
e
)
throw e
} finally {
activeUploadCalls.remove(id)?.cancel()
@@ -309,13 +377,13 @@ 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)
try {
withRetry {
withRetry(operation = "download", id = id) {
val request = Request.Builder()
.url("$server/d/$tag")
.get()
@@ -336,7 +404,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 +451,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,14 +525,14 @@ 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"
)
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
try {
withRetry {
withRetry(operation = "download-raw-resume", id = id) {
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
.coerceAtMost(existingBytes)
@@ -541,13 +609,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,56 @@
package com.rosetta.messenger.network.connection
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.DeliveryStatus
import com.rosetta.messenger.network.PacketMessage
import com.rosetta.messenger.utils.RosettaDev1Log
import kotlinx.coroutines.CoroutineScope
class OutgoingMessagePipelineService(
scope: CoroutineScope,
private val getRepository: () -> MessageRepository?,
private val sendPacket: (PacketMessage) -> Unit,
isAuthenticated: () -> Boolean,
addLog: (String) -> Unit
) {
private val retryQueueService =
RetryQueueService(
scope = scope,
sendPacket = sendPacket,
isAuthenticated = isAuthenticated,
addLog = addLog,
markOutgoingAsError = ::markOutgoingAsError
)
fun sendWithRetry(packet: PacketMessage) {
RosettaDev1Log.d(
"net/pipeline sendWithRetry msg=${packet.messageId.take(8)} " +
"to=${packet.toPublicKey.take(12)} from=${packet.fromPublicKey.take(12)}"
)
sendPacket(packet)
retryQueueService.register(packet)
}
fun resolveOutgoingRetry(messageId: String) {
RosettaDev1Log.d("net/pipeline resolveRetry msg=${messageId.take(8)}")
retryQueueService.resolve(messageId)
}
fun clearRetryQueue() {
RosettaDev1Log.d("net/pipeline clearRetryQueue")
retryQueueService.clear()
}
private suspend fun markOutgoingAsError(messageId: String, packet: PacketMessage) {
val repository = getRepository() ?: return
val opponentKey =
if (packet.fromPublicKey == repository.getCurrentAccountKey()) {
packet.toPublicKey
} else {
packet.fromPublicKey
}
val dialogKey = repository.getDialogKey(opponentKey)
RosettaDev1Log.w("net/pipeline markError msg=${messageId.take(8)} dialog=${dialogKey.take(16)}")
repository.updateMessageDeliveryStatus(dialogKey, messageId, DeliveryStatus.ERROR)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import android.graphics.BitmapFactory
import android.os.Build
import android.util.Base64
import android.util.Log
import com.rosetta.messenger.BuildConfig
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import com.google.firebase.messaging.FirebaseMessagingService
@@ -19,12 +20,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 +43,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,16 +128,16 @@ 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) }
}
}
/** Вызывается когда получено push-уведомление */
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
if (BuildConfig.DEBUG) Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
val data = remoteMessage.data
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
@@ -146,9 +154,9 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val hasNotificationContent = notificationTitle.isNotBlank() || notificationBody.isNotBlank()
if (!hasDataContent && !hasNotificationContent) {
Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)")
if (BuildConfig.DEBUG) Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)")
// Still trigger reconnect if WebSocket is disconnected
com.rosetta.messenger.network.ProtocolManager.reconnectNowIfNeeded("silent_push")
protocolGateway.reconnectNowIfNeeded("silent_push")
return
}
@@ -219,14 +227,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
isReadEvent -> {
val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey)
if (keysToClear.isEmpty()) {
Log.d(TAG, "READ push received but no dialog key in payload: $data")
if (BuildConfig.DEBUG) Log.d(TAG, "READ push received but no dialog key in payload: $data")
} else {
keysToClear.forEach { key ->
cancelNotificationForChat(applicationContext, key)
}
val titleHints = collectReadTitleHints(data, keysToClear)
cancelMatchingActiveNotifications(keysToClear, titleHints)
Log.d(
if (BuildConfig.DEBUG) Log.d(
TAG,
"READ push cleared notifications for keys=$keysToClear titles=$titleHints"
)
@@ -310,11 +318,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val now = System.currentTimeMillis()
val lastTs = lastNotifTimestamps[dedupKey]
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms")
if (BuildConfig.DEBUG) Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms")
return // duplicate push — skip
}
lastNotifTimestamps[dedupKey] = now
Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
if (BuildConfig.DEBUG) Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return
@@ -501,7 +509,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
}
private fun pushCallLog(msg: String) {
Log.d(TAG, msg)
if (BuildConfig.DEBUG) Log.d(TAG, msg)
try {
val dir = java.io.File(applicationContext.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
@@ -514,20 +522,20 @@ 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}")
if (BuildConfig.DEBUG) Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
}
}
@@ -560,7 +568,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun areNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationsEnabled.first()
preferencesManager.notificationsEnabled.first()
}
}.getOrDefault(true)
}
@@ -583,7 +591,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?) {
@@ -710,7 +718,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (matchesDeterministicId || matchesDialogKey || matchesHint) {
manager.cancel(sbn.tag, sbn.id)
Log.d(
if (BuildConfig.DEBUG) Log.d(
TAG,
"READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " +
"channel=${notification.channelId} title='$title' " +
@@ -719,14 +727,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
}
}
}.onFailure { error ->
Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
if (BuildConfig.DEBUG) Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
}
}
private fun isAvatarInNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationAvatarEnabled.first()
preferencesManager.notificationAvatarEnabled.first()
}
}.getOrDefault(true)
}
@@ -735,25 +743,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) {

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,
@@ -27,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 = {}
@@ -62,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) {
@@ -169,6 +180,8 @@ fun AuthFlow(
seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme,
isImportMode = isImportMode,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onBack = {
if (isImportMode) {
currentScreen = AuthScreen.IMPORT_SEED
@@ -201,6 +214,8 @@ fun AuthFlow(
SetProfileScreen(
isDarkTheme = isDarkTheme,
account = createdAccount,
protocolGateway = protocolGateway,
accountManager = accountManager,
onComplete = { onAuthComplete(createdAccount) },
onSkip = { onAuthComplete(createdAccount) }
)
@@ -228,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

@@ -33,10 +33,12 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -52,10 +54,10 @@ import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.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 +66,7 @@ import kotlinx.coroutines.launch
@Composable
fun DeviceConfirmScreen(
isDarkTheme: Boolean,
protocolGateway: ProtocolGateway,
onExit: () -> Unit
) {
val view = LocalView.current
@@ -110,10 +113,31 @@ fun DeviceConfirmScreen(
val onExitState by rememberUpdatedState(onExit)
val scope = rememberCoroutineScope()
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
var isResumed by remember(lifecycleOwner) {
mutableStateOf(
lifecycleOwner.lifecycle.currentState.isAtLeast(
androidx.lifecycle.Lifecycle.State.RESUMED
)
)
}
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, _ ->
isResumed = lifecycleOwner.lifecycle.currentState.isAtLeast(
androidx.lifecycle.Lifecycle.State.RESUMED
)
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm))
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
iterations = LottieConstants.IterateForever,
isPlaying = isResumed
)
val localDeviceName = remember {
@@ -131,9 +155,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

@@ -166,6 +166,10 @@ fun SeedPhraseScreen(
delay(2000)
hasCopied = false
}
scope.launch {
delay(30_000)
clipboardManager.setText(AnnotatedString(""))
}
}
) {
Icon(

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,6 +38,8 @@ fun SetPasswordScreen(
seedPhrase: List<String>,
isDarkTheme: Boolean,
isImportMode: Boolean = false,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onBack: () -> Unit,
onAccountCreated: (DecryptedAccount) -> Unit
) {
@@ -46,8 +48,6 @@ fun SetPasswordScreen(
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("") }
@@ -308,8 +308,6 @@ fun SetPasswordScreen(
)
accountManager.saveAccount(account)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
startAuthHandshakeFast(keyPair.publicKey, privateKeyHash)
accountManager.setCurrentAccount(keyPair.publicKey)
val decryptedAccount = DecryptedAccount(
publicKey = keyPair.publicKey,
privateKey = keyPair.privateKey,
@@ -317,6 +315,10 @@ fun SetPasswordScreen(
privateKeyHash = privateKeyHash,
name = truncatedKey
)
sessionCoordinator.bootstrapAuthenticatedSession(
account = decryptedAccount,
reason = "set_password"
)
onAccountCreated(decryptedAccount)
} catch (e: Exception) {
error = "Failed to create account: ${e.message}"

View File

@@ -29,8 +29,8 @@ 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.network.ProtocolManager
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import com.rosetta.messenger.utils.AvatarFileManager
@@ -71,6 +71,8 @@ private fun validateUsername(username: String): String? {
fun SetProfileScreen(
isDarkTheme: Boolean,
account: DecryptedAccount?,
protocolGateway: ProtocolGateway,
accountManager: AccountManager,
onComplete: () -> Unit,
onSkip: () -> Unit
) {
@@ -104,7 +106,7 @@ fun SetProfileScreen(
isCheckingUsername = true
delay(600) // debounce
try {
val results = ProtocolManager.searchUsers(trimmed, 3000)
val results = protocolGateway.searchUsers(trimmed, 3000)
val taken = results.any { it.username.equals(trimmed, ignoreCase = true) }
usernameAvailable = !taken
} catch (_: Exception) {
@@ -402,14 +404,13 @@ fun SetProfileScreen(
try {
// Wait for server connection (up to 8s)
val connected = withTimeoutOrNull(8000) {
while (!ProtocolManager.isAuthenticated()) {
while (!protocolGateway.isAuthenticated()) {
delay(300)
}
true
} ?: false
// Save name and username locally first
val accountManager = AccountManager(context)
if (name.trim().isNotEmpty()) {
accountManager.updateAccountName(account.publicKey, name.trim())
}
@@ -417,7 +418,7 @@ fun SetProfileScreen(
accountManager.updateAccountUsername(account.publicKey, username.trim())
}
// Trigger UI refresh in MainActivity
ProtocolManager.notifyOwnProfileUpdated()
protocolGateway.notifyOwnProfileUpdated()
// Send name and username to server
if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) {
@@ -425,16 +426,16 @@ fun SetProfileScreen(
packet.title = name.trim()
packet.username = username.trim()
packet.privateKey = account.privateKeyHash
ProtocolManager.send(packet)
protocolGateway.send(packet)
delay(1500)
// Повторяем для надёжности
if (ProtocolManager.isAuthenticated()) {
if (protocolGateway.isAuthenticated()) {
val packet2 = PacketUserInfo()
packet2.title = name.trim()
packet2.username = username.trim()
packet2.privateKey = account.privateKeyHash
ProtocolManager.send(packet2)
protocolGateway.send(packet2)
delay(500)
}
}

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()
@@ -259,6 +263,7 @@ fun UnlockScreen(
selectedAccount = selectedAccount,
password = decryptedPassword,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->
@@ -604,6 +609,7 @@ fun UnlockScreen(
selectedAccount = selectedAccount,
password = password,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->

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,713 @@
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 com.rosetta.messenger.utils.RosettaDev1Log
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()) {
RosettaDev1Log.w("voice-send slot busy: request dropped")
return
}
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
RosettaDev1Log.w("voice-send payload invalid: empty normalized voice")
chatViewModel.releaseSendSlot()
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val attachmentId = "voice_$timestamp"
val senderShort = sendContext.sender.take(12)
val recipientShort = sendContext.recipient.take(12)
RosettaDev1Log.i(
"voice-send start msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
"from=$senderShort to=$recipientShort dur=${voicePayload.durationSec}s " +
"waves=${voicePayload.normalizedWaves.size} blobLen=${voicePayload.normalizedVoiceHex.length}"
)
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)
RosettaDev1Log.d(
"voice-send upload begin msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
"saved=$isSavedMessages"
)
val uploadResult =
chatViewModel.encryptAndUploadAttachment(
EncryptAndUploadAttachmentCommand(
payload = voicePayload.normalizedVoiceHex,
attachmentPassword = encryptionContext.attachmentPassword,
attachmentId = attachmentId,
isSavedMessages = isSavedMessages
)
)
RosettaDev1Log.d(
"voice-send upload done msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
"tag=${uploadResult.transportTag.take(16)} server=${uploadResult.transportServer}"
)
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
)
)
RosettaDev1Log.i(
"voice-send packet dispatched msg=${messageId.take(8)} " +
"saved=$isSavedMessages attachments=1"
)
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
)
RosettaDev1Log.i("voice-send done msg=${messageId.take(8)} status=queued")
} catch (e: Exception) {
RosettaDev1Log.e(
"voice-send failed msg=${messageId.take(8)} " +
"from=${sendContext.sender.take(12)} to=${sendContext.recipient.take(12)}",
e
)
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 {
RosettaDev1Log.d("voice-send slot released msg=${messageId.take(8)}")
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
)
}
}

View File

@@ -0,0 +1,204 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
@Composable
fun ChatItem(
chat: Chat,
isDarkTheme: Boolean,
avatarRepository: AvatarRepository? = null,
isMuted: Boolean = false,
onClick: () -> Unit
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
Column {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
publicKey = chat.publicKey,
avatarRepository = avatarRepository,
size = TELEGRAM_DIALOG_AVATAR_SIZE,
isDarkTheme = isDarkTheme,
showOnlineIndicator = true,
isOnline = chat.isOnline,
displayName = chat.name,
enableBlurPrewarm = true
)
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
AppleEmojiText(
text = chat.name,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f),
enableLinks = false
)
if (isMuted) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
painter = TelegramIcons.Mute,
contentDescription = "Muted",
tint = secondaryTextColor,
modifier = Modifier.size(14.dp)
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatTime(chat.lastMessageTime),
fontSize = 13.sp,
color = secondaryTextColor
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
AppleEmojiText(
text = chat.lastMessage,
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f),
enableLinks = false
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (chat.isPinned) {
Icon(
painter = TelegramIcons.Pin,
contentDescription = "Pinned",
tint = secondaryTextColor.copy(alpha = 0.6f),
modifier = Modifier.size(16.dp).padding(end = 4.dp)
)
}
if (chat.unreadCount > 0) {
Box(
modifier =
Modifier.background(PrimaryBlue, CircleShape).padding(
horizontal = 8.dp,
vertical = 2.dp
),
contentAlignment = Alignment.Center
) {
Text(
text =
if (chat.unreadCount > 99) "99+"
else chat.unreadCount.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
}
}
}
Divider(
modifier = Modifier.padding(start = 84.dp),
color = dividerColor,
thickness = 0.5.dp
)
}
}
private val timeFormatCache =
ThreadLocal.withInitial { SimpleDateFormat("HH:mm", Locale.getDefault()) }
private val weekFormatCache =
ThreadLocal.withInitial { SimpleDateFormat("EEE", Locale.getDefault()) }
private val monthFormatCache =
ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) }
private val yearFormatCache =
ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) }
internal fun formatTime(date: Date): String {
val now = Calendar.getInstance()
val messageTime = Calendar.getInstance().apply { time = date }
return when {
now.get(Calendar.DATE) == messageTime.get(Calendar.DATE) -> {
timeFormatCache.get()?.format(date) ?: ""
}
now.get(Calendar.DATE) - messageTime.get(Calendar.DATE) == 1 -> {
"Yesterday"
}
now.get(Calendar.WEEK_OF_YEAR) == messageTime.get(Calendar.WEEK_OF_YEAR) -> {
weekFormatCache.get()?.format(date) ?: ""
}
now.get(Calendar.YEAR) == messageTime.get(Calendar.YEAR) -> {
monthFormatCache.get()?.format(date) ?: ""
}
else -> {
yearFormatCache.get()?.format(date) ?: ""
}
}
}

View File

@@ -0,0 +1,110 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.rosetta.messenger.R
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
import compose.icons.TablerIcons
import compose.icons.tablericons.Phone
import compose.icons.tablericons.Scan
import compose.icons.tablericons.Users
@Composable
fun ColumnScope.DrawerContent(
isDarkTheme: Boolean,
visibleTopLevelRequestsCount: Int,
onProfileClick: () -> Unit,
onRequestsClick: () -> Unit,
onCallsClick: () -> Unit,
onNewGroupClick: () -> Unit,
onQrScanClick: () -> Unit,
onSavedMessagesClick: () -> Unit,
onSettingsClick: () -> Unit
) {
Column(
modifier =
Modifier.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp)
) {
val menuIconColor = if (isDarkTheme) Color(0xFF828282) else Color(0xFF889198)
val menuTextColor =
if (isDarkTheme) Color(0xFFF4FFFFFF.toInt()) else Color(0xFF444444.toInt())
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.left_status_profile),
text = "My Profile",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = onProfileClick
)
DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.msg_archive),
text = "Requests",
iconColor = menuIconColor,
textColor = menuTextColor,
badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null,
badgeColor = accentColor,
onClick = onRequestsClick
)
DrawerMenuItemEnhanced(
icon = TablerIcons.Phone,
text = "Calls",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = onCallsClick
)
DrawerMenuItemEnhanced(
icon = TablerIcons.Users,
text = "New Group",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = onNewGroupClick
)
DrawerMenuItemEnhanced(
icon = TablerIcons.Scan,
text = "Scan QR",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = onQrScanClick
)
DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.msg_saved),
text = "Saved Messages",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = onSavedMessagesClick
)
DrawerDivider(isDarkTheme)
DrawerMenuItemEnhanced(
painter = painterResource(id = R.drawable.msg_settings_old),
text = "Settings",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = onSettingsClick
)
Spacer(modifier = Modifier.height(8.dp))
}
}

View File

@@ -0,0 +1,417 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.R
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.update.UpdateState
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronDown
import compose.icons.tablericons.Download
import compose.icons.tablericons.X
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatsDrawerHeaderAndAccounts(
isDarkTheme: Boolean,
drawerBackgroundColor: Color,
accountPublicKey: String,
accountVerified: Int,
allAccounts: List<EncryptedAccount>,
effectiveCurrentPublicKey: String,
sidebarAccountName: String,
sidebarAccountUsername: String,
avatarRepository: AvatarRepository?,
accountsSectionExpanded: Boolean,
onAccountsSectionExpandedChange: (Boolean) -> Unit,
onProfileClick: () -> Unit,
onCurrentAccountLongPress: () -> Unit,
onSwitchAccount: (String) -> Unit,
onAccountLongPress: (EncryptedAccount) -> Unit,
onAddAccountClick: () -> Unit,
onThemeToggleClick: () -> Unit,
onThemeToggleCenterChanged: (Offset) -> Unit
) {
Box(modifier = Modifier.fillMaxWidth()) {
Box(
modifier =
Modifier.matchParentSize().background(
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6)
)
)
Column(
modifier =
Modifier.fillMaxWidth()
.statusBarsPadding()
.padding(top = 16.dp, start = 20.dp, end = 20.dp, bottom = 12.dp)
) {
val isOfficialByKey = MessageRepository.isSystemAccount(accountPublicKey)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Box(
modifier =
Modifier.size(72.dp).clip(CircleShape).combinedClickable(
onClick = onProfileClick,
onLongClick = onCurrentAccountLongPress
),
contentAlignment = Alignment.Center
) {
AvatarImage(
publicKey = effectiveCurrentPublicKey,
avatarRepository = avatarRepository,
size = 72.dp,
isDarkTheme = isDarkTheme,
displayName = sidebarAccountName.ifEmpty { sidebarAccountUsername }
)
}
IconButton(
onClick = onThemeToggleClick,
modifier =
Modifier.size(48.dp).onGloballyPositioned {
val pos = it.positionInRoot()
onThemeToggleCenterChanged(
Offset(
x = pos.x + it.size.width / 2f,
y = pos.y + it.size.height / 2f
)
)
}
) {
Icon(
painter =
painterResource(
id =
if (isDarkTheme) R.drawable.day_theme_filled
else R.drawable.night_mode
),
contentDescription = if (isDarkTheme) "Light Mode" else "Dark Mode",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
if (sidebarAccountName.isNotEmpty()) {
Row(
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = sidebarAccountName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
if (accountVerified > 0 || isOfficialByKey) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = if (accountVerified > 0) accountVerified else 1,
size = 15,
modifier = Modifier.offset(y = 1.dp),
isDarkTheme = isDarkTheme,
badgeTint = if (isDarkTheme) PrimaryBlue else Color.White
)
}
}
}
if (sidebarAccountUsername.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "@$sidebarAccountUsername",
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.7f)
)
}
}
val arrowRotation by animateFloatAsState(
targetValue = if (accountsSectionExpanded) 180f else 0f,
animationSpec = tween(300),
label = "arrowRotation"
)
IconButton(
onClick = { onAccountsSectionExpandedChange(!accountsSectionExpanded) },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = if (accountsSectionExpanded) "Collapse" else "Expand",
tint = Color.White,
modifier = Modifier.size(20.dp).graphicsLayer { rotationZ = arrowRotation }
)
}
}
}
}
AnimatedVisibility(
visible = accountsSectionExpanded,
enter = expandVertically(animationSpec = tween(250)) + fadeIn(animationSpec = tween(250)),
exit = shrinkVertically(animationSpec = tween(250)) + fadeOut(animationSpec = tween(200))
) {
Column(modifier = Modifier.fillMaxWidth()) {
allAccounts.take(3).forEach { account ->
val isCurrentAccount = account.publicKey == effectiveCurrentPublicKey
val displayName =
resolveAccountDisplayName(
account.publicKey,
account.name,
account.username
)
Row(
modifier =
Modifier.fillMaxWidth().height(48.dp).combinedClickable(
onClick = {
if (!isCurrentAccount) {
onSwitchAccount(account.publicKey)
}
},
onLongClick = { onAccountLongPress(account) }
).padding(start = 14.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
AvatarImage(
publicKey = account.publicKey,
avatarRepository = avatarRepository,
size = 36.dp,
isDarkTheme = isDarkTheme,
displayName = displayName
)
if (isCurrentAccount) {
Box(
modifier =
Modifier.align(Alignment.BottomEnd).size(14.dp).background(
color = drawerBackgroundColor,
shape = CircleShape
).padding(1.5.dp).background(
color = Color(0xFF50A7EA),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(8.dp).offset(x = 0.3.dp, y = 0.dp)
)
}
}
}
Spacer(modifier = Modifier.width(22.dp))
Text(
text = displayName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color =
if (isDarkTheme) Color(0xFFF4FFFFFF.toInt()) else Color(0xFF444444),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}
Row(
modifier =
Modifier.fillMaxWidth().height(48.dp).clickable(onClick = onAddAccountClick)
.padding(start = 14.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
Icon(
painter = TelegramIcons.Add,
contentDescription = "Add Account",
tint = if (isDarkTheme) Color(0xFF828282) else Color(0xFF889198),
modifier = Modifier.size(22.dp)
)
}
Spacer(modifier = Modifier.width(22.dp))
Text(
text = "Add Account",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color(0xFFF4FFFFFF.toInt()) else Color(0xFF444444),
maxLines = 1
)
}
Divider(
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8),
thickness = 0.5.dp
)
}
}
}
@Composable
fun ChatsDrawerFooter(
isDarkTheme: Boolean,
appVersion: String,
updateState: UpdateState,
onUpdateClick: (UpdateState) -> Unit
) {
Column(
modifier =
Modifier.fillMaxWidth().windowInsetsPadding(
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
)
) {
val showUpdateBanner =
updateState is UpdateState.UpdateAvailable ||
updateState is UpdateState.Downloading ||
updateState is UpdateState.ReadyToInstall
Divider(
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8),
thickness = 0.5.dp
)
if (!showUpdateBanner) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Rosetta v$appVersion",
fontSize = 12.sp,
color = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
if (showUpdateBanner) {
Box(
modifier =
Modifier.fillMaxWidth().height(50.dp).background(
Brush.horizontalGradient(
colors = listOf(Color(0xFF69BF72), Color(0xFF53B3AD))
)
).clickable { onUpdateClick(updateState) },
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector =
when (updateState) {
is UpdateState.Downloading -> TablerIcons.X
else -> TablerIcons.Download
},
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text =
when (updateState) {
is UpdateState.Downloading -> "Downloading... ${updateState.progress}%"
is UpdateState.ReadyToInstall -> "Install Update"
is UpdateState.UpdateAvailable -> "Update Rosetta"
else -> ""
},
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
if (updateState is UpdateState.UpdateAvailable) {
Text(
text = updateState.version,
color = Color.White.copy(alpha = 0.8f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
if (updateState is UpdateState.Downloading) {
CircularProgressIndicator(
progress = updateState.progress / 100f,
modifier = Modifier.size(20.dp),
color = Color.White,
trackColor = Color.White.copy(alpha = 0.3f),
strokeWidth = 2.dp
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
}
}

View File

@@ -0,0 +1,258 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
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.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@Composable
internal fun EmptyRequestsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.no_requests))
val progress by animateLottieCompositionAsState(
composition = composition,
iterations = LottieConstants.IterateForever
)
Column(
modifier = modifier.fillMaxSize().background(backgroundColor),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
) {
LottieAnimation(
composition = composition,
progress = { progress },
modifier = Modifier.size(160.dp)
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(20.dp))
Text(
text = "No Requests",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = secondaryTextColor,
textAlign = TextAlign.Center
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(8.dp))
Text(
text = "New message requests will appear here",
fontSize = 15.sp,
color = secondaryTextColor.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
}
}
/** 📬 Экран со списком Requests (без хедера - хедер в основном TopAppBar) */
@Composable
fun RequestsScreen(
requests: List<DialogUiModel>,
isDarkTheme: Boolean,
onRequestClick: (DialogUiModel) -> Unit,
avatarRepository: AvatarRepository? = null,
blockedUsers: Set<String> = emptySet(),
pinnedChats: Set<String> = emptySet(),
isDrawerOpen: Boolean = false,
onTogglePin: (String) -> Unit = {},
onDeleteDialog: (String) -> Unit = {},
onBlockUser: (String) -> Unit = {},
onUnblockUser: (String) -> Unit = {}
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
var swipedItemKey by remember { mutableStateOf<String?>(null) }
LaunchedEffect(isDrawerOpen) {
if (isDrawerOpen) {
swipedItemKey = null
}
}
var dialogToDelete by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
if (requests.isEmpty()) {
EmptyRequestsState(isDarkTheme = isDarkTheme)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(
requests,
key = { it.opponentKey },
contentType = { "request" }
) { request ->
val isBlocked = blockedUsers.contains(request.opponentKey)
val isPinned = pinnedChats.contains(request.opponentKey)
SwipeableDialogItem(
dialog = request,
isDarkTheme = isDarkTheme,
isTyping = false,
isBlocked = isBlocked,
isSavedMessages = false,
avatarRepository = avatarRepository,
isDrawerOpen = isDrawerOpen,
isSwipedOpen = swipedItemKey == request.opponentKey,
onSwipeStarted = { swipedItemKey = request.opponentKey },
onSwipeClosed = {
if (swipedItemKey == request.opponentKey) swipedItemKey = null
},
onClick = {
swipedItemKey = null
onRequestClick(request)
},
onDelete = { dialogToDelete = request },
onBlock = { dialogToBlock = request },
onUnblock = { dialogToUnblock = request },
isPinned = isPinned,
onPin = { onTogglePin(request.opponentKey) }
)
if (request != requests.last()) {
Divider(
modifier = Modifier.padding(start = 84.dp),
color = dividerColor,
thickness = 0.5.dp
)
}
}
}
}
}
dialogToDelete?.let { dialog ->
AlertDialog(
onDismissRequest = { dialogToDelete = null },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor)
},
text = {
Text(
"Are you sure you want to delete this chat? This action cannot be undone.",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
val opponentKey = dialog.opponentKey
dialogToDelete = null
onDeleteDialog(opponentKey)
}
) { Text("Delete", color = Color(0xFFFF3B30)) }
},
dismissButton = {
TextButton(onClick = { dialogToDelete = null }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
dialogToBlock?.let { dialog ->
AlertDialog(
onDismissRequest = { dialogToBlock = null },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Block ${dialog.opponentTitle.ifEmpty { "User" }}",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"Are you sure you want to block this user? They won't be able to send you messages.",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
val opponentKey = dialog.opponentKey
dialogToBlock = null
onBlockUser(opponentKey)
}
) { Text("Block", color = Color(0xFFFF3B30)) }
},
dismissButton = {
TextButton(onClick = { dialogToBlock = null }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
dialogToUnblock?.let { dialog ->
AlertDialog(
onDismissRequest = { dialogToUnblock = null },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Unblock ${dialog.opponentTitle.ifEmpty { "User" }}",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"Are you sure you want to unblock this user? They will be able to send you messages again.",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
val opponentKey = dialog.opponentKey
dialogToUnblock = null
onUnblockUser(opponentKey)
}
) { Text("Unblock", color = Color(0xFF34C759)) }
},
dismissButton = {
TextButton(onClick = { dialogToUnblock = null }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
}

View File

@@ -0,0 +1,128 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.R
@Composable
fun RequestsSection(
count: Int,
requests: List<DialogUiModel> = emptyList(),
isDarkTheme: Boolean,
onClick: () -> Unit
) {
val titleColor = remember(isDarkTheme) {
if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF525252)
}
val subtitleColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF6E6E6E) else Color(0xFF919191) }
val iconBgColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFB8C2CC) }
val badgeColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF4E4E4E) else Color(0xFFC6C9CC) }
val lastRequest = remember(requests) { requests.firstOrNull() }
val subtitle = remember(lastRequest) {
when {
lastRequest == null -> ""
lastRequest.opponentTitle.isNotEmpty() &&
lastRequest.opponentTitle != lastRequest.opponentKey &&
lastRequest.opponentTitle != lastRequest.opponentKey.take(7) -> lastRequest.opponentTitle
lastRequest.opponentUsername.isNotEmpty() -> "@${lastRequest.opponentUsername}"
else -> lastRequest.opponentKey.take(7)
}
}
Column {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
.background(iconBgColor, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.archive_filled),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Requests",
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
color = titleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (subtitle.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = subtitle,
fontSize = 14.sp,
color = subtitleColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (count > 0) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
.background(badgeColor, CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > 99) "99+" else count.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
lineHeight = 12.sp,
modifier = Modifier.padding(horizontal = 5.dp, vertical = 3.dp)
)
}
}
}
}
}

View File

@@ -2,28 +2,33 @@ package com.rosetta.messenger.ui.chats
import android.app.Application
import androidx.compose.runtime.Immutable
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/** UI модель диалога с расшифрованным lastMessage */
@Immutable
@@ -67,11 +72,16 @@ data class ChatsUiState(
}
/** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */
class ChatsListViewModel(application: Application) : AndroidViewModel(application) {
@HiltViewModel
class ChatsListViewModel @Inject constructor(
private val app: Application,
private val protocolGateway: ProtocolGateway,
private val messageRepository: MessageRepository,
private val groupRepository: GroupRepository
) : ViewModel() {
private val database = RosettaDatabase.getDatabase(application)
private val database = RosettaDatabase.getDatabase(app)
private val dialogDao = database.dialogDao()
private val groupRepository = GroupRepository.getInstance(application)
private var currentAccount: String = ""
private var currentPrivateKey: String? = null
@@ -92,6 +102,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null
private var loadingFailSafeJob: Job? = null
// Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
@@ -104,6 +115,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// Количество requests
private val _requestsCount = MutableStateFlow(0)
val requestsCount: StateFlow<Int> = _requestsCount.asStateFlow()
val syncInProgress: StateFlow<Boolean> = protocolGateway.syncInProgress
// Заблокированные пользователи (реактивный Set из Room Flow)
private val _blockedUsers = MutableStateFlow<Set<String>>(emptySet())
@@ -132,20 +144,36 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
ChatsUiState()
)
// Загрузка (🔥 true по умолчанию — skeleton на первом кадре, чтобы не мигало empty→skeleton→empty)
private val _isLoading = MutableStateFlow(true)
// Загрузка
// Важно: false по умолчанию, чтобы исключить "вечный skeleton", если setAccount не был вызван.
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val loadingFailSafeTimeoutMs = 4500L
private val TAG = "ChatsListVM"
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
private val groupJoinedMarker = "\$a=Group joined"
private val groupCreatedMarker = "\$a=Group created"
private val attachmentTagUuidRegex =
Regex(
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
private fun isGroupKey(value: String): Boolean {
val normalized = value.trim().lowercase()
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun rosettaDev1Log(msg: String) {
runCatching {
val appContext = app
val dir = java.io.File(appContext.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n")
}
}
private data class GroupLastSenderInfo(
val senderPrefix: String,
val senderKey: String
@@ -195,7 +223,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
if (!dialogName.isNullOrBlank()) return dialogName
val cached = ProtocolManager.getCachedUserName(publicKey).orEmpty().trim()
val cached = protocolGateway.getCachedUserName(publicKey).orEmpty().trim()
if (cached.isNotBlank() && cached != publicKey) {
return cached
}
@@ -269,6 +297,40 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return deduped.values.sortedByDescending { it.lastMessageTimestamp }
}
/**
* During sync we keep list stable only when there are truly no visible dialog changes.
* This lets local sends/new system dialogs appear immediately even if sync is active.
*/
private fun canFreezeDialogsDuringSync(
dialogsList: List<com.rosetta.messenger.database.DialogEntity>
): Boolean {
val currentDialogs = _dialogs.value
if (currentDialogs.isEmpty()) return false
if (dialogsList.size != currentDialogs.size) return false
val currentByKey = currentDialogs.associateBy { it.opponentKey }
return dialogsList.all { entity ->
val current = currentByKey[entity.opponentKey] ?: return@all false
current.lastMessageTimestamp == entity.lastMessageTimestamp &&
current.unreadCount == entity.unreadCount &&
current.isOnline == entity.isOnline &&
current.lastSeen == entity.lastSeen &&
current.verified == entity.verified &&
current.opponentTitle == entity.opponentTitle &&
current.opponentUsername == entity.opponentUsername &&
current.lastMessageFromMe == entity.lastMessageFromMe &&
current.lastMessageDelivered == entity.lastMessageDelivered &&
current.lastMessageRead == entity.lastMessageRead &&
current.lastMessage == entity.lastMessage &&
current.lastMessageAttachmentType ==
resolveAttachmentType(
attachmentType = entity.lastMessageAttachmentType,
decryptedLastMessage = current.lastMessage,
lastMessageAttachments = entity.lastMessageAttachments
)
}
}
private suspend fun mapDialogListIncremental(
dialogsList: List<com.rosetta.messenger.database.DialogEntity>,
privateKey: String,
@@ -345,15 +407,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis()
if (currentAccount == publicKey) {
val resolvedPrivateKey =
when {
privateKey.isNotBlank() -> privateKey
currentAccount == publicKey -> currentPrivateKey.orEmpty()
else -> ""
}
if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
if (_isLoading.value) _isLoading.value = false
if (_isLoading.value) {
_isLoading.value = false
}
loadingFailSafeJob?.cancel()
return
}
// 🔥 Показываем skeleton пока данные грузятся
_isLoading.value = true
loadingFailSafeJob?.cancel()
loadingFailSafeJob =
viewModelScope.launch {
delay(loadingFailSafeTimeoutMs)
if (_isLoading.value) {
_isLoading.value = false
android.util.Log.w(
TAG,
"Fail-safe: forced isLoading=false after ${loadingFailSafeTimeoutMs}ms for account=${publicKey.take(8)}..."
)
rosettaDev1Log(
"Fail-safe isLoading=false account=${publicKey.take(8)} timeoutMs=$loadingFailSafeTimeoutMs"
)
}
}
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear()
@@ -369,7 +455,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
accountSubscriptionsJob?.cancel()
currentAccount = publicKey
currentPrivateKey = privateKey
currentPrivateKey = resolvedPrivateKey
// <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
DraftManager.setAccount(publicKey)
@@ -380,7 +466,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requestsCount.value = 0
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
if (resolvedPrivateKey.isNotEmpty()) {
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
}
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch {
@@ -398,19 +486,18 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
.combine(protocolGateway.syncInProgress) { dialogsList, syncing ->
dialogsList to syncing
}
.mapLatest { (dialogsList, syncing) ->
// Desktop behavior parity:
// while sync is active we keep current chats list stable (no per-message UI churn),
// then apply one consolidated update when sync finishes.
if (syncing && _dialogs.value.isNotEmpty()) {
// Keep list stable during sync only when the snapshot is effectively unchanged.
// Otherwise (new message/dialog/status) update immediately.
if (syncing && canFreezeDialogsDuringSync(dialogsList)) {
null
} else {
mapDialogListIncremental(
dialogsList = dialogsList,
privateKey = privateKey,
privateKey = resolvedPrivateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
@@ -418,10 +505,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
.filterNotNull()
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.catch { e ->
android.util.Log.e(TAG, "Dialogs flow failed in setAccount()", e)
rosettaDev1Log("Dialogs flow failed: ${e.message}")
if (_isLoading.value) _isLoading.value = false
emit(emptyList())
}
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
// 🚀 Убираем skeleton после первой загрузки
if (_isLoading.value) _isLoading.value = false
if (_isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
}
// 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
@@ -430,7 +526,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedDialogs.filter { !it.isSavedMessages }.map {
it.opponentKey
}
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
}
}
@@ -441,7 +537,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления
.combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
.combine(protocolGateway.syncInProgress) { requestsList, syncing ->
requestsList to syncing
}
.mapLatest { (requestsList, syncing) ->
@@ -450,7 +546,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else {
mapDialogListIncremental(
dialogsList = requestsList,
privateKey = privateKey,
privateKey = resolvedPrivateKey,
cache = requestsUiCache,
isRequestsFlow = true
)
@@ -465,7 +561,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao
.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
.combine(ProtocolManager.syncInProgress) { count, syncing ->
.combine(protocolGateway.syncInProgress) { count, syncing ->
if (syncing) 0 else count
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
@@ -489,7 +585,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// dialogs that still have empty titles.
launch {
var wasSyncing = false
ProtocolManager.syncInProgress.collect { syncing ->
protocolGateway.syncInProgress.collect { syncing ->
if (wasSyncing && !syncing) {
requestedUserInfoKeys.clear()
}
@@ -498,6 +594,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
} // end accountSubscriptionsJob
accountSubscriptionsJob?.invokeOnCompletion { cause ->
if (cause != null && _isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
android.util.Log.e(TAG, "accountSubscriptionsJob completed with error", cause)
rosettaDev1Log("accountSubscriptionsJob error: ${cause.message}")
}
}
}
fun forceStopLoading(reason: String) {
if (_isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
android.util.Log.w(TAG, "forceStopLoading: $reason")
rosettaDev1Log("forceStopLoading: $reason")
}
}
/**
@@ -506,6 +620,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
*/
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
if (opponentKeys.isEmpty()) return
if (privateKey.isBlank()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys =
@@ -527,7 +642,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
newKeys.forEach { key -> addPublicKey(key) }
}
ProtocolManager.send(packet)
protocolGateway.send(packet)
} catch (e: Exception) {}
}
}
@@ -595,24 +710,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
if (attachments.length() <= 0) return -1
val first = attachments.optJSONObject(0) ?: return -1
val rawType = first.opt("type")
when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"image" -> 0
"messages", "reply", "forward" -> 1
"file" -> 2
"avatar" -> 3
"call" -> 4
"voice" -> 5
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6
else -> -1
}
}
else -> -1
val parsedType = parseAttachmentTypeValue(first.opt("type"))
if (parsedType in 0..6) {
parsedType
} else {
inferLegacyAttachmentTypeFromJson(first)
}
} catch (_: Throwable) {
-1
@@ -626,34 +728,149 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
if (attachments.length() != 1) return false
val first = attachments.optJSONObject(0) ?: return false
val rawType = first.opt("type")
val typeValue =
when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"call" -> 4
else -> -1
}
}
else -> -1
}
val typeValue = parseAttachmentTypeValue(first.opt("type"))
if (typeValue == 4) return true
val preview = first.optString("preview", "").trim()
if (preview.isEmpty()) return false
val tail = preview.substringAfterLast("::", preview).trim()
if (tail.toIntOrNull() != null) return true
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(preview)
isLikelyCallAttachmentPreview(preview)
} catch (_: Throwable) {
false
}
}
private fun parseAttachmentTypeValue(rawType: Any?): Int {
return when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: run {
val token =
normalized.lowercase(Locale.ROOT)
.replace('-', '_')
.replace(' ', '_')
when (token) {
"image", "photo", "picture" -> 0
"messages", "message", "reply", "forward", "forwarded" -> 1
"file", "document", "doc" -> 2
"avatar", "profile_photo", "profile_avatar" -> 3
"call", "phone_call" -> 4
"voice", "voice_message", "voice_note", "audio", "audio_message", "audio_note", "audiomessage", "audionote" -> 5
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video_message", "video" -> 6
else -> -1
}
}
}
else -> -1
}
}
private fun inferLegacyAttachmentTypeFromJson(attachment: JSONObject): Int {
val preview = attachment.optString("preview", "")
val blob = attachment.optString("blob", "")
val width = attachment.optInt("width", 0)
val height = attachment.optInt("height", 0)
val attachmentId = attachment.optString("id", "")
val transportObj = attachment.optJSONObject("transport")
val transportTag =
attachment.optString(
"transportTag",
attachment.optString(
"transport_tag",
transportObj?.optString("transport_tag", "") ?: ""
)
)
if (isLikelyMessagesAttachmentPayload(preview = preview, blob = blob)) return 1
if (blob.isBlank() && width <= 0 && height <= 0 && isLikelyCallAttachmentPreview(preview)) return 4
if (isLikelyVideoCircleAttachmentPreview(preview = preview, attachmentId = attachmentId)) return 6
if (isLikelyVoiceAttachmentPreview(preview = preview, attachmentId = attachmentId)) return 5
if (width > 0 || height > 0) return 0
if (isLikelyFileAttachmentPreview(preview) || transportTag.isNotBlank()) return 2
return -1
}
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
if (preview.isBlank()) return false
val normalized = preview.trim()
val tail = normalized.substringAfterLast("::", normalized).trim()
if (tail.toIntOrNull() != null) return true
return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
.containsMatchIn(normalized)
}
private fun isLikelyMessagesAttachmentPayload(preview: String, blob: String): Boolean {
val payload = blob.ifBlank { preview }.trim()
if (payload.isEmpty()) return false
if (!payload.startsWith("{") && !payload.startsWith("[")) return false
val objectCandidate =
runCatching {
if (payload.startsWith("[")) JSONArray(payload).optJSONObject(0)
else JSONObject(payload)
}
.getOrNull()
?: return false
return objectCandidate.has("message_id") ||
objectCandidate.has("publicKey") ||
objectCandidate.has("message") ||
objectCandidate.has("attachments")
}
private fun isLikelyVoiceAttachmentPreview(preview: String, attachmentId: String): Boolean {
val id = attachmentId.trim().lowercase(Locale.ROOT)
if (id.startsWith("voice_") || id.startsWith("voice-") || id.startsWith("audio_") || id.startsWith("audio-")) {
return true
}
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
if (duration < 0) return false
val tail = normalized.substringAfter("::", "").trim()
if (tail.isEmpty()) return true
if (tail.startsWith("video/", ignoreCase = true)) return false
if (tail.startsWith("audio/", ignoreCase = true)) return true
if (!tail.contains(",")) return false
val values = tail.split(",")
return values.size >= 2 && values.all { it.trim().toFloatOrNull() != null }
}
private fun isLikelyVideoCircleAttachmentPreview(preview: String, attachmentId: String): Boolean {
val id = attachmentId.trim().lowercase(Locale.ROOT)
if (id.startsWith("video_circle_") || id.startsWith("video-circle-")) {
return true
}
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
if (duration < 0) return false
val mime = normalized.substringAfter("::", "").trim().lowercase(Locale.ROOT)
return mime.startsWith("video/")
}
private fun isLikelyFileAttachmentPreview(preview: String): Boolean {
val normalized = preview.trim()
if (normalized.isEmpty()) return false
val parts = normalized.split("::")
if (parts.size < 2) return false
val first = parts[0]
return when {
parts.size >= 3 && attachmentTagUuidRegex.matches(first) && parts[1].toLongOrNull() != null -> true
parts.size >= 2 && first.toLongOrNull() != null -> true
parts.size >= 2 && attachmentTagUuidRegex.matches(first) -> true
else -> false
}
}
private fun parseAttachmentsJsonArray(rawAttachments: String): JSONArray? {
val normalized = rawAttachments.trim()
if (normalized.isEmpty() || normalized == "[]") return null
@@ -771,7 +988,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
// 🗑️ 1. Очищаем ВСЕ кэши сообщений
MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey)
messageRepository.clearDialogCache(opponentKey)
// 🗑️ Очищаем кэш ChatViewModel
ChatViewModel.clearCacheForOpponent(opponentKey)
@@ -820,7 +1037,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requestsCount.value = _requests.value.size
dialogsUiCache.remove(groupPublicKey)
requestsUiCache.remove(groupPublicKey)
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
messageRepository.clearDialogCache(groupPublicKey)
ChatViewModel.clearCacheForOpponent(groupPublicKey)
}
left
@@ -880,7 +1097,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch(Dispatchers.IO) {
try {
val sharedPrefs =
getApplication<Application>()
app
.getSharedPreferences("rosetta", Application.MODE_PRIVATE)
val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: ""
@@ -895,7 +1112,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
this.privateKey = privateKeyHash
this.search = publicKey
}
ProtocolManager.send(packet)
protocolGateway.send(packet)
} catch (e: Exception) {}
}
}

View File

@@ -15,7 +15,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.ProtocolState
import compose.icons.TablerIcons
import compose.icons.tablericons.*
@@ -26,11 +26,12 @@ import kotlinx.coroutines.launch
@Composable
fun ConnectionLogsScreen(
isDarkTheme: Boolean,
protocolGateway: ProtocolGateway,
onBack: () -> Unit
) {
val logs by ProtocolManager.debugLogs.collectAsState()
val protocolState by ProtocolManager.getProtocol().state.collectAsState()
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val logs by protocolGateway.debugLogs.collectAsState()
val protocolState by protocolGateway.state.collectAsState()
val syncInProgress by protocolGateway.syncInProgress.collectAsState()
val bgColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFF5F5F5)
val cardColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
@@ -41,9 +42,9 @@ fun ConnectionLogsScreen(
val scope = rememberCoroutineScope()
DisposableEffect(Unit) {
ProtocolManager.enableUILogs(true)
protocolGateway.enableUILogs(true)
onDispose {
ProtocolManager.enableUILogs(false)
protocolGateway.enableUILogs(false)
}
}
@@ -85,7 +86,7 @@ fun ConnectionLogsScreen(
modifier = Modifier.weight(1f)
)
IconButton(onClick = { ProtocolManager.clearLogs() }) {
IconButton(onClick = { protocolGateway.clearLogs() }) {
Icon(
imageVector = TablerIcons.Trash,
contentDescription = "Clear logs",

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