From 5e6d66b762f82aafec992856428adf9649b6e82a Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 19 Apr 2026 16:51:52 +0500 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=B4=D0=B5=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?runtime=20=D0=B8=20chat-=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D1=80=D1=83,=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20use-case=20=D0=B2=20domain=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20UiEntryPoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Architecture.md | 350 +- .../com/rosetta/messenger/MainActivity.kt | 26 +- .../messenger/data/MessageRepository.kt | 2 +- .../com/rosetta/messenger/di/AppContainer.kt | 118 +- .../com/rosetta/messenger/di/UiEntryPoint.kt | 21 - .../chats/usecase/CreateAttachmentUseCases.kt | 71 + .../EncryptAndUploadAttachmentUseCase.kt | 45 + .../chats/usecase/SendForwardUseCase.kt | 10 +- .../chats/usecase/SendMediaMessageUseCase.kt | 12 +- .../chats/usecase/SendReadReceiptUseCase.kt | 25 + .../chats/usecase/SendTextMessageUseCase.kt | 12 +- .../usecase/SendTypingIndicatorUseCase.kt | 42 + .../chats/usecase/SendVoiceMessageUseCase.kt | 42 + .../chats/usecase/VideoCircleMediaUseCases.kt | 107 + .../com/rosetta/messenger/network/Protocol.kt | 2 +- .../messenger/network/ProtocolClient.kt | 7 +- .../messenger/network/ProtocolManager.kt | 80 +- .../messenger/network/ProtocolRuntime.kt | 125 +- .../messenger/network/ProtocolRuntimeCore.kt | 1434 ------- .../messenger/network/RuntimeComposition.kt | 501 +++ .../network/RuntimeConnectionControlFacade.kt | 86 + .../network/RuntimeDirectoryFacade.kt | 53 + .../network/RuntimeMessagingAssembly.kt | 119 + .../network/RuntimePacketIoFacade.kt | 88 + .../network/RuntimeRoutingAssembly.kt | 51 + .../messenger/network/RuntimeStateAssembly.kt | 61 + .../network/RuntimeTransportAssembly.kt | 54 + .../network/connection/AuthRestoreService.kt | 35 + .../connection/ConnectionEventRouter.kt | 53 + .../InboundPacketHandlerRegistrar.kt | 279 ++ .../connection/InboundTaskQueueService.kt | 49 + .../connection/NetworkConnectivityFacade.kt | 20 + .../OwnProfileFallbackTimerService.kt | 28 + .../connection/PacketSubscriptionFacade.kt | 21 + .../ProtocolAccountSessionCoordinator.kt | 90 + .../connection/ProtocolDebugLogService.kt | 132 + .../connection/ProtocolInstanceManager.kt | 58 + .../ProtocolLifecycleCoordinator.kt | 138 + .../ProtocolLifecycleStateStoreImpl.kt | 34 + .../ProtocolPostAuthBootstrapCoordinator.kt | 146 + .../ReadyPacketDispatchCoordinator.kt | 44 + .../RuntimeInitializationCoordinator.kt | 32 + .../RuntimeLifecycleStateMachine.kt | 37 + .../connection/RuntimeShutdownCoordinator.kt | 25 + .../push/RosettaFirebaseMessagingService.kt | 4 +- .../com/rosetta/messenger/ui/auth/AuthFlow.kt | 4 + .../messenger/ui/auth/DeviceConfirmScreen.kt | 8 +- .../messenger/ui/auth/SetProfileScreen.kt | 9 +- .../ui/chats/AttachmentsCoordinator.kt | 380 ++ .../ui/chats/AttachmentsFeatureCoordinator.kt | 761 ++++ .../messenger/ui/chats/ChatDetailScreen.kt | 204 +- .../ui/chats/ChatFeatureViewModels.kt | 684 ++++ .../messenger/ui/chats/ChatViewModel.kt | 3633 ++--------------- .../messenger/ui/chats/ChatsListChatItem.kt | 204 + .../ui/chats/ChatsListDrawerContent.kt | 110 + .../ui/chats/ChatsListDrawerSections.kt | 417 ++ .../ui/chats/ChatsListRequestsScreen.kt | 258 ++ .../ui/chats/ChatsListRequestsSection.kt | 128 + .../messenger/ui/chats/ChatsListScreen.kt | 1277 +----- .../messenger/ui/chats/ChatsListViewModel.kt | 1 + .../ui/chats/ConnectionLogsScreen.kt | 8 +- .../messenger/ui/chats/ForwardCoordinator.kt | 565 +++ .../messenger/ui/chats/GroupInfoScreen.kt | 11 +- .../messenger/ui/chats/GroupSetupScreen.kt | 7 +- .../messenger/ui/chats/MessagesCoordinator.kt | 539 +++ .../ui/chats/OutgoingEncryptionContext.kt | 10 + .../messenger/ui/chats/OutgoingSendContext.kt | 15 + .../messenger/ui/chats/RequestsListScreen.kt | 9 +- .../messenger/ui/chats/SearchScreen.kt | 16 +- .../chats/components/ChatDetailComponents.kt | 8 +- .../ui/chats/components/InAppCameraScreen.kt | 6 +- .../messenger/ui/settings/AppIconScreen.kt | 9 +- .../ui/settings/NotificationsScreen.kt | 9 +- .../ui/settings/OtherProfileScreen.kt | 8 +- 74 files changed, 7846 insertions(+), 6221 deletions(-) delete mode 100644 app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt create mode 100644 app/src/main/java/com/rosetta/messenger/domain/chats/usecase/CreateAttachmentUseCases.kt create mode 100644 app/src/main/java/com/rosetta/messenger/domain/chats/usecase/EncryptAndUploadAttachmentUseCase.kt rename app/src/main/java/com/rosetta/messenger/{ui => domain}/chats/usecase/SendForwardUseCase.kt (93%) rename app/src/main/java/com/rosetta/messenger/{ui => domain}/chats/usecase/SendMediaMessageUseCase.kt (78%) create mode 100644 app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendReadReceiptUseCase.kt rename app/src/main/java/com/rosetta/messenger/{ui => domain}/chats/usecase/SendTextMessageUseCase.kt (78%) create mode 100644 app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTypingIndicatorUseCase.kt create mode 100644 app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendVoiceMessageUseCase.kt create mode 100644 app/src/main/java/com/rosetta/messenger/domain/chats/usecase/VideoCircleMediaUseCases.kt delete mode 100644 app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/RuntimeComposition.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/RuntimeDirectoryFacade.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/RuntimeMessagingAssembly.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/RuntimePacketIoFacade.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/RuntimeRoutingAssembly.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/RuntimeStateAssembly.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/RuntimeTransportAssembly.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/AuthRestoreService.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/ConnectionEventRouter.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/InboundPacketHandlerRegistrar.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/InboundTaskQueueService.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/NetworkConnectivityFacade.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileFallbackTimerService.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/PacketSubscriptionFacade.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/ProtocolAccountSessionCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/ProtocolInstanceManager.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleStateStoreImpl.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/ProtocolPostAuthBootstrapCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/ReadyPacketDispatchCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/RuntimeInitializationCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/RuntimeLifecycleStateMachine.kt create mode 100644 app/src/main/java/com/rosetta/messenger/network/connection/RuntimeShutdownCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsFeatureCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListChatItem.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerContent.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerSections.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsScreen.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsSection.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/ForwardCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingEncryptionContext.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingSendContext.kt diff --git a/Architecture.md b/Architecture.md index 946534f..b15674c 100644 --- a/Architecture.md +++ b/Architecture.md @@ -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`. ### 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`. -- Следующий шаг по 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`. +- `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`. +- 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) и фокусе на тестах/дальнейшей локальной декомпозиции архитектура остается управляемой и не уходит в избыточную сложность. diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index f1ca101..cda5ac9 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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) -> 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 diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 2fba807..73efde9 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt b/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt index 7d4c4c3..754ca90 100644 --- a/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt @@ -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 +interface ProtocolGateway : ProtocolRuntimePort { val syncInProgress: StateFlow val pendingDeviceVerification: StateFlow val typingUsers: StateFlow> val typingUsersByDialogSnapshot: StateFlow>> - val debugLogs: StateFlow> val ownProfileUpdated: StateFlow 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 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 } @@ -107,92 +91,12 @@ interface IdentityGateway { } @Singleton -class ProtocolGatewayImpl @Inject constructor( - private val runtime: ProtocolRuntime -) : ProtocolGateway { - override val state: StateFlow = runtime.state - override val syncInProgress: StateFlow = runtime.syncInProgress - override val pendingDeviceVerification: StateFlow = runtime.pendingDeviceVerification - override val typingUsers: StateFlow> = runtime.typingUsers - override val typingUsersByDialogSnapshot: StateFlow>> = - runtime.typingUsersByDialogSnapshot - override val debugLogs: StateFlow> = runtime.debugLogs - override val ownProfileUpdated: StateFlow = 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 = 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 = - 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`) and avoid DI cycles. + */ class ProtocolClientImpl @Inject constructor( private val runtimeProvider: Provider ) : 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 diff --git a/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt b/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt deleted file mode 100644 index c1d05a0..0000000 --- a/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/CreateAttachmentUseCases.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/CreateAttachmentUseCases.kt new file mode 100644 index 0000000..f9eef90 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/CreateAttachmentUseCases.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/EncryptAndUploadAttachmentUseCase.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/EncryptAndUploadAttachmentUseCase.kt new file mode 100644 index 0000000..0c2b4af --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/EncryptAndUploadAttachmentUseCase.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendForwardUseCase.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendForwardUseCase.kt similarity index 93% rename from app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendForwardUseCase.kt rename to app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendForwardUseCase.kt index 0b2a887..4a577b0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendForwardUseCase.kt +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendForwardUseCase.kt @@ -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 ) -class SendForwardUseCase( - private val sendWithRetry: (PacketMessage) -> Unit +class SendForwardUseCase @Inject constructor( + private val protocolGateway: ProtocolGateway ) { fun buildForwardReplyJson( messages: List, @@ -96,7 +98,7 @@ class SendForwardUseCase( fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) { if (!isSavedMessages) { - sendWithRetry(packet) + protocolGateway.sendMessageWithRetry(packet) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendMediaMessageUseCase.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendMediaMessageUseCase.kt similarity index 78% rename from app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendMediaMessageUseCase.kt rename to app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendMediaMessageUseCase.kt index 6190c69..44be851 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendMediaMessageUseCase.kt +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendMediaMessageUseCase.kt @@ -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) } } } diff --git a/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendReadReceiptUseCase.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendReadReceiptUseCase.kt new file mode 100644 index 0000000..679d2d0 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendReadReceiptUseCase.kt @@ -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) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendTextMessageUseCase.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTextMessageUseCase.kt similarity index 78% rename from app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendTextMessageUseCase.kt rename to app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTextMessageUseCase.kt index 8af62bf..b020fb7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendTextMessageUseCase.kt +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTextMessageUseCase.kt @@ -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) } } } diff --git a/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTypingIndicatorUseCase.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTypingIndicatorUseCase.kt new file mode 100644 index 0000000..08c9195 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTypingIndicatorUseCase.kt @@ -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) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendVoiceMessageUseCase.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendVoiceMessageUseCase.kt new file mode 100644 index 0000000..2db72f0 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendVoiceMessageUseCase.kt @@ -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, + val maxWaveCount: Int = 120 +) + +data class VoiceMessagePayload( + val normalizedVoiceHex: String, + val durationSec: Int, + val normalizedWaves: List, + 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 + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/VideoCircleMediaUseCases.kt b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/VideoCircleMediaUseCases.kt new file mode 100644 index 0000000..3bd5d27 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/domain/chats/usecase/VideoCircleMediaUseCases.kt @@ -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) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 67a8f95..3e1cad8 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt index 592b970..7b36e31 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt @@ -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) } - diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 4f69e52..5334e84 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -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 + get() = runtime().state + + val debugLogs: StateFlow> + 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() +} diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt index 97d7a2a..6f106d2 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt @@ -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 get() = core.state - val syncInProgress: StateFlow get() = core.syncInProgress - val pendingDeviceVerification: StateFlow get() = core.pendingDeviceVerification - val typingUsers: StateFlow> get() = core.typingUsers - val typingUsersByDialogSnapshot: StateFlow>> get() = - core.typingUsersByDialogSnapshot - override val debugLogs: StateFlow> get() = core.debugLogs - val ownProfileUpdated: StateFlow 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 get() = runtimeComposition.state + override val syncInProgress: StateFlow get() = runtimeComposition.syncInProgress + override val pendingDeviceVerification: StateFlow get() = + runtimeComposition.pendingDeviceVerification + override val typingUsers: StateFlow> get() = runtimeComposition.typingUsers + override val typingUsersByDialogSnapshot: StateFlow>> get() = + runtimeComposition.typingUsersByDialogSnapshot + override val debugLogs: StateFlow> get() = runtimeComposition.debugLogs + override val ownProfileUpdated: StateFlow 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 = core.packetFlow(packetId) + override fun packetFlow(packetId: Int): SharedFlow = + 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 = - core.searchUsers(query, timeoutMs) + override suspend fun searchUsers(query: String, timeoutMs: Long): List = + 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 diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt deleted file mode 100644 index 4e1eab0..0000000 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt +++ /dev/null @@ -1,1434 +0,0 @@ -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.BootstrapCoordinator -import com.rosetta.messenger.network.connection.CallSignalBridge -import com.rosetta.messenger.network.connection.ConnectionOrchestrator -import com.rosetta.messenger.network.connection.DeviceRuntimeService -import com.rosetta.messenger.network.connection.NetworkReconnectWatcher -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 com.rosetta.messenger.session.IdentityStore -import com.rosetta.messenger.utils.MessageLogger -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.io.File -import java.util.* -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicLong - -/** - * Singleton manager for Protocol instance - * Ensures single connection across the app - */ -open class ProtocolRuntimeCore { - private val TAG = "ProtocolManager" - 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" - - @Volatile private var protocol: Protocol? = null - 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 protocolInstanceLock = Any() - private val packetSubscriptionRegistry = - PacketSubscriptionRegistry( - protocolProvider = ::getProtocol, - scope = scope, - addLog = ::addLog - ) - private val connectionSupervisor = - ProtocolConnectionSupervisor( - scope = scope, - onEvent = ::handleConnectionEvent, - onError = { error -> android.util.Log.e(TAG, "ConnectionSupervisor event failed", error) }, - addLog = ::addLog - ) - private val sessionGeneration = AtomicLong(0L) - private val readyPacketGate = - ReadyPacketGate( - maxSize = READY_PACKET_QUEUE_MAX, - ttlMs = READY_PACKET_QUEUE_TTL_MS - ) - private val bootstrapCoordinator = - BootstrapCoordinator( - readyPacketGate = readyPacketGate, - addLog = ::addLog, - shortKeyForLog = ::shortKeyForLog, - sendPacketDirect = { packet -> getProtocol().sendPacket(packet) } - ) - private val deviceRuntimeService = - DeviceRuntimeService( - getAppContext = { appContext }, - sendPacket = ::send - ) - private val connectionOrchestrator = - ConnectionOrchestrator( - hasActiveInternet = ::hasActiveInternet, - waitForNetworkAndReconnect = ::waitForNetworkAndReconnect, - stopWaitingForNetwork = { reason -> 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 packetRouter by lazy { - PacketRouter( - sendSearchPacket = { packet -> send(packet) }, - privateHashProvider = { - try { - getProtocol().getPrivateHash() - } catch (_: Exception) { - null - } - } - ) - } - private val outgoingMessagePipelineService = - OutgoingMessagePipelineService( - scope = scope, - getRepository = { messageRepository }, - sendPacket = { packet -> send(packet) }, - isAuthenticated = ::isAuthenticated, - addLog = ::addLog - ) - private val presenceTypingService = - PresenceTypingService( - scope = scope, - typingIndicatorTimeoutMs = TYPING_INDICATOR_TIMEOUT_MS - ) - private val syncCoordinator = - SyncCoordinator( - scope = scope, - syncRequestTimeoutMs = SYNC_REQUEST_TIMEOUT_MS, - manualSyncBacktrackMs = MANUAL_SYNC_BACKTRACK_MS, - addLog = ::addLog, - isAuthenticated = ::isAuthenticated, - getRepository = { messageRepository }, - getProtocolPublicKey = { getProtocol().getPublicKey().orEmpty() }, - sendPacket = { packet -> send(packet) }, - onSyncCompleted = ::finishSyncCycle, - whenInboundTasksFinish = ::whenInboundTasksFinish - ) - private val authBootstrapCoordinator = - AuthBootstrapCoordinator( - scope = scope, - addLog = ::addLog - ) - private val networkReconnectWatcher = - NetworkReconnectWatcher( - scope = scope, - networkWaitTimeoutMs = NETWORK_WAIT_TIMEOUT_MS, - addLog = ::addLog, - onReconnectRequested = { reason -> - postConnectionEvent(ConnectionEvent.FastReconnect(reason)) - } - ) - private val callSignalBridge = - CallSignalBridge( - sendPacket = ::send, - waitPacket = ::waitPacket, - unwaitPacket = ::unwaitPacket, - addLog = ::addLog, - shortKeyForLog = ::shortKeyForLog, - shortTextForLog = ::shortTextForLog - ) - - @Volatile private var packetHandlersRegistered = false - @Volatile private var stateMonitoringStarted = false - @Volatile private var ownProfileFallbackJob: Job? = null - - // Guard: prevent duplicate FCM token subscribe within a single session - @Volatile - private var lastSubscribedToken: String? = null - - private val _debugLogs = MutableStateFlow>(emptyList()) - val debugLogs: StateFlow> = _debugLogs.asStateFlow() - private val debugLogsBuffer = ArrayDeque(MAX_DEBUG_LOGS) - 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 - val typingUsers: StateFlow> = presenceTypingService.typingUsers - val typingUsersByDialogSnapshot: StateFlow>> = - presenceTypingService.typingUsersByDialogSnapshot - - val devices: StateFlow> = deviceRuntimeService.devices - val pendingDeviceVerification: StateFlow = - deviceRuntimeService.pendingDeviceVerification - - // Сигнал обновления own profile (username/name загружены с сервера) - val ownProfileUpdated: StateFlow = ownProfileSyncService.ownProfileUpdated - val syncInProgress: StateFlow = syncCoordinator.syncInProgress - - private fun ensureConnectionSupervisor() { - connectionSupervisor.start() - } - - private fun postConnectionEvent(event: ConnectionEvent) { - connectionSupervisor.post(event) - } - - private fun protocolToLifecycleState(state: ProtocolState): ConnectionLifecycleState = - bootstrapCoordinator.protocolToLifecycleState(state) - - private fun setConnectionLifecycleState(next: ConnectionLifecycleState, reason: String) { - if (_connectionLifecycleState.value == next) return - addLog("🧭 CONNECTION STATE: ${_connectionLifecycleState.value} -> $next ($reason)") - _connectionLifecycleState.value = next - } - - private fun recomputeConnectionLifecycleState(reason: String) { - val context = bootstrapContext - val nextState = - bootstrapCoordinator.recomputeLifecycleState( - context = context, - currentState = _connectionLifecycleState.value, - reason = reason - ) { state, updateReason -> - setConnectionLifecycleState(state, updateReason) - } - _connectionLifecycleState.value = nextState - } - - private fun clearReadyPacketQueue(reason: String) { - bootstrapCoordinator.clearReadyPacketQueue(reason) - } - - private fun enqueueReadyPacket(packet: Packet) { - val accountKey = - messageRepository?.getCurrentAccountKey()?.trim().orEmpty().ifBlank { - bootstrapContext.accountPublicKey - } - bootstrapCoordinator.enqueueReadyPacket( - packet = packet, - accountPublicKey = accountKey, - state = _connectionLifecycleState.value - ) - } - - private fun flushReadyPacketQueue(activeAccountKey: String, reason: String) { - bootstrapCoordinator.flushReadyPacketQueue(activeAccountKey, reason) - } - - private fun packetCanBypassReadyGate(packet: Packet): Boolean = - bootstrapCoordinator.packetCanBypassReadyGate(packet) - - private suspend fun handleConnectionEvent(event: ConnectionEvent) { - when (event) { - is ConnectionEvent.InitializeAccount -> { - val normalizedPublicKey = event.publicKey.trim() - val normalizedPrivateKey = event.privateKey.trim() - if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) { - addLog("⚠️ initializeAccount skipped: missing account credentials") - return - } - - val protocolState = getProtocol().state.value - addLog( - "🔐 initializeAccount pk=${shortKeyForLog(normalizedPublicKey)} keyLen=${normalizedPrivateKey.length} state=$protocolState" - ) - syncCoordinator.markSyncInProgress(false) - presenceTypingService.clear() - val repository = messageRepository - if (repository == null) { - addLog("❌ initializeAccount aborted: MessageRepository is not bound") - return - } - repository.initialize(normalizedPublicKey, normalizedPrivateKey) - - val sameAccount = - bootstrapContext.accountPublicKey.equals(normalizedPublicKey, ignoreCase = true) - if (!sameAccount) { - clearReadyPacketQueue("account_switch") - } - - bootstrapContext = - bootstrapContext.copy( - accountPublicKey = normalizedPublicKey, - accountInitialized = true, - syncCompleted = if (sameAccount) bootstrapContext.syncCompleted else false, - ownProfileResolved = if (sameAccount) bootstrapContext.ownProfileResolved else false - ) - recomputeConnectionLifecycleState("account_initialized") - - val shouldResync = - syncCoordinator.shouldResyncAfterAccountInit() || protocol?.isAuthenticated() == true - if (shouldResync) { - syncCoordinator.clearResyncRequired() - syncCoordinator.clearRequestState() - addLog( - "🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync" - ) - syncCoordinator.requestSynchronize() - } - if ( - protocol?.isAuthenticated() == true && - authBootstrapCoordinator.isBootstrapPending() - ) { - tryRunPostAuthBootstrap("account_initialized") - } - - scope.launch { - messageRepository?.checkAndSendVersionUpdateMessage() - } - } - is ConnectionEvent.Connect -> { - connectionOrchestrator.handleConnect(event.reason) - } - is ConnectionEvent.FastReconnect -> { - connectionOrchestrator.handleFastReconnect(event.reason) - } - is ConnectionEvent.Disconnect -> { - stopWaitingForNetwork(event.reason) - protocol?.disconnect() - if (event.clearCredentials) { - protocol?.clearCredentials() - } - messageRepository?.clearInitialization() - presenceTypingService.clear() - deviceRuntimeService.clear() - syncCoordinator.resetForDisconnect() - lastSubscribedToken = null - ownProfileFallbackJob?.cancel() - ownProfileFallbackJob = null - authBootstrapCoordinator.reset() - bootstrapContext = ConnectionBootstrapContext() - clearReadyPacketQueue("disconnect:${event.reason}") - recomputeConnectionLifecycleState("disconnect:${event.reason}") - } - is ConnectionEvent.Authenticate -> { - connectionOrchestrator.handleAuthenticate(event.publicKey, event.privateHash) - } - is ConnectionEvent.ProtocolStateChanged -> { - val previousProtocolState = bootstrapContext.protocolState - val newProtocolState = event.state - - if ( - newProtocolState == ProtocolState.AUTHENTICATED && - previousProtocolState != ProtocolState.AUTHENTICATED - ) { - lastSubscribedToken = null - stopWaitingForNetwork("authenticated") - ownProfileFallbackJob?.cancel() - val generation = sessionGeneration.incrementAndGet() - authBootstrapCoordinator.onAuthenticatedSessionStarted() - bootstrapContext = - bootstrapContext.copy( - protocolState = newProtocolState, - authenticated = true, - syncCompleted = false, - ownProfileResolved = false - ) - recomputeConnectionLifecycleState("protocol_authenticated") - ownProfileFallbackJob = - scope.launch { - delay(BOOTSTRAP_OWN_PROFILE_FALLBACK_MS) - postConnectionEvent( - ConnectionEvent.OwnProfileFallbackTimeout(generation) - ) - } - onAuthenticated() - return - } - - if ( - newProtocolState != ProtocolState.AUTHENTICATED && - newProtocolState != ProtocolState.HANDSHAKING - ) { - syncCoordinator.clearRequestState() - syncCoordinator.markSyncInProgress(false) - lastSubscribedToken = null - cancelAllOutgoingRetries() - ownProfileFallbackJob?.cancel() - ownProfileFallbackJob = null - authBootstrapCoordinator.reset() - bootstrapContext = - bootstrapContext.copy( - protocolState = newProtocolState, - authenticated = false, - syncCompleted = false, - ownProfileResolved = false - ) - recomputeConnectionLifecycleState("protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}") - return - } - - if (newProtocolState == ProtocolState.HANDSHAKING && bootstrapContext.authenticated) { - ownProfileFallbackJob?.cancel() - ownProfileFallbackJob = null - authBootstrapCoordinator.reset() - bootstrapContext = - bootstrapContext.copy( - protocolState = newProtocolState, - authenticated = false, - syncCompleted = false, - ownProfileResolved = false - ) - recomputeConnectionLifecycleState("protocol_re_handshaking") - return - } - - bootstrapContext = bootstrapContext.copy(protocolState = newProtocolState) - recomputeConnectionLifecycleState("protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}") - } - is ConnectionEvent.SendPacket -> { - val packet = event.packet - val lifecycle = _connectionLifecycleState.value - if (packetCanBypassReadyGate(packet) || lifecycle == ConnectionLifecycleState.READY) { - getProtocol().sendPacket(packet) - } else { - enqueueReadyPacket(packet) - if (!isAuthenticated()) { - if (!hasActiveInternet()) { - waitForNetworkAndReconnect("ready_gate_send") - } else { - getProtocol().reconnectNowIfNeeded("ready_gate_send") - } - } - } - } - is ConnectionEvent.SyncCompleted -> { - syncCoordinator.onSyncCompletedStateApplied() - addLog(event.reason) - retryWaitingMessages() - requestMissingUserInfo() - - bootstrapContext = bootstrapContext.copy(syncCompleted = true) - recomputeConnectionLifecycleState("sync_completed") - } - is ConnectionEvent.OwnProfileResolved -> { - val accountPublicKey = bootstrapContext.accountPublicKey - val matchesAccount = - accountPublicKey.isBlank() || - event.publicKey.equals(accountPublicKey, ignoreCase = true) - if (!matchesAccount) return - ownProfileFallbackJob?.cancel() - ownProfileFallbackJob = null - bootstrapContext = bootstrapContext.copy(ownProfileResolved = true) - IdentityStore.updateOwnProfile( - publicKey = event.publicKey, - resolved = true, - reason = "protocol_own_profile_resolved" - ) - recomputeConnectionLifecycleState("own_profile_resolved") - } - is ConnectionEvent.OwnProfileFallbackTimeout -> { - if (sessionGeneration.get() != event.sessionGeneration) return - if (!bootstrapContext.authenticated || bootstrapContext.ownProfileResolved) return - addLog( - "⏱️ Own profile fetch timeout — continuing bootstrap for ${shortKeyForLog(bootstrapContext.accountPublicKey)}" - ) - bootstrapContext = bootstrapContext.copy(ownProfileResolved = true) - val accountPublicKey = bootstrapContext.accountPublicKey - if (accountPublicKey.isNotBlank()) { - IdentityStore.updateOwnProfile( - publicKey = accountPublicKey, - resolved = true, - reason = "protocol_own_profile_fallback_timeout" - ) - } - recomputeConnectionLifecycleState("own_profile_fallback_timeout") - } - } - } - - // Keep heavy protocol/message UI logs disabled by default. - private var uiLogsEnabled = false - private val _connectionLifecycleState = MutableStateFlow(ConnectionLifecycleState.DISCONNECTED) - val connectionLifecycleState: StateFlow = _connectionLifecycleState.asStateFlow() - private var bootstrapContext = ConnectionBootstrapContext() - // Desktop parity: sequential task queue matching dialogQueue.ts (promise chain). - // Uses Channel to guarantee strict FIFO ordering (Mutex+lastInboundJob had a race - // condition: Dispatchers.IO doesn't guarantee FIFO, so the last-launched job could - // finish before earlier ones, causing whenInboundTasksFinish to return prematurely - // and BATCH_END to advance the sync timestamp while messages were still processing). - private val inboundTaskChannel = Channel Unit>(Channel.UNLIMITED) - // Tracks the tail of the sequential processing chain (like desktop's `tail` promise) - @Volatile private var inboundQueueDrainJob: Job? = null - - fun addLog(message: String) { - var normalizedMessage = message - val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK") - if (isHeartbeatOk) { - val now = System.currentTimeMillis() - if (now - lastHeartbeatOkLogAtMs < HEARTBEAT_OK_LOG_MIN_INTERVAL_MS) { - 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 >= MAX_DEBUG_LOGS) { - debugLogsBuffer.removeFirst() - } - debugLogsBuffer.addLast(line) - } - flushDebugLogsThrottled() - } - - /** - * Keep crash_reports trace lightweight when UI logs are disabled. - * This avoids excessive disk writes during long sync sessions. - */ - 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 = appContext ?: return - runCatching { - val dir = File(context.filesDir, "crash_reports") - if (!dir.exists()) dir.mkdirs() - val traceFile = File(dir, PROTOCOL_TRACE_FILE_NAME) - synchronized(protocolTraceLock) { - if (traceFile.exists() && traceFile.length() > PROTOCOL_TRACE_MAX_BYTES) { - val tail = runCatching { - traceFile.readText(Charsets.UTF_8).takeLast(PROTOCOL_TRACE_KEEP_BYTES) - }.getOrDefault("") - traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8) - } - traceFile.appendText("$line\n", Charsets.UTF_8) - } - } - } - - fun enableUILogs(enabled: Boolean) { - uiLogsEnabled = enabled - MessageLogger.setEnabled(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 flushDebugLogsThrottled() { - debugFlushPending.set(true) - if (debugFlushJob?.isActive == true) return - debugFlushJob = - scope.launch { - while (debugFlushPending.getAndSet(false)) { - delay(DEBUG_LOG_FLUSH_DELAY_MS) - val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() } - _debugLogs.value = snapshot - } - } - } - - 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") - } - } - - /** - * 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 - } - - /** - * Инициализация с контекстом для доступа к MessageRepository - */ - fun initialize(context: Context) { - appContext = context.applicationContext - if (messageRepository == null || groupRepository == null || accountManager == null) { - addLog("⚠️ initialize called before dependencies were bound via DI") - } - ensureConnectionSupervisor() - if (!packetHandlersRegistered) { - setupPacketHandlers() - packetHandlersRegistered = true - } - if (!stateMonitoringStarted) { - setupStateMonitoring() - stateMonitoringStarted = true - } - } - - /** - * 🔍 Мониторинг состояния соединения - */ - private fun setupStateMonitoring() { - scope.launch { - getProtocol().state.collect { newState -> - postConnectionEvent(ConnectionEvent.ProtocolStateChanged(newState)) - } - } - } - - /** - * 🔥 Инициализация аккаунта - КРИТИЧНО для получения сообщений! - * Должен вызываться после авторизации пользователя - */ - fun initializeAccount(publicKey: String, privateKey: String) { - postConnectionEvent( - ConnectionEvent.InitializeAccount(publicKey = publicKey, privateKey = privateKey) - ) - } - - /** - * Настройка обработчиков пакетов - */ - private fun setupPacketHandlers() { - // Обработчик входящих сообщений (0x06) - // Desktop parity: desktop client does not send PacketDelivery manually. - waitPacket(0x06) { packet -> - val messagePacket = packet as PacketMessage - - launchInboundPacketTask { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) { - syncCoordinator.requireResyncAfterAccountInit("⏳ Incoming message before account init, scheduling re-sync") - markInboundProcessingFailure("Incoming packet skipped before account init") - return@launchInboundPacketTask - } - val processed = repository.handleIncomingMessage(messagePacket) - if (!processed) { - markInboundProcessingFailure( - "Message processing failed for ${messagePacket.messageId.take(8)}" - ) - return@launchInboundPacketTask - } - if (!syncCoordinator.isBatchInProgress()) { - repository.updateLastSyncTimestamp(messagePacket.timestamp) - } - } - } - - // Обработчик доставки (0x08) - waitPacket(0x08) { packet -> - val deliveryPacket = packet as PacketDelivery - - launchInboundPacketTask { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) { - syncCoordinator.requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync") - markInboundProcessingFailure("Delivery packet skipped before account init") - 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()) - } - } - } - - // Обработчик прочтения (0x07) - // В Desktop PacketRead не содержит messageId - сообщает что собеседник прочитал сообщения - waitPacket(0x07) { packet -> - val readPacket = packet as PacketRead - - launchInboundPacketTask { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) { - syncCoordinator.requireResyncAfterAccountInit("⏳ Read status before account init, scheduling re-sync") - markInboundProcessingFailure("Read packet skipped before account init") - return@launchInboundPacketTask - } - val ownKey = getProtocol().getPublicKey().orEmpty() - if (ownKey.isBlank()) { - syncCoordinator.requireResyncAfterAccountInit("⏳ Read status before protocol account init, scheduling re-sync") - markInboundProcessingFailure("Read packet skipped before protocol account init") - 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()) - } - } - } - } - - // 🔐 New device login attempt (0x09) — desktop parity (system Safe message) - waitPacket(0x09) { packet -> - val devicePacket = packet as PacketDeviceNew - - addLog( - "🔐 DEVICE LOGIN ATTEMPT: ip=${devicePacket.ipAddress}, device=${devicePacket.device.deviceName}, os=${devicePacket.device.deviceOs}" - ) - - launchInboundPacketTask { - messageRepository?.addDeviceLoginSystemMessage( - ipAddress = devicePacket.ipAddress, - deviceId = devicePacket.device.deviceId, - deviceName = devicePacket.device.deviceName, - deviceOs = devicePacket.device.deviceOs - ) - } - } - - // 🔄 Обработчик батчевой синхронизации (0x19) - waitPacket(0x19) { packet -> - syncCoordinator.handleSyncPacket(packet as PacketSync) - } - - // 👥 Обработчик синхронизации групп (0x14) - // Desktop parity: во время sync сервер отправляет PacketGroupJoin с groupString. - waitPacket(0x14) { packet -> - val joinPacket = packet as PacketGroupJoin - - launchInboundPacketTask { - val repository = messageRepository - val groups = groupRepository - 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) { - android.util.Log.w(TAG, "Failed to sync group packet", e) - } - } - } - - // 🟢 Обработчик онлайн-статуса (0x05) - waitPacket(0x05) { packet -> - val onlinePacket = packet as PacketOnlineState - - scope.launch { - if (messageRepository != null) { - onlinePacket.publicKeysState.forEach { item -> - val isOnline = item.state == OnlineState.ONLINE - messageRepository?.updateOnlineStatus(item.publicKey, isOnline) - } - } - } - } - - // Обработчик typing (0x0B) - waitPacket(0x0B) { packet -> - presenceTypingService.handleTypingPacket(packet as PacketTyping) { - getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { - messageRepository?.getCurrentAccountKey()?.trim().orEmpty() - } - } - } - - // 📱 Обработчик списка устройств (0x17) - waitPacket(0x17) { packet -> - deviceRuntimeService.handleDeviceList(packet as PacketDeviceList) - } - - // 🔥 Обработчик поиска/user info (0x03) - // Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера - // + обновляет own profile (username/name) аналогично Desktop useUserInformation() - waitPacket(0x03) { packet -> - val searchPacket = packet as PacketSearch - - scope.launch(Dispatchers.IO) { - val ownPublicKey = - getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { - messageRepository?.getCurrentAccountKey()?.trim().orEmpty() - } - - packetRouter.onSearchPacket(searchPacket) { user -> - val normalizedUserPublicKey = user.publicKey.trim() - messageRepository?.updateDialogUserInfo( - normalizedUserPublicKey, - user.title, - user.username, - user.verified - ) - - val ownProfileResolved = - ownProfileSyncService.applyOwnProfileFromSearch( - ownPublicKey = ownPublicKey, - user = user - ) - if (ownProfileResolved) { - postConnectionEvent(ConnectionEvent.OwnProfileResolved(user.publicKey)) - } - } - } - } - - // 🚀 Обработчик транспортного сервера (0x0F) - waitPacket(0x0F) { packet -> - val transportPacket = packet as PacketRequestTransport - TransportManager.setTransportServer(transportPacket.transportServer) - } - } - - /** - * Desktop parity: sequential task queue (like dialogQueue.ts runTaskInQueue / whenFinish). - * - * Desktop uses a promise chain: `tail = tail.then(fn).catch(...)` which guarantees - * strict FIFO ordering and `whenFinish = () => tail` returns a promise that resolves - * only after ALL queued tasks complete. - * - * We reproduce this with a Channel Unit> (UNLIMITED buffer) consumed - * by a single coroutine. Tasks are executed strictly in the order they were submitted, - * and `whenInboundTasksFinish()` waits for the queue to drain completely. - */ - private fun ensureInboundQueueDrainRunning() { - if (inboundQueueDrainJob?.isActive == true) return - inboundQueueDrainJob = scope.launch { - for (task in inboundTaskChannel) { - try { - task() - } catch (t: Throwable) { - markInboundProcessingFailure("Dialog queue error", t) - } - } - } - } - - private fun launchInboundPacketTask(block: suspend () -> Unit): Boolean { - ensureInboundQueueDrainRunning() - syncCoordinator.trackInboundTaskQueued() - val result = inboundTaskChannel.trySend(block) - if (result.isFailure) { - markInboundProcessingFailure( - "Failed to enqueue inbound task", - result.exceptionOrNull() - ) - return false - } - return true - } - - /** - * Desktop parity: equivalent of `await whenFinish()` in useSynchronize.ts. - * Sends a sentinel task into the sequential queue and suspends until it executes. - * Since the queue is strictly FIFO, when the sentinel runs, all previously - * submitted tasks are guaranteed to have completed. - */ - private suspend fun whenInboundTasksFinish(): Boolean { - val done = CompletableDeferred() - if (!launchInboundPacketTask { done.complete(Unit) }) { - return false - } - done.await() - return true - } - - private fun isGroupDialogKey(value: String): Boolean { - val normalized = value.trim().lowercase(Locale.ROOT) - return normalized.startsWith("#group:") || normalized.startsWith("group:") - } - - fun getTypingUsersForDialog(dialogKey: String): Set { - return presenceTypingService.getTypingUsersForDialog(dialogKey) - } - - private fun canRunPostAuthBootstrap(): Boolean { - val repository = messageRepository ?: return false - if (!repository.isInitialized()) return false - val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty() - if (repositoryAccount.isBlank()) return false - val protocolAccount = getProtocol().getPublicKey()?.trim().orEmpty() - if (protocolAccount.isBlank()) return true - return repositoryAccount.equals(protocolAccount, ignoreCase = true) - } - - private fun tryRunPostAuthBootstrap(trigger: String) { - authBootstrapCoordinator.tryRun( - trigger = trigger, - canRun = ::canRunPostAuthBootstrap, - onDeferred = { - val repositoryAccount = - messageRepository?.getCurrentAccountKey()?.let { shortKeyForLog(it) } - ?: "" - val protocolAccount = - getProtocol().getPublicKey()?.let { shortKeyForLog(it) } - ?: "" - addLog( - "⏳ AUTH bootstrap deferred trigger=$trigger repo=$repositoryAccount proto=$protocolAccount" - ) - } - ) { - syncCoordinator.markSyncInProgress(false) - TransportManager.requestTransportServer() - com.rosetta.messenger.update.UpdateManager.requestSduServer() - fetchOwnProfile() - syncCoordinator.requestSynchronize() - subscribePushTokenIfAvailable() - } - } - - private fun onAuthenticated() { - tryRunPostAuthBootstrap("state_authenticated") - } - - private fun finishSyncCycle(reason: String) { - postConnectionEvent(ConnectionEvent.SyncCompleted(reason)) - } - - /** - * Send FCM push token to server (SUBSCRIBE). - * Deduplicates: won't re-send the same token within one connection session. - * Called internally on AUTHENTICATED and can be called from - * [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken] - * when Firebase rotates the token mid-session. - * - * @param forceToken if non-null, use this token instead of reading SharedPreferences - * (used by onNewToken which already has the fresh token). - */ - fun subscribePushTokenIfAvailable(forceToken: String? = null) { - val context = appContext ?: return - val token = (forceToken - ?: context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) - .getString("fcm_token", null)) - ?.trim() - .orEmpty() - if (token.isEmpty()) return - - // Dedup: don't send the same token twice in one connection session - if (token == lastSubscribedToken) { - 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 - } - send(subPacket) - lastSubscribedToken = token - addLog("🔔 Push token SUBSCRIBE sent") - - // Сохраняем FCM токен в crash_reports для просмотра через rosettadev1 - try { - val dir = java.io.File(context.filesDir, "crash_reports") - if (!dir.exists()) dir.mkdirs() - val f = java.io.File(dir, "fcm_token.txt") - val ts = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date()) - f.writeText("=== FCM TOKEN ===\n\nTimestamp: $ts\nDeviceId: $deviceId\n\nToken:\n$token\n") - } catch (_: Throwable) {} - } - - /** - * Retry messages stuck in WAITING status on reconnect. - * Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are - * rarely force-killed. On Android, the app can be killed mid-send, leaving messages - * in WAITING status in the DB. This method resends them after sync completes. - * - * Messages older than 80s (MESSAGE_MAX_TIME_TO_DELEVERED_S) are marked ERROR. - */ - private fun retryWaitingMessages() { - scope.launch { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) return@launch - try { - repository.retryWaitingMessages() - } catch (e: Exception) { - android.util.Log.e(TAG, "retryWaitingMessages failed", e) - } - } - } - - /** - * Desktop parity: after sync completes, resolve names/usernames for all dialogs - * that still have empty titles. Clears the one-shot guard first so that previously - * failed requests can be retried. - */ - private fun requestMissingUserInfo() { - scope.launch { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) return@launch - try { - repository.clearUserInfoRequestCache() - repository.requestMissingUserInfo() - } catch (e: Exception) { - android.util.Log.e(TAG, "requestMissingUserInfo failed", e) - } - } - } - - private fun hasActiveInternet(): Boolean { - return networkReconnectWatcher.hasActiveInternet(appContext) - } - - private fun stopWaitingForNetwork(reason: String? = null) { - networkReconnectWatcher.stop(appContext, reason) - } - - private fun waitForNetworkAndReconnect(reason: String) { - networkReconnectWatcher.waitForNetwork(appContext, reason) - } - - /** - * Get or create Protocol instance - */ - fun getProtocol(): Protocol { - protocol?.let { return it } - - synchronized(protocolInstanceLock) { - protocol?.let { return it } - - val created = - Protocol( - serverAddress = SERVER_ADDRESS, - logger = { msg -> addLog(msg) }, - isNetworkAvailable = { hasActiveInternet() }, - onNetworkUnavailable = { waitForNetworkAndReconnect("protocol_connect") } - ) - protocol = created - addLog("🧩 Protocol singleton created: id=${System.identityHashCode(created)}") - return created - } - } - - /** - * Get connection state flow - */ - val state: StateFlow - get() = getProtocol().state - - /** - * Get last error flow - */ - val lastError: StateFlow - get() = getProtocol().lastError - - /** - * Connect to server - */ - fun connect() { - postConnectionEvent(ConnectionEvent.Connect(reason = "api_connect")) - } - - /** - * Trigger immediate reconnect on app foreground (skip waiting backoff timer). - */ - fun reconnectNowIfNeeded(reason: String = "foreground_resume") { - postConnectionEvent(ConnectionEvent.FastReconnect(reason = reason)) - } - - /** - * Desktop parity: синхронизация при каждом заходе в приложение. - * Desktop вызывает trySync() на каждый handshakeExchangeComplete. - * На Android — вызываем из onResume(), если уже AUTHENTICATED и не идёт sync. - * Дебаунс 5 секунд чтобы не спамить при быстром alt-tab. - */ - fun syncOnForeground() { - syncCoordinator.syncOnForeground() - } - - /** - * Manual sync trigger from UI. - * Rewinds lastSync a bit to safely re-fetch recent packets and re-starts sync. - */ - fun forceSynchronize(backtrackMs: Long = MANUAL_SYNC_BACKTRACK_MS) { - if (!isAuthenticated()) { - reconnectNowIfNeeded("manual_sync_button") - return - } - syncCoordinator.forceSynchronize(backtrackMs) - } - - /** - * Authenticate with server - */ - fun authenticate(publicKey: String, privateHash: String) { - postConnectionEvent( - ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash) - ) - } - - /** - * Restore auth handshake credentials from local account cache. - * Used when process is awakened by push and UI unlock flow wasn't executed yet. - */ - fun restoreAuthFromStoredCredentials( - preferredPublicKey: String? = null, - reason: String = "background_restore" - ): Boolean { - val accountManager = accountManager - 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 - } - - /** - * Запрашивает собственный профиль с сервера (username, name/title). - * Аналог Desktop: useUserInformation(ownPublicKey) → PacketSearch(0x03) - */ - private fun fetchOwnProfile() { - val packet = - ownProfileSyncService.buildOwnProfilePacket( - publicKey = getProtocol().getPublicKey(), - privateHash = getProtocol().getPrivateHash() - ) ?: return - send(packet) - } - - /** - * 🔍 Resolve publicKey → user title (like Desktop useUserInformation) - * Checks cache first, then sends PacketSearch and waits for response. - * Returns title or null on timeout/not found. - * @param timeoutMs max wait time for server response (default 3s) - */ - suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? { - return packetRouter.resolveUserName(publicKey = publicKey, timeoutMs = timeoutMs) - } - - /** - * 🔍 Get cached user info (no network request) - */ - fun getCachedUserName(publicKey: String): String? { - return packetRouter.getCachedUserName(publicKey) - } - - /** - * 🔍 Get full cached user info (no network request) - */ - fun notifyOwnProfileUpdated() { - ownProfileSyncService.notifyOwnProfileUpdated() - } - - fun getCachedUserInfo(publicKey: String): SearchUser? { - return packetRouter.getCachedUserInfo(publicKey) - } - - /** - * 🔍 Get cached user by username (no network request). - * Username compare is case-insensitive and ignores '@'. - */ - fun getCachedUserByUsername(username: String): SearchUser? { - return packetRouter.getCachedUserByUsername(username) - } - - /** - * 🔍 Resolve publicKey → full SearchUser (with server request if needed) - */ - suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? { - return packetRouter.resolveUserInfo(publicKey = publicKey, timeoutMs = timeoutMs) - } - - /** - * 🔍 Search users by query (usually username without '@'). - * Returns raw PacketSearch users list for the exact query. - */ - suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List { - return packetRouter.searchUsers(query = query, timeoutMs = timeoutMs) - } - - /** - * Accept a pending device login request. - */ - fun acceptDevice(deviceId: String) { - deviceRuntimeService.acceptDevice(deviceId) - } - - /** - * Decline a pending device login request. - */ - fun declineDevice(deviceId: String) { - deviceRuntimeService.declineDevice(deviceId) - } - - /** - * Send packet (simplified) - */ - fun send(packet: Packet) { - postConnectionEvent(ConnectionEvent.SendPacket(packet)) - } - - /** - * Send an outgoing message packet and register it for automatic retry. - */ - fun sendMessageWithRetry(packet: PacketMessage) { - outgoingMessagePipelineService.sendWithRetry(packet) - } - - /** - * iOS parity: cancel retry and clean up state for a resolved outgoing message. - * Called when delivery ACK (0x08) is received. - */ - fun resolveOutgoingRetry(messageId: String) { - outgoingMessagePipelineService.resolveOutgoingRetry(messageId) - } - - /** - * Cancel all pending outgoing retry jobs (e.g., on disconnect). - */ - private fun cancelAllOutgoingRetries() { - outgoingMessagePipelineService.clearRetryQueue() - } - - /** - * Send packet (legacy name) - */ - fun sendPacket(packet: Packet) { - send(packet) - } - - /** - * Send call signaling packet (0x1A). - */ - fun sendCallSignal( - signalType: SignalType, - src: String = "", - dst: String = "", - sharedPublic: String = "", - callId: String = "", - joinToken: String = "" - ) { - callSignalBridge.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken) - } - - /** - * Send WebRTC signaling packet (0x1B). - */ - fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) { - callSignalBridge.sendWebRtcSignal(signalType, sdpOrCandidate) - } - - /** - * Request ICE servers from server (0x1C). - */ - fun requestIceServers() { - callSignalBridge.requestIceServers() - } - - /** - * Typed subscribe for call signaling packets (0x1A). - * Returns wrapper callback for subsequent unwait. - */ - fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit { - return callSignalBridge.waitCallSignal(callback) - } - - fun unwaitCallSignal(callback: (Packet) -> Unit) { - callSignalBridge.unwaitCallSignal(callback) - } - - /** - * Typed subscribe for WebRTC packets (0x1B). - * Returns wrapper callback for subsequent unwait. - */ - fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit { - return callSignalBridge.waitWebRtcSignal(callback) - } - - fun unwaitWebRtcSignal(callback: (Packet) -> Unit) { - callSignalBridge.unwaitWebRtcSignal(callback) - } - - /** - * Typed subscribe for ICE servers packet (0x1C). - * Returns wrapper callback for subsequent unwait. - */ - fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit { - return callSignalBridge.waitIceServers(callback) - } - - fun unwaitIceServers(callback: (Packet) -> Unit) { - callSignalBridge.unwaitIceServers(callback) - } - - /** - * Register packet handler - */ - fun waitPacket(packetId: Int, callback: (Packet) -> Unit) { - packetSubscriptionRegistry.addCallback(packetId, callback) - } - - /** - * Unregister packet handler - */ - fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) { - packetSubscriptionRegistry.removeCallback(packetId, callback) - } - - /** - * SharedFlow fan-out stream for packet id. - */ - fun packetFlow(packetId: Int): SharedFlow { - return packetSubscriptionRegistry.flow(packetId) - } - - private fun shortKeyForLog(value: String, visible: Int = 8): String { - val trimmed = value.trim() - if (trimmed.isBlank()) return "" - 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 "" - return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…" - } - - /** - * Disconnect and clear - */ - fun disconnect() { - postConnectionEvent( - ConnectionEvent.Disconnect( - reason = "manual_disconnect", - clearCredentials = true - ) - ) - } - - /** - * Destroy instance completely - */ - fun destroy() { - stopWaitingForNetwork("destroy") - packetSubscriptionRegistry.destroy() - synchronized(protocolInstanceLock) { - protocol?.destroy() - protocol = null - } - messageRepository?.clearInitialization() - presenceTypingService.clear() - deviceRuntimeService.clear() - syncCoordinator.resetForDisconnect() - authBootstrapCoordinator.reset() - scope.cancel() - } - - /** - * Check if authenticated - */ - fun isAuthenticated(): Boolean = protocol?.isAuthenticated() ?: false - - /** - * Check if connected - */ - fun isConnected(): Boolean = protocol?.isConnected() ?: false -} diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimeComposition.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimeComposition.kt new file mode 100644 index 0000000..ace2410 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimeComposition.kt @@ -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 = + 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> = debugLogService.debugLogs + val typingUsers: StateFlow> = presenceTypingService.typingUsers + val typingUsersByDialogSnapshot: StateFlow>> = + presenceTypingService.typingUsersByDialogSnapshot + + val devices: StateFlow> = deviceRuntimeService.devices + val pendingDeviceVerification: StateFlow = + deviceRuntimeService.pendingDeviceVerification + + // Сигнал обновления own profile (username/name загружены с сервера) + val ownProfileUpdated: StateFlow = ownProfileSyncService.ownProfileUpdated + val syncInProgress: StateFlow = 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 + 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 "" + 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 "" + return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…" + } + + private fun isAuthenticated(): Boolean = connectionControlFacade.isAuthenticated() +} diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt new file mode 100644 index 0000000..717ff25 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt @@ -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() + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimeDirectoryFacade.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimeDirectoryFacade.kt new file mode 100644 index 0000000..d81e016 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimeDirectoryFacade.kt @@ -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 { + 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 { + return packetRouter.searchUsers(query = query, timeoutMs = timeoutMs) + } + + fun acceptDevice(deviceId: String) { + deviceRuntimeService.acceptDevice(deviceId) + } + + fun declineDevice(deviceId: String) { + deviceRuntimeService.declineDevice(deviceId) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimeMessagingAssembly.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimeMessagingAssembly.kt new file mode 100644 index 0000000..4f46069 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimeMessagingAssembly.kt @@ -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 + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimePacketIoFacade.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimePacketIoFacade.kt new file mode 100644 index 0000000..2893d5d --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimePacketIoFacade.kt @@ -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 { + return packetSubscriptionFacade.packetFlow(packetId) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimeRoutingAssembly.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimeRoutingAssembly.kt new file mode 100644 index 0000000..1fd4ffb --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimeRoutingAssembly.kt @@ -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) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimeStateAssembly.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimeStateAssembly.kt new file mode 100644 index 0000000..6caebb8 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimeStateAssembly.kt @@ -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 = + lifecycleStateMachine.connectionLifecycleState + + fun recomputeConnectionLifecycleState(reason: String) { + lifecycleStateMachine.recompute(reason) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/RuntimeTransportAssembly.kt b/app/src/main/java/com/rosetta/messenger/network/RuntimeTransportAssembly.kt new file mode 100644 index 0000000..a90f843 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/RuntimeTransportAssembly.kt @@ -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() + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/AuthRestoreService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/AuthRestoreService.kt new file mode 100644 index 0000000..2962862 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/AuthRestoreService.kt @@ -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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ConnectionEventRouter.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ConnectionEventRouter.kt new file mode 100644 index 0000000..a740e94 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ConnectionEventRouter.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/InboundPacketHandlerRegistrar.kt b/app/src/main/java/com/rosetta/messenger/network/connection/InboundPacketHandlerRegistrar.kt new file mode 100644 index 0000000..332d45d --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/InboundPacketHandlerRegistrar.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/InboundTaskQueueService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/InboundTaskQueueService.kt new file mode 100644 index 0000000..9064370 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/InboundTaskQueueService.kt @@ -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 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() + 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) + } + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/NetworkConnectivityFacade.kt b/app/src/main/java/com/rosetta/messenger/network/connection/NetworkConnectivityFacade.kt new file mode 100644 index 0000000..a372075 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/NetworkConnectivityFacade.kt @@ -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) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileFallbackTimerService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileFallbackTimerService.kt new file mode 100644 index 0000000..ab06243 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileFallbackTimerService.kt @@ -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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/PacketSubscriptionFacade.kt b/app/src/main/java/com/rosetta/messenger/network/connection/PacketSubscriptionFacade.kt new file mode 100644 index 0000000..9342525 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/PacketSubscriptionFacade.kt @@ -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 { + return packetSubscriptionRegistry.flow(packetId) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolAccountSessionCoordinator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolAccountSessionCoordinator.kt new file mode 100644 index 0000000..d2f9b9a --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolAccountSessionCoordinator.kt @@ -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") + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt new file mode 100644 index 0000000..10df5aa --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt @@ -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>(emptyList()) + val debugLogs: StateFlow> = _debugLogs.asStateFlow() + private val debugLogsBuffer = ArrayDeque(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 + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolInstanceManager.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolInstanceManager.kt new file mode 100644 index 0000000..f9c6537 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolInstanceManager.kt @@ -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 + get() = getOrCreateProtocol().state + + val lastError: StateFlow + 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 +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleCoordinator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleCoordinator.kt new file mode 100644 index 0000000..c961bcc --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleCoordinator.kt @@ -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") + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleStateStoreImpl.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleStateStoreImpl.kt new file mode 100644 index 0000000..908d554 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleStateStoreImpl.kt @@ -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() +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolPostAuthBootstrapCoordinator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolPostAuthBootstrapCoordinator.kt new file mode 100644 index 0000000..54f36f3 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ProtocolPostAuthBootstrapCoordinator.kt @@ -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) } + ?: "" + val protocolAccount = getProtocolPublicKey()?.let { shortKeyForLog(it) } ?: "" + 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) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ReadyPacketDispatchCoordinator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ReadyPacketDispatchCoordinator.kt new file mode 100644 index 0000000..c16c9cc --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ReadyPacketDispatchCoordinator.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeInitializationCoordinator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeInitializationCoordinator.kt new file mode 100644 index 0000000..6603aea --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeInitializationCoordinator.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeLifecycleStateMachine.kt b/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeLifecycleStateMachine.kt new file mode 100644 index 0000000..6efe969 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeLifecycleStateMachine.kt @@ -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.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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeShutdownCoordinator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeShutdownCoordinator.kt new file mode 100644 index 0000000..62cc4b9 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/RuntimeShutdownCoordinator.kt @@ -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() + } +} diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index 5f0bfeb..70427c2 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt index 60e6062..46d9e55 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt @@ -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 = 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) } ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt index 96ed3da..24b1e25 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt @@ -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 { diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt index dc587d3..946e8b8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt @@ -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("") } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsCoordinator.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsCoordinator.kt new file mode 100644 index 0000000..c4b2ed0 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsCoordinator.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsFeatureCoordinator.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsFeatureCoordinator.kt new file mode 100644 index 0000000..f10014b --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsFeatureCoordinator.kt @@ -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, 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() + 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, 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() + 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() + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 1f7011c..3d994f2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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( } // �🔥 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( // �🔥 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( // �📷 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) -> 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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt new file mode 100644 index 0000000..62e0964 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt @@ -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> = chatViewModel.messages + val messagesWithDates: StateFlow>> = chatViewModel.messagesWithDates + val isLoading: StateFlow = chatViewModel.isLoading + val isLoadingMore: StateFlow = chatViewModel.isLoadingMore + val groupRequiresRejoin: StateFlow = chatViewModel.groupRequiresRejoin + val inputText: StateFlow = chatViewModel.inputText + val replyMessages: StateFlow> = chatViewModel.replyMessages + val isForwardMode: StateFlow = chatViewModel.isForwardMode + val pendingDeleteIds: StateFlow> = 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) = chatViewModel.setReplyMessages(messages) + + fun setForwardMessages(messages: List) = 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 + ) = 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) { + 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, caption: String = "") { + chatViewModel.attachmentsFeatureCoordinator.sendImageGroupFromUris( + imageUris = imageUris, + caption = caption + ) + } + + fun sendImageGroup(images: List, 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 = chatViewModel.opponentTyping + val typingDisplayName: StateFlow = chatViewModel.typingDisplayName + val typingDisplayPublicKey: StateFlow = chatViewModel.typingDisplayPublicKey + val opponentOnline: StateFlow = 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 + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 64f11d5..00a23aa 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -3,10 +3,8 @@ package com.rosetta.messenger.ui.chats import android.app.Application import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.media.MediaMetadataRetriever import android.os.SystemClock import android.util.Base64 -import android.webkit.MimeTypeMap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager @@ -14,21 +12,35 @@ import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.di.ProtocolGateway +import com.rosetta.messenger.domain.chats.usecase.EncodeVideoUriToHexUseCase +import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand +import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentUseCase +import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentCommand +import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentUseCase +import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentCommand +import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentUseCase +import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand +import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentUseCase +import com.rosetta.messenger.domain.chats.usecase.ResolveVideoCircleMetaUseCase +import com.rosetta.messenger.domain.chats.usecase.SendReadReceiptCommand +import com.rosetta.messenger.domain.chats.usecase.SendReadReceiptUseCase +import com.rosetta.messenger.domain.chats.usecase.SendForwardUseCase +import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand +import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageUseCase +import com.rosetta.messenger.domain.chats.usecase.SendTextMessageCommand +import com.rosetta.messenger.domain.chats.usecase.SendTextMessageUseCase +import com.rosetta.messenger.domain.chats.usecase.SendTypingIndicatorCommand +import com.rosetta.messenger.domain.chats.usecase.SendTypingIndicatorUseCase +import com.rosetta.messenger.domain.chats.usecase.SendVoiceMessageCommand +import com.rosetta.messenger.domain.chats.usecase.SendVoiceMessageUseCase import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.* import com.rosetta.messenger.ui.components.metaball.DevicePerformanceClass import com.rosetta.messenger.ui.components.metaball.PerformanceClass import com.rosetta.messenger.ui.chats.models.* -import com.rosetta.messenger.ui.chats.usecase.ForwardPayloadMessage -import com.rosetta.messenger.ui.chats.usecase.SendForwardUseCase -import com.rosetta.messenger.ui.chats.usecase.SendMediaMessageCommand -import com.rosetta.messenger.ui.chats.usecase.SendMediaMessageUseCase -import com.rosetta.messenger.ui.chats.usecase.SendTextMessageCommand -import com.rosetta.messenger.ui.chats.usecase.SendTextMessageUseCase import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.MessageLogger -import com.rosetta.messenger.utils.MessageThrottleManager import java.io.File import java.util.Date import java.util.Locale @@ -55,7 +67,19 @@ import javax.inject.Inject class ChatViewModel @Inject constructor( private val app: Application, private val protocolGateway: ProtocolGateway, - private val messageRepository: MessageRepository + private val messageRepository: MessageRepository, + private val sendTextMessageUseCase: SendTextMessageUseCase, + private val sendMediaMessageUseCase: SendMediaMessageUseCase, + private val sendForwardUseCase: SendForwardUseCase, + private val sendVoiceMessageUseCase: SendVoiceMessageUseCase, + private val sendTypingIndicatorUseCase: SendTypingIndicatorUseCase, + private val sendReadReceiptUseCase: SendReadReceiptUseCase, + private val createFileAttachmentUseCase: CreateFileAttachmentUseCase, + private val createAvatarAttachmentUseCase: CreateAvatarAttachmentUseCase, + private val createVideoCircleAttachmentUseCase: CreateVideoCircleAttachmentUseCase, + private val encryptAndUploadAttachmentUseCase: EncryptAndUploadAttachmentUseCase, + private val resolveVideoCircleMetaUseCase: ResolveVideoCircleMetaUseCase, + private val encodeVideoUriToHexUseCase: EncodeVideoUriToHexUseCase ) : ViewModel() { companion object { @@ -130,14 +154,6 @@ class ChatViewModel @Inject constructor( private val searchIndexDao = database.messageSearchIndexDao() private val groupDao = database.groupDao() private val pinnedMessageDao = database.pinnedMessageDao() - // MessageRepository для подписки на события новых сообщений - private val sendTextMessageUseCase = - SendTextMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) }) - private val sendMediaMessageUseCase = - SendMediaMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) }) - private val sendForwardUseCase = - SendForwardUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) }) - // 🔥 Кэш расшифрованных сообщений (messageId -> plainText) private val decryptionCache = ConcurrentHashMap() private val groupKeyCache = ConcurrentHashMap() @@ -296,10 +312,6 @@ class ChatViewModel @Inject constructor( // Защита от двойной отправки private var isSending = false - private var pendingTextSendRequested = false - private var pendingTextSendReason: String = "" - private var pendingSendRecoveryJob: Job? = null - // 🔥 Throttling перенесён в глобальный MessageThrottleManager // Job для отмены загрузки при смене диалога private var loadingJob: Job? = null @@ -320,6 +332,12 @@ class ChatViewModel @Inject constructor( private fun shortTraceKey(value: String, maxLength: Int = 14): String = if (value.length <= maxLength) value else value.take(maxLength) + private fun shortSendKey(value: String?): String { + val normalized = value?.trim().orEmpty() + if (normalized.isEmpty()) return "" + return if (normalized.length <= 12) normalized else "${normalized.take(12)}…" + } + @Synchronized private fun appendChatOpenTraceEvent(event: String, details: String = "") { val traceLine = @@ -554,6 +572,296 @@ class ChatViewModel @Inject constructor( setupDeliveryStatusListener() } + internal val messagesCoordinator: MessagesCoordinator by lazy { MessagesCoordinator(this) } + internal val forwardCoordinator: ForwardCoordinator by lazy { ForwardCoordinator(this, sendForwardUseCase) } + internal val attachmentsCoordinator: AttachmentsCoordinator by lazy { AttachmentsCoordinator(this) } + internal val attachmentsFeatureCoordinator: AttachmentsFeatureCoordinator by lazy { + AttachmentsFeatureCoordinator( + chatViewModel = this, + resolveVideoCircleMetaUseCase = resolveVideoCircleMetaUseCase, + encodeVideoUriToHexUseCase = encodeVideoUriToHexUseCase + ) + } + val messagesViewModel: MessagesViewModel by lazy { MessagesViewModel(this) } + val voiceRecordingViewModel: VoiceRecordingViewModel by lazy { VoiceRecordingViewModel(this) } + val attachmentsViewModel: AttachmentsViewModel by lazy { AttachmentsViewModel(this) } + val typingViewModel: TypingViewModel by lazy { TypingViewModel(this) } + + internal fun resolveOutgoingSendContext(): OutgoingSendContext? { + val recipient = opponentKey ?: return null + val sender = myPublicKey ?: return null + val privateKey = myPrivateKey ?: return null + return OutgoingSendContext( + recipient = recipient, + sender = sender, + privateKey = privateKey + ) + } + + @Synchronized + internal fun tryAcquireSendSlot(): Boolean { + if (isSending) return false + isSending = true + return true + } + + @Synchronized + internal fun releaseSendSlot() { + isSending = false + } + + internal fun isSendSlotBusy(): Boolean = isSending + + internal fun hasRuntimeKeysForSend(): Boolean { + return !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() + } + + internal fun resolveRepositoryRuntimeKeys(): Pair? { + val repositoryPublicKey = messageRepository.getCurrentAccountKey()?.trim().orEmpty() + val repositoryPrivateKey = messageRepository.getCurrentPrivateKey()?.trim().orEmpty() + if (repositoryPublicKey.isBlank() || repositoryPrivateKey.isBlank()) return null + return repositoryPublicKey to repositoryPrivateKey + } + + internal fun currentRecipientForSend(): String? = opponentKey?.trim()?.takeIf { it.isNotEmpty() } + + internal fun currentSenderPublicKeyForSend(): String? = + myPublicKey?.trim()?.takeIf { it.isNotEmpty() } + + internal fun currentSenderPrivateKeyForSend(): String? = + myPrivateKey?.trim()?.takeIf { it.isNotEmpty() } + + internal fun isCurrentDialogTarget(recipientPublicKey: String): Boolean { + return recipientPublicKey == opponentKey + } + + internal fun addOutgoingMessageOptimistic(message: ChatMessage) { + addMessageSafely(message) + } + + internal fun clearInputText() { + _inputText.value = "" + } + + internal fun appContext(): Application = app + + internal fun launchOnIo(block: suspend CoroutineScope.() -> Unit): Job { + return viewModelScope.launch(Dispatchers.IO, block = block) + } + + internal fun launchInViewModel(block: suspend CoroutineScope.() -> Unit): Job { + return viewModelScope.launch(block = block) + } + + internal fun launchBackgroundUpload(block: suspend CoroutineScope.() -> Unit): Job { + return backgroundUploadScope.launch(block = block) + } + + internal fun registerOutgoingImageUploadJob(messageId: String, job: Job) { + outgoingImageUploadJobs[messageId] = job + job.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) } + } + + internal fun buildVoicePayload( + voiceHex: String, + durationSec: Int, + waves: List + ) = + sendVoiceMessageUseCase( + SendVoiceMessageCommand( + voiceHex = voiceHex, + durationSec = durationSec, + waves = waves + ) + ) + + internal fun sendMediaMessage(command: SendMediaMessageCommand) { + sendMediaMessageUseCase(command) + } + + internal suspend fun encryptAndUploadAttachment(command: EncryptAndUploadAttachmentCommand) = + encryptAndUploadAttachmentUseCase(command) + + internal fun createFileAttachment(command: CreateFileAttachmentCommand) = + createFileAttachmentUseCase(command) + + internal fun createAvatarAttachment(command: CreateAvatarAttachmentCommand) = + createAvatarAttachmentUseCase(command) + + internal fun createVideoCircleAttachment(command: CreateVideoCircleAttachmentCommand) = + createVideoCircleAttachmentUseCase(command) + + internal suspend fun saveOutgoingMessage( + messageId: String, + text: String, + encryptedContent: String, + encryptedKey: String, + timestamp: Long, + delivered: Int, + attachmentsJson: String, + accountPublicKey: String, + accountPrivateKey: String, + opponentPublicKey: String + ) { + saveMessageToDatabase( + messageId = messageId, + text = text, + encryptedContent = encryptedContent, + encryptedKey = encryptedKey, + timestamp = timestamp, + isFromMe = true, + delivered = delivered, + attachmentsJson = attachmentsJson, + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + opponentPublicKey = opponentPublicKey + ) + } + + internal suspend fun saveOutgoingDialog( + lastMessage: String, + timestamp: Long, + accountPublicKey: String, + accountPrivateKey: String, + opponentPublicKey: String + ) { + saveDialog( + lastMessage = lastMessage, + timestamp = timestamp, + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + opponentPublicKey = opponentPublicKey + ) + } + + internal fun updateMessageStatusUi(messageId: String, status: MessageStatus) { + updateMessageStatus(messageId, status) + } + + internal fun cacheDecryptedText(messageId: String, text: String) { + decryptionCache[messageId] = text + } + + internal suspend fun updateMessageStatusDb(messageId: String, delivered: Int) { + updateMessageStatusInDb(messageId, delivered) + } + + internal suspend fun updateMessageStatusAndAttachmentsDb( + messageId: String, + delivered: Int, + attachmentsJson: String + ) { + updateMessageStatusAndAttachmentsInDb( + messageId = messageId, + delivered = delivered, + attachmentsJson = attachmentsJson + ) + } + + internal suspend fun refreshDialogFromMessagesForForward( + accountPublicKey: String, + opponentPublicKey: String + ) { + val account = accountPublicKey.trim().takeIf { it.isNotEmpty() } ?: return + val opponent = opponentPublicKey.trim().takeIf { it.isNotEmpty() } ?: return + + runCatching { + if (opponent == account) { + dialogDao.updateSavedMessagesDialogFromMessages(account) + } else { + dialogDao.updateDialogFromMessages(account, opponent) + } + } + } + + internal fun sendTextMessage(command: SendTextMessageCommand) = sendTextMessageUseCase(command) + + internal fun replyFallbackName(): String { + return opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } } + } + + internal fun resolveReplyPreviewTextForSend( + text: String, + attachments: List + ): String { + return resolveReplyPreviewText(text, attachments) + } + + internal fun buildStoredGroupEncryptedKey(groupKey: String, privateKey: String): String { + return buildStoredGroupKey(groupKey, privateKey) + } + + internal fun resolveTypingSendContext(): TypingSendContext? { + val opponent = opponentKey ?: return null + val sender = myPublicKey ?: return null + val privateKey = myPrivateKey ?: return null + return TypingSendContext( + opponent = opponent, + sender = sender, + privateKey = privateKey, + isGroupDialog = isGroupDialogKey(opponent), + isOpponentOnline = _opponentOnline.value + ) + } + + internal fun decideTypingSend(command: SendTypingIndicatorCommand) = + sendTypingIndicatorUseCase(command) + + internal fun lastTypingSentTimeMs(): Long = lastTypingSentTime + + internal fun setLastTypingSentTimeMs(value: Long) { + lastTypingSentTime = value + } + + internal fun typingThrottleMs(): Long = TYPING_THROTTLE_MS + + internal fun sendTypingPacket(privateKey: String, sender: String, opponent: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) + val packet = + PacketTyping().apply { + this.privateKey = privateKeyHash + fromPublicKey = sender + toPublicKey = opponent + } + protocolGateway.send(packet) + } catch (_: Exception) { + } + } + } + + internal fun logPhotoEvent(messageId: String, details: String) { + logPhotoPipeline(messageId, details) + } + + internal fun addProtocolLog(message: String) { + protocolGateway.addLog(message) + } + + internal fun logPhotoErrorEvent(messageId: String, stage: String, error: Throwable) { + logPhotoPipelineError(messageId, stage, error) + } + + internal fun shortPhotoLogId(value: String, maxLength: Int = 12): String { + return shortPhotoId(value, maxLength) + } + + internal fun isViewModelCleared(): Boolean = isCleared + + internal fun resolveFileSizeForUri(videoUri: android.net.Uri): Long { + return runCatching { com.rosetta.messenger.utils.MediaUtils.getFileSize(app, videoUri) } + .getOrDefault(0L) + } + + internal fun maxMediaBytes(): Long { + return com.rosetta.messenger.utils.MediaUtils.MAX_FILE_SIZE_MB * 1024L * 1024L + } + + internal fun trySendTextMessage(allowPendingRecovery: Boolean) { + messagesCoordinator.trySendMessage(allowPendingRecovery = allowPendingRecovery) + } + // 🔥 Debounce для защиты от спама входящих сообщений private var pendingMessageUpdates = mutableSetOf() private var messageUpdateJob: kotlinx.coroutines.Job? = null @@ -939,19 +1247,16 @@ class ChatViewModel @Inject constructor( ) } - /** 🔄 Очистить localUri в attachments сообщения (после успешной отправки) */ - private fun updateMessageAttachments(messageId: String, localUri: String?) { - _messages.value = - _messages.value.map { msg -> - if (msg.id == messageId) { - val updatedAttachments = - msg.attachments.map { att -> att.copy(localUri = localUri ?: "") } - msg.copy(attachments = updatedAttachments) - } else msg - } + internal fun currentMessagesForAttachments(): List = _messages.value - // 🔥 Также обновляем кэш! - updateCacheFromCurrentMessages() + internal fun replaceMessagesForAttachments( + messages: List, + syncCache: Boolean + ) { + _messages.value = messages + if (syncCache) { + updateCacheFromCurrentMessages() + } } /** 🔥 Обновить кэш из текущих сообщений (для синхронизации после изменений) */ @@ -1028,7 +1333,7 @@ class ChatViewModel @Inject constructor( if (!opponentKey.isNullOrBlank()) { observeOpponentOnlineStatus() } - triggerPendingTextSendIfReady("keys_updated") + messagesCoordinator.onSendContextChanged("keys_updated") } /** @@ -1041,7 +1346,7 @@ class ChatViewModel @Inject constructor( val currentDialogKey = opponentKey?.trim().orEmpty() if (currentDialogKey.equals(normalizedPublicKey, ignoreCase = true)) { - triggerPendingTextSendIfReady("send_context_ready") + messagesCoordinator.onSendContextChanged("send_context_ready") return } @@ -1053,7 +1358,7 @@ class ChatViewModel @Inject constructor( groupKeyCache.remove(normalizedPublicKey) } protocolGateway.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}") - triggerPendingTextSendIfReady("send_context_bound") + messagesCoordinator.onSendContextChanged("send_context_bound") } /** Открыть диалог */ @@ -1167,7 +1472,7 @@ class ChatViewModel @Inject constructor( // First-run auth race: if user tapped send before dialog binding finished, // flush pending send as soon as dialog context becomes valid. - triggerPendingTextSendIfReady("open_dialog") + messagesCoordinator.onSendContextChanged("open_dialog") // � P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer loadMessagesFromDatabase(delayMs = 0L) @@ -2573,7 +2878,7 @@ class ChatViewModel @Inject constructor( dialog?.opponentTitle?.ifEmpty { dialog.opponentUsername }?.ifEmpty { null } } catch (_: Exception) { null } - // 2. Try ProtocolManager cache (previously resolved) + // 2. Try runtime cache (previously resolved) val cachedName = dbName ?: protocolGateway.getCachedUserName(fwdPublicKey) // 3. Server resolve via PacketSearch (like desktop useUserInformation) @@ -2755,7 +3060,7 @@ class ChatViewModel @Inject constructor( val fwdDialog = dialogDao.getDialog(account, replyPublicKey) fwdDialog?.opponentTitle?.ifEmpty { fwdDialog.opponentUsername }?.ifEmpty { null } } catch (_: Exception) { null } - // 2. Try ProtocolManager cache + // 2. Try runtime cache val cachedName = dbName ?: protocolGateway.getCachedUserName(replyPublicKey) // 3. Server resolve via PacketSearch (like desktop useUserInformation) val serverName = if (cachedName == null) { @@ -2872,16 +3177,7 @@ class ChatViewModel @Inject constructor( return decrypted } - private data class OutgoingEncryptionContext( - val encryptedContent: String, - val encryptedKey: String, - val aesChachaKey: String, - val plainKeyAndNonce: ByteArray?, - val attachmentPassword: String, - val isGroup: Boolean - ) - - private suspend fun buildEncryptionContext( + internal suspend fun buildEncryptionContext( plaintext: String, recipient: String, privateKey: String @@ -2911,250 +3207,39 @@ class ChatViewModel @Inject constructor( } } - private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String { - return CryptoManager.encryptWithPassword(payload, context.attachmentPassword) + internal fun encryptAttachmentPayloadForTextSend( + payload: String, + context: OutgoingEncryptionContext + ): String { + return forwardCoordinator.encryptAttachmentPayloadForTextSend( + payload = payload, + context = context + ) } - private data class ForwardSourceMessage( - val messageId: String, - val senderPublicKey: String, - val chachaKeyPlainHex: String, - val attachments: List - ) - - private data class ForwardRewriteResult( - val rewrittenAttachments: Map, - val rewrittenMessageIds: Set - ) - - private fun forwardAttachmentRewriteKey(messageId: String, attachmentId: String): String { - return "$messageId::$attachmentId" + internal fun forwardAttachmentRewriteKeyForTextSend( + messageId: String, + attachmentId: String + ): String { + return forwardCoordinator.forwardAttachmentRewriteKeyForTextSend( + messageId = messageId, + attachmentId = 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 { - myPublicKey?.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, + internal suspend fun prepareForwardAttachmentRewritesForTextSend( + sourceMessages: List, encryptionContext: OutgoingEncryptionContext, privateKey: String, isSavedMessages: Boolean, timestamp: Long - ): ForwardRewriteResult { - if (sourceMessages.isEmpty()) { - return ForwardRewriteResult(emptyMap(), emptySet()) - } - - val rewritten = mutableMapOf() - val rewrittenMessageIds = mutableSetOf() - var forwardAttachmentIndex = 0 - - for (sourceMessage in sourceMessages) { - val candidates = sourceMessage.attachments.filter { shouldReuploadForwardAttachment(it.type) } - if (candidates.isEmpty()) continue - - val stagedForMessage = mutableMapOf() - 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 { - myPublicKey?.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 + ): Pair, Set> { + return forwardCoordinator.prepareForwardAttachmentRewritesForTextSend( + sourceMessages = sourceMessages, + encryptionContext = encryptionContext, + privateKey = privateKey, + isSavedMessages = isSavedMessages, + timestamp = timestamp ) } @@ -3439,7 +3524,7 @@ class ChatViewModel @Inject constructor( /** * Resolve a publicKey to a SearchUser for profile navigation. - * Tries: local DB → ProtocolManager cache → server resolve. + * Tries: local DB → runtime cache → server resolve. */ suspend fun resolveUserForProfile(publicKey: String): SearchUser? { if (publicKey.isEmpty()) return null @@ -3475,7 +3560,7 @@ class ChatViewModel @Inject constructor( } } catch (_: Exception) {} - // 2. Try ProtocolManager cache + // 2. Try runtime cache val cached = protocolGateway.getCachedUserInfo(publicKey) if (cached != null) { return SearchUser( @@ -3551,17 +3636,7 @@ class ChatViewModel @Inject constructor( /** 🔥 Повторить отправку сообщения (для ошибки) */ fun retryMessage(message: ChatMessage) { - // Удаляем старое сообщение - deleteMessage(message.id) - - // Устанавливаем текст в инпут и отправляем - _inputText.value = message.text - - // Отправляем с небольшой задержкой - viewModelScope.launch { - delay(100) - sendMessage() - } + messagesViewModel.retryMessage(message) } /** @@ -3578,542 +3653,8 @@ class ChatViewModel @Inject constructor( ) } - private data class SendCommand( - val messageId: String, - val timestamp: Long, - val text: String, - val replyMessages: List, - val isForward: Boolean, - val senderPublicKey: String, - val senderPrivateKey: String, - val recipientPublicKey: String - ) { - val dialogThrottleKey: String - get() = "$senderPublicKey:$recipientPublicKey" - } - fun sendMessage() { - trySendMessage(allowPendingRecovery = true) - } - - private fun shortSendKey(value: String?): String { - val normalized = value?.trim().orEmpty() - if (normalized.isEmpty()) return "" - 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 - ) { - protocolGateway.addLog( - "⚠️ SEND_BLOCKED reason=$reason textLen=$textLength hasReply=$hasReply recipient=${shortSendKey(recipient)} sender=${shortSendKey(sender)} hasPriv=$hasPrivateKey isSending=$isSending" - ) - } - - private fun recoverRuntimeKeysIfMissing(): Boolean { - val hasKeysInViewModel = !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() - if (hasKeysInViewModel) return true - - val repositoryPublicKey = messageRepository.getCurrentAccountKey()?.trim().orEmpty() - val repositoryPrivateKey = messageRepository.getCurrentPrivateKey()?.trim().orEmpty() - - if (repositoryPublicKey.isNotEmpty() && repositoryPrivateKey.isNotEmpty()) { - setUserKeys(repositoryPublicKey, repositoryPrivateKey) - protocolGateway.addLog( - "🔄 SEND_RECOVERY restored_keys pk=${shortSendKey(repositoryPublicKey)}" - ) - } - - return !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() - } - - private fun schedulePendingTextSendRecovery(reason: String, hasPayload: Boolean) { - if (!hasPayload) return - pendingTextSendRequested = true - pendingTextSendReason = reason - - if (pendingSendRecoveryJob?.isActive == true) return - - protocolGateway.addLog("⏳ SEND_RECOVERY queued reason=$reason") - pendingSendRecoveryJob = - viewModelScope.launch { - repeat(10) { attempt -> - delay(if (attempt < 4) 180L else 350L) - recoverRuntimeKeysIfMissing() - triggerPendingTextSendIfReady("timer_${attempt + 1}") - if (!pendingTextSendRequested) return@launch - } - - if (pendingTextSendRequested) { - protocolGateway.addLog("⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason") - } - pendingTextSendRequested = false - pendingTextSendReason = "" - pendingSendRecoveryJob = null - } - } - - private fun triggerPendingTextSendIfReady(trigger: String) { - if (!pendingTextSendRequested) return - - val hasPayload = _inputText.value.trim().isNotEmpty() || _replyMessages.value.isNotEmpty() - if (!hasPayload) { - pendingTextSendRequested = false - pendingTextSendReason = "" - pendingSendRecoveryJob?.cancel() - pendingSendRecoveryJob = null - return - } - - val recipientReady = !opponentKey.isNullOrBlank() - val keysReady = !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() - if (!recipientReady || !keysReady || isSending) return - - protocolGateway.addLog("🚀 SEND_RECOVERY flush trigger=$trigger") - pendingTextSendRequested = false - pendingTextSendReason = "" - pendingSendRecoveryJob?.cancel() - pendingSendRecoveryJob = null - trySendMessage(allowPendingRecovery = false) - } - - private fun trySendMessage(allowPendingRecovery: Boolean) { - val text = _inputText.value.trim() - val replyMsgsToSend = _replyMessages.value.toList() - val isForward = _isForwardMode.value - val hasPayload = text.isNotEmpty() || replyMsgsToSend.isNotEmpty() - - // Разрешаем отправку пустого текста если есть reply/forward - if (!hasPayload) { - return - } - - if (myPublicKey.isNullOrBlank() || myPrivateKey.isNullOrBlank()) { - recoverRuntimeKeysIfMissing() - } - - val recipient = opponentKey?.trim()?.takeIf { it.isNotEmpty() } - val sender = myPublicKey?.trim()?.takeIf { it.isNotEmpty() } - val privateKey = myPrivateKey?.trim()?.takeIf { it.isNotEmpty() } - - 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 (isSending) { - logSendBlocked( - reason = "is_sending", - textLength = text.length, - hasReply = replyMsgsToSend.isNotEmpty(), - recipient = recipient, - sender = sender, - hasPrivateKey = true - ) - return - } - - // 🔥 Глобальный throttle - защита от спама сообщениями (app-wide) - 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 - } - - isSending = true - - val messageId = command.messageId - val timestamp = command.timestamp - - // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение) - // Используется для обычного reply (не forward). - val replyData: ReplyData? = - if (command.replyMessages.isNotEmpty()) { - val firstReply = command.replyMessages.first() - // 🖼️ Получаем attachments из текущих сообщений для превью - // Fallback на firstReply.attachments для forward из другого чата - val replyAttachments = - _messages.value.find { it.id == firstReply.messageId }?.attachments - ?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES } - // 🔥 Use actual senderName from ForwardMessage (preserves real author) - val firstReplySenderName = if (firstReply.isOutgoing) "You" - else firstReply.senderName.ifEmpty { - opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } } - } - ReplyData( - messageId = firstReply.messageId, - senderName = firstReplySenderName, - text = resolveReplyPreviewText(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 - - // 📨 В forward режиме показываем ВСЕ пересылаемые сообщения в optimistic bubble, - // а не только первое. Иначе визуально выглядит как будто отправилось одно сообщение. - val optimisticForwardedMessages: List = - if (command.isForward && command.replyMessages.isNotEmpty()) { - command.replyMessages.map { msg -> - val senderDisplayName = - if (msg.isOutgoing) "You" - else msg.senderName.ifEmpty { - opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } } - } - val resolvedAttachments = - _messages.value.find { it.id == msg.messageId }?.attachments - ?: msg.attachments.filter { it.type != AttachmentType.MESSAGES } - ReplyData( - messageId = msg.messageId, - senderName = senderDisplayName, - text = resolveReplyPreviewText(msg.text, resolvedAttachments), - isFromMe = msg.isOutgoing, - isForwarded = true, - forwardedFromName = senderDisplayName, - attachments = resolvedAttachments, - senderPublicKey = msg.publicKey.ifEmpty { - if (msg.isOutgoing) command.senderPublicKey - else command.recipientPublicKey - }, - recipientPrivateKey = command.senderPrivateKey - ) - } - } else { - emptyList() - } - - // Сохраняем режим forward для отправки ПЕРЕД очисткой - val isForwardToSend = command.isForward - - // 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble - val optimisticMessage = - ChatMessage( - id = messageId, - text = command.text, // Только основной текст, без prefix - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - replyData = if (isForwardToSend) null else replyData, - forwardedMessages = optimisticForwardedMessages - ) - - // � Безопасное добавление с проверкой дубликатов - addMessageSafely(optimisticMessage) - _inputText.value = "" - - // � Очищаем черновик после отправки - com.rosetta.messenger.data.DraftManager.clearDraft(command.recipientPublicKey) - - // �🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации - clearReplyMessages() - - // Кэшируем текст - decryptionCache[messageId] = command.text - - // 2. 🔥 Шифрование и отправка в IO потоке - viewModelScope.launch(Dispatchers.IO) { - try { - val encryptionContext = - 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) - - // 🔥 Формируем attachments с reply (как в React Native) - val messageAttachments = mutableListOf() - var replyBlobForDatabase = "" // Зашифрованный blob для БД (приватным ключом) - - val isSavedMessages = (command.senderPublicKey == command.recipientPublicKey) - val forwardSources = - if (isForwardToSend && command.replyMessages.isNotEmpty()) { - command.replyMessages.map { msg -> - ForwardSourceMessage( - messageId = msg.messageId, - senderPublicKey = msg.publicKey, - chachaKeyPlainHex = msg.chachaKeyPlainHex, - attachments = msg.attachments - ) - } - } else { - emptyList() - } - val forwardRewriteResult = - prepareForwardAttachmentRewrites( - context = app, - sourceMessages = forwardSources, - encryptionContext = encryptionContext, - privateKey = command.senderPrivateKey, - isSavedMessages = isSavedMessages, - timestamp = timestamp - ) - val forwardedAttMap = forwardRewriteResult.rewrittenAttachments - val rewrittenForwardMessageIds = forwardRewriteResult.rewrittenMessageIds - val outgoingForwardPlainKeyHex = - encryptionContext.plainKeyAndNonce - ?.joinToString("") { "%02x".format(it) } - .orEmpty() - - if (command.replyMessages.isNotEmpty()) { - - // Формируем JSON массив с цитируемыми сообщениями (как в Desktop) - val replyJsonArray = JSONArray() - command.replyMessages.forEach { msg -> - val attachmentsArray = JSONArray() - msg.attachments.forEach { att -> - // Для forward IMAGE: подставляем НОВЫЙ id/preview/transport. - val fwdInfo = - forwardedAttMap[ - forwardAttachmentRewriteKey( - msg.messageId, - att.id - ) - ] - val attId = fwdInfo?.id ?: att.id - val attPreview = fwdInfo?.preview ?: att.preview - val attTransportTag = fwdInfo?.transportTag ?: att.transportTag - val attTransportServer = fwdInfo?.transportServer ?: att.transportServer - - attachmentsArray.put( - JSONObject().apply { - put("id", attId) - put("type", att.type.value) - put("preview", attPreview) - put("width", att.width) - put("height", att.height) - put( - "blob", - if (att.type == AttachmentType.MESSAGES) att.blob - else "" - ) - put("transportTag", attTransportTag) - put("transportServer", attTransportServer) - put( - "transport", - JSONObject().apply { - put("transport_tag", attTransportTag) - put("transport_server", attTransportServer) - } - ) - } - ) - } - - val replyJson = - JSONObject().apply { - put("message_id", msg.messageId) - put("publicKey", msg.publicKey) - put("message", msg.text) - put("timestamp", msg.timestamp) - put("attachments", attachmentsArray) - if (isForwardToSend) { - put("forwarded", true) - put("senderName", msg.senderName) - val effectiveForwardPlainKey = - if (msg.messageId in rewrittenForwardMessageIds && - outgoingForwardPlainKeyHex.isNotEmpty() - ) { - outgoingForwardPlainKeyHex - } else { - msg.chachaKeyPlainHex - } - if (effectiveForwardPlainKey.isNotEmpty()) { - put( - "chacha_key_plain", - effectiveForwardPlainKey - ) - } - } - } - replyJsonArray.put(replyJson) - } - - val replyBlobPlaintext = replyJsonArray.toString() - - val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext) - - replyBlobForDatabase = - CryptoManager.encryptWithPassword(replyBlobPlaintext, command.senderPrivateKey) - - val replyAttachmentId = "reply_${timestamp}" - messageAttachments.add( - MessageAttachment( - id = replyAttachmentId, - blob = encryptedReplyBlob, - type = AttachmentType.MESSAGES, - preview = "" - ) - ) - } - - val packet = - sendTextMessageUseCase( - SendTextMessageCommand( - fromPublicKey = command.senderPublicKey, - toPublicKey = command.recipientPublicKey, - encryptedContent = encryptedContent, - encryptedKey = encryptedKey, - aesChachaKey = aesChachaKey, - privateKeyHash = privateKeyHash, - messageId = messageId, - timestamp = timestamp, - attachments = messageAttachments, - isSavedMessages = isSavedMessages - ) - ) - - // 🔥 DEBUG: Log packet before sending - packet.attachments.forEachIndexed { idx, att -> } - - withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - } - - // 4. 💾 Сохранение в БД с attachments - // ⚠️ НЕ сохраняем blob для IMAGE/FILE - слишком большие (SQLite CursorWindow 2MB - // limit) - // Только MESSAGES (reply) сохраняем - они небольшие - val attachmentsJson = - if (messageAttachments.isNotEmpty()) { - JSONArray() - .apply { - messageAttachments.forEach { att -> - put( - JSONObject().apply { - put("id", att.id) - put("type", att.type.value) - put("preview", att.preview) - put("width", att.width) - put("height", att.height) - put("transportTag", att.transportTag) - put("transportServer", att.transportServer) - // Только для MESSAGES сохраняем blob (reply - // data небольшие) - // Для IMAGE/FILE - пустой blob - val blobToSave = - when (att.type) { - AttachmentType.MESSAGES -> - replyBlobForDatabase - ?: "" - else -> "" // IMAGE, FILE - не - // сохраняем blob - } - put("blob", blobToSave) - } - ) - } - } - .toString() - } else "[]" - - saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = encryptedContent, - encryptedKey = - if (encryptionContext.isGroup) { - buildStoredGroupKey( - encryptionContext.attachmentPassword, - command.senderPrivateKey - ) - } else { - encryptedKey - }, - timestamp = timestamp, - isFromMe = true, - delivered = - if (isSavedMessages) 1 - else 0, // 📁 Saved Messages: сразу DELIVERED (1), иначе SENDING (0) - attachmentsJson = attachmentsJson, - accountPublicKey = command.senderPublicKey, - accountPrivateKey = command.senderPrivateKey, - opponentPublicKey = command.recipientPublicKey - ) - - saveDialog( - lastMessage = command.text, - timestamp = timestamp, - accountPublicKey = command.senderPublicKey, - accountPrivateKey = command.senderPrivateKey, - opponentPublicKey = command.recipientPublicKey - ) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } - updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - saveDialog( - lastMessage = command.text, - timestamp = timestamp, - accountPublicKey = command.senderPublicKey, - accountPrivateKey = command.senderPrivateKey, - opponentPublicKey = command.recipientPublicKey - ) - } finally { - isSending = false - triggerPendingTextSendIfReady("send_finished") - } - } + messagesViewModel.sendMessage() } /** @@ -4124,257 +3665,10 @@ class ChatViewModel @Inject constructor( recipientPublicKey: String, forwardMessages: List ) { - val sender = myPublicKey ?: return - val privateKey = myPrivateKey ?: return - if (forwardMessages.isEmpty()) return - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - val isCurrentDialogTarget = recipientPublicKey == opponentKey - - viewModelScope.launch(Dispatchers.IO) { - try { - val context = app - val isSavedMessages = (sender == recipientPublicKey) - val db = RosettaDatabase.getDatabase(context) - val dialogDao = db.dialogDao() - - suspend fun refreshTargetDialog() { - if (isSavedMessages) { - dialogDao.updateSavedMessagesDialogFromMessages(sender) - } else { - dialogDao.updateDialogFromMessages(sender, recipientPublicKey) - } - } - - // Шифрование (пустой текст для forward) - val encryptionContext = - 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 { fm -> - ForwardPayloadMessage( - messageId = fm.messageId, - senderPublicKey = fm.senderPublicKey, - senderName = fm.senderName, - text = fm.text, - timestamp = fm.timestamp, - chachaKeyPlain = fm.chachaKeyPlain, - attachments = fm.attachments - ) - } - - fun buildForwardReplyJson( - includeLocalUri: Boolean - ): JSONArray { - return sendForwardUseCase.buildForwardReplyJson( - messages = forwardPayloadMessages, - rewrittenAttachments = forwardedAttMap, - rewrittenMessageIds = rewrittenForwardMessageIds, - outgoingForwardPlainKeyHex = outgoingForwardPlainKeyHex, - includeLocalUri = includeLocalUri, - rewriteKey = ::forwardAttachmentRewriteKey - ) - } - - // 1) 🚀 Optimistic forward: мгновенно показываем сообщение в текущем диалоге - if (isCurrentDialogTarget) { - val optimisticForwardedMessages = - forwardMessages.map { fm -> - val senderDisplayName = - fm.senderName.ifEmpty { - if (fm.senderPublicKey == sender) "You" else "User" - } - ReplyData( - messageId = fm.messageId, - senderName = senderDisplayName, - text = fm.text, - isFromMe = fm.senderPublicKey == sender, - isForwarded = true, - forwardedFromName = senderDisplayName, - attachments = fm.attachments.filter { it.type != AttachmentType.MESSAGES }, - senderPublicKey = fm.senderPublicKey, - recipientPrivateKey = privateKey - ) - } - withContext(Dispatchers.Main) { - addMessageSafely( - ChatMessage( - id = messageId, - text = "", - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - forwardedMessages = optimisticForwardedMessages - ) - ) - } - } - - // 2) 💾 Optimistic запись в БД (до загрузки файлов), чтобы сообщение было видно сразу - 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() - - saveMessageToDatabase( - messageId = messageId, - text = "", - encryptedContent = encryptedContent, - encryptedKey = - if (encryptionContext.isGroup) { - buildStoredGroupKey( - encryptionContext.attachmentPassword, - privateKey - ) - } else { - encryptedKey - }, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = optimisticAttachmentsJson, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipientPublicKey - ) - refreshTargetDialog() - - if (isSavedMessages && isCurrentDialogTarget) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - } - - // Desktop/iOS parity: передаём оригинальные transport tags + chacha_key_plain, - // без перезагрузки картинок на CDN - 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 { att -> - put( - JSONObject().apply { - put("id", att.id) - put("type", att.type.value) - put("preview", att.preview) - put("width", att.width) - put("height", att.height) - put( - "blob", - when (att.type) { - AttachmentType.MESSAGES -> - replyBlobForDatabase - else -> "" - } - ) - } - ) - } - } - .toString() - - updateMessageStatusAndAttachmentsInDb( - messageId = messageId, - delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = finalAttachmentsJson - ) - - if (isCurrentDialogTarget) { - withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - } - } - refreshTargetDialog() - } catch (e: Exception) { - if (isCurrentDialogTarget) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } - } - updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - } - } + forwardCoordinator.sendForwardDirectly( + recipientPublicKey = recipientPublicKey, + forwardMessages = forwardMessages + ) } /** @@ -4385,208 +3679,28 @@ class ChatViewModel @Inject constructor( * @param caption Подпись к изображению */ fun sendImageFromUri(imageUri: android.net.Uri, caption: String = "") { - val recipient = opponentKey - val sender = myPublicKey - val privateKey = myPrivateKey - val context = app - - if (recipient == null || sender == null || privateKey == null) { - protocolGateway.addLog( - "❌ IMG send aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})" - ) - return - } - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - val text = caption.trim() - val attachmentId = "img_$timestamp" - - logPhotoPipeline( - messageId, - "start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${shortPhotoId(attachmentId, 12)}" - ) - - // 🔥 КРИТИЧНО: Получаем размеры СРАЗУ (быстрая операция - только читает заголовок файла) - // Это предотвращает "расширение" пузырька при первом показе - val (imageWidth, imageHeight) = - com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) - logPhotoPipeline(messageId, "dimensions: ${imageWidth}x$imageHeight") - - // 1. 🚀 МГНОВЕННО показываем optimistic сообщение с localUri И РАЗМЕРАМИ - // Используем URI напрямую для отображения (без конвертации в base64) - val optimisticMessage = - 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() // 🔥 Используем - // localUri для - // мгновенного показа - ) - ) - ) - addMessageSafely(optimisticMessage) - _inputText.value = "" - logPhotoPipeline(messageId, "optimistic UI added") - - // 2. 🔄 В фоне, независимо от жизненного цикла экрана: - // сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет. - val uploadJob = backgroundUploadScope.launch { - try { - logPhotoPipeline(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() - - saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = "", - encryptedKey = "", - timestamp = timestamp, - isFromMe = true, - delivered = 0, - attachmentsJson = optimisticAttachmentsJson, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - - // Обновляем dialogs сразу, чтобы в списке чатов мгновенно показать "Photo" + часы - // даже если пользователь вышел из экрана чата во время загрузки. - saveDialog( - lastMessage = if (text.isNotEmpty()) text else "photo", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - logPhotoPipeline(messageId, "optimistic dialog updated") - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)") - } - - try { - val convertStartedAt = System.currentTimeMillis() - val (width, height) = - com.rosetta.messenger.utils.MediaUtils.getImageDimensions(context, imageUri) - - val imageBase64 = - com.rosetta.messenger.utils.MediaUtils.uriToBase64Image(context, imageUri) - if (imageBase64 == null) { - logPhotoPipeline(messageId, "base64 conversion returned null") - if (!isCleared) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } - } - return@launch - } - logPhotoPipeline( - messageId, - "base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms" - ) - - val blurhash = - com.rosetta.messenger.utils.MediaUtils.generateBlurhash(context, imageUri) - logPhotoPipeline(messageId, "blurhash ready: len=${blurhash.length}") - - if (!isCleared) { - withContext(Dispatchers.Main) { - updateOptimisticImageMessage(messageId, imageBase64, blurhash, width, height) - } - logPhotoPipeline(messageId, "optimistic payload updated in UI") - } - - sendImageMessageInternal( - messageId = messageId, - imageBase64 = imageBase64, - blurhash = blurhash, - caption = text, - width = width, - height = height, - timestamp = timestamp, - recipient = recipient, - sender = sender, - privateKey = privateKey - ) - logPhotoPipeline(messageId, "pipeline completed") - } catch (e: CancellationException) { - logPhotoPipeline(messageId, "pipeline cancelled by user") - throw e - } catch (e: Exception) { - logPhotoPipelineError(messageId, "prepare+convert", e) - if (!isCleared) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } - } - } - } - outgoingImageUploadJobs[messageId] = uploadJob - uploadJob.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) } + attachmentsViewModel.sendImageFromUri(imageUri = imageUri, caption = caption) } /** 🔄 Обновляет optimistic сообщение с реальными данными изображения */ - private fun updateOptimisticImageMessage( + internal fun updateOptimisticImageMessage( messageId: String, base64: String, blurhash: String, width: Int, height: Int ) { - val currentMessages = _messages.value.toMutableList() - val index = currentMessages.indexOfFirst { it.id == messageId } - if (index != -1) { - val message = currentMessages[index] - val updatedAttachments = - message.attachments.map { att -> - if (att.type == AttachmentType.IMAGE) { - att.copy( - preview = blurhash, - blob = base64, - width = width, - height = height - ) - } else att - } - currentMessages[index] = message.copy(attachments = updatedAttachments) - _messages.value = currentMessages - } + attachmentsCoordinator.updateOptimisticImageMessage( + messageId = messageId, + base64 = base64, + blurhash = blurhash, + width = width, + height = height + ) } /** 📤 Внутренняя функция отправки изображения (уже с готовым base64) */ - private suspend fun sendImageMessageInternal( + internal suspend fun sendImageMessageInternal( messageId: String, imageBase64: String, blurhash: String, @@ -4598,175 +3712,18 @@ class ChatViewModel @Inject constructor( sender: String, privateKey: String ) { - var packetSentToProtocol = false - try { - val context = app - val pipelineStartedAt = System.currentTimeMillis() - logPhotoPipeline( - messageId, - "internal send start: base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}, captionLen=${caption.length}" - ) - - // Шифрование текста - val encryptStartedAt = System.currentTimeMillis() - val encryptionContext = - 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 - logPhotoPipeline( - messageId, - "text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}, elapsed=${System.currentTimeMillis() - encryptStartedAt}ms" - ) - - val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - - // 🚀 Шифруем изображение с ChaCha ключом для Transport Server - val blobEncryptStartedAt = System.currentTimeMillis() - val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext) - logPhotoPipeline( - messageId, - "blob encrypted: len=${encryptedImageBlob.length}, elapsed=${System.currentTimeMillis() - blobEncryptStartedAt}ms" - ) - - val attachmentId = "img_$timestamp" - logPhotoPipeline( - messageId, - "attachment prepared: id=${shortPhotoId(attachmentId, 12)}, size=${width}x$height" - ) - - // 📤 Загружаем на Transport Server - val isSavedMessages = (sender == recipient) - var uploadTag = "" - - if (!isSavedMessages) { - logPhotoPipeline(messageId, "upload start: attachment=${shortPhotoId(attachmentId, 12)}") - uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) - logPhotoPipeline( - messageId, - "upload done: tag=${shortPhotoId(uploadTag, 12)}" - ) - } else { - logPhotoPipeline(messageId, "saved-messages mode: upload skipped") - } - - val attachmentTransportServer = - if (uploadTag.isNotEmpty()) { - TransportManager.getTransportServer().orEmpty() - } else { - "" - } - val previewValue = blurhash - - val imageAttachment = - MessageAttachment( - id = attachmentId, - blob = "", - type = AttachmentType.IMAGE, - preview = previewValue, - width = width, - height = height, - transportTag = uploadTag, - transportServer = attachmentTransportServer - ) - - val packet = - PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.aesChachaKey = aesChachaKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(imageAttachment) - } - - // Отправляем пакет - sendMediaMessageUseCase.dispatch(packet, isSavedMessages) - if (!isSavedMessages) { - packetSentToProtocol = true - logPhotoPipeline(messageId, "packet sent to protocol") - } else { - logPhotoPipeline(messageId, "saved-messages mode: packet send skipped") - } - - // 💾 Сохраняем изображение в файл локально - val savedLocally = - AttachmentFileManager.saveAttachment( - context = context, - blob = imageBase64, - attachmentId = attachmentId, - publicKey = sender, - privateKey = privateKey - ) - logPhotoPipeline(messageId, "local file cache saved=$savedLocally") - - // Сохраняем в БД - val attachmentsJson = - JSONArray() - .apply { - put( - JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.IMAGE.value) - put("preview", previewValue) - put("blob", "") - put("width", width) - put("height", height) - put("transportTag", uploadTag) - put("transportServer", attachmentTransportServer) - } - ) - } - .toString() - - // 🔄 Обновляем статус на SENT и очищаем localUri в attachments - // (сообщение уже существует в БД от optimistic UI, поэтому просто обновляем) - val finalAttachmentsJson = attachmentsJson // Уже без localUri - - val deliveryStatus = if (isSavedMessages) 1 else 0 - updateMessageStatusAndAttachmentsInDb(messageId, deliveryStatus, finalAttachmentsJson) - logPhotoPipeline(messageId, "db status+attachments updated") - - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - // Также очищаем localUri в UI - updateMessageAttachments(messageId, null) - } - logPhotoPipeline(messageId, "ui status switched to SENT") - - saveDialog( - lastMessage = if (caption.isNotEmpty()) caption else "photo", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - logPhotoPipeline( - messageId, - "dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms" - ) - } catch (e: CancellationException) { - logPhotoPipeline(messageId, "internal-send cancelled") - throw e - } catch (e: Exception) { - logPhotoPipelineError(messageId, "internal-send", e) - if (packetSentToProtocol) { - // Packet already sent to server: local post-send failure (cache/DB/UI) must not mark message as failed. - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - logPhotoPipeline(messageId, "post-send non-fatal error: status kept as SENT") - } else { - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } - } - } + attachmentsCoordinator.sendImageMessageInternal( + messageId = messageId, + imageBase64 = imageBase64, + blurhash = blurhash, + caption = caption, + width = width, + height = height, + timestamp = timestamp, + recipient = recipient, + sender = sender, + privateKey = privateKey + ) } /** @@ -4784,181 +3741,13 @@ class ChatViewModel @Inject constructor( width: Int = 0, height: Int = 0 ) { - val recipient = opponentKey - val sender = myPublicKey - val privateKey = myPrivateKey - - if (recipient == null || sender == null || privateKey == null) { - return - } - if (isSending) { - return - } - - isSending = true - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - val text = caption.trim() - - // 1. 🚀 Optimistic UI - показываем сообщение с placeholder - val optimisticMessage = - ChatMessage( - id = messageId, - text = text, - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = - listOf( - MessageAttachment( - id = "img_$timestamp", - type = AttachmentType.IMAGE, - preview = blurhash, - blob = imageBase64, // Для локального отображения - width = width, - height = height - ) - ) - ) - // 🔥 Безопасное добавление с проверкой дубликатов - addMessageSafely(optimisticMessage) - _inputText.value = "" - - backgroundUploadScope.launch { - try { - // Шифрование текста - val encryptionContext = - buildEncryptionContext( - plaintext = text, - 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) - - // 🚀 Шифруем изображение с ChaCha ключом для Transport Server - val encryptedImageBlob = encryptAttachmentPayload(imageBase64, encryptionContext) - - val attachmentId = "img_$timestamp" - - // 📤 Загружаем на Transport Server (как в desktop) - // НЕ для Saved Messages - там не нужно загружать - val isSavedMessages = (sender == recipient) - var uploadTag = "" - - if (!isSavedMessages) { - uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) - } - - val attachmentTransportServer = - if (uploadTag.isNotEmpty()) { - TransportManager.getTransportServer().orEmpty() - } else { - "" - } - val previewValue = blurhash - - val imageAttachment = - MessageAttachment( - id = attachmentId, - blob = "", // 🔥 Пустой blob - файл на Transport Server! - type = AttachmentType.IMAGE, - preview = previewValue, - width = width, - height = height, - transportTag = uploadTag, - transportServer = attachmentTransportServer - ) - - val packet = - PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.aesChachaKey = aesChachaKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(imageAttachment) - } - - // Отправляем пакет (без blob!) - sendMediaMessageUseCase.dispatch(packet, isSavedMessages) - - // 💾 Сохраняем изображение в файл локально (как в desktop) - AttachmentFileManager.saveAttachment( - context = app, - blob = imageBase64, - attachmentId = attachmentId, - publicKey = sender, - privateKey = privateKey - ) - - // ⚠️ НЕ сохраняем blob в БД - он слишком большой (SQLite CursorWindow 2MB limit) - // Изображение хранится в файловой системе - val attachmentsJson = - JSONArray() - .apply { - put( - JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.IMAGE.value) - put("preview", previewValue) - put("blob", "") // Пустой blob - не сохраняем в БД! - put("width", width) - put("height", height) - put("transportTag", uploadTag) - put("transportServer", attachmentTransportServer) - } - ) - } - .toString() - - saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = encryptedContent, - encryptedKey = - if (encryptionContext.isGroup) { - buildStoredGroupKey( - encryptionContext.attachmentPassword, - privateKey - ) - } else { - encryptedKey - }, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 1 else 0, // SENDING для обычных - attachmentsJson = attachmentsJson, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - - // После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR). - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - - saveDialog( - lastMessage = if (text.isNotEmpty()) text else "photo", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - } catch (e: Exception) { - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } - } finally { - isSending = false - } - } + attachmentsViewModel.sendImageMessage( + imageBase64 = imageBase64, + blurhash = blurhash, + caption = caption, + width = width, + height = height + ) } /** @@ -4973,950 +3762,7 @@ class ChatViewModel @Inject constructor( val height: Int = 0 ) - /** - * 🖼️ Отправка группы изображений из URI как одного media-group сообщения. - * Нужна для корректного коллажа у получателя (а не отдельных фото-сообщений). - */ - fun sendImageGroupFromUris(imageUris: List, caption: String = "") { - if (imageUris.isEmpty()) return - - if (imageUris.size == 1) { - sendImageFromUri(imageUris.first(), caption) - return - } - - val recipient = opponentKey - val sender = myPublicKey - val privateKey = myPrivateKey - val context = app - val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8) - - if (recipient == null || sender == null || privateKey == null) { - protocolGateway.addLog( - "❌ IMG-GROUP $groupDebugId | aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})" - ) - return - } - if (isSending) { - protocolGateway.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress") - return - } - - isSending = true - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - val text = caption.trim() - val attachmentIds = imageUris.indices.map { index -> "img_${timestamp}_$index" } - - val optimisticAttachments = - imageUris.mapIndexed { index, uri -> - MessageAttachment( - id = attachmentIds[index], - blob = "", - type = AttachmentType.IMAGE, - preview = "", - width = 0, - height = 0, - localUri = uri.toString() - ) - } - - addMessageSafely( - ChatMessage( - id = messageId, - text = text, - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = optimisticAttachments - ) - ) - _inputText.value = "" - - protocolGateway.addLog( - "📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}" - ) - - backgroundUploadScope.launch { - try { - val optimisticAttachmentsJson = - JSONArray().apply { - imageUris.forEachIndexed { index, uri -> - put( - JSONObject().apply { - put("id", attachmentIds[index]) - put("type", AttachmentType.IMAGE.value) - put("preview", "") - put("blob", "") - put("width", 0) - put("height", 0) - put("localUri", uri.toString()) - } - ) - } - }.toString() - - saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = "", - encryptedKey = "", - timestamp = timestamp, - isFromMe = true, - delivered = 0, - attachmentsJson = optimisticAttachmentsJson, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - - saveDialog( - lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - } catch (_: Exception) { - protocolGateway.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)") - } - - val preparedImages = - imageUris.mapIndexed { index, uri -> - val (width, height) = - com.rosetta.messenger.utils.MediaUtils.getImageDimensions( - context, - uri - ) - val imageBase64 = - com.rosetta.messenger.utils.MediaUtils.uriToBase64Image( - context, - uri - ) - ?: run { - protocolGateway.addLog( - "❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed" - ) - throw IllegalStateException( - "group item#$index base64 conversion failed" - ) - } - val blurhash = - com.rosetta.messenger.utils.MediaUtils.generateBlurhash( - context, - uri - ) - protocolGateway.addLog( - "📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}" - ) - index to - ImageData( - base64 = imageBase64, - blurhash = blurhash, - width = width, - height = height - ) - } - - if (preparedImages.isEmpty()) { - protocolGateway.addLog( - "❌ IMG-GROUP $groupDebugId | no prepared images, send canceled" - ) - updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } - isSending = false - return@launch - } - protocolGateway.addLog( - "📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}" - ) - - try { - val groupStartedAt = System.currentTimeMillis() - val encryptionContext = - buildEncryptionContext( - plaintext = text, - 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 networkAttachments = mutableListOf() - val finalDbAttachments = JSONArray() - val finalAttachmentsById = mutableMapOf() - - for ((originalIndex, imageData) in preparedImages) { - val attachmentId = attachmentIds[originalIndex] - val encryptedImageBlob = - encryptAttachmentPayload(imageData.base64, encryptionContext) - val uploadTag = - if (!isSavedMessages) { - TransportManager.uploadFile(attachmentId, encryptedImageBlob) - } else { - "" - } - val attachmentTransportServer = - if (uploadTag.isNotEmpty()) { - TransportManager.getTransportServer().orEmpty() - } else { - "" - } - val previewValue = imageData.blurhash - - AttachmentFileManager.saveAttachment( - context = context, - blob = imageData.base64, - attachmentId = attachmentId, - publicKey = sender, - privateKey = privateKey - ) - - val finalAttachment = - MessageAttachment( - id = attachmentId, - blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob, - type = AttachmentType.IMAGE, - preview = previewValue, - width = imageData.width, - height = imageData.height, - localUri = "", - transportTag = uploadTag, - transportServer = attachmentTransportServer - ) - networkAttachments.add(finalAttachment) - finalAttachmentsById[attachmentId] = finalAttachment - - finalDbAttachments.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) - } - ) - } - - val packet = - PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.aesChachaKey = aesChachaKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = networkAttachments - } - - sendMediaMessageUseCase.dispatch(packet, isSavedMessages) - - updateMessageStatusAndAttachmentsInDb( - messageId = messageId, - delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = finalDbAttachments.toString() - ) - - withContext(Dispatchers.Main) { - _messages.value = - _messages.value.map { msg -> - if (msg.id != messageId) return@map msg - msg.copy( - status = MessageStatus.SENT, - attachments = - msg.attachments.map { current -> - val final = finalAttachmentsById[current.id] - if (final != null) { - current.copy( - preview = final.preview, - width = final.width, - height = final.height, - localUri = "" - ) - } else { - current.copy(localUri = "") - } - } - ) - } - updateCacheFromCurrentMessages() - } - - saveDialog( - lastMessage = if (text.isNotEmpty()) text else "📷 ${imageUris.size} photos", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - logPhotoPipeline( - messageId, - "group-from-uri completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms" - ) - } catch (e: Exception) { - logPhotoPipelineError(messageId, "group-from-uri", e) - updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } - } finally { - isSending = false - } - } - } - - fun sendImageGroup(images: List, caption: String = "") { - if (images.isEmpty()) return - - // Если одно изображение - отправляем обычным способом - if (images.size == 1) { - sendImageMessage( - images[0].base64, - images[0].blurhash, - caption, - images[0].width, - images[0].height - ) - return - } - - val recipient = opponentKey - val sender = myPublicKey - val privateKey = myPrivateKey - - if (recipient == null || sender == null || privateKey == null) { - return - } - if (isSending) { - return - } - - isSending = true - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - val text = caption.trim() - logPhotoPipeline( - messageId, - "group start: count=${images.size}, captionLen=${text.length}" - ) - - // Создаём attachments для всех изображений - 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 - ) - } - - // 1. 🚀 Optimistic UI - val optimisticMessage = - ChatMessage( - id = messageId, - text = text, - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = attachmentsList - ) - // 🔥 Безопасное добавление с проверкой дубликатов - addMessageSafely(optimisticMessage) - _inputText.value = "" - - backgroundUploadScope.launch { - try { - val groupStartedAt = System.currentTimeMillis() - // Шифрование текста - val encryptionContext = - buildEncryptionContext( - plaintext = text, - recipient = recipient, - privateKey = privateKey - ) ?: throw IllegalStateException("Cannot resolve chat encryption context") - val encryptedContent = encryptionContext.encryptedContent - val encryptedKey = encryptionContext.encryptedKey - val aesChachaKey = encryptionContext.aesChachaKey - logPhotoPipeline( - messageId, - "group text encrypted: contentLen=${encryptedContent.length}, keyLen=${encryptedKey.length}" - ) - - val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - - // Загружаем каждое изображение на Transport Server и создаём attachments - val networkAttachments = mutableListOf() - val attachmentsJsonArray = JSONArray() - - for ((index, imageData) in images.withIndex()) { - val attachmentId = "img_${timestamp}_$index" - logPhotoPipeline( - messageId, - "group item#$index start: id=${shortPhotoId(attachmentId, 12)}, size=${imageData.width}x${imageData.height}" - ) - - // Шифруем изображение с ChaCha ключом - val encryptedImageBlob = - encryptAttachmentPayload(imageData.base64, encryptionContext) - - // Загружаем на Transport Server - val uploadTag = TransportManager.uploadFile(attachmentId, encryptedImageBlob) - val attachmentTransportServer = - if (uploadTag.isNotEmpty()) { - TransportManager.getTransportServer().orEmpty() - } else { - "" - } - val previewValue = imageData.blurhash - logPhotoPipeline( - messageId, - "group item#$index upload done: tag=${shortPhotoId(uploadTag, 12)}" - ) - - // Сохраняем в файл локально - AttachmentFileManager.saveAttachment( - context = app, - blob = imageData.base64, - attachmentId = attachmentId, - publicKey = sender, - privateKey = privateKey - ) - - // Для сети - networkAttachments.add( - MessageAttachment( - id = attachmentId, - blob = if (uploadTag.isNotEmpty()) "" else encryptedImageBlob, - 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", "") // Пустой blob - изображения в файловой системе - put("width", imageData.width) - put("height", imageData.height) - put("transportTag", uploadTag) - put("transportServer", attachmentTransportServer) - } - ) - } - - // Создаём пакет - val packet = - PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.aesChachaKey = aesChachaKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = networkAttachments - } - - // Для Saved Messages не отправляем на сервер - val isSavedMessages = (sender == recipient) - sendMediaMessageUseCase.dispatch(packet, isSavedMessages) - if (!isSavedMessages) { - logPhotoPipeline(messageId, "group packet sent") - } - - // Сохраняем в БД - saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = encryptedContent, - encryptedKey = - if (encryptionContext.isGroup) { - buildStoredGroupKey( - encryptionContext.attachmentPassword, - privateKey - ) - } else { - encryptedKey - }, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = attachmentsJsonArray.toString(), - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - - // После успешной отправки медиа переводим в SENT. - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - - saveDialog( - lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - logPhotoPipeline( - messageId, - "group completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms" - ) - } catch (e: Exception) { - logPhotoPipelineError(messageId, "group-send", e) - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } - } finally { - isSending = false - } - } - } - - /** - * 📄 Отправка сообщения с файлом - * @param fileBase64 Base64 содержимого файла - * @param fileName Имя файла - * @param fileSize Размер файла в байтах - * @param caption Подпись к файлу (опционально) - */ - fun sendFileMessage( - fileBase64: String, - fileName: String, - fileSize: Long, - caption: String = "" - ) { - val recipient = opponentKey - val sender = myPublicKey - val privateKey = myPrivateKey - - if (recipient == null || sender == null || privateKey == null) { - return - } - if (isSending) { - return - } - - isSending = true - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - val text = caption.trim() - val preview = "$fileSize::$fileName" // Format: "size::name" как в Desktop - - // 1. 🚀 Optimistic UI - val optimisticMessage = - ChatMessage( - id = messageId, - text = text, - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = - listOf( - MessageAttachment( - id = "file_$timestamp", - type = AttachmentType.FILE, - preview = preview, - blob = fileBase64 - ) - ) - ) - // 🔥 Безопасное добавление с проверкой дубликатов - addMessageSafely(optimisticMessage) - _inputText.value = "" - - viewModelScope.launch(Dispatchers.IO) { - try { - // 💾 Сохраняем файл локально чтобы отправитель мог его открыть - try { - val appContext = app - 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) - } - } catch (_: Exception) {} - - val encryptionContext = - buildEncryptionContext( - plaintext = text, - 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) - - // 🚀 Шифруем файл с ChaCha ключом для Transport Server - val encryptedFileBlob = encryptAttachmentPayload(fileBase64, encryptionContext) - - val attachmentId = "file_$timestamp" - - // 📤 Загружаем на Transport Server (как в desktop) - // НЕ для Saved Messages - там не нужно загружать - val isSavedMessages = (sender == recipient) - var uploadTag = "" - - if (!isSavedMessages) { - uploadTag = TransportManager.uploadFile(attachmentId, encryptedFileBlob) - } - - val attachmentTransportServer = - if (uploadTag.isNotEmpty()) { - TransportManager.getTransportServer().orEmpty() - } else { - "" - } - val previewValue = preview - - val fileAttachment = - MessageAttachment( - id = attachmentId, - blob = "", // 🔥 Пустой blob - файл на Transport Server! - type = AttachmentType.FILE, - preview = previewValue, - transportTag = uploadTag, - transportServer = attachmentTransportServer - ) - - val packet = - PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.aesChachaKey = aesChachaKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(fileAttachment) - } - - // Отправляем пакет (без blob!) - sendMediaMessageUseCase.dispatch(packet, isSavedMessages) - - // ⚠️ НЕ сохраняем файл локально - они слишком большие - // Файлы загружаются с Transport Server при необходимости - val attachmentsJson = - JSONArray() - .apply { - put( - JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.FILE.value) - put("preview", previewValue) - put("blob", "") // Пустой blob - не сохраняем в БД! - put("transportTag", uploadTag) - put("transportServer", attachmentTransportServer) - } - ) - } - .toString() - - // 🔥 Сохраняем сначала с SENDING, потом обновляем на SENT - saveMessageToDatabase( - messageId = messageId, - text = text, - encryptedContent = encryptedContent, - encryptedKey = - if (encryptionContext.isGroup) { - buildStoredGroupKey( - encryptionContext.attachmentPassword, - privateKey - ) - } else { - encryptedKey - }, - timestamp = timestamp, - isFromMe = true, - delivered = - if (isSavedMessages) 1 - else 0, // SENDING для обычных, DELIVERED для saved - attachmentsJson = attachmentsJson, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - - // Для обычных чатов статус подтверждаем только по PacketDelivery(messageId). - withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - } - - saveDialog( - lastMessage = if (text.isNotEmpty()) text else "file", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - } catch (e: Exception) { - withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } - updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - saveDialog( - lastMessage = if (text.isNotEmpty()) text else "file", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - } finally { - isSending = false - } - } - } - - private data class VideoCircleMeta( - val durationSec: Int, - val width: Int, - val height: Int, - val mimeType: String - ) - - 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) - } - - private fun resolveVideoCircleMeta( - application: Application, - videoUri: android.net.Uri - ): VideoCircleMeta { - var durationSec = 1 - var width = 0 - var height = 0 - - val mimeType = - application.contentResolver.getType(videoUri)?.trim().orEmpty().ifBlank { - val ext = - MimeTypeMap.getFileExtensionFromUrl(videoUri.toString()) - ?.lowercase(Locale.ROOT) - ?: "" - MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "video/mp4" - } - - runCatching { - val retriever = MediaMetadataRetriever() - retriever.setDataSource(application, 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 - ) - } - - private suspend fun encodeVideoUriToHex( - application: Application, - videoUri: android.net.Uri - ): String? { - return withContext(Dispatchers.IO) { - runCatching { - application.contentResolver.openInputStream(videoUri)?.use { stream -> - val bytes = stream.readBytes() - if (bytes.isEmpty()) null else bytesToHex(bytes) - } - }.getOrNull() - } - } - - /** - * 🎥 Отправка видео-кружка (video note) из URI. - * Использует такой же transport + шифрование пайплайн, как voice attachment. - */ - fun sendVideoCircleFromUri(videoUri: android.net.Uri) { - val recipient = opponentKey - val sender = myPublicKey - val privateKey = myPrivateKey - val context = app - - if (recipient == null || sender == null || privateKey == null) { - return - } - if (isSending) { - return - } - - val fileSize = runCatching { com.rosetta.messenger.utils.MediaUtils.getFileSize(context, videoUri) } - .getOrDefault(0L) - val maxBytes = com.rosetta.messenger.utils.MediaUtils.MAX_FILE_SIZE_MB * 1024L * 1024L - if (fileSize > 0L && fileSize > maxBytes) { - return - } - - isSending = true - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - val attachmentId = "video_circle_$timestamp" - val meta = resolveVideoCircleMeta(context, videoUri) - val preview = "${meta.durationSec}::${meta.mimeType}" - - val optimisticMessage = - ChatMessage( - id = messageId, - text = "", - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = - listOf( - MessageAttachment( - id = attachmentId, - blob = "", - type = AttachmentType.VIDEO_CIRCLE, - preview = preview, - width = meta.width, - height = meta.height, - localUri = videoUri.toString() - ) - ) - ) - addMessageSafely(optimisticMessage) - _inputText.value = "" - - backgroundUploadScope.launch { - 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() - - saveMessageToDatabase( - messageId = messageId, - text = "", - encryptedContent = "", - encryptedKey = "", - timestamp = timestamp, - isFromMe = true, - delivered = 0, - attachmentsJson = optimisticAttachmentsJson, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - saveDialog( - lastMessage = "Video message", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - } catch (_: Exception) { - } - - try { - val videoHex = encodeVideoUriToHex(context, videoUri) - if (videoHex.isNullOrBlank()) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } - return@launch - } - sendVideoCircleMessageInternal( - messageId = messageId, - attachmentId = attachmentId, - timestamp = timestamp, - videoHex = videoHex, - preview = preview, - width = meta.width, - height = meta.height, - recipient = recipient, - sender = sender, - privateKey = privateKey - ) - } catch (_: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } - updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - } finally { - isSending = false - } - } - } - - private suspend fun sendVideoCircleMessageInternal( + internal suspend fun sendVideoCircleMessageInternal( messageId: String, attachmentId: String, timestamp: Long, @@ -5928,123 +3774,18 @@ class ChatViewModel @Inject constructor( sender: String, privateKey: String ) { - var packetSentToProtocol = false - try { - val application = app - - val encryptionContext = - 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 encryptedVideoBlob = encryptAttachmentPayload(videoHex, encryptionContext) - - val isSavedMessages = (sender == recipient) - val uploadTag = - if (!isSavedMessages) { - TransportManager.uploadFile(attachmentId, encryptedVideoBlob) - } else { - "" - } - val attachmentTransportServer = - if (uploadTag.isNotEmpty()) { - TransportManager.getTransportServer().orEmpty() - } else { - "" - } - - val videoAttachment = - MessageAttachment( - id = attachmentId, - blob = "", - type = AttachmentType.VIDEO_CIRCLE, - preview = preview, - width = width, - height = height, - transportTag = uploadTag, - transportServer = attachmentTransportServer - ) - - sendMediaMessageUseCase( - 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() - - updateMessageStatusAndAttachmentsInDb( - messageId = messageId, - delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = attachmentsJson - ) - - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - updateMessageAttachments(messageId, null) - } - saveDialog( - lastMessage = "Video message", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - } catch (_: Exception) { - if (packetSentToProtocol) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - } else { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } - } - } + attachmentsCoordinator.sendVideoCircleMessageInternal( + messageId = messageId, + attachmentId = attachmentId, + timestamp = timestamp, + videoHex = videoHex, + preview = preview, + width = width, + height = height, + recipient = recipient, + sender = sender, + privateKey = privateKey + ) } /** @@ -6057,451 +3798,11 @@ class ChatViewModel @Inject constructor( durationSec: Int, waves: List ) { - val recipient = opponentKey - val sender = myPublicKey - val privateKey = myPrivateKey - - if (recipient == null || sender == null || privateKey == null) { - return - } - if (isSending) { - return - } - - val normalizedVoiceHex = voiceHex.trim() - if (normalizedVoiceHex.isEmpty()) { - return - } - - val normalizedDuration = durationSec.coerceAtLeast(1) - val normalizedWaves = - waves.asSequence() - .map { it.coerceIn(0f, 1f) } - .take(120) - .toList() - val wavesPreview = - normalizedWaves.joinToString(",") { - String.format(Locale.US, "%.3f", it) - } - val preview = "$normalizedDuration::$wavesPreview" - - isSending = true - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - val attachmentId = "voice_$timestamp" - - // 1. 🚀 Optimistic UI - val optimisticMessage = - ChatMessage( - id = messageId, - text = "", - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = - listOf( - MessageAttachment( - id = attachmentId, - type = AttachmentType.VOICE, - preview = preview, - blob = normalizedVoiceHex - ) - ) - ) - addMessageSafely(optimisticMessage) - _inputText.value = "" - - viewModelScope.launch(Dispatchers.IO) { - try { - val encryptionContext = - 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 encryptedVoiceBlob = - encryptAttachmentPayload(normalizedVoiceHex, encryptionContext) - - val isSavedMessages = (sender == recipient) - var uploadTag = "" - if (!isSavedMessages) { - uploadTag = TransportManager.uploadFile(attachmentId, encryptedVoiceBlob) - } - val attachmentTransportServer = - if (uploadTag.isNotEmpty()) { - TransportManager.getTransportServer().orEmpty() - } else { - "" - } - - val voiceAttachment = - MessageAttachment( - id = attachmentId, - blob = "", - type = AttachmentType.VOICE, - preview = preview, - transportTag = uploadTag, - transportServer = attachmentTransportServer - ) - - val packet = - PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.aesChachaKey = aesChachaKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(voiceAttachment) - } - - sendMediaMessageUseCase.dispatch(packet, isSavedMessages) - - // Для отправителя сохраняем voice blob локально в encrypted cache. - runCatching { - AttachmentFileManager.saveAttachment( - context = app, - blob = normalizedVoiceHex, - attachmentId = attachmentId, - publicKey = sender, - privateKey = privateKey - ) - } - - val attachmentsJson = - JSONArray() - .apply { - put( - JSONObject().apply { - put("id", attachmentId) - put("type", AttachmentType.VOICE.value) - put("preview", preview) - put("blob", "") - put("transportTag", uploadTag) - put("transportServer", attachmentTransportServer) - } - ) - } - .toString() - - saveMessageToDatabase( - messageId = messageId, - text = "", - encryptedContent = encryptedContent, - encryptedKey = - if (encryptionContext.isGroup) { - buildStoredGroupKey( - encryptionContext.attachmentPassword, - privateKey - ) - } else { - encryptedKey - }, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 1 else 0, - attachmentsJson = attachmentsJson, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - - withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - } - - saveDialog( - lastMessage = "Voice message", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - } catch (_: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - } - updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - saveDialog( - lastMessage = "Voice message", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = privateKey, - opponentPublicKey = recipient - ) - } finally { - isSending = false - } - } - } - - /** - * Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение - */ - fun sendAvatarMessage() { - val recipient = opponentKey - val sender = myPublicKey - val userPrivateKey = myPrivateKey - - if (recipient == null || sender == null || userPrivateKey == null) { - return - } - if (isSending) { - return - } - - isSending = true - - val messageId = UUID.randomUUID().toString().replace("-", "").take(32) - val timestamp = System.currentTimeMillis() - - viewModelScope.launch(Dispatchers.IO) { - try { - // Получаем свой аватар из AvatarRepository - val avatarDao = database.avatarDao() - val myAvatar = avatarDao.getLatestAvatar(sender) - - if (myAvatar == null) { - withContext(Dispatchers.Main) { - android.widget.Toast.makeText( - app, - "No avatar to send", - android.widget.Toast.LENGTH_SHORT - ) - .show() - } - isSending = false - return@launch - } - - // Читаем и расшифровываем аватар - val avatarBlob = - com.rosetta.messenger.utils.AvatarFileManager.readAvatar( - app, - myAvatar.avatar - ) - - if (avatarBlob == null || avatarBlob.isEmpty()) { - withContext(Dispatchers.Main) { - android.widget.Toast.makeText( - app, - "Failed to read avatar", - android.widget.Toast.LENGTH_SHORT - ) - .show() - } - isSending = false - return@launch - } - - // 🔥 КРИТИЧНО: Desktop ожидает полный data URL, а не просто Base64! - // Добавляем префикс если его нет - val avatarDataUrl = - if (avatarBlob.startsWith("data:image")) { - avatarBlob - } else { - "data:image/png;base64,$avatarBlob" - } - - // Генерируем blurhash для preview (как на desktop) - val avatarBlurhash = - withContext(Dispatchers.IO) { - try { - val bitmap = base64ToBitmap(avatarBlob) - if (bitmap != null) { - com.rosetta.messenger.utils.MediaUtils - .generateBlurhashFromBitmap(bitmap) - } else { - "" - } - } catch (e: Exception) { - "" - } - } - - // 1. 🚀 Optimistic UI - val optimisticMessage = - ChatMessage( - id = messageId, - text = "", - isOutgoing = true, - timestamp = Date(timestamp), - status = MessageStatus.SENDING, - attachments = - listOf( - MessageAttachment( - id = "avatar_$timestamp", - type = AttachmentType.AVATAR, - preview = avatarBlurhash, - blob = avatarBlob // Для локального - // отображения - ) - ) - ) - withContext(Dispatchers.Main) { addMessageSafely(optimisticMessage) } - - // 2. Шифрование текста (пустой текст для аватарки) - val encryptionContext = - buildEncryptionContext( - plaintext = "", - recipient = recipient, - privateKey = userPrivateKey - ) ?: throw IllegalStateException("Cannot resolve chat encryption context") - val encryptedContent = encryptionContext.encryptedContent - val encryptedKey = encryptionContext.encryptedKey - val aesChachaKey = encryptionContext.aesChachaKey - - val privateKeyHash = CryptoManager.generatePrivateKeyHash(userPrivateKey) - - // 🔥 КРИТИЧНО: Как в desktop - шифруем аватар с ChaCha ключом (plainKeyAndNonce) - // НЕ с AVATAR_PASSWORD! AVATAR_PASSWORD используется только для локального хранения - // Используем avatarDataUrl (с префиксом data:image/...) а не avatarBlob! - val encryptedAvatarBlob = encryptAttachmentPayload(avatarDataUrl, encryptionContext) - - val avatarAttachmentId = "avatar_$timestamp" - - // 📤 Загружаем на Transport Server (как в desktop!) - val isSavedMessages = (sender == recipient) - var uploadTag = "" - - if (!isSavedMessages) { - try { - uploadTag = - TransportManager.uploadFile(avatarAttachmentId, encryptedAvatarBlob) - } catch (e: Exception) { - throw e - } - } - - val attachmentTransportServer = - if (uploadTag.isNotEmpty()) { - TransportManager.getTransportServer().orEmpty() - } else { - "" - } - val previewValue = avatarBlurhash - - val avatarAttachment = - MessageAttachment( - id = avatarAttachmentId, - blob = "", // 🔥 ПУСТОЙ blob - файл на Transport Server! - type = AttachmentType.AVATAR, - preview = previewValue, - transportTag = uploadTag, - transportServer = attachmentTransportServer - ) - - // 3. Отправляем пакет (с ПУСТЫМ blob!) - val packet = - PacketMessage().apply { - fromPublicKey = sender - toPublicKey = recipient - content = encryptedContent - chachaKey = encryptedKey - this.aesChachaKey = aesChachaKey - this.timestamp = timestamp - this.privateKey = privateKeyHash - this.messageId = messageId - attachments = listOf(avatarAttachment) - } - - sendMediaMessageUseCase.dispatch(packet, isSavedMessages) - - // 💾 Сохраняем аватар в файл локально (как IMAGE - с приватным ключом) - AttachmentFileManager.saveAttachment( - context = app, - blob = avatarBlob, - attachmentId = avatarAttachmentId, - publicKey = sender, - privateKey = userPrivateKey - ) - - // 4. 💾 Сохраняем в БД (БЕЗ blob - он в файле) - как в sendImageMessage - val attachmentsJson = - JSONArray() - .apply { - put( - JSONObject().apply { - put("id", avatarAttachmentId) - put("type", AttachmentType.AVATAR.value) - put("preview", previewValue) - put("blob", "") // Пустой blob - не сохраняем в БД! - put("transportTag", uploadTag) - put("transportServer", attachmentTransportServer) - } - ) - } - .toString() - - saveMessageToDatabase( - messageId = messageId, - text = "", // Аватар без текста - encryptedContent = encryptedContent, - encryptedKey = - if (encryptionContext.isGroup) { - buildStoredGroupKey( - encryptionContext.attachmentPassword, - userPrivateKey - ) - } else { - encryptedKey - }, - timestamp = timestamp, - isFromMe = true, - delivered = if (isSavedMessages) 1 else 0, // Как в sendImageMessage - attachmentsJson = attachmentsJson, - accountPublicKey = sender, - accountPrivateKey = userPrivateKey, - opponentPublicKey = recipient - ) - - // Обновляем UI: для обычных чатов остаёмся в SENDING до PacketDelivery(messageId). - withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } - } - - saveDialog( - lastMessage = "\$a=Avatar", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = userPrivateKey, - opponentPublicKey = recipient - ) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - updateMessageStatus(messageId, MessageStatus.ERROR) - android.widget.Toast.makeText( - app, - "Failed to send avatar: ${e.message}", - android.widget.Toast.LENGTH_SHORT - ) - .show() - } - updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) - saveDialog( - lastMessage = "\$a=Avatar", - timestamp = timestamp, - accountPublicKey = sender, - accountPrivateKey = userPrivateKey, - opponentPublicKey = recipient - ) - } finally { - isSending = false - } - } + voiceRecordingViewModel.sendVoiceMessage( + voiceHex = voiceHex, + durationSec = durationSec, + waves = waves + ) } /** @@ -6882,53 +4183,7 @@ class ChatViewModel @Inject constructor( * Messages - не отправляем (нельзя печатать самому себе) */ fun sendTypingIndicator() { - val now = System.currentTimeMillis() - if (now - lastTypingSentTime < TYPING_THROTTLE_MS) return - - val opponent = - opponentKey - ?: run { - return - } - val sender = - myPublicKey - ?: run { - return - } - - // 📁 Для Saved Messages - не отправляем typing indicator - if (opponent.equals(sender, ignoreCase = true)) { - return - } - - // ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн - // (для групп продолжаем отправлять — кто-то из участников может быть в сети) - if (!isGroupDialogKey(opponent) && !_opponentOnline.value) { - return - } - - val privateKey = - myPrivateKey - ?: run { - return - } - - lastTypingSentTime = now - - viewModelScope.launch(Dispatchers.IO) { - try { - val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - - val packet = - PacketTyping().apply { - this.privateKey = privateKeyHash - fromPublicKey = sender - toPublicKey = opponent - } - - protocolGateway.send(packet) - } catch (e: Exception) {} - } + typingViewModel.sendTypingIndicator() } /** @@ -6961,16 +4216,13 @@ class ChatViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { try { val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey) - - // Desktop формат: privateKey, fromPublicKey, toPublicKey - val packet = - PacketRead().apply { - this.privateKey = privateKeyHash - fromPublicKey = sender // Мы (кто прочитал) - toPublicKey = opponent // Кому отправляем уведомление (собеседник) - } - - protocolGateway.send(packet) + sendReadReceiptUseCase( + SendReadReceiptCommand( + privateKeyHash = privateKeyHash, + fromPublicKey = sender, + toPublicKey = opponent + ) + ) // ✅ Обновляем timestamp ПОСЛЕ успешной отправки lastReadMessageTimestamp = incomingTs MessageLogger.logReadReceiptSent(opponent) @@ -6979,12 +4231,12 @@ class ChatViewModel @Inject constructor( // 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную) try { kotlinx.coroutines.delay(2000) - protocolGateway.send( - PacketRead().apply { - this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) - fromPublicKey = sender - toPublicKey = opponent - } + sendReadReceiptUseCase( + SendReadReceiptCommand( + privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey), + fromPublicKey = sender, + toPublicKey = opponent + ) ) lastReadMessageTimestamp = incomingTs MessageLogger.logReadReceiptSent(opponent, retry = true) @@ -7128,10 +4380,7 @@ class ChatViewModel @Inject constructor( typingNameResolveJob?.cancel() draftSaveJob?.cancel() pinnedCollectionJob?.cancel() - pendingSendRecoveryJob?.cancel() - pendingSendRecoveryJob = null - pendingTextSendRequested = false - pendingTextSendReason = "" + messagesCoordinator.onCleared() outgoingImageUploadJobs.values.forEach { it.cancel() } outgoingImageUploadJobs.clear() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListChatItem.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListChatItem.kt new file mode 100644 index 0000000..c8d36d8 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListChatItem.kt @@ -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) ?: "" + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerContent.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerContent.kt new file mode 100644 index 0000000..5aabda1 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerContent.kt @@ -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)) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerSections.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerSections.kt new file mode 100644 index 0000000..eb5100a --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerSections.kt @@ -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, + 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)) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsScreen.kt new file mode 100644 index 0000000..96af62e --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsScreen.kt @@ -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, + isDarkTheme: Boolean, + onRequestClick: (DialogUiModel) -> Unit, + avatarRepository: AvatarRepository? = null, + blockedUsers: Set = emptySet(), + pinnedChats: Set = 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(null) } + + LaunchedEffect(isDrawerOpen) { + if (isDrawerOpen) { + swipedItemKey = null + } + } + + var dialogToDelete by remember { mutableStateOf(null) } + var dialogToBlock by remember { mutableStateOf(null) } + var dialogToUnblock by remember { mutableStateOf(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) + } + } + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsSection.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsSection.kt new file mode 100644 index 0000000..132d519 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsSection.kt @@ -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 = 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) + ) + } + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 1a7c2b4..e88f8df 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -69,8 +69,6 @@ import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.di.ProtocolGateway -import com.rosetta.messenger.di.UiEntryPoint -import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallUiState @@ -267,12 +265,12 @@ private fun rosettaDev1Log(context: Context, tag: String, message: String) { } } -private val TELEGRAM_DIALOG_AVATAR_START = 10.dp -private val TELEGRAM_DIALOG_TEXT_START = 72.dp -private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp -private val TELEGRAM_DIALOG_ROW_HEIGHT = 72.dp -private val TELEGRAM_DIALOG_VERTICAL_PADDING = 9.dp -private val TELEGRAM_DIALOG_AVATAR_GAP = +internal val TELEGRAM_DIALOG_AVATAR_START = 10.dp +internal val TELEGRAM_DIALOG_TEXT_START = 72.dp +internal val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp +internal val TELEGRAM_DIALOG_ROW_HEIGHT = 72.dp +internal val TELEGRAM_DIALOG_VERTICAL_PADDING = 9.dp +internal val TELEGRAM_DIALOG_AVATAR_GAP = TELEGRAM_DIALOG_TEXT_START - TELEGRAM_DIALOG_AVATAR_START - TELEGRAM_DIALOG_AVATAR_SIZE private fun maxRevealRadius(center: Offset, bounds: IntSize): Float { @@ -309,6 +307,9 @@ fun ChatsListScreen( onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, pinnedChats: Set = emptySet(), onTogglePin: (String) -> Unit = {}, + protocolGateway: ProtocolGateway, + accountManager: AccountManager, + preferencesManager: com.rosetta.messenger.data.PreferencesManager, chatsViewModel: ChatsListViewModel = hiltViewModel(), avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, callUiState: CallUiState = CallUiState(), @@ -327,10 +328,6 @@ fun ChatsListScreen( val view = androidx.compose.ui.platform.LocalView.current 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 accountManager = remember(uiDeps) { uiDeps.accountManager() } - val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -939,620 +936,139 @@ fun ChatsListScreen( Modifier.fillMaxSize() .background(drawerBackgroundColor) ) { - // ═══════════════════════════════════════════════════════════ - // 🎨 DRAWER HEADER - // ═══════════════════════════════════════════════════════════ - // Header: цвет шапки сайдбара - Box(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .matchParentSize() - .background( - if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6) - ) - ) - - // Content поверх фона - Column( - modifier = - Modifier.fillMaxWidth() - .statusBarsPadding() - .padding( - top = 16.dp, - start = 20.dp, - end = 20.dp, - bottom = 12.dp - ) - ) { - val isOfficialByKey = - MessageRepository.isSystemAccount( - accountPublicKey - ) - // Avatar row with theme toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - // Avatar - Box( - modifier = - Modifier.size(72.dp) - .clip(CircleShape) - .combinedClickable( - onClick = { - scope.launch { - accountsSectionExpanded = false - drawerState.close() - kotlinx.coroutines.delay(150) - onProfileClick() - } - }, - onLongClick = { - hapticFeedback.performHapticFeedback( - HapticFeedbackType.LongPress - ) - accountToDelete = - allAccounts - .firstOrNull { - it.publicKey == - accountPublicKey - } - } - ), - contentAlignment = - Alignment.Center - ) { - AvatarImage( - publicKey = - effectiveCurrentPublicKey, - avatarRepository = - avatarRepository, - size = 72.dp, - isDarkTheme = - isDarkTheme, - displayName = - sidebarAccountName - .ifEmpty { - sidebarAccountUsername - } - ) - } - - // Theme toggle icon - IconButton( - onClick = { startThemeReveal() }, - modifier = - Modifier.size(48.dp) - .onGloballyPositioned { - val pos = - it.positionInRoot() - themeToggleCenterInRoot = - 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) - ) - } + ChatsDrawerHeaderAndAccounts( + isDarkTheme = isDarkTheme, + drawerBackgroundColor = drawerBackgroundColor, + accountPublicKey = accountPublicKey, + accountVerified = accountVerified, + allAccounts = allAccounts, + effectiveCurrentPublicKey = effectiveCurrentPublicKey, + sidebarAccountName = sidebarAccountName, + sidebarAccountUsername = sidebarAccountUsername, + avatarRepository = avatarRepository, + accountsSectionExpanded = accountsSectionExpanded, + onAccountsSectionExpandedChange = { accountsSectionExpanded = it }, + onProfileClick = { + scope.launch { + accountsSectionExpanded = false + drawerState.close() + kotlinx.coroutines.delay(150) + onProfileClick() } - - Spacer( - modifier = - Modifier.height(8.dp) + }, + onCurrentAccountLongPress = { + hapticFeedback.performHapticFeedback( + HapticFeedbackType.LongPress ) - - // Display name + arrow row - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - // Display name - if (sidebarAccountName.isNotEmpty()) { - Row( - horizontalArrangement = 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 - ) - } - } - } - - // Username - if (sidebarAccountUsername.isNotEmpty()) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "@$sidebarAccountUsername", - fontSize = 13.sp, - color = Color.White.copy(alpha = 0.7f) - ) - } - } - - // Chevron arrow (toggles accounts section) - val arrowRotation by animateFloatAsState( - targetValue = if (accountsSectionExpanded) 180f else 0f, - animationSpec = tween(300), - label = "arrowRotation" - ) - IconButton( - onClick = { accountsSectionExpanded = !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 } - ) + accountToDelete = + allAccounts.firstOrNull { + it.publicKey == accountPublicKey } + }, + onSwitchAccount = { publicKey -> + scope.launch { + accountsSectionExpanded = false + drawerState.close() + kotlinx.coroutines.delay(150) + onSwitchAccount(publicKey) } - } - } - - // ═══════════════════════════════════════════════════════════ - // � ACCOUNTS SECTION (like Telegram) - // ═══════════════════════════════════════════════════════════ - 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()) { - // All accounts list (limit to 3 in expanded sidebar) - 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) { - scope.launch { - accountsSectionExpanded = false - drawerState.close() - kotlinx.coroutines.delay(150) - onSwitchAccount(account.publicKey) - } - } - }, - onLongClick = { - hapticFeedback.performHapticFeedback( - HapticFeedbackType.LongPress - ) - accountToDelete = account - } - ) - .padding(start = 14.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Account avatar - Box( - modifier = Modifier.size(36.dp), - contentAlignment = Alignment.Center - ) { - AvatarImage( - publicKey = account.publicKey, - avatarRepository = avatarRepository, - size = 36.dp, - isDarkTheme = isDarkTheme, - displayName = displayName - ) - // Green checkmark for current account - 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)) - - // Account name - 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) - ) - } - } - - // Add Account row - Row( - modifier = - Modifier.fillMaxWidth() - .height(48.dp) - .clickable { - scope.launch { - drawerState.close() - kotlinx.coroutines.delay(150) - onAddAccount() - } - } - .padding(start = 14.dp, end = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Plus icon in circle area - 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 + }, + onAccountLongPress = { account -> + hapticFeedback.performHapticFeedback( + HapticFeedbackType.LongPress ) + accountToDelete = account + }, + onAddAccountClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(150) + onAddAccount() + } + }, + onThemeToggleClick = { startThemeReveal() }, + onThemeToggleCenterChanged = { center -> + themeToggleCenterInRoot = center } - - // Divider after accounts section - Divider( - color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8), - thickness = 0.5.dp - ) - } - } // Close AnimatedVisibility + ) // ═══════════════════════════════════════════════════════════ - // �📱 MENU ITEMS + // 📱 MENU ITEMS // ═══════════════════════════════════════════════════════════ - 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 - - // 👤 Profile - DrawerMenuItemEnhanced( - painter = painterResource(id = R.drawable.left_status_profile), - text = "My Profile", - iconColor = menuIconColor, - textColor = menuTextColor, - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines - .delay(100) - onProfileClick() - } + DrawerContent( + isDarkTheme = isDarkTheme, + visibleTopLevelRequestsCount = visibleTopLevelRequestsCount, + onProfileClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(100) + onProfileClick() } - ) - - // 📦 Requests - 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 = { - scope.launch { - drawerState.close() - kotlinx.coroutines - .delay(100) - setInlineRequestsVisible(true) - } + }, + onRequestsClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(100) + setInlineRequestsVisible(true) } - ) - - // 📞 Calls - DrawerMenuItemEnhanced( - icon = TablerIcons.Phone, - text = "Calls", - iconColor = menuIconColor, - textColor = menuTextColor, - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines - .delay(100) - setInlineCallsVisible(true) - onCallsClick() - } + }, + onCallsClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(100) + setInlineCallsVisible(true) + onCallsClick() } - ) - - // 👥 New Group - DrawerMenuItemEnhanced( - icon = TablerIcons.Users, - text = "New Group", - iconColor = menuIconColor, - textColor = menuTextColor, - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines - .delay(100) - onNewGroupClick() - } + }, + onNewGroupClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(100) + onNewGroupClick() } - ) - - // 📷 Scan QR - DrawerMenuItemEnhanced( - icon = TablerIcons.Scan, - text = "Scan QR", - iconColor = menuIconColor, - textColor = menuTextColor, - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines.delay(100) - onQrScanClick() - } + }, + onQrScanClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(100) + onQrScanClick() } - ) - - // 📖 Saved Messages - DrawerMenuItemEnhanced( - painter = painterResource(id = R.drawable.msg_saved), - text = "Saved Messages", - iconColor = menuIconColor, - textColor = menuTextColor, - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines - .delay(250) - onSavedMessagesClick() - } + }, + onSavedMessagesClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(250) + onSavedMessagesClick() } - ) - - DrawerDivider(isDarkTheme) - - // ⚙️ Settings - DrawerMenuItemEnhanced( - painter = painterResource(id = R.drawable.msg_settings_old), - text = "Settings", - iconColor = menuIconColor, - textColor = menuTextColor, - onClick = { - scope.launch { - drawerState.close() - kotlinx.coroutines - .delay(100) - onSettingsClick() - } + }, + onSettingsClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(100) + onSettingsClick() } - ) - - // Keep distance from footer divider so it never overlays Settings. - Spacer(modifier = Modifier.height(8.dp)) - - } + } + ) // ═══════════════════════════════════════════════════════════ // FOOTER - Version + Update Banner // ═══════════════════════════════════════════════════════════ - Column( - modifier = - Modifier.fillMaxWidth() - .windowInsetsPadding( - WindowInsets - .navigationBars - .only( - WindowInsetsSides.Bottom - ) - ) - ) { - // Telegram-style update banner - val curUpdate = sduUpdateState - val showUpdateBanner = curUpdate is UpdateState.UpdateAvailable || - curUpdate is UpdateState.Downloading || - curUpdate is UpdateState.ReadyToInstall - - Divider( - color = - if (isDarkTheme) - Color(0xFF2A2A2A) - else Color(0xFFE8E8E8), - thickness = 0.5.dp - ) - - // Version info — прячем когда есть баннер обновления - if (!showUpdateBanner) { - Row( - modifier = - Modifier.fillMaxWidth() - .padding( - horizontal = 20.dp, - vertical = 12.dp - ), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Rosetta v${BuildConfig.VERSION_NAME}", - fontSize = 12.sp, - color = - if (isDarkTheme) - Color(0xFF666666) - else - Color(0xFF999999) - ) + ChatsDrawerFooter( + isDarkTheme = isDarkTheme, + appVersion = BuildConfig.VERSION_NAME, + updateState = sduUpdateState, + onUpdateClick = { update -> + when (update) { + is UpdateState.UpdateAvailable -> + UpdateManager.downloadAndInstall(context) + is UpdateState.Downloading -> + UpdateManager.cancelDownload() + is UpdateState.ReadyToInstall -> + UpdateManager.downloadAndInstall(context) + else -> {} } - 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 { - when (curUpdate) { - is UpdateState.UpdateAvailable -> - UpdateManager.downloadAndInstall(context) - is UpdateState.Downloading -> - UpdateManager.cancelDownload() - is UpdateState.ReadyToInstall -> - UpdateManager.downloadAndInstall(context) - else -> {} - } - }, - contentAlignment = Alignment.CenterStart - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = when (curUpdate) { - 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 (curUpdate) { - is UpdateState.Downloading -> - "Downloading... ${curUpdate.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 (curUpdate is UpdateState.UpdateAvailable) { - Text( - text = curUpdate.version, - color = Color.White.copy(alpha = 0.8f), - fontSize = 13.sp, - fontWeight = FontWeight.Medium - ) - } - if (curUpdate is UpdateState.Downloading) { - CircularProgressIndicator( - progress = curUpdate.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)) - } - } + ) } } } @@ -2039,8 +1555,9 @@ fun ChatsListScreen( // независимо val chatsState = topLevelChatsState val isLoading = topLevelIsLoading - val requests = if (syncInProgress) emptyList() else chatsState.requests - val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount + val hideRequests = syncInProgress || protocolState != ProtocolState.AUTHENTICATED + val requests = if (hideRequests) emptyList() else chatsState.requests + val requestsCount = if (hideRequests) 0 else chatsState.requestsCount val showSkeleton by produceState( @@ -3664,245 +3181,6 @@ private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) } } -@Composable -private 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 = Arrangement.Center - ) { - LottieAnimation( - composition = composition, - progress = { progress }, - modifier = Modifier.size(160.dp) - ) - - Spacer(modifier = Modifier.height(20.dp)) - - Text( - text = "No Requests", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = secondaryTextColor, - textAlign = TextAlign.Center - ) - - 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 - ) - } -} - -// Chat item for list -@Composable -fun ChatItem( - chat: Chat, - isDarkTheme: Boolean, - avatarRepository: com.rosetta.messenger.repository.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 - ) { - // Avatar with real image - 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) { - // Read status - 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 для отображения эмодзи - 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) { - // Pin icon - if (chat.isPinned) { - Icon( - painter = TelegramIcons.Pin, - contentDescription = "Pinned", - tint = - secondaryTextColor.copy( - alpha = 0.6f - ), - modifier = - Modifier.size(16.dp) - .padding(end = 4.dp) - ) - } - - // Unread badge - if (chat.unreadCount > 0) { - Box( - modifier = - Modifier.clip(CircleShape) - .background( - PrimaryBlue - ) - .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 - ) - } -} - -// Cache для SimpleDateFormat - создание дорогостоящее -private val timeFormatCache = - java.lang.ThreadLocal.withInitial { SimpleDateFormat("HH:mm", Locale.getDefault()) } -private val weekFormatCache = - java.lang.ThreadLocal.withInitial { SimpleDateFormat("EEE", Locale.getDefault()) } -private val monthFormatCache = - java.lang.ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) } -private val yearFormatCache = - java.lang.ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) } - -private 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) ?: "" - } - } -} - /** Элемент меню в боковом drawer */ @Composable fun DrawerMenuItem( @@ -5419,315 +4697,6 @@ private fun RequestsRouteContent( } } -/** 📬 Секция Requests — Telegram Archived Chats style */ -@Composable -fun RequestsSection( - count: Int, - requests: List = emptyList(), - isDarkTheme: Boolean, - onClick: () -> Unit -) { - // Telegram archived chats uses muted colors: - // Title: #525252 (light) vs regular #222222 - // Message: #919191 (light) - // Badge: always grey #c6c9cc (light) - // Avatar bg: #B8C2CC (light) / #3A3A3C (dark) - 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) } - - // Последний запрос — показываем имя отправителя как subtitle - 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 - ) { - // Иконка — круглый аватар как Archived Chats в Telegram - Box( - modifier = - Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE) - .clip(CircleShape) - .background(iconBgColor), - 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 - ) - - // Нижняя строка: subtitle (последний запрос) - if (subtitle.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = subtitle, - fontSize = 14.sp, - color = subtitleColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - // Badge — вертикально отцентрирован в строке - if (count > 0) { - Spacer(modifier = Modifier.width(8.dp)) - Box( - modifier = - Modifier - .defaultMinSize(minWidth = 22.dp, minHeight = 22.dp) - .clip(CircleShape) - .background(badgeColor), - 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) - ) - } - } - } - } -} - -/** 📬 Экран со списком Requests (без хедера - хедер в основном TopAppBar) */ -@Composable -fun RequestsScreen( - requests: List, - isDarkTheme: Boolean, - onRequestClick: (DialogUiModel) -> Unit, - avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, - blockedUsers: Set = emptySet(), - pinnedChats: Set = 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(null) } - - LaunchedEffect(isDrawerOpen) { - if (isDrawerOpen) { - swipedItemKey = null - } - } - - var dialogToDelete by remember { mutableStateOf(null) } - var dialogToBlock by remember { mutableStateOf(null) } - var dialogToUnblock by remember { mutableStateOf(null) } - - Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) { - if (requests.isEmpty()) { - // Empty state with Lottie animation - EmptyRequestsState(isDarkTheme = isDarkTheme) - } else { - // Requests list - 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) - } - } - ) - } -} - @Composable private fun FileDownloadsScreen( downloads: List, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 49aab1d..f3c945e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -115,6 +115,7 @@ class ChatsListViewModel @Inject constructor( // Количество requests private val _requestsCount = MutableStateFlow(0) val requestsCount: StateFlow = _requestsCount.asStateFlow() + val syncInProgress: StateFlow = protocolGateway.syncInProgress // Заблокированные пользователи (реактивный Set из Room Flow) private val _blockedUsers = MutableStateFlow>(emptySet()) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt index 4bdd741..2d5aa49 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardCoordinator.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardCoordinator.kt new file mode 100644 index 0000000..d086d02 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardCoordinator.kt @@ -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 + ) + + private data class ForwardRewriteResult( + val rewrittenAttachments: Map, + val rewrittenMessageIds: Set + ) + + fun sendForwardDirectly( + recipientPublicKey: String, + forwardMessages: List + ) { + 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, + encryptionContext: OutgoingEncryptionContext, + privateKey: String, + isSavedMessages: Boolean, + timestamp: Long + ): Pair, Set> { + 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, + encryptionContext: OutgoingEncryptionContext, + privateKey: String, + isSavedMessages: Boolean, + timestamp: Long + ): ForwardRewriteResult { + if (sourceMessages.isEmpty()) { + return ForwardRewriteResult(emptyMap(), emptySet()) + } + + val rewritten = mutableMapOf() + val rewrittenMessageIds = mutableSetOf() + var forwardAttachmentIndex = 0 + + for (sourceMessage in sourceMessages) { + val candidates = + sourceMessage.attachments.filter { shouldReuploadForwardAttachment(it.type) } + if (candidates.isEmpty()) continue + + val stagedForMessage = mutableMapOf() + 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 + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index ca220e4..96e21ec 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 9e52712..ff59aef 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt new file mode 100644 index 0000000..35371e8 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt @@ -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, + 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 = + 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() + 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() to emptySet() + } + 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 "" + 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 = "" + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingEncryptionContext.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingEncryptionContext.kt new file mode 100644 index 0000000..a700779 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingEncryptionContext.kt @@ -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 +) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingSendContext.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingSendContext.kt new file mode 100644 index 0000000..849e582 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingSendContext.kt @@ -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 +) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt index ca7947f..07797d6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 0f9dcc1..a0eec1e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -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>(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, 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>(emptyList()) } var isLoading by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index c7ee980..d7642d4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt index de38bb3..c94f1bc 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -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) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt index e92ce94..efaffb8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt index c8eea5d..9418c4f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt @@ -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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 1695f53..bd20307 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -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( } // �🟢 Наблюдаем за онлайн статусом пользователя в реальном времени - val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } val onlineStatus by messageRepository .observeUserOnlineStatus(user.publicKey)