refactor: декомпозировать runtime и chat-архитектуру, вынести use-case в domain и убрать UiEntryPoint

This commit is contained in:
2026-04-19 16:51:52 +05:00
parent 15bca1ec34
commit 5e6d66b762
74 changed files with 7846 additions and 6221 deletions

View File

@@ -1,19 +1,21 @@
# Rosetta Android — Architecture
> Документ отражает текущее состояние `rosetta-android` (ветка `dev`) по коду на 2026-04-18.
> Документ отражает текущее состояние `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: `ProtocolRuntime` -> `ProtocolRuntimeCore` (+ compatibility facade `ProtocolManager`), `CallManager`, `TransportManager`, `UpdateManager`.
- 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-логика сети вынесена в instance-класс `ProtocolRuntimeCore`.
`ProtocolManager` сохранен как тонкий compatibility facade.
Основная runtime-логика сети вынесена в `RuntimeComposition`, а DI-вход в runtime идет напрямую через `ProtocolRuntime`.
`ProtocolManager` переведен в минимальный legacy compatibility facade поверх `ProtocolRuntimeAccess`.
DI-вход в network core идет через `ProtocolRuntime` (Hilt singleton).
---
@@ -36,7 +38,19 @@ flowchart TB
D3["IdentityGateway"]
D4["AccountManager / PreferencesManager"]
D5["MessageRepository / GroupRepository"]
D6["UiEntryPoint + EntryPointAccessors"]
end
subgraph CHAT_UI["Chat UI Orchestration"]
C1["ChatDetailScreen / ChatsListScreen"]
C2["ChatViewModel (host)"]
C3["Messages/Voice/Attachments/Typing ViewModel"]
C4["Messages/Forward/Attachments Coordinator"]
end
subgraph CHAT_DOMAIN["Chat Domain UseCases"]
U1["SendText / SendMedia / SendForward"]
U2["SendVoice / SendTyping / SendReadReceipt"]
U3["CreateAttachment / EncryptAndUpload"]
end
subgraph SESSION["Session / Identity Runtime"]
@@ -48,7 +62,7 @@ flowchart TB
subgraph NET["Network Runtime"]
N0["ProtocolRuntime"]
N1["ProtocolRuntimeCore"]
N1C["RuntimeComposition"]
N1A["ProtocolManager (compat facade)"]
N2["Protocol"]
N3["PacketSubscriptionRegistry"]
@@ -65,13 +79,17 @@ flowchart TB
DI --> SESSION
DI --> NET
DI --> DATA
DI --> CHAT_UI
CHAT_UI --> CHAT_DOMAIN
CHAT_UI --> DATA
CHAT_DOMAIN --> D1
D1 --> D1A
D1A --> N1
N1A --> N1
D1A --> N1C
N1A --> N1C
SESSION --> NET
DATA --> NET
DATA --> R3
NET --> N2
N1C --> N2
```
---
@@ -84,16 +102,17 @@ flowchart TB
- Основные модули:
- `AppDataModule`: `AccountManager`, `PreferencesManager`.
- `AppGatewayModule`: биндинги `ProtocolGateway`, `SessionCoordinator`, `IdentityGateway`, `ProtocolClient`.
- `ProtocolGatewayImpl` и `ProtocolClientImpl` делегируют в `ProtocolRuntime`, а не напрямую в UI-слой.
- `ProtocolGateway` теперь биндится напрямую на `ProtocolRuntime` (без отдельного `ProtocolGatewayImpl` proxy-класса).
- `ProtocolClientImpl` остается узким техническим adapter-слоем для repository (`send/sendWithRetry/addLog/wait/unwait`) и делегирует в `ProtocolRuntime` через `Provider<ProtocolRuntime>`.
### 3.2 UI bridge для composable-слоя
UI-композаблы получают зависимости через `UiEntryPoint` + `EntryPointAccessors.fromApplication(...)`.
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), чтобы не было тихого отката в `ProtocolManager`.
- доступ до install запрещен (fail-fast), чтобы не было тихого отката в legacy facade.
### 3.3 Разрыв DI-cycle (Hilt)
После перехода на `ProtocolRuntime` был закрыт цикл зависимостей:
@@ -152,9 +171,30 @@ stateDiagram-v2
## 5. Network orchestration после декомпозиции
`ProtocolRuntime` — DI-фасад runtime слоя.
`ProtocolRuntimeCore` содержит runtime state machine и делегирует отдельные зоны ответственности:
`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`.
@@ -166,26 +206,52 @@ stateDiagram-v2
- `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
PM["ProtocolRuntimeCore"] --> CO["ConnectionOrchestrator"]
PM --> BC["BootstrapCoordinator"]
PM --> SC["SyncCoordinator"]
PM --> PT["PresenceTypingService"]
PM --> PR["PacketRouter"]
PM --> OPS["OwnProfileSyncService"]
PM --> RQ["RetryQueueService"]
PM --> ABC["AuthBootstrapCoordinator"]
PM --> NRW["NetworkReconnectWatcher"]
PM --> DVS["DeviceVerificationService"]
PM --> CSB["CallSignalBridge"]
PM --> PSR["PacketSubscriptionRegistry"]
PM --> SUP["ProtocolConnectionSupervisor"]
PM --> RPG["ReadyPacketGate"]
PM --> P["Protocol (WebSocket + packet codec)"]
flowchart TB
PR["ProtocolRuntime (ProtocolGateway impl)"] --> RC["RuntimeComposition"]
RC --> RCC["RuntimeConnectionControlFacade"]
RC --> RDF["RuntimeDirectoryFacade"]
RC --> RPF["RuntimePacketIoFacade"]
RC --> CO["ConnectionOrchestrator"]
RC --> PIM["ProtocolInstanceManager"]
RC --> RLSM["RuntimeLifecycleStateMachine"]
RC --> RIC["RuntimeInitializationCoordinator"]
RC --> PLSS["ProtocolLifecycleStateStoreImpl"]
RC --> OPFT["OwnProfileFallbackTimerService"]
RC --> ARS["AuthRestoreService"]
RC --> RSC["RuntimeShutdownCoordinator"]
RC --> CER["ConnectionEventRouter"]
RC --> NCF["NetworkConnectivityFacade"]
RC --> PLC["ProtocolLifecycleCoordinator"]
RC --> PAC["ProtocolAccountSessionCoordinator"]
RC --> RPDC["ReadyPacketDispatchCoordinator"]
RC --> PABC["ProtocolPostAuthBootstrapCoordinator"]
RC --> BC["BootstrapCoordinator"]
RC --> SC["SyncCoordinator"]
RC --> PT["PresenceTypingService"]
RC --> PR["PacketRouter"]
RC --> OPS["OwnProfileSyncService"]
RC --> RQ["RetryQueueService"]
RC --> ABC["AuthBootstrapCoordinator"]
RC --> NRW["NetworkReconnectWatcher"]
RC --> DVS["DeviceVerificationService"]
RC --> CSB["CallSignalBridge"]
RC --> PSF["PacketSubscriptionFacade"]
RC --> PSR["PacketSubscriptionRegistry"]
RC --> IPR["InboundPacketHandlerRegistrar"]
RC --> IQ["InboundTaskQueueService"]
RC --> SUP["ProtocolConnectionSupervisor"]
RC --> RPG["ReadyPacketGate"]
PIM --> P["Protocol (WebSocket + packet codec)"]
```
---
@@ -201,7 +267,7 @@ flowchart TB
```mermaid
sequenceDiagram
participant Feature as Feature/Service
participant PM as ProtocolRuntimeCore
participant PM as Runtime API (Core/Facade)
participant REG as PacketSubscriptionRegistry
participant P as Protocol
@@ -216,30 +282,102 @@ sequenceDiagram
---
## 7. Отправка сообщений: use-cases + retry
## 7. Чат-модуль: декомпозиция и message pipeline
Из `ChatViewModel` выделены use-cases:
### 7.1 Domain слой для сценариев отправки
Use-case слой вынесен из UI-пакета в `domain/chats/usecase`:
- `SendTextMessageUseCase`
- `SendMediaMessageUseCase`
- `SendForwardUseCase`
- `SendVoiceMessageUseCase`
- `SendTypingIndicatorUseCase`
- `SendReadReceiptUseCase`
- `CreateFileAttachmentUseCase`
- `CreateAvatarAttachmentUseCase`
- `CreateVideoCircleAttachmentUseCase`
- `EncryptAndUploadAttachmentUseCase`
Текущий поток:
1. ViewModel готовит command и шифрованный payload.
2. UseCase собирает `PacketMessage`.
3. UseCase вызывает `protocolGateway.sendMessageWithRetry(packet)`.
4. `ProtocolRuntimeCore` регистрирует пакет в `RetryQueueService` и отправляет в сеть.
5. Если lifecycle еще не `READY`, пакет попадает в `ReadyPacketGate` и flush после `READY`.
Роли 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
VM["ChatViewModel"] --> UC["Send*UseCase"]
FVM["Feature ViewModel"] --> CVM["ChatViewModel (host)"]
CVM --> COORD["Messages/Forward/Attachments Coordinator"]
CVM --> UC["domain/chats/usecase/*"]
COORD --> UC
UC --> GW["ProtocolGateway.sendMessageWithRetry"]
GW --> PM["ProtocolRuntimeCore"]
PM --> RQ["RetryQueueService"]
PM --> RG["ReadyPacketGate"]
PM --> P["Protocol.sendPacket"]
GW --> PR["ProtocolRuntime"]
PR --> RC["RuntimeComposition"]
RC --> RQ["RetryQueueService"]
RC --> RG["ReadyPacketGate"]
RC --> P["Protocol.sendPacket"]
```
### 7.2 Декомпозиция ChatViewModel (host + feature/coordinator слой)
Для UI-слоя введены feature-facade viewmodel-классы:
- `MessagesViewModel`
- `VoiceRecordingViewModel`
- `AttachmentsViewModel`
- `TypingViewModel`
Они живут в `ui/chats/ChatFeatureViewModels.kt` и компонуются внутри `ChatViewModel`.
Текущий статус:
- `VoiceRecordingViewModel` содержит реальный send-pipeline голосовых сообщений.
- `TypingViewModel` содержит реальную отправку typing indicator (throttle + packet send).
- `MessagesViewModel` содержит orchestration-level entrypoint (`sendMessage`, `retryMessage`), а core text send pipeline вынесен в `MessagesCoordinator` (pending recovery/throttle + reply/forward packet assembly).
- `ForwardCoordinator` вынесен из `ChatViewModel`: `sendForwardDirectly` + forward rewrite/re-upload helper-ветка (включая payload resolve из cache/download).
- `AttachmentsCoordinator` вынесен из `ChatViewModel`: `updateOptimisticImageMessage`, `sendImageMessageInternal`, `sendVideoCircleMessageInternal` + local cache/update (`localUri` cleanup после отправки).
- `AttachmentsFeatureCoordinator` вынесен из `AttachmentsViewModel`: high-level media orchestration для `sendImageGroup*`, `sendFileMessage`, `sendVideoCircleFromUri`, `sendAvatarMessage`.
- `AttachmentsViewModel` теперь концентрируется на facade-методах и `sendImageFromUri`/`sendImageMessage`, делегируя крупные media-ветки в coordinator-слой.
```mermaid
flowchart TB
CD["ChatDetailScreen"] --> MVM["MessagesViewModel"]
CD --> TVM["TypingViewModel"]
CD --> VVM["VoiceRecordingViewModel"]
CD --> AVM["AttachmentsViewModel"]
MVM --> CVM["ChatViewModel"]
TVM --> CVM
VVM --> CVM
AVM --> CVM
CVM --> MCO["MessagesCoordinator"]
CVM --> FCO["ForwardCoordinator"]
CVM --> ACO["AttachmentsCoordinator"]
CVM --> U["domain/chats/usecase/*"]
MCO --> U
FCO --> U
ACO --> U
```
Важно: после вынесения `MessagesCoordinator`, `ForwardCoordinator` и `AttachmentsCoordinator` `ChatViewModel` выступает как host-state и bridge для feature/coordinator подсистем.
### 7.3 Декомпозиция ChatsListScreen
Из `ChatsListScreen.kt` вынесены отдельные composable-секции:
- `ChatItem` -> `ChatsListChatItem.kt`
- `RequestsSection` -> `ChatsListRequestsSection.kt`
- `DrawerContent` -> `ChatsListDrawerContent.kt`
Результат:
- основной файл экрана меньше и проще для навигации;
- повторно используемые куски UI имеют явные file boundaries;
- дальнейший рефакторинг drawer/request/chat list можно делать независимо.
---
## 8. Auth/bootstrap: фактический runtime flow
@@ -250,7 +388,7 @@ sequenceDiagram
participant SC as SessionCoordinatorImpl
participant SS as SessionStore
participant PG as ProtocolGateway
participant PM as ProtocolRuntimeCore
participant RC as RuntimeComposition
participant AM as AccountManager
UI->>SC: bootstrapAuthenticatedSession(account, reason)
@@ -262,9 +400,9 @@ sequenceDiagram
SC->>AM: setCurrentAccount(public)
SC->>SS: dispatch(Ready)
PM-->>PM: HANDSHAKE -> AUTHENTICATED -> BOOTSTRAPPING
PM-->>PM: SyncCompleted + OwnProfileResolved
PM-->>PM: connectionLifecycleState = READY
RC-->>RC: HANDSHAKE -> AUTHENTICATED -> BOOTSTRAPPING
RC-->>RC: SyncCompleted + OwnProfileResolved
RC-->>RC: connectionLifecycleState = READY
```
Важно: `SessionState.Ready` (app-session готова) и `connectionLifecycleState = READY` (сеть готова) — это разные state-модели.
@@ -273,7 +411,7 @@ sequenceDiagram
## 9. Состояния соединения (network lifecycle)
`ProtocolRuntimeCore.connectionLifecycleState`:
`RuntimeComposition.connectionLifecycleState`:
- `DISCONNECTED`
- `CONNECTING`
- `HANDSHAKING`
@@ -300,9 +438,15 @@ stateDiagram-v2
## 10. Ключевые файлы новой архитектуры
- `app/src/main/java/com/rosetta/messenger/di/AppContainer.kt`
- `app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt`
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt`
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.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`
@@ -316,6 +460,20 @@ stateDiagram-v2
- `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`
@@ -324,19 +482,95 @@ stateDiagram-v2
- `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/usecase/SendTextMessageUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendMediaMessageUseCase.kt`
- `app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendForwardUseCase.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. Что осталось как технический долг
- `ProtocolRuntimeCore` все еще содержит много cross-cutting логики и требует дальнейшей декомпозиции.
- UI больше не использует `UiDependencyAccess.get(...)`, но часть экранов все еще берет зависимости через `UiEntryPoint` (следующий шаг: передача зависимостей параметрами/через VM).
- DI-адаптеры (`ProtocolGatewayImpl`, `ProtocolClientImpl`) переведены на `ProtocolRuntime`, dependency-cycle закрыт через `Provider<ProtocolRuntime>`.
- Следующий шаг по network core: продолжить декомпозицию `ProtocolRuntimeCore` (например: `ProtocolLifecycleLogger`, `AuthRestoreService`, `ProtocolTraceService`) и сократить фасад `ProtocolManager` до полного legacy-режима.
Актуальные открытые хвосты:
- `RuntimeComposition` остается composition-root, но уже существенно сжат (около 501 строки) после выноса `RuntimeTransportAssembly`, `RuntimeMessagingAssembly`, `RuntimeStateAssembly`, `RuntimeRoutingAssembly` и удаления публичных proxy-методов; следующий шаг перенос части lifecycle/orchestration helper-кода в отдельные domain-oriented service/adapters.
- `ProtocolRuntime` и `ProtocolRuntimePort` все еще имеют широкий proxy-surface; нужен audit методов и дальнейшее сужение публичного API по use-case группам.
- `ChatViewModel` остается крупным host-классом (state + bridge/proxy API к feature/coordinator/use-case слоям).
- High-level media сценарии теперь в `AttachmentsFeatureCoordinator`, но остаются крупными и требуют дальнейшей декомпозиции на более узкие coordinator/service/use-case блоки.
- Тестовое покрытие архитектурных слоев все еще недостаточно:
- сейчас в `app/src/test` всего 7 unit-тестов (в основном crypto/data/helpers), в `app/src/androidTest` — 1 тест;
- не покрыты network runtime/coordinator слои (`RuntimeComposition`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat orchestration (`Messages/Forward/Attachments*`).
Уже закрыто и больше не считается техдолгом:
- `UiDependencyAccess.get(...)` удален из `ui/*`.
- `UiEntryPoint`/`EntryPointAccessors` убраны из UI-экранов (явная передача зависимостей через `MainActivity`/`ViewModel`).
- DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` закрыт через `Provider<ProtocolRuntime>`.
- `ProtocolManager` переведен в минимальный legacy compatibility API (тонкие прокси к `ProtocolRuntimeAccess`).
---
## 12. Guardrails против переусложнения
Чтобы декомпозиция не превращалась в «архитектуру ради архитектуры», применяются следующие правила:
1. Лимит глубины runtime-цепочки вызова: не более 3 логических слоев после DI-entry (`ProtocolRuntime -> Runtime*Facade -> service`; `RuntimeComposition` остается composition-root/wiring-слоем, а не обязательным proxy-hop).
2. Новый слой/класс допускается только если он дает измеримый выигрыш:
- убирает минимум 80-120 строк связанной orchestration-логики из текущего класса, или
- убирает минимум 2 внешние зависимости из текущего класса.
3. Каждый шаг рефакторинга считается завершенным только после: `compileDebugKotlin` + минимум одного smoke-сценария по затронутому флоу + обновления `Architecture.md`.
4. Если после выноса сложность чтения/изменения не снизилась (по факту код не стал проще), такой вынос считается кандидатом на откат/консолидацию.
5. Для event-driven runtime-chain (`ProtocolConnectionSupervisor` + `ConnectionEventRouter`) эти два элемента считаются одним orchestration-этапом при анализе hop-depth.
6. `ProtocolClientImpl` трактуется как инфраструктурный DI-adapter и учитывается отдельно от business-flow hop budget.
---
## 13. Плюсы и минусы текущей архитектуры
### 13.1 Плюсы
- Четко выделены слои: UI, domain use-cases, network runtime, session/identity, data/persistence.
- DI через Hilt и `ProtocolGateway`/`SessionCoordinator` снижает прямую связанность между UI и transport/runtime.
- Убраны `UiEntryPoint`/`EntryPointAccessors` из UI-экранов, что улучшило явность зависимостей.
- Закрыт критичный DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` через `Provider<ProtocolRuntime>`.
- Network runtime декомпозирован на отдельные сервисы/coordinator-ы с более узкими зонами ответственности.
- Сокращен DI runtime path: `ProtocolGateway` биндится напрямую на `ProtocolRuntime`, runtime работает напрямую с `RuntimeComposition`.
- Централизован packet subscription fan-out (`PacketSubscriptionRegistry` + `PacketSubscriptionFacade`), что снижает риск дублирующих low-level подписок.
- В chat-модуле выделен domain use-case слой и вынесены крупные сценарии в coordinators.
### 13.2 Минусы
- `RuntimeComposition` и `ChatViewModel` остаются очень крупными hotspot-классами и концентрируют много связей.
- Runtime API-слой пока широкий: много proxy-методов усложняют контроль границ и эволюцию surface API.
- В части chat/media orchestration (`AttachmentsFeatureCoordinator`, `MessagesCoordinator`, `ForwardCoordinator`) сохраняются большие high-level сценарии.
- Мало unit/integration тестов на архитектурно-критичные runtime/chat orchestration компоненты.
- В проекте остаются несколько точек доступа к runtime (`ProtocolRuntime`, `ProtocolRuntimePort`, `ProtocolManager` legacy), что повышает cognitive load для новых разработчиков.
- Стоимость входа в кодовую базу выросла: для трассировки одного бизнес-флоу нужно проходить больше слоев, чем раньше.
### 13.3 Итог оценки
- Текущая архитектура стала заметно лучше по управляемости зависимостей и изоляции ответственности.
- Главные риски сместились из “монолитного класса” в “размер composition/API surface и недотестированность orchestration”.
- При соблюдении guardrails (секция 12) и фокусе на тестах/дальнейшей локальной декомпозиции архитектура остается управляемой и не уходит в избыточную сложность.

View File

@@ -177,7 +177,7 @@ class MainActivity : FragmentActivity() {
RecentSearchesManager.init(this)
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
// 🔥 Инициализируем Protocol runtime для обработки онлайн статусов
protocolGateway.initialize(this)
CallManager.initialize(this)
com.rosetta.messenger.ui.chats.components.AttachmentDownloadDebugLogger.init(this)
@@ -511,6 +511,7 @@ class MainActivity : FragmentActivity() {
hasExistingAccount = screen == "auth_unlock",
accounts = accountInfoList,
accountManager = accountManager,
protocolGateway = protocolGateway,
sessionCoordinator = sessionCoordinator,
startInCreateMode = startCreateAccountFlow,
onAuthComplete = { account ->
@@ -619,6 +620,7 @@ class MainActivity : FragmentActivity() {
},
accountManager = accountManager,
preferencesManager = preferencesManager,
messageRepository = messageRepository,
groupRepository = groupRepository,
protocolGateway = protocolGateway,
identityGateway = identityGateway,
@@ -775,6 +777,7 @@ class MainActivity : FragmentActivity() {
"device_confirm" -> {
DeviceConfirmScreen(
isDarkTheme = isDarkTheme,
protocolGateway = protocolGateway,
onExit = {
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
@@ -1120,6 +1123,7 @@ fun MainScreen(
onNavStackChanged: (List<Screen>) -> Unit = {},
accountManager: AccountManager,
preferencesManager: PreferencesManager,
messageRepository: MessageRepository,
groupRepository: GroupRepository,
protocolGateway: ProtocolGateway,
identityGateway: IdentityGateway,
@@ -1736,6 +1740,9 @@ fun MainScreen(
onTogglePin = { opponentKey ->
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
},
protocolGateway = protocolGateway,
accountManager = accountManager,
preferencesManager = prefsManager,
chatsViewModel = chatsListViewModel,
avatarRepository = avatarRepository,
callUiState = callUiState,
@@ -1828,6 +1835,7 @@ fun MainScreen(
) {
NotificationsScreen(
isDarkTheme = isDarkTheme,
preferencesManager = prefsManager,
onBack = { navStack = navStack.filterNot { it is Screen.Notifications } }
)
}
@@ -1951,6 +1959,7 @@ fun MainScreen(
) {
com.rosetta.messenger.ui.settings.AppIconScreen(
isDarkTheme = isDarkTheme,
preferencesManager = prefsManager,
onBack = { navStack = navStack.filterNot { it is Screen.AppIcon } }
)
}
@@ -2007,6 +2016,10 @@ fun MainScreen(
// Экран чата
ChatDetailScreen(
user = currentChatUser,
protocolGateway = protocolGateway,
preferencesManager = prefsManager,
messageRepository = messageRepository,
groupRepository = groupRepository,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
currentUserName = accountName,
@@ -2107,6 +2120,10 @@ fun MainScreen(
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme,
protocolGateway = protocolGateway,
messageRepository = messageRepository,
preferencesManager = prefsManager,
groupRepository = groupRepository,
avatarRepository = avatarRepository,
onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } },
onMemberClick = { member ->
@@ -2141,6 +2158,8 @@ fun MainScreen(
SearchScreen(
privateKeyHash = privateKeyHash,
currentUserPublicKey = accountPublicKey,
accountManager = accountManager,
messageRepository = messageRepository,
isDarkTheme = isDarkTheme,
protocolState = protocolState,
onBackClick = { navStack = navStack.filterNot { it is Screen.Search } },
@@ -2172,6 +2191,8 @@ fun MainScreen(
accountPrivateKey = accountPrivateKey,
accountName = accountName,
accountUsername = accountUsername,
messageRepository = messageRepository,
groupRepository = groupRepository,
avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
@@ -2216,6 +2237,7 @@ fun MainScreen(
) {
ConnectionLogsScreen(
isDarkTheme = isDarkTheme,
protocolGateway = protocolGateway,
onBack = { navStack = navStack.filterNot { it is Screen.ConnectionLogs } }
)
}
@@ -2237,6 +2259,8 @@ fun MainScreen(
OtherProfileScreen(
user = currentOtherUser,
isDarkTheme = isDarkTheme,
preferencesManager = prefsManager,
messageRepository = messageRepository,
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
onSwipeBackEnabledChanged = { enabled ->
isOtherProfileSwipeEnabled = enabled

View File

@@ -1496,7 +1496,7 @@ class MessageRepository @Inject constructor(
}
/**
* 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

View File

@@ -8,10 +8,9 @@ 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.ProtocolManager
import com.rosetta.messenger.network.ProtocolRuntime
import com.rosetta.messenger.network.ProtocolRuntimeCore
import com.rosetta.messenger.network.ProtocolState
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
@@ -30,46 +29,31 @@ import javax.inject.Provider
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
interface ProtocolGateway {
val state: StateFlow<ProtocolState>
interface ProtocolGateway : ProtocolRuntimePort {
val syncInProgress: StateFlow<Boolean>
val pendingDeviceVerification: StateFlow<DeviceEntry?>
val typingUsers: StateFlow<Set<String>>
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>>
val debugLogs: StateFlow<List<String>>
val ownProfileUpdated: StateFlow<Long>
fun initialize(context: Context)
fun initializeAccount(publicKey: String, privateKey: String)
fun connect()
fun authenticate(publicKey: String, privateHash: String)
fun reconnectNowIfNeeded(reason: String)
fun disconnect()
fun isAuthenticated(): Boolean
fun getPrivateHash(): String?
fun subscribePushTokenIfAvailable(forceToken: String? = null)
fun addLog(message: String)
fun enableUILogs(enabled: Boolean)
fun clearLogs()
fun resolveOutgoingRetry(messageId: String)
fun getCachedUserByUsername(username: String): SearchUser?
fun getCachedUserName(publicKey: String): String?
fun getCachedUserInfo(publicKey: String): SearchUser?
fun acceptDevice(deviceId: String)
fun declineDevice(deviceId: String)
fun send(packet: Packet)
fun sendPacket(packet: Packet)
fun sendMessageWithRetry(packet: PacketMessage)
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
fun packetFlow(packetId: Int): SharedFlow<Packet>
fun notifyOwnProfileUpdated()
fun restoreAuthFromStoredCredentials(
preferredPublicKey: String? = null,
reason: String = "background_restore"
): Boolean
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser?
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
}
@@ -107,92 +91,12 @@ interface IdentityGateway {
}
@Singleton
class ProtocolGatewayImpl @Inject constructor(
private val runtime: ProtocolRuntime
) : ProtocolGateway {
override val state: StateFlow<ProtocolState> = runtime.state
override val syncInProgress: StateFlow<Boolean> = runtime.syncInProgress
override val pendingDeviceVerification: StateFlow<DeviceEntry?> = runtime.pendingDeviceVerification
override val typingUsers: StateFlow<Set<String>> = runtime.typingUsers
override val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
runtime.typingUsersByDialogSnapshot
override val debugLogs: StateFlow<List<String>> = runtime.debugLogs
override val ownProfileUpdated: StateFlow<Long> = runtime.ownProfileUpdated
override fun initialize(context: Context) = runtime.initialize(context)
override fun initializeAccount(publicKey: String, privateKey: String) =
runtime.initializeAccount(publicKey, privateKey)
override fun connect() = runtime.connect()
override fun authenticate(publicKey: String, privateHash: String) =
runtime.authenticate(publicKey, privateHash)
override fun reconnectNowIfNeeded(reason: String) = runtime.reconnectNowIfNeeded(reason)
override fun disconnect() = runtime.disconnect()
override fun isAuthenticated(): Boolean = runtime.isAuthenticated()
override fun getPrivateHash(): String? = runtime.getPrivateHash()
override fun subscribePushTokenIfAvailable(forceToken: String?) =
runtime.subscribePushTokenIfAvailable(forceToken)
override fun addLog(message: String) = runtime.addLog(message)
override fun enableUILogs(enabled: Boolean) = runtime.enableUILogs(enabled)
override fun clearLogs() = runtime.clearLogs()
override fun resolveOutgoingRetry(messageId: String) = runtime.resolveOutgoingRetry(messageId)
override fun getCachedUserByUsername(username: String): SearchUser? =
runtime.getCachedUserByUsername(username)
override fun getCachedUserName(publicKey: String): String? =
runtime.getCachedUserName(publicKey)
override fun getCachedUserInfo(publicKey: String): SearchUser? =
runtime.getCachedUserInfo(publicKey)
override fun acceptDevice(deviceId: String) = runtime.acceptDevice(deviceId)
override fun declineDevice(deviceId: String) = runtime.declineDevice(deviceId)
override fun send(packet: Packet) = runtime.send(packet)
override fun sendPacket(packet: Packet) = runtime.sendPacket(packet)
override fun sendMessageWithRetry(packet: PacketMessage) = runtime.sendMessageWithRetry(packet)
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
runtime.waitPacket(packetId, callback)
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
runtime.unwaitPacket(packetId, callback)
override fun packetFlow(packetId: Int): SharedFlow<Packet> = runtime.packetFlow(packetId)
override fun notifyOwnProfileUpdated() = runtime.notifyOwnProfileUpdated()
override fun restoreAuthFromStoredCredentials(
preferredPublicKey: String?,
reason: String
): Boolean = runtime.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
runtime.resolveUserName(publicKey, timeoutMs)
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
runtime.resolveUserInfo(publicKey, timeoutMs)
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
runtime.searchUsers(query, timeoutMs)
}
@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 {
@@ -270,7 +174,7 @@ object AppDataModule {
@Provides
@Singleton
fun provideProtocolRuntimeCore(): ProtocolRuntimeCore = ProtocolManager
fun provideRuntimeComposition(): RuntimeComposition = RuntimeComposition()
}
@@ -279,7 +183,7 @@ object AppDataModule {
abstract class AppGatewayModule {
@Binds
@Singleton
abstract fun bindProtocolGateway(impl: ProtocolGatewayImpl): ProtocolGateway
abstract fun bindProtocolGateway(runtime: ProtocolRuntime): ProtocolGateway
@Binds
@Singleton

View File

@@ -1,21 +0,0 @@
package com.rosetta.messenger.di
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.PreferencesManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UiEntryPoint {
fun protocolGateway(): ProtocolGateway
fun sessionCoordinator(): SessionCoordinator
fun identityGateway(): IdentityGateway
fun accountManager(): AccountManager
fun preferencesManager(): PreferencesManager
fun messageRepository(): MessageRepository
fun groupRepository(): GroupRepository
}

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

@@ -1,10 +1,12 @@
package com.rosetta.messenger.ui.chats.usecase
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,
@@ -16,8 +18,8 @@ data class ForwardPayloadMessage(
val attachments: List<MessageAttachment>
)
class SendForwardUseCase(
private val sendWithRetry: (PacketMessage) -> Unit
class SendForwardUseCase @Inject constructor(
private val protocolGateway: ProtocolGateway
) {
fun buildForwardReplyJson(
messages: List<ForwardPayloadMessage>,
@@ -96,7 +98,7 @@ class SendForwardUseCase(
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
if (!isSavedMessages) {
sendWithRetry(packet)
protocolGateway.sendMessageWithRetry(packet)
}
}
}

View File

@@ -1,7 +1,9 @@
package com.rosetta.messenger.ui.chats.usecase
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,
@@ -16,8 +18,8 @@ data class SendMediaMessageCommand(
val isSavedMessages: Boolean
)
class SendMediaMessageUseCase(
private val sendWithRetry: (PacketMessage) -> Unit
class SendMediaMessageUseCase @Inject constructor(
private val protocolGateway: ProtocolGateway
) {
operator fun invoke(command: SendMediaMessageCommand): PacketMessage {
val packet =
@@ -34,14 +36,14 @@ class SendMediaMessageUseCase(
}
if (!command.isSavedMessages) {
sendWithRetry(packet)
protocolGateway.sendMessageWithRetry(packet)
}
return packet
}
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
if (!isSavedMessages) {
sendWithRetry(packet)
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

@@ -1,7 +1,9 @@
package com.rosetta.messenger.ui.chats.usecase
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,
@@ -16,8 +18,8 @@ data class SendTextMessageCommand(
val isSavedMessages: Boolean
)
class SendTextMessageUseCase(
private val sendWithRetry: (PacketMessage) -> Unit
class SendTextMessageUseCase @Inject constructor(
private val protocolGateway: ProtocolGateway
) {
operator fun invoke(command: SendTextMessageCommand): PacketMessage {
val packet =
@@ -34,14 +36,14 @@ class SendTextMessageUseCase(
}
if (!command.isSavedMessages) {
sendWithRetry(packet)
protocolGateway.sendMessageWithRetry(packet)
}
return packet
}
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
if (!isSavedMessages) {
sendWithRetry(packet)
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

@@ -527,7 +527,7 @@ class Protocol(
private var lastPrivateHash: String? = null
private var lastDevice: HandshakeDevice = HandshakeDevice()
// Getters for ProtocolManager to fetch own profile
// Getters for runtime layers to fetch own profile
fun getPublicKey(): String? = lastPublicKey
fun getPrivateHash(): String? = lastPrivateHash

View File

@@ -1,5 +1,11 @@
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)
@@ -7,4 +13,3 @@ interface ProtocolClient {
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
}

View File

@@ -1,7 +1,81 @@
package com.rosetta.messenger.network
import kotlinx.coroutines.flow.StateFlow
/**
* Compatibility facade for legacy static call-sites.
* Runtime logic lives in [ProtocolRuntimeCore].
* Minimal compatibility facade for legacy static call-sites.
*
* New code should use injected [ProtocolRuntime]/[ProtocolGateway] or [ProtocolRuntimeAccess] directly.
*/
object ProtocolManager : ProtocolRuntimeCore()
@Deprecated(
message = "Use injected ProtocolRuntime/ProtocolGateway or ProtocolRuntimeAccess directly."
)
object ProtocolManager {
val state: StateFlow<ProtocolState>
get() = runtime().state
val debugLogs: StateFlow<List<String>>
get() = runtime().debugLogs
fun addLog(message: String) = runtime().addLog(message)
fun send(packet: Packet) = runtime().send(packet)
fun sendPacket(packet: Packet) = runtime().sendPacket(packet)
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
runtime().waitPacket(packetId, callback)
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
runtime().unwaitPacket(packetId, callback)
fun requestIceServers() = runtime().requestIceServers()
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit =
runtime().waitCallSignal(callback)
fun unwaitCallSignal(callback: (Packet) -> Unit) =
runtime().unwaitCallSignal(callback)
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit =
runtime().waitWebRtcSignal(callback)
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) =
runtime().unwaitWebRtcSignal(callback)
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit =
runtime().waitIceServers(callback)
fun unwaitIceServers(callback: (Packet) -> Unit) =
runtime().unwaitIceServers(callback)
fun getCachedUserInfo(publicKey: String): SearchUser? =
runtime().getCachedUserInfo(publicKey)
fun isAuthenticated(): Boolean = runtime().isAuthenticated()
fun restoreAuthFromStoredCredentials(
preferredPublicKey: String? = null,
reason: String = "background_restore"
): Boolean = runtime().restoreAuthFromStoredCredentials(preferredPublicKey, reason)
fun reconnectNowIfNeeded(reason: String = "foreground_resume") =
runtime().reconnectNowIfNeeded(reason)
fun sendCallSignal(
signalType: SignalType,
src: String = "",
dst: String = "",
sharedPublic: String = "",
callId: String = "",
joinToken: String = ""
) = runtime().sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) =
runtime().sendWebRtcSignal(signalType, sdpOrCandidate)
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? =
runtime().resolveUserInfo(publicKey, timeoutMs)
private fun runtime(): ProtocolRuntimePort = ProtocolRuntimeAccess.get()
}

View File

@@ -4,6 +4,7 @@ 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
@@ -11,119 +12,127 @@ import javax.inject.Singleton
@Singleton
class ProtocolRuntime @Inject constructor(
private val core: ProtocolRuntimeCore,
private val runtimeComposition: RuntimeComposition,
private val messageRepository: MessageRepository,
private val groupRepository: GroupRepository,
private val accountManager: AccountManager
) : ProtocolRuntimePort {
) : ProtocolRuntimePort, ProtocolGateway {
init {
bindDependencies()
}
override val state: StateFlow<ProtocolState> get() = core.state
val syncInProgress: StateFlow<Boolean> get() = core.syncInProgress
val pendingDeviceVerification: StateFlow<DeviceEntry?> get() = core.pendingDeviceVerification
val typingUsers: StateFlow<Set<String>> get() = core.typingUsers
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> get() =
core.typingUsersByDialogSnapshot
override val debugLogs: StateFlow<List<String>> get() = core.debugLogs
val ownProfileUpdated: StateFlow<Long> get() = core.ownProfileUpdated
private val connectionControlApi by lazy { runtimeComposition.connectionControlApi() }
private val directoryApi by lazy { runtimeComposition.directoryApi() }
private val packetIoApi by lazy { runtimeComposition.packetIoApi() }
fun initialize(context: Context) {
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()
core.initialize(context)
connectionControlApi.initialize(context)
}
fun initializeAccount(publicKey: String, privateKey: String) =
core.initializeAccount(publicKey, privateKey)
override fun initializeAccount(publicKey: String, privateKey: String) =
connectionControlApi.initializeAccount(publicKey, privateKey)
fun connect() = core.connect()
override fun connect() = connectionControlApi.connect()
fun authenticate(publicKey: String, privateHash: String) =
core.authenticate(publicKey, privateHash)
override fun authenticate(publicKey: String, privateHash: String) =
connectionControlApi.authenticate(publicKey, privateHash)
override fun reconnectNowIfNeeded(reason: String) = core.reconnectNowIfNeeded(reason)
override fun reconnectNowIfNeeded(reason: String) =
connectionControlApi.reconnectNowIfNeeded(reason)
fun disconnect() = core.disconnect()
override fun disconnect() = connectionControlApi.disconnect()
override fun isAuthenticated(): Boolean = core.isAuthenticated()
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
fun getPrivateHash(): String? =
runCatching { core.getProtocol().getPrivateHash() }.getOrNull()
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()
fun subscribePushTokenIfAvailable(forceToken: String? = null) =
core.subscribePushTokenIfAvailable(forceToken)
override fun subscribePushTokenIfAvailable(forceToken: String?) =
connectionControlApi.subscribePushToken(forceToken)
override fun addLog(message: String) = core.addLog(message)
override fun addLog(message: String) = runtimeComposition.addLog(message)
fun enableUILogs(enabled: Boolean) = core.enableUILogs(enabled)
override fun enableUILogs(enabled: Boolean) = runtimeComposition.enableUILogs(enabled)
fun clearLogs() = core.clearLogs()
override fun clearLogs() = runtimeComposition.clearLogs()
fun resolveOutgoingRetry(messageId: String) = core.resolveOutgoingRetry(messageId)
override fun resolveOutgoingRetry(messageId: String) =
packetIoApi.resolveOutgoingRetry(messageId)
fun getCachedUserByUsername(username: String): SearchUser? =
core.getCachedUserByUsername(username)
override fun getCachedUserByUsername(username: String): SearchUser? =
directoryApi.getCachedUserByUsername(username)
fun getCachedUserName(publicKey: String): String? =
core.getCachedUserName(publicKey)
override fun getCachedUserName(publicKey: String): String? =
directoryApi.getCachedUserName(publicKey)
override fun getCachedUserInfo(publicKey: String): SearchUser? =
core.getCachedUserInfo(publicKey)
directoryApi.getCachedUserInfo(publicKey)
fun acceptDevice(deviceId: String) = core.acceptDevice(deviceId)
override fun acceptDevice(deviceId: String) = directoryApi.acceptDevice(deviceId)
fun declineDevice(deviceId: String) = core.declineDevice(deviceId)
override fun declineDevice(deviceId: String) = directoryApi.declineDevice(deviceId)
override fun send(packet: Packet) = core.send(packet)
override fun send(packet: Packet) = packetIoApi.send(packet)
override fun sendPacket(packet: Packet) = core.sendPacket(packet)
override fun sendPacket(packet: Packet) = packetIoApi.sendPacket(packet)
fun sendMessageWithRetry(packet: PacketMessage) = core.sendMessageWithRetry(packet)
override fun sendMessageWithRetry(packet: PacketMessage) =
packetIoApi.sendMessageWithRetry(packet)
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
core.waitPacket(packetId, callback)
packetIoApi.waitPacket(packetId, callback)
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
core.unwaitPacket(packetId, callback)
packetIoApi.unwaitPacket(packetId, callback)
fun packetFlow(packetId: Int): SharedFlow<Packet> = core.packetFlow(packetId)
override fun packetFlow(packetId: Int): SharedFlow<Packet> =
packetIoApi.packetFlow(packetId)
fun notifyOwnProfileUpdated() = core.notifyOwnProfileUpdated()
override fun notifyOwnProfileUpdated() = directoryApi.notifyOwnProfileUpdated()
override fun restoreAuthFromStoredCredentials(
preferredPublicKey: String?,
reason: String
): Boolean = core.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
): Boolean = connectionControlApi.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? =
core.resolveUserName(publicKey, timeoutMs)
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
directoryApi.resolveUserName(publicKey, timeoutMs)
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
core.resolveUserInfo(publicKey, timeoutMs)
directoryApi.resolveUserInfo(publicKey, timeoutMs)
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> =
core.searchUsers(query, timeoutMs)
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
directoryApi.searchUsers(query, timeoutMs)
override fun requestIceServers() = core.requestIceServers()
override fun requestIceServers() = packetIoApi.requestIceServers()
override fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit =
core.waitCallSignal(callback)
packetIoApi.waitCallSignal(callback)
override fun unwaitCallSignal(callback: (Packet) -> Unit) =
core.unwaitCallSignal(callback)
packetIoApi.unwaitCallSignal(callback)
override fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit =
core.waitWebRtcSignal(callback)
packetIoApi.waitWebRtcSignal(callback)
override fun unwaitWebRtcSignal(callback: (Packet) -> Unit) =
core.unwaitWebRtcSignal(callback)
packetIoApi.unwaitWebRtcSignal(callback)
override fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit =
core.waitIceServers(callback)
packetIoApi.waitIceServers(callback)
override fun unwaitIceServers(callback: (Packet) -> Unit) =
core.unwaitIceServers(callback)
packetIoApi.unwaitIceServers(callback)
override fun sendCallSignal(
signalType: SignalType,
@@ -132,13 +141,13 @@ class ProtocolRuntime @Inject constructor(
sharedPublic: String,
callId: String,
joinToken: String
) = core.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
) = packetIoApi.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
override fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) =
core.sendWebRtcSignal(signalType, sdpOrCandidate)
packetIoApi.sendWebRtcSignal(signalType, sdpOrCandidate)
private fun bindDependencies() {
core.bindDependencies(
runtimeComposition.bindDependencies(
messageRepository = messageRepository,
groupRepository = groupRepository,
accountManager = accountManager

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -127,7 +127,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
saveFcmToken(token)
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
// Используем единую точку отправки в ProtocolManager (с дедупликацией).
// Используем единую runtime-точку отправки (с дедупликацией).
if (protocolGateway.isAuthenticated()) {
runCatching { protocolGateway.subscribePushTokenIfAvailable(forceToken = token) }
}
@@ -751,7 +751,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
}.getOrDefault(false)
}
/** Получить имя пользователя по publicKey (кэш ProtocolManager → БД dialogs) */
/** Получить имя пользователя по publicKey (runtime-кэш → БД dialogs) */
private fun resolveNameForKey(publicKey: String?): String? {
if (publicKey.isNullOrBlank()) return null
// 1. In-memory cache

View File

@@ -8,6 +8,7 @@ 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 {
@@ -28,6 +29,7 @@ fun AuthFlow(
hasExistingAccount: Boolean,
accounts: List<AccountInfo> = emptyList(),
accountManager: AccountManager,
protocolGateway: ProtocolGateway,
sessionCoordinator: SessionCoordinator,
startInCreateMode: Boolean = false,
onAuthComplete: (DecryptedAccount?) -> Unit,
@@ -212,6 +214,8 @@ fun AuthFlow(
SetProfileScreen(
isDarkTheme = isDarkTheme,
account = createdAccount,
protocolGateway = protocolGateway,
accountManager = accountManager,
onComplete = { onAuthComplete(createdAccount) },
onSkip = { onAuthComplete(createdAccount) }
)

View File

@@ -37,7 +37,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -53,8 +52,7 @@ 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.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.DeviceResolveSolution
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketDeviceResolve
@@ -66,11 +64,9 @@ import kotlinx.coroutines.launch
@Composable
fun DeviceConfirmScreen(
isDarkTheme: Boolean,
protocolGateway: ProtocolGateway,
onExit: () -> Unit
) {
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {

View File

@@ -27,9 +27,9 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.PacketUserInfo
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
@@ -71,13 +71,12 @@ private fun validateUsername(username: String): String? {
fun SetProfileScreen(
isDarkTheme: Boolean,
account: DecryptedAccount?,
protocolGateway: ProtocolGateway,
accountManager: AccountManager,
onComplete: () -> Unit,
onSkip: () -> Unit
) {
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val accountManager = remember(uiDeps) { uiDeps.accountManager() }
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }

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

@@ -93,15 +93,15 @@ 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.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.repository.AvatarRepository
@@ -323,6 +323,10 @@ fun ChatDetailScreen(
user: SearchUser,
onBack: () -> Unit,
onNavigateToChat: (SearchUser) -> Unit,
protocolGateway: ProtocolGateway,
preferencesManager: PreferencesManager,
messageRepository: MessageRepository,
groupRepository: GroupRepository,
onCallClick: (SearchUser) -> Unit = {},
onUserProfileClick: (SearchUser) -> Unit = {},
onGroupInfoClick: (SearchUser) -> Unit = {},
@@ -342,11 +346,11 @@ fun ChatDetailScreen(
onVoiceWaveGestureChanged: (Boolean) -> Unit = {}
) {
val viewModel: ChatViewModel = hiltViewModel(key = "chat_${user.publicKey}")
val messagesViewModel = remember(viewModel) { viewModel.messagesViewModel }
val typingViewModel = remember(viewModel) { viewModel.typingViewModel }
val voiceRecordingViewModel = remember(viewModel) { viewModel.voiceRecordingViewModel }
val attachmentsViewModel = remember(viewModel) { viewModel.attachmentsViewModel }
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
@@ -437,9 +441,9 @@ fun ChatDetailScreen(
var contextMenuIsPinned by remember { mutableStateOf(false) }
// 📌 PINNED MESSAGES
val pinnedMessages by viewModel.pinnedMessages.collectAsState()
val pinnedMessagePreviews by viewModel.pinnedMessagePreviews.collectAsState()
val currentPinnedIndex by viewModel.currentPinnedIndex.collectAsState()
val pinnedMessages by messagesViewModel.pinnedMessages.collectAsState()
val pinnedMessagePreviews by messagesViewModel.pinnedMessagePreviews.collectAsState()
val currentPinnedIndex by messagesViewModel.currentPinnedIndex.collectAsState()
var isPinnedBannerDismissed by remember { mutableStateOf(false) }
// Логирование изменений selection mode
@@ -547,7 +551,7 @@ fun ChatDetailScreen(
val resolvedVerified =
runCatching {
viewModel.resolveUserForProfile(normalizedPublicKey)?.verified
messagesViewModel.resolveUserForProfile(normalizedPublicKey)?.verified
?: 0
}
.getOrDefault(0)
@@ -724,7 +728,7 @@ fun ChatDetailScreen(
val base64 = MediaUtils.uriToBase64File(context, uri)
if (base64 != null) {
viewModel.sendFileMessage(
attachmentsViewModel.sendFileMessage(
base64,
fileName,
fileSize
@@ -737,7 +741,6 @@ fun ChatDetailScreen(
// 📨 Forward: список диалогов для выбора (загружаем из базы)
val chatsListViewModel: ChatsListViewModel = hiltViewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val groupMembersCacheKey =
remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
@@ -802,7 +805,7 @@ fun ChatDetailScreen(
normalizedMembers
.filter { !it.equals(currentUserPublicKey.trim(), ignoreCase = true) }
.mapNotNull { memberKey ->
val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null
val resolvedUser = messagesViewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null
val normalizedUsername = resolvedUser.username.trim().trimStart('@')
MentionCandidate(
username = normalizedUsername,
@@ -839,17 +842,17 @@ fun ChatDetailScreen(
.collectAsState(initial = false)
// Подключаем к ViewModel
val messages by viewModel.messages.collectAsState()
val isTyping by viewModel.opponentTyping.collectAsState()
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
val messages by messagesViewModel.messages.collectAsState()
val isTyping by typingViewModel.opponentTyping.collectAsState()
val typingDisplayName by typingViewModel.typingDisplayName.collectAsState()
val typingDisplayPublicKey by typingViewModel.typingDisplayPublicKey.collectAsState()
@Suppress("UNUSED_VARIABLE")
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
val rawIsOnline by viewModel.opponentOnline.collectAsState()
val isLoadingMore by messagesViewModel.isLoadingMore.collectAsState()
val rawIsOnline by typingViewModel.opponentOnline.collectAsState()
// If typing, the user is obviously online — never show "offline" while typing
val isOnline = rawIsOnline || isTyping
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
val chatOpenMetrics by viewModel.chatOpenMetrics.collectAsState()
val isLoading by messagesViewModel.isLoading.collectAsState() // 🔥 Для скелетона
val chatOpenMetrics by messagesViewModel.chatOpenMetrics.collectAsState()
val performanceClass =
remember(context.applicationContext) {
DevicePerformanceClass.get(context.applicationContext)
@@ -860,7 +863,7 @@ fun ChatDetailScreen(
performanceClass == PerformanceClass.AVERAGE) &&
chatOpenMetrics.firstListLayoutMs == null
}
val groupRequiresRejoin by viewModel.groupRequiresRejoin.collectAsState()
val groupRequiresRejoin by messagesViewModel.groupRequiresRejoin.collectAsState()
val showMessageSkeleton by
produceState(initialValue = false, key1 = isLoading) {
if (!isLoading) {
@@ -872,9 +875,9 @@ fun ChatDetailScreen(
}
// <20>🔥 Reply/Forward state
val replyMessages by viewModel.replyMessages.collectAsState()
val isForwardMode by viewModel.isForwardMode.collectAsState()
val pendingDeleteIds by viewModel.pendingDeleteIds.collectAsState()
val replyMessages by messagesViewModel.replyMessages.collectAsState()
val isForwardMode by messagesViewModel.isForwardMode.collectAsState()
val pendingDeleteIds by messagesViewModel.pendingDeleteIds.collectAsState()
// Avatar-сообщения не должны попадать в selection ни при каких условиях.
val avatarMessageIds =
@@ -951,9 +954,9 @@ fun ChatDetailScreen(
// Загружаем когда осталось 5 элементов до конца и не идёт загрузка
if (total > 0 &&
lastVisible >= total - 5 &&
!viewModel.isLoadingMore.value
!messagesViewModel.isLoadingMore.value
) {
viewModel.loadMoreMessages()
messagesViewModel.loadMoreMessages()
}
}
}
@@ -1010,7 +1013,7 @@ fun ChatDetailScreen(
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
// (dedup + sort + date headers off the main thread)
val messagesWithDates by viewModel.messagesWithDates.collectAsState()
val messagesWithDates by messagesViewModel.messagesWithDates.collectAsState()
val resolveSenderPublicKey: (ChatMessage?) -> String =
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
{ msg ->
@@ -1256,7 +1259,7 @@ fun ChatDetailScreen(
if (normalizedPublicKey.isNotBlank()) {
scope.launch {
val resolvedUser =
viewModel.resolveUserForProfile(normalizedPublicKey)
messagesViewModel.resolveUserForProfile(normalizedPublicKey)
if (resolvedUser != null) {
showContextMenu = false
contextMenuMessage = null
@@ -1275,12 +1278,12 @@ fun ChatDetailScreen(
// Находим индекс сообщения в списке
var messageIndex = messagesWithDates.indexOfFirst { it.first.id == messageId }
if (messageIndex == -1) {
val loaded = viewModel.ensureMessageLoaded(messageId)
val loaded = messagesViewModel.ensureMessageLoaded(messageId)
if (loaded) {
for (attempt in 0 until 8) {
delay(16)
messageIndex =
viewModel.messagesWithDates.value.indexOfFirst {
messagesViewModel.messagesWithDates.value.indexOfFirst {
it.first.id == messageId
}
if (messageIndex != -1) break
@@ -1420,15 +1423,15 @@ fun ChatDetailScreen(
when (event) {
Lifecycle.Event.ON_RESUME -> {
isScreenActive = true
viewModel.setDialogActive(true)
viewModel.markVisibleMessagesAsRead()
messagesViewModel.setDialogActive(true)
messagesViewModel.markVisibleMessagesAsRead()
// 🔥 Убираем уведомление этого чата из шторки
com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey)
}
Lifecycle.Event.ON_PAUSE -> {
isScreenActive = false
viewModel.setDialogActive(false)
messagesViewModel.setDialogActive(false)
}
Lifecycle.Event.ON_STOP -> {
// Hard-stop camera/picker overlays when app goes background.
@@ -1444,7 +1447,7 @@ fun ChatDetailScreen(
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
viewModel.closeDialog()
messagesViewModel.closeDialog()
}
}
@@ -1462,20 +1465,20 @@ fun ChatDetailScreen(
) {
val normalizedPublicKey = currentUserPublicKey.trim()
val normalizedPrivateKey = currentUserPrivateKey.trim()
viewModel.setUserKeys(normalizedPublicKey, normalizedPrivateKey)
messagesViewModel.setUserKeys(normalizedPublicKey, normalizedPrivateKey)
if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) {
// Fresh registration path can render Chat UI before account keys arrive.
// Avoid opening dialog with empty sender/private key.
return@LaunchedEffect
}
viewModel.openDialog(user.publicKey, user.title, user.username, user.verified)
viewModel.markVisibleMessagesAsRead()
messagesViewModel.openDialog(user.publicKey, user.title, user.username, user.verified)
messagesViewModel.markVisibleMessagesAsRead()
// 🔥 Убираем уведомление этого чата из шторки при заходе
com.rosetta.messenger.push.RosettaFirebaseMessagingService
.cancelNotificationForChat(context, user.publicKey)
// Подписываемся на онлайн статус собеседника
if (!isSavedMessages && !isGroupChat) {
viewModel.subscribeToOnlineStatus()
messagesViewModel.subscribeToOnlineStatus()
}
}
@@ -1488,7 +1491,7 @@ fun ChatDetailScreen(
.distinctUntilChanged()
.collect { isReady ->
if (isReady) {
viewModel.markFirstListLayoutReady()
messagesViewModel.markFirstListLayoutReady()
}
}
}
@@ -1498,7 +1501,7 @@ fun ChatDetailScreen(
if (!deferredEmojiPreloadStarted && chatOpenMetrics.firstListLayoutMs != null) {
deferredEmojiPreloadStarted = true
delay(300)
viewModel.addChatOpenTraceEvent("deferred_emoji_preload_start")
messagesViewModel.addChatOpenTraceEvent("deferred_emoji_preload_start")
withContext(Dispatchers.Default) {
com.rosetta.messenger.ui.components.EmojiCache.preload(context)
}
@@ -1509,14 +1512,14 @@ fun ChatDetailScreen(
LaunchedEffect(user.publicKey, forwardTrigger) {
val pendingForwards = ForwardManager.consumeForwardMessagesForChat(user.publicKey)
if (pendingForwards.isNotEmpty()) {
viewModel.sendForwardDirectly(user.publicKey, pendingForwards)
messagesViewModel.sendForwardDirectly(user.publicKey, pendingForwards)
}
}
// Отмечаем сообщения как прочитанные только когда экран активен (RESUMED)
LaunchedEffect(messages, isScreenActive) {
if (messages.isNotEmpty() && isScreenActive) {
viewModel.markVisibleMessagesAsRead()
messagesViewModel.markVisibleMessagesAsRead()
}
}
@@ -1810,9 +1813,9 @@ fun ChatDetailScreen(
selectedPinMessageId
?: return@IconButton
if (selectedPinMessageIsPinned) {
viewModel.unpinMessage(targetId)
messagesViewModel.unpinMessage(targetId)
} else {
viewModel.pinMessage(targetId)
messagesViewModel.pinMessage(targetId)
isPinnedBannerDismissed =
false
}
@@ -1865,10 +1868,8 @@ fun ChatDetailScreen(
.forEach {
msg
->
viewModel
.deleteMessage(
msg.id
)
messagesViewModel
.deleteMessage(msg.id)
}
selectedMessages =
emptySet()
@@ -2449,7 +2450,7 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme,
onBannerClick = {
if (pinnedMessages.isNotEmpty()) {
val messageId = viewModel.navigateToNextPinned()
val messageId = messagesViewModel.navigateToNextPinned()
if (messageId != null) {
scrollToMessage(messageId)
}
@@ -2460,7 +2461,7 @@ fun ChatDetailScreen(
// 📌 Открепляем текущий показанный пин
val pinIdx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1)
val pinToRemove = pinnedMessages[pinIdx]
viewModel.unpinMessage(pinToRemove.messageId)
messagesViewModel.unpinMessage(pinToRemove.messageId)
}
}
)
@@ -2688,7 +2689,7 @@ fun ChatDetailScreen(
{ it.id }
)
)
viewModel
messagesViewModel
.setReplyMessages(
selectedMsgs
)
@@ -2905,20 +2906,21 @@ fun ChatDetailScreen(
}
}
} else if (!isSystemAccount) {
// INPUT BAR
// INPUT BAR
Column {
ChatInputBarSection(
viewModel = viewModel,
messagesViewModel = messagesViewModel,
typingViewModel = typingViewModel,
isSavedMessages = isSavedMessages,
onSend = {
isSendingMessage = true
viewModel.ensureSendContext(
messagesViewModel.ensureSendContext(
publicKey = user.publicKey,
title = user.title,
username = user.username,
verified = user.verified
)
viewModel.sendMessage()
messagesViewModel.sendMessage()
scope.launch {
delay(100)
listState.animateScrollToItem(0)
@@ -2928,13 +2930,13 @@ fun ChatDetailScreen(
},
onSendVoiceMessage = { voiceHex, durationSec, waves ->
isSendingMessage = true
viewModel.ensureSendContext(
messagesViewModel.ensureSendContext(
publicKey = user.publicKey,
title = user.title,
username = user.username,
verified = user.verified
)
viewModel.sendVoiceMessage(
voiceRecordingViewModel.sendVoiceMessage(
voiceHex = voiceHex,
durationSec = durationSec,
waves = waves
@@ -2954,7 +2956,7 @@ fun ChatDetailScreen(
replyMessages = replyMessages,
isForwardMode = isForwardMode,
onCloseReply = {
viewModel.clearReplyMessages()
messagesViewModel.clearReplyMessages()
},
onShowForwardOptions = { panelMessages ->
if (panelMessages.isEmpty()) {
@@ -3011,7 +3013,7 @@ fun ChatDetailScreen(
showMediaPicker = true
},
myPublicKey =
viewModel.myPublicKey ?: "",
messagesViewModel.myPublicKey ?: "",
opponentPublicKey = user.publicKey,
myPrivateKey = currentUserPrivateKey,
isGroupChat = isGroupChat,
@@ -3475,6 +3477,8 @@ fun ChatDetailScreen(
currentUserPublicKey,
currentUserUsername =
currentUserUsername,
groupRepository =
groupRepository,
avatarRepository =
avatarRepository,
onLongClick = {
@@ -3558,7 +3562,7 @@ fun ChatDetailScreen(
contextMenuMessage = message
showContextMenu = true
scope.launch {
contextMenuIsPinned = viewModel.isMessagePinned(message.id)
contextMenuIsPinned = messagesViewModel.isMessagePinned(message.id)
}
}
},
@@ -3574,12 +3578,8 @@ fun ChatDetailScreen(
if (!hasAvatar &&
!isSystemAccount
) {
viewModel
.setReplyMessages(
listOf(
message
)
)
messagesViewModel
.setReplyMessages(listOf(message))
}
},
onVoiceWaveGestureActiveChanged = { active ->
@@ -3593,20 +3593,16 @@ fun ChatDetailScreen(
)
},
onRetry = {
viewModel
.retryMessage(
message
)
messagesViewModel
.retryMessage(message)
},
onDelete = {
viewModel
.deleteMessage(
message.id
)
messagesViewModel
.deleteMessage(message.id)
},
onCancelPhotoUpload = {
attachmentId ->
viewModel
attachmentsViewModel
.cancelOutgoingImageUpload(
message.id,
attachmentId
@@ -3652,7 +3648,7 @@ fun ChatDetailScreen(
onForwardedSenderClick = { senderPublicKey ->
// Open profile of the forwarded message sender
scope.launch {
val resolvedUser = viewModel.resolveUserForProfile(senderPublicKey)
val resolvedUser = messagesViewModel.resolveUserForProfile(senderPublicKey)
if (resolvedUser != null) {
showContextMenu = false
contextMenuMessage = null
@@ -3693,7 +3689,7 @@ fun ChatDetailScreen(
if (targetPublicKey.isBlank()) {
val resolvedByUsername =
viewModel.resolveUserByUsername(normalizedUsername)
messagesViewModel.resolveUserByUsername(normalizedUsername)
if (resolvedByUsername != null) {
showContextMenu = false
contextMenuMessage = null
@@ -3717,7 +3713,7 @@ fun ChatDetailScreen(
return@launch
}
val resolvedUser = viewModel.resolveUserForProfile(targetPublicKey)
val resolvedUser = messagesViewModel.resolveUserForProfile(targetPublicKey)
if (resolvedUser != null) {
showContextMenu = false
contextMenuMessage = null
@@ -3744,7 +3740,7 @@ fun ChatDetailScreen(
hasText = extractCopyableMessageText(msg).isNotBlank(),
isSystemAccount = isSystemAccount,
onReply = {
viewModel.setReplyMessages(listOf(msg))
messagesViewModel.setReplyMessages(listOf(msg))
showContextMenu = false
contextMenuMessage = null
},
@@ -3797,16 +3793,16 @@ fun ChatDetailScreen(
},
onPin = {
if (contextMenuIsPinned) {
viewModel.unpinMessage(msg.id)
messagesViewModel.unpinMessage(msg.id)
} else {
viewModel.pinMessage(msg.id)
messagesViewModel.pinMessage(msg.id)
isPinnedBannerDismissed = false
}
showContextMenu = false
contextMenuMessage = null
},
onDelete = {
viewModel.deleteMessage(msg.id)
messagesViewModel.deleteMessage(msg.id)
showContextMenu = false
contextMenuMessage = null
}
@@ -4007,14 +4003,14 @@ fun ChatDetailScreen(
showMediaPicker = false
inputFocusTrigger++
if (imageUris.isNotEmpty()) {
viewModel.sendImageGroupFromUris(
attachmentsViewModel.sendImageGroupFromUris(
imageUris,
caption
)
}
if (videoUris.isNotEmpty()) {
videoUris.forEach { uri ->
viewModel.sendVideoCircleFromUri(uri)
attachmentsViewModel.sendVideoCircleFromUri(uri)
}
}
}
@@ -4023,9 +4019,9 @@ fun ChatDetailScreen(
showMediaPicker = false
inputFocusTrigger++
if (mediaItem.isVideo) {
viewModel.sendVideoCircleFromUri(mediaItem.uri)
attachmentsViewModel.sendVideoCircleFromUri(mediaItem.uri)
} else {
viewModel.sendImageFromUri(mediaItem.uri, caption)
attachmentsViewModel.sendImageFromUri(mediaItem.uri, caption)
}
},
onOpenCamera = {
@@ -4064,7 +4060,7 @@ fun ChatDetailScreen(
}
val base64 = MediaUtils.uriToBase64File(context, uri)
if (base64 != null) {
viewModel.sendFileMessage(base64, fileName, fileSize)
attachmentsViewModel.sendFileMessage(base64, fileName, fileSize)
}
}
}
@@ -4083,12 +4079,12 @@ fun ChatDetailScreen(
}
val base64 = MediaUtils.uriToBase64File(context, uri)
if (base64 != null) {
viewModel.sendFileMessage(base64, fileName, fileSize)
attachmentsViewModel.sendFileMessage(base64, fileName, fileSize)
}
}
},
onAvatarClick = {
viewModel.sendAvatarMessage()
attachmentsViewModel.sendAvatarMessage()
},
recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
@@ -4124,14 +4120,14 @@ fun ChatDetailScreen(
showMediaPicker = false
inputFocusTrigger++
if (imageUris.isNotEmpty()) {
viewModel.sendImageGroupFromUris(
attachmentsViewModel.sendImageGroupFromUris(
imageUris,
caption
)
}
if (videoUris.isNotEmpty()) {
videoUris.forEach { uri ->
viewModel.sendVideoCircleFromUri(uri)
attachmentsViewModel.sendVideoCircleFromUri(uri)
}
}
}
@@ -4140,9 +4136,9 @@ fun ChatDetailScreen(
showMediaPicker = false
inputFocusTrigger++
if (mediaItem.isVideo) {
viewModel.sendVideoCircleFromUri(mediaItem.uri)
attachmentsViewModel.sendVideoCircleFromUri(mediaItem.uri)
} else {
viewModel.sendImageFromUri(mediaItem.uri, caption)
attachmentsViewModel.sendImageFromUri(mediaItem.uri, caption)
}
},
onOpenCamera = {
@@ -4165,7 +4161,7 @@ fun ChatDetailScreen(
filePickerLauncher.launch("*/*")
},
onAvatarClick = {
viewModel.sendAvatarMessage()
attachmentsViewModel.sendAvatarMessage()
},
recipientName = user.title,
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
@@ -4480,7 +4476,7 @@ fun ChatDetailScreen(
// Мультивыбор оставляем прямой отправкой как раньше.
selectedDialogs.forEach { dialog ->
viewModel.sendForwardDirectly(
messagesViewModel.sendForwardDirectly(
dialog.opponentKey,
forwardMessages
)
@@ -4517,7 +4513,7 @@ fun ChatDetailScreen(
onCaptionChange = { simplePickerPreviewCaption = it },
isDarkTheme = isDarkTheme,
onSend = { editedUri, caption ->
viewModel.sendImageFromUri(editedUri, caption)
attachmentsViewModel.sendImageFromUri(editedUri, caption)
showMediaPicker = false
simplePickerPreviewUri = null
simplePickerPreviewSourceThumb = null
@@ -4561,6 +4557,7 @@ fun ChatDetailScreen(
// <20>📷 In-App Camera — FULLSCREEN поверх Scaffold (вне content lambda)
if (showInAppCamera) {
InAppCameraScreen(
preferencesManager = preferencesManager,
onDismiss = { showInAppCamera = false },
onPhotoTaken = { photoUri ->
// После камеры открываем тот же fullscreen-редактор,
@@ -4581,7 +4578,7 @@ fun ChatDetailScreen(
onCaptionChange = { pendingCameraPhotoCaption = it },
isDarkTheme = isDarkTheme,
onSend = { editedUri, caption ->
viewModel.sendImageFromUri(editedUri, caption)
attachmentsViewModel.sendImageFromUri(editedUri, caption)
showMediaPicker = false
pendingCameraPhotoUri = null
pendingCameraPhotoCaption = ""
@@ -4612,7 +4609,7 @@ fun ChatDetailScreen(
?.trim()
.orEmpty()
viewModel.sendImageGroupFromUris(imageUris, groupCaption)
attachmentsViewModel.sendImageGroupFromUris(imageUris, groupCaption)
showMediaPicker = false
},
isDarkTheme = isDarkTheme,
@@ -4625,7 +4622,8 @@ fun ChatDetailScreen(
@Composable
private fun ChatInputBarSection(
viewModel: ChatViewModel,
messagesViewModel: MessagesViewModel,
typingViewModel: TypingViewModel,
isSavedMessages: Boolean,
onSend: () -> Unit,
onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List<Float>) -> Unit,
@@ -4655,14 +4653,14 @@ private fun ChatInputBarSection(
suppressKeyboard: Boolean,
hasNativeNavigationBar: Boolean
) {
val inputText by viewModel.inputText.collectAsState()
val inputText by messagesViewModel.inputText.collectAsState()
MessageInputBar(
value = inputText,
onValueChange = {
viewModel.updateInputText(it)
messagesViewModel.updateInputText(it)
if (it.isNotEmpty() && !isSavedMessages) {
viewModel.sendTypingIndicator()
typingViewModel.sendTypingIndicator()
}
},
onSend = onSend,

View File

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

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

@@ -115,6 +115,7 @@ class ChatsListViewModel @Inject constructor(
// Количество 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())

View File

@@ -11,13 +11,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.ProtocolState
import compose.icons.TablerIcons
import compose.icons.tablericons.*
@@ -28,11 +26,9 @@ import kotlinx.coroutines.launch
@Composable
fun ConnectionLogsScreen(
isDarkTheme: Boolean,
protocolGateway: ProtocolGateway,
onBack: () -> Unit
) {
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val logs by protocolGateway.debugLogs.collectAsState()
val protocolState by protocolGateway.state.collectAsState()
val syncInProgress by protocolGateway.syncInProgress.collectAsState()

View File

@@ -0,0 +1,565 @@
package com.rosetta.messenger.ui.chats
import android.app.Application
import android.util.Base64
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.domain.chats.usecase.ForwardPayloadMessage
import com.rosetta.messenger.domain.chats.usecase.SendForwardUseCase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.DeliveryStatus
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.PacketMessage
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.ui.chats.models.ChatMessage
import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.ui.chats.models.ReplyData
import com.rosetta.messenger.utils.AttachmentFileManager
import java.io.File
import java.util.Date
import java.util.Locale
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
internal class ForwardCoordinator(
private val chatViewModel: ChatViewModel,
private val sendForwardUseCase: SendForwardUseCase
) {
private val forwardUuidRegex =
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 data class ForwardSourceMessage(
val messageId: String,
val senderPublicKey: String,
val chachaKeyPlainHex: String,
val attachments: List<MessageAttachment>
)
private data class ForwardRewriteResult(
val rewrittenAttachments: Map<String, MessageAttachment>,
val rewrittenMessageIds: Set<String>
)
fun sendForwardDirectly(
recipientPublicKey: String,
forwardMessages: List<ForwardManager.ForwardMessage>
) {
val sender = chatViewModel.currentSenderPublicKeyForSend() ?: return
val privateKey = chatViewModel.currentSenderPrivateKeyForSend() ?: return
if (forwardMessages.isEmpty()) return
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val isCurrentDialogTarget = chatViewModel.isCurrentDialogTarget(recipientPublicKey)
chatViewModel.launchOnIo {
try {
val context = chatViewModel.appContext()
val isSavedMessages = (sender == recipientPublicKey)
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = "",
recipient = recipientPublicKey,
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 replyAttachmentId = "reply_$timestamp"
val forwardSources =
forwardMessages.map { message ->
ForwardSourceMessage(
messageId = message.messageId,
senderPublicKey = message.senderPublicKey,
chachaKeyPlainHex = message.chachaKeyPlain,
attachments = message.attachments
)
}
val forwardRewriteResult =
prepareForwardAttachmentRewrites(
context = context,
sourceMessages = forwardSources,
encryptionContext = encryptionContext,
privateKey = privateKey,
isSavedMessages = isSavedMessages,
timestamp = timestamp
)
val forwardedAttMap = forwardRewriteResult.rewrittenAttachments
val rewrittenForwardMessageIds = forwardRewriteResult.rewrittenMessageIds
val outgoingForwardPlainKeyHex =
encryptionContext.plainKeyAndNonce
?.joinToString("") { "%02x".format(it) }
.orEmpty()
val forwardPayloadMessages =
forwardMessages.map { message ->
ForwardPayloadMessage(
messageId = message.messageId,
senderPublicKey = message.senderPublicKey,
senderName = message.senderName,
text = message.text,
timestamp = message.timestamp,
chachaKeyPlain = message.chachaKeyPlain,
attachments = message.attachments
)
}
fun buildForwardReplyJson(includeLocalUri: Boolean): JSONArray {
return sendForwardUseCase.buildForwardReplyJson(
messages = forwardPayloadMessages,
rewrittenAttachments = forwardedAttMap,
rewrittenMessageIds = rewrittenForwardMessageIds,
outgoingForwardPlainKeyHex = outgoingForwardPlainKeyHex,
includeLocalUri = includeLocalUri,
rewriteKey = ::forwardAttachmentRewriteKey
)
}
if (isCurrentDialogTarget) {
val optimisticForwardedMessages =
forwardMessages.map { message ->
val senderDisplayName =
message.senderName.ifEmpty {
if (message.senderPublicKey == sender) "You" else "User"
}
ReplyData(
messageId = message.messageId,
senderName = senderDisplayName,
text = message.text,
isFromMe = message.senderPublicKey == sender,
isForwarded = true,
forwardedFromName = senderDisplayName,
attachments = message.attachments.filter { it.type != AttachmentType.MESSAGES },
senderPublicKey = message.senderPublicKey,
recipientPrivateKey = privateKey
)
}
withContext(Dispatchers.Main) {
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
forwardedMessages = optimisticForwardedMessages
)
)
}
}
val optimisticReplyBlobPlaintext = buildForwardReplyJson(includeLocalUri = true).toString()
val optimisticReplyBlobForDatabase =
CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey)
val optimisticAttachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", replyAttachmentId)
put("type", AttachmentType.MESSAGES.value)
put("preview", "")
put("width", 0)
put("height", 0)
put("blob", optimisticReplyBlobForDatabase)
}
)
}
.toString()
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey =
if (encryptionContext.isGroup) {
chatViewModel.buildStoredGroupEncryptedKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = optimisticAttachmentsJson,
accountPublicKey = sender,
accountPrivateKey = privateKey,
opponentPublicKey = recipientPublicKey
)
chatViewModel.refreshDialogFromMessagesForForward(sender, recipientPublicKey)
if (isSavedMessages && isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
}
val replyBlobPlaintext = buildForwardReplyJson(includeLocalUri = false).toString()
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
val replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
val finalMessageAttachments =
listOf(
sendForwardUseCase.buildForwardAttachment(
replyAttachmentId = replyAttachmentId,
encryptedReplyBlob = encryptedReplyBlob
)
)
val packet =
PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipientPublicKey
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = finalMessageAttachments
}
sendForwardUseCase.dispatch(packet, isSavedMessages)
val finalAttachmentsJson =
JSONArray()
.apply {
finalMessageAttachments.forEach { attachment ->
put(
JSONObject().apply {
put("id", attachment.id)
put("type", attachment.type.value)
put("preview", attachment.preview)
put("width", attachment.width)
put("height", attachment.height)
put(
"blob",
when (attachment.type) {
AttachmentType.MESSAGES -> replyBlobForDatabase
else -> ""
}
)
}
)
}
}
.toString()
chatViewModel.updateMessageStatusAndAttachmentsDb(
messageId = messageId,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = finalAttachmentsJson
)
if (isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
if (isSavedMessages) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
}
}
chatViewModel.refreshDialogFromMessagesForForward(sender, recipientPublicKey)
} catch (_: Exception) {
if (isCurrentDialogTarget) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
}
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
}
}
}
fun encryptAttachmentPayloadForTextSend(
payload: String,
context: OutgoingEncryptionContext
): String {
return encryptAttachmentPayload(payload, context)
}
fun forwardAttachmentRewriteKeyForTextSend(messageId: String, attachmentId: String): String {
return forwardAttachmentRewriteKey(messageId, attachmentId)
}
suspend fun prepareForwardAttachmentRewritesForTextSend(
sourceMessages: List<ChatViewModel.ReplyMessage>,
encryptionContext: OutgoingEncryptionContext,
privateKey: String,
isSavedMessages: Boolean,
timestamp: Long
): Pair<Map<String, MessageAttachment>, Set<String>> {
val forwardSources =
sourceMessages.map { message ->
ForwardSourceMessage(
messageId = message.messageId,
senderPublicKey = message.publicKey,
chachaKeyPlainHex = message.chachaKeyPlainHex,
attachments = message.attachments
)
}
val result =
prepareForwardAttachmentRewrites(
context = chatViewModel.appContext(),
sourceMessages = forwardSources,
encryptionContext = encryptionContext,
privateKey = privateKey,
isSavedMessages = isSavedMessages,
timestamp = timestamp
)
return result.rewrittenAttachments to result.rewrittenMessageIds
}
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
return CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
}
private fun forwardAttachmentRewriteKey(messageId: String, attachmentId: String): String {
return "$messageId::$attachmentId"
}
private fun shouldReuploadForwardAttachment(type: AttachmentType): Boolean {
return type == AttachmentType.IMAGE ||
type == AttachmentType.FILE ||
type == AttachmentType.VOICE ||
type == AttachmentType.VIDEO_CIRCLE
}
private fun decodeHexBytes(value: String): ByteArray? {
val normalized = value.trim().lowercase(Locale.ROOT)
if (normalized.isEmpty() || normalized.length % 2 != 0) return null
return runCatching {
ByteArray(normalized.length / 2) { index ->
normalized.substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}
.getOrNull()
?.takeIf { it.isNotEmpty() }
}
private fun extractForwardFileName(preview: String): String {
val normalized = preview.trim()
if (normalized.isEmpty()) return ""
val parts = normalized.split("::")
return when {
parts.size >= 3 && forwardUuidRegex.matches(parts[0]) -> {
parts.drop(2).joinToString("::").trim()
}
parts.size >= 2 -> {
parts.drop(1).joinToString("::").trim()
}
else -> normalized
}
}
private fun decodeBase64PayloadForForward(value: String): ByteArray? {
val normalized = value.trim()
if (normalized.isEmpty()) return null
val payload =
when {
normalized.contains("base64,", ignoreCase = true) -> {
normalized.substringAfter("base64,", "")
}
normalized.substringBefore(",").contains("base64", ignoreCase = true) -> {
normalized.substringAfter(",", "")
}
else -> normalized
}
if (payload.isEmpty()) return null
return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
}
private suspend fun resolveForwardAttachmentPayload(
context: Application,
sourceMessage: ForwardSourceMessage,
attachment: MessageAttachment,
privateKey: String
): String? {
if (attachment.blob.isNotBlank()) {
return attachment.blob
}
if (attachment.id.isBlank()) {
return null
}
val normalizedPublicKey =
sourceMessage.senderPublicKey.ifBlank {
chatViewModel.currentSenderPublicKeyForSend()?.trim().orEmpty()
}
if (normalizedPublicKey.isNotBlank()) {
val cachedPayload =
AttachmentFileManager.readAttachment(
context = context,
attachmentId = attachment.id,
publicKey = normalizedPublicKey,
privateKey = privateKey
)
if (!cachedPayload.isNullOrBlank()) {
return cachedPayload
}
}
if (attachment.type == AttachmentType.FILE) {
val fileName = extractForwardFileName(attachment.preview)
if (fileName.isNotBlank()) {
val localFile = File(context.filesDir, "rosetta_downloads/$fileName")
if (localFile.exists() && localFile.length() > 0L) {
return runCatching {
Base64.encodeToString(localFile.readBytes(), Base64.NO_WRAP)
}
.getOrNull()
}
}
}
val downloadTag = attachment.transportTag.trim()
val plainKey = decodeHexBytes(sourceMessage.chachaKeyPlainHex)
if (downloadTag.isBlank() || plainKey == null) {
return null
}
val encrypted =
runCatching {
TransportManager.downloadFile(
attachment.id,
downloadTag,
attachment.transportServer.ifBlank { null }
)
}
.getOrNull()
.orEmpty()
if (encrypted.isBlank()) {
return null
}
return MessageCrypto.decryptAttachmentBlobWithPlainKey(encrypted, plainKey)
?: MessageCrypto.decryptReplyBlob(encrypted, plainKey).takeIf { it.isNotEmpty() }
}
private suspend fun prepareForwardAttachmentRewrites(
context: Application,
sourceMessages: List<ForwardSourceMessage>,
encryptionContext: OutgoingEncryptionContext,
privateKey: String,
isSavedMessages: Boolean,
timestamp: Long
): ForwardRewriteResult {
if (sourceMessages.isEmpty()) {
return ForwardRewriteResult(emptyMap(), emptySet())
}
val rewritten = mutableMapOf<String, MessageAttachment>()
val rewrittenMessageIds = mutableSetOf<String>()
var forwardAttachmentIndex = 0
for (sourceMessage in sourceMessages) {
val candidates =
sourceMessage.attachments.filter { shouldReuploadForwardAttachment(it.type) }
if (candidates.isEmpty()) continue
val stagedForMessage = mutableMapOf<String, MessageAttachment>()
var allRewritten = true
for (attachment in candidates) {
val payload =
resolveForwardAttachmentPayload(
context = context,
sourceMessage = sourceMessage,
attachment = attachment,
privateKey = privateKey
)
if (payload.isNullOrBlank()) {
allRewritten = false
break
}
val encryptedBlob = encryptAttachmentPayload(payload, encryptionContext)
val newAttachmentId = "fwd_${timestamp}_${forwardAttachmentIndex++}"
val uploadTag =
if (!isSavedMessages) {
runCatching { TransportManager.uploadFile(newAttachmentId, encryptedBlob) }
.getOrDefault("")
} else {
""
}
val transportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val normalizedPreview =
if (attachment.type == AttachmentType.IMAGE) {
attachment.preview.substringAfter("::", attachment.preview)
} else {
attachment.preview
}
stagedForMessage[forwardAttachmentRewriteKey(sourceMessage.messageId, attachment.id)] =
attachment.copy(
id = newAttachmentId,
preview = normalizedPreview,
blob = "",
localUri = "",
transportTag = uploadTag,
transportServer = transportServer
)
if (attachment.type == AttachmentType.IMAGE ||
attachment.type == AttachmentType.VOICE ||
attachment.type == AttachmentType.VIDEO_CIRCLE
) {
runCatching {
AttachmentFileManager.saveAttachment(
context = context,
blob = payload,
attachmentId = newAttachmentId,
publicKey =
sourceMessage.senderPublicKey.ifBlank {
chatViewModel.currentSenderPublicKeyForSend()?.trim().orEmpty()
},
privateKey = privateKey
)
}
}
if (isSavedMessages && attachment.type == AttachmentType.FILE) {
val fileName = extractForwardFileName(attachment.preview)
val payloadBytes = decodeBase64PayloadForForward(payload)
if (fileName.isNotBlank() && payloadBytes != null) {
runCatching {
val downloadsDir =
File(context.filesDir, "rosetta_downloads").apply { mkdirs() }
File(downloadsDir, fileName).writeBytes(payloadBytes)
}
}
}
}
if (allRewritten && stagedForMessage.size == candidates.size) {
rewritten.putAll(stagedForMessage)
rewrittenMessageIds.add(sourceMessage.messageId)
}
}
return ForwardRewriteResult(
rewrittenAttachments = rewritten,
rewrittenMessageIds = rewrittenMessageIds
)
}
}

View File

@@ -122,8 +122,6 @@ import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
@@ -318,6 +316,10 @@ fun GroupInfoScreen(
currentUserPublicKey: String,
currentUserPrivateKey: String,
isDarkTheme: Boolean,
protocolGateway: ProtocolGateway,
messageRepository: MessageRepository,
preferencesManager: PreferencesManager,
groupRepository: GroupRepository,
avatarRepository: AvatarRepository? = null,
onBack: () -> Unit,
onMemberClick: (SearchUser) -> Unit = {},
@@ -325,11 +327,6 @@ fun GroupInfoScreen(
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
) {
val context = androidx.compose.ui.platform.LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val view = LocalView.current
val focusManager = LocalFocusManager.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current

View File

@@ -77,8 +77,6 @@ import androidx.core.view.WindowInsetsCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.R
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.DialogDao
@@ -117,6 +115,8 @@ fun GroupSetupScreen(
accountPrivateKey: String,
accountName: String,
accountUsername: String,
messageRepository: MessageRepository,
groupRepository: GroupRepository,
avatarRepository: AvatarRepository? = null,
dialogDao: DialogDao? = null,
onBack: () -> Unit,
@@ -124,9 +124,6 @@ fun GroupSetupScreen(
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val view = LocalView.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current

View File

@@ -0,0 +1,539 @@
package com.rosetta.messenger.ui.chats
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.domain.chats.usecase.SendTextMessageCommand
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.ui.chats.models.ReplyData
import com.rosetta.messenger.utils.MessageThrottleManager
import java.util.Date
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
internal class MessagesCoordinator(
private val chatViewModel: ChatViewModel
) {
private var pendingTextSendRequested = false
private var pendingTextSendReason: String = ""
private var pendingSendRecoveryJob: Job? = null
private data class SendCommand(
val messageId: String,
val timestamp: Long,
val text: String,
val replyMessages: List<ChatViewModel.ReplyMessage>,
val isForward: Boolean,
val senderPublicKey: String,
val senderPrivateKey: String,
val recipientPublicKey: String
) {
val dialogThrottleKey: String
get() = "$senderPublicKey:$recipientPublicKey"
}
fun onSendContextChanged(trigger: String) {
triggerPendingTextSendIfReady(trigger)
}
fun onCleared() {
clearPendingRecovery(cancelJob = true)
}
fun trySendMessage(allowPendingRecovery: Boolean) {
val text = chatViewModel.inputText.value.trim()
val replyMsgsToSend = chatViewModel.replyMessages.value.toList()
val isForward = chatViewModel.isForwardMode.value
val hasPayload = text.isNotEmpty() || replyMsgsToSend.isNotEmpty()
if (!hasPayload) return
if (!chatViewModel.hasRuntimeKeysForSend()) {
recoverRuntimeKeysIfMissing()
}
val recipient = chatViewModel.currentRecipientForSend()
val sender = chatViewModel.currentSenderPublicKeyForSend()
val privateKey = chatViewModel.currentSenderPrivateKeyForSend()
if (recipient == null) {
logSendBlocked(
reason = "no_dialog",
textLength = text.length,
hasReply = replyMsgsToSend.isNotEmpty(),
recipient = null,
sender = sender,
hasPrivateKey = privateKey != null
)
if (allowPendingRecovery) {
schedulePendingTextSendRecovery(reason = "no_dialog", hasPayload = hasPayload)
}
return
}
if (sender == null || privateKey == null) {
logSendBlocked(
reason = "no_keys",
textLength = text.length,
hasReply = replyMsgsToSend.isNotEmpty(),
recipient = recipient,
sender = sender,
hasPrivateKey = privateKey != null
)
if (allowPendingRecovery) {
schedulePendingTextSendRecovery(reason = "no_keys", hasPayload = hasPayload)
}
return
}
if (chatViewModel.isSendSlotBusy()) {
logSendBlocked(
reason = "is_sending",
textLength = text.length,
hasReply = replyMsgsToSend.isNotEmpty(),
recipient = recipient,
sender = sender,
hasPrivateKey = true
)
return
}
val command =
SendCommand(
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
timestamp = System.currentTimeMillis(),
text = text,
replyMessages = replyMsgsToSend,
isForward = isForward,
senderPublicKey = sender,
senderPrivateKey = privateKey,
recipientPublicKey = recipient
)
if (!MessageThrottleManager.canSendWithContent(command.dialogThrottleKey, command.text.hashCode())) {
logSendBlocked(
reason = "throttle",
textLength = command.text.length,
hasReply = command.replyMessages.isNotEmpty(),
recipient = command.recipientPublicKey,
sender = command.senderPublicKey,
hasPrivateKey = true
)
return
}
if (!chatViewModel.tryAcquireSendSlot()) {
logSendBlocked(
reason = "is_sending",
textLength = command.text.length,
hasReply = command.replyMessages.isNotEmpty(),
recipient = command.recipientPublicKey,
sender = command.senderPublicKey,
hasPrivateKey = true
)
return
}
val messageId = command.messageId
val timestamp = command.timestamp
val fallbackName = chatViewModel.replyFallbackName()
val currentMessages = chatViewModel.messages.value
val replyData: ReplyData? =
if (command.replyMessages.isNotEmpty()) {
val firstReply = command.replyMessages.first()
val replyAttachments =
currentMessages.find { it.id == firstReply.messageId }?.attachments
?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES }
val firstReplySenderName =
if (firstReply.isOutgoing) {
"You"
} else {
firstReply.senderName.ifEmpty { fallbackName }
}
ReplyData(
messageId = firstReply.messageId,
senderName = firstReplySenderName,
text = chatViewModel.resolveReplyPreviewTextForSend(firstReply.text, replyAttachments),
isFromMe = firstReply.isOutgoing,
isForwarded = command.isForward,
forwardedFromName = if (command.isForward) firstReplySenderName else "",
attachments = replyAttachments,
senderPublicKey =
firstReply.publicKey.ifEmpty {
if (firstReply.isOutgoing) command.senderPublicKey else command.recipientPublicKey
},
recipientPrivateKey = command.senderPrivateKey
)
} else {
null
}
val optimisticForwardedMessages: List<ReplyData> =
if (command.isForward && command.replyMessages.isNotEmpty()) {
command.replyMessages.map { message ->
val senderDisplayName =
if (message.isOutgoing) {
"You"
} else {
message.senderName.ifEmpty { fallbackName }
}
val resolvedAttachments =
currentMessages.find { it.id == message.messageId }?.attachments
?: message.attachments.filter { it.type != AttachmentType.MESSAGES }
ReplyData(
messageId = message.messageId,
senderName = senderDisplayName,
text = chatViewModel.resolveReplyPreviewTextForSend(message.text, resolvedAttachments),
isFromMe = message.isOutgoing,
isForwarded = true,
forwardedFromName = senderDisplayName,
attachments = resolvedAttachments,
senderPublicKey =
message.publicKey.ifEmpty {
if (message.isOutgoing) command.senderPublicKey
else command.recipientPublicKey
},
recipientPrivateKey = command.senderPrivateKey
)
}
} else {
emptyList()
}
val isForwardToSend = command.isForward
chatViewModel.addOutgoingMessageOptimistic(
ChatMessage(
id = messageId,
text = command.text,
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
replyData = if (isForwardToSend) null else replyData,
forwardedMessages = optimisticForwardedMessages
)
)
chatViewModel.clearInputText()
DraftManager.clearDraft(command.recipientPublicKey)
chatViewModel.clearReplyMessages()
chatViewModel.cacheDecryptedText(messageId, command.text)
chatViewModel.launchOnIo {
try {
val encryptionContext =
chatViewModel.buildEncryptionContext(
plaintext = command.text,
recipient = command.recipientPublicKey,
privateKey = command.senderPrivateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(command.senderPrivateKey)
val messageAttachments = mutableListOf<MessageAttachment>()
var replyBlobForDatabase = ""
val isSavedMessages = (command.senderPublicKey == command.recipientPublicKey)
val (forwardedAttachments, rewrittenForwardMessageIds) =
if (isForwardToSend && command.replyMessages.isNotEmpty()) {
chatViewModel.prepareForwardAttachmentRewritesForTextSend(
sourceMessages = command.replyMessages,
encryptionContext = encryptionContext,
privateKey = command.senderPrivateKey,
isSavedMessages = isSavedMessages,
timestamp = timestamp
)
} else {
emptyMap<String, MessageAttachment>() to emptySet<String>()
}
val outgoingForwardPlainKeyHex =
encryptionContext.plainKeyAndNonce
?.joinToString("") { "%02x".format(it) }
.orEmpty()
if (command.replyMessages.isNotEmpty()) {
val replyJsonArray = JSONArray()
command.replyMessages.forEach { message ->
val attachmentsArray = JSONArray()
message.attachments.forEach { attachment ->
val rewrittenAttachment =
forwardedAttachments[
chatViewModel.forwardAttachmentRewriteKeyForTextSend(
message.messageId,
attachment.id
)
]
val attachmentId = rewrittenAttachment?.id ?: attachment.id
val attachmentPreview = rewrittenAttachment?.preview ?: attachment.preview
val attachmentTransportTag =
rewrittenAttachment?.transportTag ?: attachment.transportTag
val attachmentTransportServer =
rewrittenAttachment?.transportServer ?: attachment.transportServer
attachmentsArray.put(
JSONObject().apply {
put("id", attachmentId)
put("type", attachment.type.value)
put("preview", attachmentPreview)
put("width", attachment.width)
put("height", attachment.height)
put(
"blob",
if (attachment.type == AttachmentType.MESSAGES) attachment.blob else ""
)
put("transportTag", attachmentTransportTag)
put("transportServer", attachmentTransportServer)
put(
"transport",
JSONObject().apply {
put("transport_tag", attachmentTransportTag)
put("transport_server", attachmentTransportServer)
}
)
}
)
}
val replyJson =
JSONObject().apply {
put("message_id", message.messageId)
put("publicKey", message.publicKey)
put("message", message.text)
put("timestamp", message.timestamp)
put("attachments", attachmentsArray)
if (isForwardToSend) {
put("forwarded", true)
put("senderName", message.senderName)
val effectiveForwardPlainKey =
if (message.messageId in rewrittenForwardMessageIds &&
outgoingForwardPlainKeyHex.isNotEmpty()
) {
outgoingForwardPlainKeyHex
} else {
message.chachaKeyPlainHex
}
if (effectiveForwardPlainKey.isNotEmpty()) {
put("chacha_key_plain", effectiveForwardPlainKey)
}
}
}
replyJsonArray.put(replyJson)
}
val replyBlobPlaintext = replyJsonArray.toString()
val encryptedReplyBlob =
chatViewModel.encryptAttachmentPayloadForTextSend(
payload = replyBlobPlaintext,
context = encryptionContext
)
replyBlobForDatabase =
CryptoManager.encryptWithPassword(replyBlobPlaintext, command.senderPrivateKey)
val replyAttachmentId = "reply_$timestamp"
messageAttachments.add(
MessageAttachment(
id = replyAttachmentId,
blob = encryptedReplyBlob,
type = AttachmentType.MESSAGES,
preview = ""
)
)
}
chatViewModel.sendTextMessage(
SendTextMessageCommand(
fromPublicKey = command.senderPublicKey,
toPublicKey = command.recipientPublicKey,
encryptedContent = encryptedContent,
encryptedKey = encryptedKey,
aesChachaKey = aesChachaKey,
privateKeyHash = privateKeyHash,
messageId = messageId,
timestamp = timestamp,
attachments = messageAttachments,
isSavedMessages = isSavedMessages
)
)
withContext(Dispatchers.Main) {
if (isSavedMessages) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
}
}
val attachmentsJson =
if (messageAttachments.isNotEmpty()) {
JSONArray()
.apply {
messageAttachments.forEach { attachment ->
put(
JSONObject().apply {
put("id", attachment.id)
put("type", attachment.type.value)
put("preview", attachment.preview)
put("width", attachment.width)
put("height", attachment.height)
put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer)
val blobToSave =
when (attachment.type) {
AttachmentType.MESSAGES -> replyBlobForDatabase
else -> ""
}
put("blob", blobToSave)
}
)
}
}
.toString()
} else {
"[]"
}
chatViewModel.saveOutgoingMessage(
messageId = messageId,
text = text,
encryptedContent = encryptedContent,
encryptedKey =
if (encryptionContext.isGroup) {
chatViewModel.buildStoredGroupEncryptedKey(
encryptionContext.attachmentPassword,
command.senderPrivateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson,
accountPublicKey = command.senderPublicKey,
accountPrivateKey = command.senderPrivateKey,
opponentPublicKey = command.recipientPublicKey
)
chatViewModel.saveOutgoingDialog(
lastMessage = command.text,
timestamp = timestamp,
accountPublicKey = command.senderPublicKey,
accountPrivateKey = command.senderPrivateKey,
opponentPublicKey = command.recipientPublicKey
)
} catch (_: Exception) {
withContext(Dispatchers.Main) {
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
}
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
chatViewModel.saveOutgoingDialog(
lastMessage = command.text,
timestamp = timestamp,
accountPublicKey = command.senderPublicKey,
accountPrivateKey = command.senderPrivateKey,
opponentPublicKey = command.recipientPublicKey
)
} finally {
chatViewModel.releaseSendSlot()
triggerPendingTextSendIfReady("send_finished")
}
}
}
private fun shortSendKey(value: String?): String {
val normalized = value?.trim().orEmpty()
if (normalized.isEmpty()) return "<empty>"
return if (normalized.length <= 12) normalized else "${normalized.take(12)}"
}
private fun logSendBlocked(
reason: String,
textLength: Int,
hasReply: Boolean,
recipient: String?,
sender: String?,
hasPrivateKey: Boolean
) {
chatViewModel.addProtocolLog(
"⚠️ SEND_BLOCKED reason=$reason textLen=$textLength hasReply=$hasReply recipient=${shortSendKey(recipient)} sender=${shortSendKey(sender)} hasPriv=$hasPrivateKey isSending=${chatViewModel.isSendSlotBusy()}"
)
}
private fun recoverRuntimeKeysIfMissing(): Boolean {
if (chatViewModel.hasRuntimeKeysForSend()) return true
val repositoryKeys = chatViewModel.resolveRepositoryRuntimeKeys()
if (repositoryKeys != null) {
chatViewModel.setUserKeys(repositoryKeys.first, repositoryKeys.second)
chatViewModel.addProtocolLog(
"🔄 SEND_RECOVERY restored_keys pk=${shortSendKey(repositoryKeys.first)}"
)
}
return chatViewModel.hasRuntimeKeysForSend()
}
private fun schedulePendingTextSendRecovery(reason: String, hasPayload: Boolean) {
if (!hasPayload) return
pendingTextSendRequested = true
pendingTextSendReason = reason
if (pendingSendRecoveryJob?.isActive == true) return
chatViewModel.addProtocolLog("⏳ SEND_RECOVERY queued reason=$reason")
pendingSendRecoveryJob =
chatViewModel.launchInViewModel {
repeat(10) { attempt ->
delay(if (attempt < 4) 180L else 350L)
recoverRuntimeKeysIfMissing()
triggerPendingTextSendIfReady("timer_${attempt + 1}")
if (!pendingTextSendRequested) return@launchInViewModel
}
if (pendingTextSendRequested) {
chatViewModel.addProtocolLog(
"⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason"
)
}
clearPendingRecovery(cancelJob = false)
}
}
private fun triggerPendingTextSendIfReady(trigger: String) {
if (!pendingTextSendRequested) return
val hasPayload =
chatViewModel.inputText.value.trim().isNotEmpty() ||
chatViewModel.replyMessages.value.isNotEmpty()
if (!hasPayload) {
clearPendingRecovery(cancelJob = true)
return
}
val recipientReady = chatViewModel.currentRecipientForSend() != null
val keysReady = chatViewModel.hasRuntimeKeysForSend()
if (!recipientReady || !keysReady || chatViewModel.isSendSlotBusy()) return
chatViewModel.addProtocolLog("🚀 SEND_RECOVERY flush trigger=$trigger")
clearPendingRecovery(cancelJob = true)
trySendMessage(allowPendingRecovery = false)
}
private fun clearPendingRecovery(cancelJob: Boolean) {
if (cancelJob) {
pendingSendRecoveryJob?.cancel()
}
pendingSendRecoveryJob = null
pendingTextSendRequested = false
pendingTextSendReason = ""
}
}

View File

@@ -0,0 +1,10 @@
package com.rosetta.messenger.ui.chats
internal data class OutgoingEncryptionContext(
val encryptedContent: String,
val encryptedKey: String,
val aesChachaKey: String,
val plainKeyAndNonce: ByteArray?,
val attachmentPassword: String,
val isGroup: Boolean
)

View File

@@ -0,0 +1,15 @@
package com.rosetta.messenger.ui.chats
internal data class OutgoingSendContext(
val recipient: String,
val sender: String,
val privateKey: String
)
internal data class TypingSendContext(
val opponent: String,
val sender: String,
val privateKey: String,
val isGroupDialog: Boolean,
val isOpponentOnline: Boolean
)

View File

@@ -14,15 +14,11 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -42,11 +38,8 @@ fun RequestsListScreen(
onUserSelect: (SearchUser) -> Unit,
avatarRepository: AvatarRepository? = null
) {
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val chatsState by chatsViewModel.chatsState.collectAsState()
val syncInProgress by protocolGateway.syncInProgress.collectAsState()
val syncInProgress by chatsViewModel.syncInProgress.collectAsState()
val requests = if (syncInProgress) emptyList() else chatsState.requests
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
val scope = rememberCoroutineScope()

View File

@@ -57,8 +57,8 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.network.ProtocolState
@@ -96,6 +96,8 @@ private enum class SearchTab(val title: String) {
fun SearchScreen(
privateKeyHash: String,
currentUserPublicKey: String,
accountManager: AccountManager,
messageRepository: MessageRepository,
isDarkTheme: Boolean,
protocolState: ProtocolState,
onBackClick: () -> Unit,
@@ -105,8 +107,6 @@ fun SearchScreen(
) {
// Context и View для мгновенного закрытия клавиатуры
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val accountManager = remember(uiDeps) { uiDeps.accountManager() }
val view = LocalView.current
val focusManager = LocalFocusManager.current
if (!view.isInEditMode) {
@@ -398,6 +398,7 @@ fun SearchScreen(
MessagesTabContent(
searchQuery = searchQuery,
currentUserPublicKey = currentUserPublicKey,
messageRepository = messageRepository,
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
@@ -409,6 +410,7 @@ fun SearchScreen(
SearchTab.MEDIA -> {
MediaTabContent(
currentUserPublicKey = currentUserPublicKey,
messageRepository = messageRepository,
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
@@ -988,6 +990,7 @@ private data class MessageSearchResult(
private fun MessagesTabContent(
searchQuery: String,
currentUserPublicKey: String,
messageRepository: MessageRepository,
isDarkTheme: Boolean,
textColor: Color,
secondaryTextColor: Color,
@@ -996,8 +999,6 @@ private fun MessagesTabContent(
onUserSelect: (SearchUser) -> Unit
) {
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
var results by remember { mutableStateOf<List<MessageSearchResult>>(emptyList()) }
var isSearching by remember { mutableStateOf(false) }
val dividerColor = remember(isDarkTheme) {
@@ -1479,14 +1480,13 @@ private data class MediaItem(
@Composable
private fun MediaTabContent(
currentUserPublicKey: String,
messageRepository: MessageRepository,
isDarkTheme: Boolean,
textColor: Color,
secondaryTextColor: Color,
onOpenImageViewer: (images: List<com.rosetta.messenger.ui.chats.components.ViewableImage>, initialIndex: Int, privateKey: String) -> Unit = { _, _, _ -> }
) {
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }

View File

@@ -63,8 +63,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.MessageAttachment
@@ -394,6 +393,7 @@ fun MessageBubble(
isGroupSenderAdmin: Boolean = false,
currentUserPublicKey: String = "",
currentUserUsername: String = "",
groupRepository: GroupRepository,
avatarRepository: AvatarRepository? = null,
onLongClick: () -> Unit = {},
onClick: () -> Unit = {},
@@ -1420,6 +1420,7 @@ fun MessageBubble(
inviteText = message.text,
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
groupRepository = groupRepository,
accountPublicKey = currentUserPublicKey,
accountPrivateKey = privateKey,
actionsEnabled = !isSelectionMode,
@@ -1683,6 +1684,7 @@ private fun GroupInviteInlineCard(
inviteText: String,
isOutgoing: Boolean,
isDarkTheme: Boolean,
groupRepository: GroupRepository,
accountPublicKey: String,
accountPrivateKey: String,
actionsEnabled: Boolean,
@@ -1694,8 +1696,6 @@ private fun GroupInviteInlineCard(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val normalizedInvite = remember(inviteText) { inviteText.trim() }
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }

View File

@@ -72,8 +72,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.ui.graphics.graphicsLayer
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.data.PreferencesManager
/**
* 📷 In-App Camera Screen - как в Telegram
@@ -82,6 +81,7 @@ import dagger.hilt.android.EntryPointAccessors
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun InAppCameraScreen(
preferencesManager: PreferencesManager,
onDismiss: () -> Unit,
onPhotoTaken: (Uri) -> Unit // Вызывается с URI сделанного фото
) {
@@ -92,8 +92,6 @@ fun InAppCameraScreen(
val view = LocalView.current
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
// Camera state
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }

View File

@@ -29,8 +29,6 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft
import com.rosetta.messenger.R
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -54,16 +52,15 @@ private val iconOptions = listOf(
@Composable
fun AppIconScreen(
isDarkTheme: Boolean,
preferencesManager: PreferencesManager,
onBack: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val prefs = remember(uiDeps) { uiDeps.preferencesManager() }
var currentIcon by remember { mutableStateOf("default") }
LaunchedEffect(Unit) {
currentIcon = prefs.appIcon.first()
currentIcon = preferencesManager.appIcon.first()
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
@@ -160,7 +157,7 @@ fun AppIconScreen(
.clickable {
if (!isSelected) {
scope.launch {
changeAppIcon(context, prefs, option.id)
changeAppIcon(context, preferencesManager, option.id)
currentIcon = option.id
}
}

View File

@@ -19,7 +19,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -27,22 +26,18 @@ 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.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.ui.icons.TelegramIcons
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft
import kotlinx.coroutines.launch
import androidx.compose.ui.platform.LocalContext
@Composable
fun NotificationsScreen(
isDarkTheme: Boolean,
preferencesManager: PreferencesManager,
onBack: () -> Unit
) {
val context = LocalContext.current
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope()

View File

@@ -85,8 +85,7 @@ import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.UiEntryPoint
import dagger.hilt.android.EntryPointAccessors
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
@@ -186,6 +185,8 @@ private fun calculateAverageColor(bitmap: android.graphics.Bitmap): Color {
fun OtherProfileScreen(
user: SearchUser,
isDarkTheme: Boolean,
preferencesManager: PreferencesManager,
messageRepository: MessageRepository,
onBack: () -> Unit,
onSwipeBackEnabledChanged: (Boolean) -> Unit = {},
avatarRepository: AvatarRepository? = null,
@@ -257,8 +258,6 @@ fun OtherProfileScreen(
val coroutineScope = rememberCoroutineScope()
// 🔕 Mute state
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
var notificationsEnabled by remember { mutableStateOf(true) }
// 🔥 Загружаем статус блокировки при открытии экрана
@@ -359,7 +358,6 @@ fun OtherProfileScreen(
}
// <20>🟢 Наблюдаем за онлайн статусом пользователя в реальном времени
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val onlineStatus by
messageRepository
.observeUserOnlineStatus(user.publicKey)