refactor: декомпозировать runtime и chat-архитектуру, вынести use-case в domain и убрать UiEntryPoint
This commit is contained in:
350
Architecture.md
350
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<ProtocolRuntime>`.
|
||||
|
||||
### 3.2 UI bridge для composable-слоя
|
||||
UI-композаблы получают зависимости через `UiEntryPoint` + `EntryPointAccessors.fromApplication(...)`.
|
||||
UI-композаблы больше не получают runtime-зависимости через `UiEntryPoint`/`EntryPointAccessors`.
|
||||
`UiDependencyAccess.get(...)` из `ui/*` удален (DoD: 0 вхождений).
|
||||
|
||||
Для non-Hilt `object`-ов (`CallManager`, `TransportManager`, `UpdateManager`, utils)
|
||||
используется `ProtocolRuntimeAccess` + `ProtocolRuntimePort`:
|
||||
- runtime ставится в `RosettaApplication` через `ProtocolRuntimeAccess.install(protocolRuntime)`;
|
||||
- доступ до install запрещен (fail-fast), чтобы не было тихого отката в `ProtocolManager`.
|
||||
- доступ до install запрещен (fail-fast), чтобы не было тихого отката в legacy facade.
|
||||
|
||||
### 3.3 Разрыв DI-cycle (Hilt)
|
||||
После перехода на `ProtocolRuntime` был закрыт цикл зависимостей:
|
||||
@@ -152,9 +171,30 @@ stateDiagram-v2
|
||||
|
||||
## 5. Network orchestration после декомпозиции
|
||||
|
||||
`ProtocolRuntime` — DI-фасад runtime слоя.
|
||||
`ProtocolRuntimeCore` содержит runtime state machine и делегирует отдельные зоны ответственности:
|
||||
`ProtocolRuntime` — DI-фасад runtime слоя и реализация `ProtocolGateway`/`ProtocolRuntimePort`.
|
||||
`RuntimeComposition` — composition-root runtime слоя (сборка service graph + orchestration wiring) и делегирует отдельные зоны ответственности:
|
||||
- Публичные runtime API proxy-методы (connect/auth/directory/packet I/O) убраны из `RuntimeComposition`; публичный runtime surface теперь удерживается в `ProtocolRuntime` + `Runtime*Facade`.
|
||||
- `RuntimeTransportAssembly`: отдельный assembly-блок transport/network wiring (`NetworkReconnectWatcher`, `NetworkConnectivityFacade`, `ProtocolInstanceManager`, `PacketSubscriptionRegistry/Facade`).
|
||||
- `RuntimeMessagingAssembly`: отдельный assembly-блок packet/message/sync wiring (`PacketRouter`, `OutgoingMessagePipelineService`, `PresenceTypingService`, `SyncCoordinator`, `CallSignalBridge`, `InboundPacketHandlerRegistrar`).
|
||||
- `RuntimeStateAssembly`: отдельный assembly-блок connection-state wiring (`ReadyPacketGate`, `BootstrapCoordinator`, `RuntimeLifecycleStateMachine`, `OwnProfileFallbackTimerService`, `ProtocolLifecycleStateStoreImpl`).
|
||||
- `RuntimeRoutingAssembly`: отдельный assembly-блок event-routing wiring (`ConnectionEventRouter` + `ProtocolConnectionSupervisor` как единый orchestration-шаг).
|
||||
- `RuntimeConnectionControlFacade`: high-level connection/session control API (`initialize*`, `connect/reconnect/sync/auth`, `disconnect/destroy`, auth/connect checks).
|
||||
- `RuntimeDirectoryFacade`: directory/device/typing API (`resolve/search user`, cached user lookup, own-profile signal, device accept/decline, typing snapshot by dialog).
|
||||
- `RuntimePacketIoFacade`: packet I/O API (`send/sendWithRetry/resolveRetry`, call/webrtc/ice bridge, `wait/unwait/packetFlow`).
|
||||
- `ProtocolInstanceManager`: singleton lifecycle `Protocol` (create/state/lastError/disconnect/destroy/isAuthenticated/isConnected).
|
||||
- `RuntimeLifecycleStateMachine`: runtime lifecycle state (`ConnectionLifecycleState` + `ConnectionBootstrapContext`) и пересчет transition-логики через `BootstrapCoordinator`.
|
||||
- `RuntimeInitializationCoordinator`: one-time bootstrap runtime (`initialize`, регистрация packet handlers, старт state monitoring, проверка bound DI dependencies).
|
||||
- `ProtocolLifecycleStateStoreImpl`: отдельное lifecycle-state хранилище (`bootstrapContext`, `sessionGeneration`, last-subscribed-token clear hooks, own-profile fallback timer hooks).
|
||||
- `OwnProfileFallbackTimerService`: управление таймером own-profile fallback (`schedule/cancel`) с генерацией timeout-события.
|
||||
- `AuthRestoreService`: восстановление auth-handshake credentials из локального кеша аккаунта (`preferredPublicKey`/fallback + validation + authenticate trigger).
|
||||
- `RuntimeShutdownCoordinator`: централизованный graceful runtime shutdown (`stop watcher`, `destroy subscriptions/protocol`, `clear runtime state/services`, `cancel scope`).
|
||||
- `ConnectionEventRouter`: маршрутизация `ConnectionEvent` к соответствующим coordinator/service handlers без `when(event)` внутри core.
|
||||
- `NetworkConnectivityFacade`: единая обертка network-availability/wait/stop policy поверх `NetworkReconnectWatcher`.
|
||||
- `ConnectionOrchestrator`: connect/reconnect/authenticate + network-aware поведение.
|
||||
- `ProtocolLifecycleCoordinator`: lifecycle/auth/bootstrap transitions (`ProtocolStateChanged`, `SyncCompleted`, own-profile resolved/fallback).
|
||||
- `ProtocolAccountSessionCoordinator`: account-bound transitions (`InitializeAccount`, `Disconnect`) и reset account/session state.
|
||||
- `ReadyPacketDispatchCoordinator`: обработка `SendPacket` через ready-gate (`bypass/enqueue/flush trigger + reconnect policy`).
|
||||
- `ProtocolPostAuthBootstrapCoordinator`: post-auth orchestration (`canRun/tryRun bootstrap`, own profile fetch, push subscribe, post-sync retry/missing-user-info).
|
||||
- `BootstrapCoordinator`: пересчет lifecycle (`AUTHENTICATED`/`BOOTSTRAPPING`/`READY`) и работа с `ReadyPacketGate`.
|
||||
- `SyncCoordinator`: sync state machine (request/timeout, BATCH_START/BATCH_END/NOT_NEEDED, foreground/manual sync).
|
||||
- `PresenceTypingService`: in-memory typing presence с TTL и snapshot `StateFlow`.
|
||||
@@ -166,26 +206,52 @@ stateDiagram-v2
|
||||
- `DeviceVerificationService`: состояние списка устройств + pending verification + resolve packets.
|
||||
- `DeviceRuntimeService`: device-id/handshake device + device verification orchestration.
|
||||
- `CallSignalBridge`: call/webrtc/ice signal send+subscribe bridge.
|
||||
- `PacketSubscriptionFacade`: thin bridge `waitPacket/unwaitPacket/packetFlow` API поверх `PacketSubscriptionRegistry`.
|
||||
- `PacketSubscriptionRegistry`: централизованные подписки на пакеты и fan-out.
|
||||
- `InboundPacketHandlerRegistrar`: централизованная регистрация inbound packet handlers (`0x03/0x05/0x06/0x07/0x08/0x09/0x0B/0x0F/0x14/0x17/0x19`) и делегирование в sync/repository/device/typing/profile сервисы.
|
||||
- `InboundTaskQueueService`: sequential inbound task queue (`enqueue` + `whenTasksFinish`) для Desktop parity (`dialogQueue` semantics).
|
||||
- `OutgoingMessagePipelineService`: отправка `PacketMessage` с retry/error policy.
|
||||
- `ProtocolDebugLogService`: буферизация UI-логов, throttle flush и персистентный protocol trace.
|
||||
|
||||
На hot-path `ProtocolRuntime` берет runtime API (`RuntimeConnectionControlFacade`/`RuntimeDirectoryFacade`/`RuntimePacketIoFacade`) напрямую из `RuntimeComposition`, поэтому лишний proxy-hop через публичные методы composition не используется.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PM["ProtocolRuntimeCore"] --> CO["ConnectionOrchestrator"]
|
||||
PM --> BC["BootstrapCoordinator"]
|
||||
PM --> SC["SyncCoordinator"]
|
||||
PM --> PT["PresenceTypingService"]
|
||||
PM --> PR["PacketRouter"]
|
||||
PM --> OPS["OwnProfileSyncService"]
|
||||
PM --> RQ["RetryQueueService"]
|
||||
PM --> ABC["AuthBootstrapCoordinator"]
|
||||
PM --> NRW["NetworkReconnectWatcher"]
|
||||
PM --> DVS["DeviceVerificationService"]
|
||||
PM --> CSB["CallSignalBridge"]
|
||||
PM --> PSR["PacketSubscriptionRegistry"]
|
||||
PM --> SUP["ProtocolConnectionSupervisor"]
|
||||
PM --> RPG["ReadyPacketGate"]
|
||||
PM --> P["Protocol (WebSocket + packet codec)"]
|
||||
flowchart TB
|
||||
PR["ProtocolRuntime (ProtocolGateway impl)"] --> RC["RuntimeComposition"]
|
||||
RC --> RCC["RuntimeConnectionControlFacade"]
|
||||
RC --> RDF["RuntimeDirectoryFacade"]
|
||||
RC --> RPF["RuntimePacketIoFacade"]
|
||||
RC --> CO["ConnectionOrchestrator"]
|
||||
RC --> PIM["ProtocolInstanceManager"]
|
||||
RC --> RLSM["RuntimeLifecycleStateMachine"]
|
||||
RC --> RIC["RuntimeInitializationCoordinator"]
|
||||
RC --> PLSS["ProtocolLifecycleStateStoreImpl"]
|
||||
RC --> OPFT["OwnProfileFallbackTimerService"]
|
||||
RC --> ARS["AuthRestoreService"]
|
||||
RC --> RSC["RuntimeShutdownCoordinator"]
|
||||
RC --> CER["ConnectionEventRouter"]
|
||||
RC --> NCF["NetworkConnectivityFacade"]
|
||||
RC --> PLC["ProtocolLifecycleCoordinator"]
|
||||
RC --> PAC["ProtocolAccountSessionCoordinator"]
|
||||
RC --> RPDC["ReadyPacketDispatchCoordinator"]
|
||||
RC --> PABC["ProtocolPostAuthBootstrapCoordinator"]
|
||||
RC --> BC["BootstrapCoordinator"]
|
||||
RC --> SC["SyncCoordinator"]
|
||||
RC --> PT["PresenceTypingService"]
|
||||
RC --> PR["PacketRouter"]
|
||||
RC --> OPS["OwnProfileSyncService"]
|
||||
RC --> RQ["RetryQueueService"]
|
||||
RC --> ABC["AuthBootstrapCoordinator"]
|
||||
RC --> NRW["NetworkReconnectWatcher"]
|
||||
RC --> DVS["DeviceVerificationService"]
|
||||
RC --> CSB["CallSignalBridge"]
|
||||
RC --> PSF["PacketSubscriptionFacade"]
|
||||
RC --> PSR["PacketSubscriptionRegistry"]
|
||||
RC --> IPR["InboundPacketHandlerRegistrar"]
|
||||
RC --> IQ["InboundTaskQueueService"]
|
||||
RC --> SUP["ProtocolConnectionSupervisor"]
|
||||
RC --> RPG["ReadyPacketGate"]
|
||||
PIM --> P["Protocol (WebSocket + packet codec)"]
|
||||
```
|
||||
|
||||
---
|
||||
@@ -201,7 +267,7 @@ flowchart TB
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Feature as Feature/Service
|
||||
participant PM as ProtocolRuntimeCore
|
||||
participant PM as Runtime API (Core/Facade)
|
||||
participant REG as PacketSubscriptionRegistry
|
||||
participant P as Protocol
|
||||
|
||||
@@ -216,30 +282,102 @@ sequenceDiagram
|
||||
|
||||
---
|
||||
|
||||
## 7. Отправка сообщений: use-cases + retry
|
||||
## 7. Чат-модуль: декомпозиция и message pipeline
|
||||
|
||||
Из `ChatViewModel` выделены use-cases:
|
||||
### 7.1 Domain слой для сценариев отправки
|
||||
|
||||
Use-case слой вынесен из UI-пакета в `domain/chats/usecase`:
|
||||
- `SendTextMessageUseCase`
|
||||
- `SendMediaMessageUseCase`
|
||||
- `SendForwardUseCase`
|
||||
- `SendVoiceMessageUseCase`
|
||||
- `SendTypingIndicatorUseCase`
|
||||
- `SendReadReceiptUseCase`
|
||||
- `CreateFileAttachmentUseCase`
|
||||
- `CreateAvatarAttachmentUseCase`
|
||||
- `CreateVideoCircleAttachmentUseCase`
|
||||
- `EncryptAndUploadAttachmentUseCase`
|
||||
|
||||
Текущий поток:
|
||||
1. ViewModel готовит command и шифрованный payload.
|
||||
2. UseCase собирает `PacketMessage`.
|
||||
3. UseCase вызывает `protocolGateway.sendMessageWithRetry(packet)`.
|
||||
4. `ProtocolRuntimeCore` регистрирует пакет в `RetryQueueService` и отправляет в сеть.
|
||||
5. Если lifecycle еще не `READY`, пакет попадает в `ReadyPacketGate` и flush после `READY`.
|
||||
Роли use-case слоя:
|
||||
- `SendTextMessageUseCase`/`SendMediaMessageUseCase`: сборка `PacketMessage` + dispatch через `ProtocolGateway` (с учетом `isSavedMessages`).
|
||||
- `SendForwardUseCase`: сборка forward-reply JSON, сборка forward attachment и dispatch.
|
||||
- `SendVoiceMessageUseCase`/`SendTypingIndicatorUseCase`: normalization/decision логика (preview waveform, throttle/guard).
|
||||
- `SendReadReceiptUseCase`: отдельный сценарий отправки `PacketRead`.
|
||||
- `Create*AttachmentUseCase`: типобезопасная сборка attachment-моделей.
|
||||
- `EncryptAndUploadAttachmentUseCase`: общий шаг `encrypt + upload` с возвратом `transportTag/transportServer`.
|
||||
|
||||
Текущий поток отправки:
|
||||
1. Feature VM/Coordinator через `ChatViewModel`-host формирует command + encryption context.
|
||||
2. UseCase строит payload/decision (`PacketMessage` или typed decision model).
|
||||
3. `ProtocolGateway.sendMessageWithRetry(...)` уводит пакет в network runtime.
|
||||
4. `RuntimeComposition` (через `ProtocolRuntime`) регистрирует пакет в `RetryQueueService` и отправляет в сеть.
|
||||
5. До `READY` пакет буферизуется через `ReadyPacketGate`, затем flush.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
VM["ChatViewModel"] --> UC["Send*UseCase"]
|
||||
FVM["Feature ViewModel"] --> CVM["ChatViewModel (host)"]
|
||||
CVM --> COORD["Messages/Forward/Attachments Coordinator"]
|
||||
CVM --> UC["domain/chats/usecase/*"]
|
||||
COORD --> UC
|
||||
UC --> GW["ProtocolGateway.sendMessageWithRetry"]
|
||||
GW --> PM["ProtocolRuntimeCore"]
|
||||
PM --> RQ["RetryQueueService"]
|
||||
PM --> RG["ReadyPacketGate"]
|
||||
PM --> P["Protocol.sendPacket"]
|
||||
GW --> PR["ProtocolRuntime"]
|
||||
PR --> RC["RuntimeComposition"]
|
||||
RC --> RQ["RetryQueueService"]
|
||||
RC --> RG["ReadyPacketGate"]
|
||||
RC --> P["Protocol.sendPacket"]
|
||||
```
|
||||
|
||||
### 7.2 Декомпозиция ChatViewModel (host + feature/coordinator слой)
|
||||
|
||||
Для UI-слоя введены feature-facade viewmodel-классы:
|
||||
- `MessagesViewModel`
|
||||
- `VoiceRecordingViewModel`
|
||||
- `AttachmentsViewModel`
|
||||
- `TypingViewModel`
|
||||
|
||||
Они живут в `ui/chats/ChatFeatureViewModels.kt` и компонуются внутри `ChatViewModel`.
|
||||
Текущий статус:
|
||||
- `VoiceRecordingViewModel` содержит реальный send-pipeline голосовых сообщений.
|
||||
- `TypingViewModel` содержит реальную отправку typing indicator (throttle + packet send).
|
||||
- `MessagesViewModel` содержит orchestration-level entrypoint (`sendMessage`, `retryMessage`), а core text send pipeline вынесен в `MessagesCoordinator` (pending recovery/throttle + reply/forward packet assembly).
|
||||
- `ForwardCoordinator` вынесен из `ChatViewModel`: `sendForwardDirectly` + forward rewrite/re-upload helper-ветка (включая payload resolve из cache/download).
|
||||
- `AttachmentsCoordinator` вынесен из `ChatViewModel`: `updateOptimisticImageMessage`, `sendImageMessageInternal`, `sendVideoCircleMessageInternal` + local cache/update (`localUri` cleanup после отправки).
|
||||
- `AttachmentsFeatureCoordinator` вынесен из `AttachmentsViewModel`: high-level media orchestration для `sendImageGroup*`, `sendFileMessage`, `sendVideoCircleFromUri`, `sendAvatarMessage`.
|
||||
- `AttachmentsViewModel` теперь концентрируется на facade-методах и `sendImageFromUri`/`sendImageMessage`, делегируя крупные media-ветки в coordinator-слой.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
CD["ChatDetailScreen"] --> MVM["MessagesViewModel"]
|
||||
CD --> TVM["TypingViewModel"]
|
||||
CD --> VVM["VoiceRecordingViewModel"]
|
||||
CD --> AVM["AttachmentsViewModel"]
|
||||
MVM --> CVM["ChatViewModel"]
|
||||
TVM --> CVM
|
||||
VVM --> CVM
|
||||
AVM --> CVM
|
||||
CVM --> MCO["MessagesCoordinator"]
|
||||
CVM --> FCO["ForwardCoordinator"]
|
||||
CVM --> ACO["AttachmentsCoordinator"]
|
||||
CVM --> U["domain/chats/usecase/*"]
|
||||
MCO --> U
|
||||
FCO --> U
|
||||
ACO --> U
|
||||
```
|
||||
|
||||
Важно: после вынесения `MessagesCoordinator`, `ForwardCoordinator` и `AttachmentsCoordinator` `ChatViewModel` выступает как host-state и bridge для feature/coordinator подсистем.
|
||||
|
||||
### 7.3 Декомпозиция ChatsListScreen
|
||||
|
||||
Из `ChatsListScreen.kt` вынесены отдельные composable-секции:
|
||||
- `ChatItem` -> `ChatsListChatItem.kt`
|
||||
- `RequestsSection` -> `ChatsListRequestsSection.kt`
|
||||
- `DrawerContent` -> `ChatsListDrawerContent.kt`
|
||||
|
||||
Результат:
|
||||
- основной файл экрана меньше и проще для навигации;
|
||||
- повторно используемые куски UI имеют явные file boundaries;
|
||||
- дальнейший рефакторинг drawer/request/chat list можно делать независимо.
|
||||
|
||||
---
|
||||
|
||||
## 8. Auth/bootstrap: фактический runtime flow
|
||||
@@ -250,7 +388,7 @@ sequenceDiagram
|
||||
participant SC as SessionCoordinatorImpl
|
||||
participant SS as SessionStore
|
||||
participant PG as ProtocolGateway
|
||||
participant PM as ProtocolRuntimeCore
|
||||
participant RC as RuntimeComposition
|
||||
participant AM as AccountManager
|
||||
|
||||
UI->>SC: bootstrapAuthenticatedSession(account, reason)
|
||||
@@ -262,9 +400,9 @@ sequenceDiagram
|
||||
SC->>AM: setCurrentAccount(public)
|
||||
SC->>SS: dispatch(Ready)
|
||||
|
||||
PM-->>PM: HANDSHAKE -> AUTHENTICATED -> BOOTSTRAPPING
|
||||
PM-->>PM: SyncCompleted + OwnProfileResolved
|
||||
PM-->>PM: connectionLifecycleState = READY
|
||||
RC-->>RC: HANDSHAKE -> AUTHENTICATED -> BOOTSTRAPPING
|
||||
RC-->>RC: SyncCompleted + OwnProfileResolved
|
||||
RC-->>RC: connectionLifecycleState = READY
|
||||
```
|
||||
|
||||
Важно: `SessionState.Ready` (app-session готова) и `connectionLifecycleState = READY` (сеть готова) — это разные state-модели.
|
||||
@@ -273,7 +411,7 @@ sequenceDiagram
|
||||
|
||||
## 9. Состояния соединения (network lifecycle)
|
||||
|
||||
`ProtocolRuntimeCore.connectionLifecycleState`:
|
||||
`RuntimeComposition.connectionLifecycleState`:
|
||||
- `DISCONNECTED`
|
||||
- `CONNECTING`
|
||||
- `HANDSHAKING`
|
||||
@@ -300,9 +438,15 @@ stateDiagram-v2
|
||||
## 10. Ключевые файлы новой архитектуры
|
||||
|
||||
- `app/src/main/java/com/rosetta/messenger/di/AppContainer.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeComposition.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeTransportAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeMessagingAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeStateAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeRoutingAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeDirectoryFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimePacketIoFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeAccess.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/AppSessionCoordinator.kt`
|
||||
@@ -316,6 +460,20 @@ stateDiagram-v2
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolConnectionSupervisor.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ReadyPacketGate.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ConnectionOrchestrator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolInstanceManager.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeLifecycleStateMachine.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeInitializationCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleStateStoreImpl.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileFallbackTimerService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/AuthRestoreService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeShutdownCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ConnectionEventRouter.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/NetworkConnectivityFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PacketSubscriptionFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolAccountSessionCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ReadyPacketDispatchCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolPostAuthBootstrapCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/BootstrapCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/SyncCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PresenceTypingService.kt`
|
||||
@@ -324,19 +482,95 @@ stateDiagram-v2
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/DeviceVerificationService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/DeviceRuntimeService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/CallSignalBridge.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/InboundPacketHandlerRegistrar.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/InboundTaskQueueService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PacketRouter.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileSyncService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RetryQueueService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendTextMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendMediaMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/usecase/SendForwardUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ForwardCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsFeatureCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingEncryptionContext.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingSendContext.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListChatItem.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsSection.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerContent.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerSections.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTextMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendMediaMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendForwardUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendVoiceMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTypingIndicatorUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendReadReceiptUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/CreateAttachmentUseCases.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/EncryptAndUploadAttachmentUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/VideoCircleMediaUseCases.kt`
|
||||
|
||||
---
|
||||
|
||||
## 11. Что осталось как технический долг
|
||||
|
||||
- `ProtocolRuntimeCore` все еще содержит много cross-cutting логики и требует дальнейшей декомпозиции.
|
||||
- UI больше не использует `UiDependencyAccess.get(...)`, но часть экранов все еще берет зависимости через `UiEntryPoint` (следующий шаг: передача зависимостей параметрами/через VM).
|
||||
- DI-адаптеры (`ProtocolGatewayImpl`, `ProtocolClientImpl`) переведены на `ProtocolRuntime`, dependency-cycle закрыт через `Provider<ProtocolRuntime>`.
|
||||
- Следующий шаг по network core: продолжить декомпозицию `ProtocolRuntimeCore` (например: `ProtocolLifecycleLogger`, `AuthRestoreService`, `ProtocolTraceService`) и сократить фасад `ProtocolManager` до полного legacy-режима.
|
||||
Актуальные открытые хвосты:
|
||||
- `RuntimeComposition` остается composition-root, но уже существенно сжат (около 501 строки) после выноса `RuntimeTransportAssembly`, `RuntimeMessagingAssembly`, `RuntimeStateAssembly`, `RuntimeRoutingAssembly` и удаления публичных proxy-методов; следующий шаг — перенос части lifecycle/orchestration helper-кода в отдельные domain-oriented service/adapters.
|
||||
- `ProtocolRuntime` и `ProtocolRuntimePort` все еще имеют широкий proxy-surface; нужен audit методов и дальнейшее сужение публичного API по use-case группам.
|
||||
- `ChatViewModel` остается крупным host-классом (state + bridge/proxy API к feature/coordinator/use-case слоям).
|
||||
- High-level media сценарии теперь в `AttachmentsFeatureCoordinator`, но остаются крупными и требуют дальнейшей декомпозиции на более узкие coordinator/service/use-case блоки.
|
||||
- Тестовое покрытие архитектурных слоев все еще недостаточно:
|
||||
- сейчас в `app/src/test` всего 7 unit-тестов (в основном crypto/data/helpers), в `app/src/androidTest` — 1 тест;
|
||||
- не покрыты network runtime/coordinator слои (`RuntimeComposition`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat orchestration (`Messages/Forward/Attachments*`).
|
||||
|
||||
Уже закрыто и больше не считается техдолгом:
|
||||
- `UiDependencyAccess.get(...)` удален из `ui/*`.
|
||||
- `UiEntryPoint`/`EntryPointAccessors` убраны из UI-экранов (явная передача зависимостей через `MainActivity`/`ViewModel`).
|
||||
- DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` закрыт через `Provider<ProtocolRuntime>`.
|
||||
- `ProtocolManager` переведен в минимальный legacy compatibility API (тонкие прокси к `ProtocolRuntimeAccess`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Guardrails против переусложнения
|
||||
|
||||
Чтобы декомпозиция не превращалась в «архитектуру ради архитектуры», применяются следующие правила:
|
||||
|
||||
1. Лимит глубины runtime-цепочки вызова: не более 3 логических слоев после DI-entry (`ProtocolRuntime -> Runtime*Facade -> service`; `RuntimeComposition` остается composition-root/wiring-слоем, а не обязательным proxy-hop).
|
||||
2. Новый слой/класс допускается только если он дает измеримый выигрыш:
|
||||
- убирает минимум 80-120 строк связанной orchestration-логики из текущего класса, или
|
||||
- убирает минимум 2 внешние зависимости из текущего класса.
|
||||
3. Каждый шаг рефакторинга считается завершенным только после: `compileDebugKotlin` + минимум одного smoke-сценария по затронутому флоу + обновления `Architecture.md`.
|
||||
4. Если после выноса сложность чтения/изменения не снизилась (по факту код не стал проще), такой вынос считается кандидатом на откат/консолидацию.
|
||||
5. Для event-driven runtime-chain (`ProtocolConnectionSupervisor` + `ConnectionEventRouter`) эти два элемента считаются одним orchestration-этапом при анализе hop-depth.
|
||||
6. `ProtocolClientImpl` трактуется как инфраструктурный DI-adapter и учитывается отдельно от business-flow hop budget.
|
||||
|
||||
---
|
||||
|
||||
## 13. Плюсы и минусы текущей архитектуры
|
||||
|
||||
### 13.1 Плюсы
|
||||
- Четко выделены слои: UI, domain use-cases, network runtime, session/identity, data/persistence.
|
||||
- DI через Hilt и `ProtocolGateway`/`SessionCoordinator` снижает прямую связанность между UI и transport/runtime.
|
||||
- Убраны `UiEntryPoint`/`EntryPointAccessors` из UI-экранов, что улучшило явность зависимостей.
|
||||
- Закрыт критичный DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` через `Provider<ProtocolRuntime>`.
|
||||
- Network runtime декомпозирован на отдельные сервисы/coordinator-ы с более узкими зонами ответственности.
|
||||
- Сокращен DI runtime path: `ProtocolGateway` биндится напрямую на `ProtocolRuntime`, runtime работает напрямую с `RuntimeComposition`.
|
||||
- Централизован packet subscription fan-out (`PacketSubscriptionRegistry` + `PacketSubscriptionFacade`), что снижает риск дублирующих low-level подписок.
|
||||
- В chat-модуле выделен domain use-case слой и вынесены крупные сценарии в coordinators.
|
||||
|
||||
### 13.2 Минусы
|
||||
- `RuntimeComposition` и `ChatViewModel` остаются очень крупными hotspot-классами и концентрируют много связей.
|
||||
- Runtime API-слой пока широкий: много proxy-методов усложняют контроль границ и эволюцию surface API.
|
||||
- В части chat/media orchestration (`AttachmentsFeatureCoordinator`, `MessagesCoordinator`, `ForwardCoordinator`) сохраняются большие high-level сценарии.
|
||||
- Мало unit/integration тестов на архитектурно-критичные runtime/chat orchestration компоненты.
|
||||
- В проекте остаются несколько точек доступа к runtime (`ProtocolRuntime`, `ProtocolRuntimePort`, `ProtocolManager` legacy), что повышает cognitive load для новых разработчиков.
|
||||
- Стоимость входа в кодовую базу выросла: для трассировки одного бизнес-флоу нужно проходить больше слоев, чем раньше.
|
||||
|
||||
### 13.3 Итог оценки
|
||||
- Текущая архитектура стала заметно лучше по управляемости зависимостей и изоляции ответственности.
|
||||
- Главные риски сместились из “монолитного класса” в “размер composition/API surface и недотестированность orchestration”.
|
||||
- При соблюдении guardrails (секция 12) и фокусе на тестах/дальнейшей локальной декомпозиции архитектура остается управляемой и не уходит в избыточную сложность.
|
||||
|
||||
@@ -177,7 +177,7 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
RecentSearchesManager.init(this)
|
||||
|
||||
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
|
||||
// 🔥 Инициализируем Protocol runtime для обработки онлайн статусов
|
||||
protocolGateway.initialize(this)
|
||||
CallManager.initialize(this)
|
||||
com.rosetta.messenger.ui.chats.components.AttachmentDownloadDebugLogger.init(this)
|
||||
@@ -511,6 +511,7 @@ class MainActivity : FragmentActivity() {
|
||||
hasExistingAccount = screen == "auth_unlock",
|
||||
accounts = accountInfoList,
|
||||
accountManager = accountManager,
|
||||
protocolGateway = protocolGateway,
|
||||
sessionCoordinator = sessionCoordinator,
|
||||
startInCreateMode = startCreateAccountFlow,
|
||||
onAuthComplete = { account ->
|
||||
@@ -619,6 +620,7 @@ class MainActivity : FragmentActivity() {
|
||||
},
|
||||
accountManager = accountManager,
|
||||
preferencesManager = preferencesManager,
|
||||
messageRepository = messageRepository,
|
||||
groupRepository = groupRepository,
|
||||
protocolGateway = protocolGateway,
|
||||
identityGateway = identityGateway,
|
||||
@@ -775,6 +777,7 @@ class MainActivity : FragmentActivity() {
|
||||
"device_confirm" -> {
|
||||
DeviceConfirmScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
protocolGateway = protocolGateway,
|
||||
onExit = {
|
||||
preservedMainNavStack = emptyList()
|
||||
preservedMainNavAccountKey = ""
|
||||
@@ -1120,6 +1123,7 @@ fun MainScreen(
|
||||
onNavStackChanged: (List<Screen>) -> Unit = {},
|
||||
accountManager: AccountManager,
|
||||
preferencesManager: PreferencesManager,
|
||||
messageRepository: MessageRepository,
|
||||
groupRepository: GroupRepository,
|
||||
protocolGateway: ProtocolGateway,
|
||||
identityGateway: IdentityGateway,
|
||||
@@ -1736,6 +1740,9 @@ fun MainScreen(
|
||||
onTogglePin = { opponentKey ->
|
||||
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
|
||||
},
|
||||
protocolGateway = protocolGateway,
|
||||
accountManager = accountManager,
|
||||
preferencesManager = prefsManager,
|
||||
chatsViewModel = chatsListViewModel,
|
||||
avatarRepository = avatarRepository,
|
||||
callUiState = callUiState,
|
||||
@@ -1828,6 +1835,7 @@ fun MainScreen(
|
||||
) {
|
||||
NotificationsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
preferencesManager = prefsManager,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Notifications } }
|
||||
)
|
||||
}
|
||||
@@ -1951,6 +1959,7 @@ fun MainScreen(
|
||||
) {
|
||||
com.rosetta.messenger.ui.settings.AppIconScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
preferencesManager = prefsManager,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.AppIcon } }
|
||||
)
|
||||
}
|
||||
@@ -2007,6 +2016,10 @@ fun MainScreen(
|
||||
// Экран чата
|
||||
ChatDetailScreen(
|
||||
user = currentChatUser,
|
||||
protocolGateway = protocolGateway,
|
||||
preferencesManager = prefsManager,
|
||||
messageRepository = messageRepository,
|
||||
groupRepository = groupRepository,
|
||||
currentUserPublicKey = accountPublicKey,
|
||||
currentUserPrivateKey = accountPrivateKey,
|
||||
currentUserName = accountName,
|
||||
@@ -2107,6 +2120,10 @@ fun MainScreen(
|
||||
currentUserPublicKey = accountPublicKey,
|
||||
currentUserPrivateKey = accountPrivateKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
protocolGateway = protocolGateway,
|
||||
messageRepository = messageRepository,
|
||||
preferencesManager = prefsManager,
|
||||
groupRepository = groupRepository,
|
||||
avatarRepository = avatarRepository,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } },
|
||||
onMemberClick = { member ->
|
||||
@@ -2141,6 +2158,8 @@ fun MainScreen(
|
||||
SearchScreen(
|
||||
privateKeyHash = privateKeyHash,
|
||||
currentUserPublicKey = accountPublicKey,
|
||||
accountManager = accountManager,
|
||||
messageRepository = messageRepository,
|
||||
isDarkTheme = isDarkTheme,
|
||||
protocolState = protocolState,
|
||||
onBackClick = { navStack = navStack.filterNot { it is Screen.Search } },
|
||||
@@ -2172,6 +2191,8 @@ fun MainScreen(
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
accountName = accountName,
|
||||
accountUsername = accountUsername,
|
||||
messageRepository = messageRepository,
|
||||
groupRepository = groupRepository,
|
||||
avatarRepository = avatarRepository,
|
||||
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
|
||||
@@ -2216,6 +2237,7 @@ fun MainScreen(
|
||||
) {
|
||||
ConnectionLogsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
protocolGateway = protocolGateway,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.ConnectionLogs } }
|
||||
)
|
||||
}
|
||||
@@ -2237,6 +2259,8 @@ fun MainScreen(
|
||||
OtherProfileScreen(
|
||||
user = currentOtherUser,
|
||||
isDarkTheme = isDarkTheme,
|
||||
preferencesManager = prefsManager,
|
||||
messageRepository = messageRepository,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
|
||||
onSwipeBackEnabledChanged = { enabled ->
|
||||
isOtherProfileSwipeEnabled = enabled
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,10 +8,9 @@ import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import com.rosetta.messenger.network.ProtocolClient
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolRuntime
|
||||
import com.rosetta.messenger.network.ProtocolRuntimeCore
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.network.ProtocolRuntimePort
|
||||
import com.rosetta.messenger.network.RuntimeComposition
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.session.SessionAction
|
||||
import com.rosetta.messenger.session.IdentityStateSnapshot
|
||||
@@ -30,46 +29,31 @@ import javax.inject.Provider
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ProtocolGateway {
|
||||
val state: StateFlow<ProtocolState>
|
||||
interface ProtocolGateway : ProtocolRuntimePort {
|
||||
val syncInProgress: StateFlow<Boolean>
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?>
|
||||
val typingUsers: StateFlow<Set<String>>
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>>
|
||||
val debugLogs: StateFlow<List<String>>
|
||||
val ownProfileUpdated: StateFlow<Long>
|
||||
|
||||
fun initialize(context: Context)
|
||||
fun initializeAccount(publicKey: String, privateKey: String)
|
||||
fun connect()
|
||||
fun authenticate(publicKey: String, privateHash: String)
|
||||
fun reconnectNowIfNeeded(reason: String)
|
||||
fun disconnect()
|
||||
fun isAuthenticated(): Boolean
|
||||
fun getPrivateHash(): String?
|
||||
fun subscribePushTokenIfAvailable(forceToken: String? = null)
|
||||
fun addLog(message: String)
|
||||
fun enableUILogs(enabled: Boolean)
|
||||
fun clearLogs()
|
||||
fun resolveOutgoingRetry(messageId: String)
|
||||
fun getCachedUserByUsername(username: String): SearchUser?
|
||||
fun getCachedUserName(publicKey: String): String?
|
||||
fun getCachedUserInfo(publicKey: String): SearchUser?
|
||||
fun acceptDevice(deviceId: String)
|
||||
fun declineDevice(deviceId: String)
|
||||
fun send(packet: Packet)
|
||||
fun sendPacket(packet: Packet)
|
||||
fun sendMessageWithRetry(packet: PacketMessage)
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
fun packetFlow(packetId: Int): SharedFlow<Packet>
|
||||
fun notifyOwnProfileUpdated()
|
||||
fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String? = null,
|
||||
reason: String = "background_restore"
|
||||
): Boolean
|
||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
|
||||
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser?
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
|
||||
}
|
||||
|
||||
@@ -107,92 +91,12 @@ interface IdentityGateway {
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class ProtocolGatewayImpl @Inject constructor(
|
||||
private val runtime: ProtocolRuntime
|
||||
) : ProtocolGateway {
|
||||
override val state: StateFlow<ProtocolState> = runtime.state
|
||||
override val syncInProgress: StateFlow<Boolean> = runtime.syncInProgress
|
||||
override val pendingDeviceVerification: StateFlow<DeviceEntry?> = runtime.pendingDeviceVerification
|
||||
override val typingUsers: StateFlow<Set<String>> = runtime.typingUsers
|
||||
override val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
|
||||
runtime.typingUsersByDialogSnapshot
|
||||
override val debugLogs: StateFlow<List<String>> = runtime.debugLogs
|
||||
override val ownProfileUpdated: StateFlow<Long> = runtime.ownProfileUpdated
|
||||
|
||||
override fun initialize(context: Context) = runtime.initialize(context)
|
||||
|
||||
override fun initializeAccount(publicKey: String, privateKey: String) =
|
||||
runtime.initializeAccount(publicKey, privateKey)
|
||||
|
||||
override fun connect() = runtime.connect()
|
||||
|
||||
override fun authenticate(publicKey: String, privateHash: String) =
|
||||
runtime.authenticate(publicKey, privateHash)
|
||||
|
||||
override fun reconnectNowIfNeeded(reason: String) = runtime.reconnectNowIfNeeded(reason)
|
||||
|
||||
override fun disconnect() = runtime.disconnect()
|
||||
|
||||
override fun isAuthenticated(): Boolean = runtime.isAuthenticated()
|
||||
|
||||
override fun getPrivateHash(): String? = runtime.getPrivateHash()
|
||||
|
||||
override fun subscribePushTokenIfAvailable(forceToken: String?) =
|
||||
runtime.subscribePushTokenIfAvailable(forceToken)
|
||||
|
||||
override fun addLog(message: String) = runtime.addLog(message)
|
||||
|
||||
override fun enableUILogs(enabled: Boolean) = runtime.enableUILogs(enabled)
|
||||
|
||||
override fun clearLogs() = runtime.clearLogs()
|
||||
|
||||
override fun resolveOutgoingRetry(messageId: String) = runtime.resolveOutgoingRetry(messageId)
|
||||
|
||||
override fun getCachedUserByUsername(username: String): SearchUser? =
|
||||
runtime.getCachedUserByUsername(username)
|
||||
|
||||
override fun getCachedUserName(publicKey: String): String? =
|
||||
runtime.getCachedUserName(publicKey)
|
||||
|
||||
override fun getCachedUserInfo(publicKey: String): SearchUser? =
|
||||
runtime.getCachedUserInfo(publicKey)
|
||||
|
||||
override fun acceptDevice(deviceId: String) = runtime.acceptDevice(deviceId)
|
||||
|
||||
override fun declineDevice(deviceId: String) = runtime.declineDevice(deviceId)
|
||||
|
||||
override fun send(packet: Packet) = runtime.send(packet)
|
||||
|
||||
override fun sendPacket(packet: Packet) = runtime.sendPacket(packet)
|
||||
|
||||
override fun sendMessageWithRetry(packet: PacketMessage) = runtime.sendMessageWithRetry(packet)
|
||||
|
||||
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
runtime.waitPacket(packetId, callback)
|
||||
|
||||
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
runtime.unwaitPacket(packetId, callback)
|
||||
|
||||
override fun packetFlow(packetId: Int): SharedFlow<Packet> = runtime.packetFlow(packetId)
|
||||
|
||||
override fun notifyOwnProfileUpdated() = runtime.notifyOwnProfileUpdated()
|
||||
|
||||
override fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String?,
|
||||
reason: String
|
||||
): Boolean = runtime.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||
|
||||
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
|
||||
runtime.resolveUserName(publicKey, timeoutMs)
|
||||
|
||||
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
|
||||
runtime.resolveUserInfo(publicKey, timeoutMs)
|
||||
|
||||
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
|
||||
runtime.searchUsers(query, timeoutMs)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
/**
|
||||
* Thin infrastructure adapter for repositories.
|
||||
*
|
||||
* This bridge is intentionally excluded from business-flow hop-depth accounting and exists
|
||||
* to keep lazy runtime access (`Provider<ProtocolRuntime>`) and avoid DI cycles.
|
||||
*/
|
||||
class ProtocolClientImpl @Inject constructor(
|
||||
private val runtimeProvider: Provider<ProtocolRuntime>
|
||||
) : ProtocolClient {
|
||||
@@ -270,7 +174,7 @@ object AppDataModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideProtocolRuntimeCore(): ProtocolRuntimeCore = ProtocolManager
|
||||
fun provideRuntimeComposition(): RuntimeComposition = RuntimeComposition()
|
||||
|
||||
}
|
||||
|
||||
@@ -279,7 +183,7 @@ object AppDataModule {
|
||||
abstract class AppGatewayModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProtocolGateway(impl: ProtocolGatewayImpl): ProtocolGateway
|
||||
abstract fun bindProtocolGateway(runtime: ProtocolRuntime): ProtocolGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package com.rosetta.messenger.ui.chats.usecase
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ForwardPayloadMessage(
|
||||
val messageId: String,
|
||||
@@ -16,8 +18,8 @@ data class ForwardPayloadMessage(
|
||||
val attachments: List<MessageAttachment>
|
||||
)
|
||||
|
||||
class SendForwardUseCase(
|
||||
private val sendWithRetry: (PacketMessage) -> Unit
|
||||
class SendForwardUseCase @Inject constructor(
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) {
|
||||
fun buildForwardReplyJson(
|
||||
messages: List<ForwardPayloadMessage>,
|
||||
@@ -96,7 +98,7 @@ class SendForwardUseCase(
|
||||
|
||||
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
|
||||
if (!isSavedMessages) {
|
||||
sendWithRetry(packet)
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SendVoiceMessageCommand(
|
||||
val voiceHex: String,
|
||||
val durationSec: Int,
|
||||
val waves: List<Float>,
|
||||
val maxWaveCount: Int = 120
|
||||
)
|
||||
|
||||
data class VoiceMessagePayload(
|
||||
val normalizedVoiceHex: String,
|
||||
val durationSec: Int,
|
||||
val normalizedWaves: List<Float>,
|
||||
val preview: String
|
||||
)
|
||||
|
||||
class SendVoiceMessageUseCase @Inject constructor() {
|
||||
operator fun invoke(command: SendVoiceMessageCommand): VoiceMessagePayload? {
|
||||
val normalizedVoiceHex = command.voiceHex.trim()
|
||||
if (normalizedVoiceHex.isEmpty()) return null
|
||||
|
||||
val normalizedDuration = command.durationSec.coerceAtLeast(1)
|
||||
val normalizedWaves =
|
||||
command.waves
|
||||
.asSequence()
|
||||
.map { it.coerceIn(0f, 1f) }
|
||||
.take(command.maxWaveCount)
|
||||
.toList()
|
||||
val wavesPreview = normalizedWaves.joinToString(",") { String.format(Locale.US, "%.3f", it) }
|
||||
val preview = "$normalizedDuration::$wavesPreview"
|
||||
|
||||
return VoiceMessagePayload(
|
||||
normalizedVoiceHex = normalizedVoiceHex,
|
||||
durationSec = normalizedDuration,
|
||||
normalizedWaves = normalizedWaves,
|
||||
preview = preview
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,81 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Compatibility facade for legacy static call-sites.
|
||||
* Runtime logic lives in [ProtocolRuntimeCore].
|
||||
* Minimal compatibility facade for legacy static call-sites.
|
||||
*
|
||||
* New code should use injected [ProtocolRuntime]/[ProtocolGateway] or [ProtocolRuntimeAccess] directly.
|
||||
*/
|
||||
object ProtocolManager : ProtocolRuntimeCore()
|
||||
@Deprecated(
|
||||
message = "Use injected ProtocolRuntime/ProtocolGateway or ProtocolRuntimeAccess directly."
|
||||
)
|
||||
object ProtocolManager {
|
||||
val state: StateFlow<ProtocolState>
|
||||
get() = runtime().state
|
||||
|
||||
val debugLogs: StateFlow<List<String>>
|
||||
get() = runtime().debugLogs
|
||||
|
||||
fun addLog(message: String) = runtime().addLog(message)
|
||||
|
||||
fun send(packet: Packet) = runtime().send(packet)
|
||||
|
||||
fun sendPacket(packet: Packet) = runtime().sendPacket(packet)
|
||||
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
runtime().waitPacket(packetId, callback)
|
||||
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
runtime().unwaitPacket(packetId, callback)
|
||||
|
||||
fun requestIceServers() = runtime().requestIceServers()
|
||||
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit =
|
||||
runtime().waitCallSignal(callback)
|
||||
|
||||
fun unwaitCallSignal(callback: (Packet) -> Unit) =
|
||||
runtime().unwaitCallSignal(callback)
|
||||
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit =
|
||||
runtime().waitWebRtcSignal(callback)
|
||||
|
||||
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) =
|
||||
runtime().unwaitWebRtcSignal(callback)
|
||||
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit =
|
||||
runtime().waitIceServers(callback)
|
||||
|
||||
fun unwaitIceServers(callback: (Packet) -> Unit) =
|
||||
runtime().unwaitIceServers(callback)
|
||||
|
||||
fun getCachedUserInfo(publicKey: String): SearchUser? =
|
||||
runtime().getCachedUserInfo(publicKey)
|
||||
|
||||
fun isAuthenticated(): Boolean = runtime().isAuthenticated()
|
||||
|
||||
fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String? = null,
|
||||
reason: String = "background_restore"
|
||||
): Boolean = runtime().restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground_resume") =
|
||||
runtime().reconnectNowIfNeeded(reason)
|
||||
|
||||
fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String = "",
|
||||
dst: String = "",
|
||||
sharedPublic: String = "",
|
||||
callId: String = "",
|
||||
joinToken: String = ""
|
||||
) = runtime().sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
|
||||
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) =
|
||||
runtime().sendWebRtcSignal(signalType, sdpOrCandidate)
|
||||
|
||||
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? =
|
||||
runtime().resolveUserInfo(publicKey, timeoutMs)
|
||||
|
||||
private fun runtime(): ProtocolRuntimePort = ProtocolRuntimeAccess.get()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
@@ -11,119 +12,127 @@ import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ProtocolRuntime @Inject constructor(
|
||||
private val core: ProtocolRuntimeCore,
|
||||
private val runtimeComposition: RuntimeComposition,
|
||||
private val messageRepository: MessageRepository,
|
||||
private val groupRepository: GroupRepository,
|
||||
private val accountManager: AccountManager
|
||||
) : ProtocolRuntimePort {
|
||||
) : ProtocolRuntimePort, ProtocolGateway {
|
||||
init {
|
||||
bindDependencies()
|
||||
}
|
||||
|
||||
override val state: StateFlow<ProtocolState> get() = core.state
|
||||
val syncInProgress: StateFlow<Boolean> get() = core.syncInProgress
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?> get() = core.pendingDeviceVerification
|
||||
val typingUsers: StateFlow<Set<String>> get() = core.typingUsers
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> get() =
|
||||
core.typingUsersByDialogSnapshot
|
||||
override val debugLogs: StateFlow<List<String>> get() = core.debugLogs
|
||||
val ownProfileUpdated: StateFlow<Long> get() = core.ownProfileUpdated
|
||||
private val connectionControlApi by lazy { runtimeComposition.connectionControlApi() }
|
||||
private val directoryApi by lazy { runtimeComposition.directoryApi() }
|
||||
private val packetIoApi by lazy { runtimeComposition.packetIoApi() }
|
||||
|
||||
fun initialize(context: Context) {
|
||||
override val state: StateFlow<ProtocolState> get() = runtimeComposition.state
|
||||
override val syncInProgress: StateFlow<Boolean> get() = runtimeComposition.syncInProgress
|
||||
override val pendingDeviceVerification: StateFlow<DeviceEntry?> get() =
|
||||
runtimeComposition.pendingDeviceVerification
|
||||
override val typingUsers: StateFlow<Set<String>> get() = runtimeComposition.typingUsers
|
||||
override val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> get() =
|
||||
runtimeComposition.typingUsersByDialogSnapshot
|
||||
override val debugLogs: StateFlow<List<String>> get() = runtimeComposition.debugLogs
|
||||
override val ownProfileUpdated: StateFlow<Long> get() = runtimeComposition.ownProfileUpdated
|
||||
|
||||
override fun initialize(context: Context) {
|
||||
bindDependencies()
|
||||
core.initialize(context)
|
||||
connectionControlApi.initialize(context)
|
||||
}
|
||||
|
||||
fun initializeAccount(publicKey: String, privateKey: String) =
|
||||
core.initializeAccount(publicKey, privateKey)
|
||||
override fun initializeAccount(publicKey: String, privateKey: String) =
|
||||
connectionControlApi.initializeAccount(publicKey, privateKey)
|
||||
|
||||
fun connect() = core.connect()
|
||||
override fun connect() = connectionControlApi.connect()
|
||||
|
||||
fun authenticate(publicKey: String, privateHash: String) =
|
||||
core.authenticate(publicKey, privateHash)
|
||||
override fun authenticate(publicKey: String, privateHash: String) =
|
||||
connectionControlApi.authenticate(publicKey, privateHash)
|
||||
|
||||
override fun reconnectNowIfNeeded(reason: String) = core.reconnectNowIfNeeded(reason)
|
||||
override fun reconnectNowIfNeeded(reason: String) =
|
||||
connectionControlApi.reconnectNowIfNeeded(reason)
|
||||
|
||||
fun disconnect() = core.disconnect()
|
||||
override fun disconnect() = connectionControlApi.disconnect()
|
||||
|
||||
override fun isAuthenticated(): Boolean = core.isAuthenticated()
|
||||
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
|
||||
|
||||
fun getPrivateHash(): String? =
|
||||
runCatching { core.getProtocol().getPrivateHash() }.getOrNull()
|
||||
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()
|
||||
|
||||
fun subscribePushTokenIfAvailable(forceToken: String? = null) =
|
||||
core.subscribePushTokenIfAvailable(forceToken)
|
||||
override fun subscribePushTokenIfAvailable(forceToken: String?) =
|
||||
connectionControlApi.subscribePushToken(forceToken)
|
||||
|
||||
override fun addLog(message: String) = core.addLog(message)
|
||||
override fun addLog(message: String) = runtimeComposition.addLog(message)
|
||||
|
||||
fun enableUILogs(enabled: Boolean) = core.enableUILogs(enabled)
|
||||
override fun enableUILogs(enabled: Boolean) = runtimeComposition.enableUILogs(enabled)
|
||||
|
||||
fun clearLogs() = core.clearLogs()
|
||||
override fun clearLogs() = runtimeComposition.clearLogs()
|
||||
|
||||
fun resolveOutgoingRetry(messageId: String) = core.resolveOutgoingRetry(messageId)
|
||||
override fun resolveOutgoingRetry(messageId: String) =
|
||||
packetIoApi.resolveOutgoingRetry(messageId)
|
||||
|
||||
fun getCachedUserByUsername(username: String): SearchUser? =
|
||||
core.getCachedUserByUsername(username)
|
||||
override fun getCachedUserByUsername(username: String): SearchUser? =
|
||||
directoryApi.getCachedUserByUsername(username)
|
||||
|
||||
fun getCachedUserName(publicKey: String): String? =
|
||||
core.getCachedUserName(publicKey)
|
||||
override fun getCachedUserName(publicKey: String): String? =
|
||||
directoryApi.getCachedUserName(publicKey)
|
||||
|
||||
override fun getCachedUserInfo(publicKey: String): SearchUser? =
|
||||
core.getCachedUserInfo(publicKey)
|
||||
directoryApi.getCachedUserInfo(publicKey)
|
||||
|
||||
fun acceptDevice(deviceId: String) = core.acceptDevice(deviceId)
|
||||
override fun acceptDevice(deviceId: String) = directoryApi.acceptDevice(deviceId)
|
||||
|
||||
fun declineDevice(deviceId: String) = core.declineDevice(deviceId)
|
||||
override fun declineDevice(deviceId: String) = directoryApi.declineDevice(deviceId)
|
||||
|
||||
override fun send(packet: Packet) = core.send(packet)
|
||||
override fun send(packet: Packet) = packetIoApi.send(packet)
|
||||
|
||||
override fun sendPacket(packet: Packet) = core.sendPacket(packet)
|
||||
override fun sendPacket(packet: Packet) = packetIoApi.sendPacket(packet)
|
||||
|
||||
fun sendMessageWithRetry(packet: PacketMessage) = core.sendMessageWithRetry(packet)
|
||||
override fun sendMessageWithRetry(packet: PacketMessage) =
|
||||
packetIoApi.sendMessageWithRetry(packet)
|
||||
|
||||
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
core.waitPacket(packetId, callback)
|
||||
packetIoApi.waitPacket(packetId, callback)
|
||||
|
||||
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
core.unwaitPacket(packetId, callback)
|
||||
packetIoApi.unwaitPacket(packetId, callback)
|
||||
|
||||
fun packetFlow(packetId: Int): SharedFlow<Packet> = core.packetFlow(packetId)
|
||||
override fun packetFlow(packetId: Int): SharedFlow<Packet> =
|
||||
packetIoApi.packetFlow(packetId)
|
||||
|
||||
fun notifyOwnProfileUpdated() = core.notifyOwnProfileUpdated()
|
||||
override fun notifyOwnProfileUpdated() = directoryApi.notifyOwnProfileUpdated()
|
||||
|
||||
override fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String?,
|
||||
reason: String
|
||||
): Boolean = core.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||
): Boolean = connectionControlApi.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||
|
||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? =
|
||||
core.resolveUserName(publicKey, timeoutMs)
|
||||
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
|
||||
directoryApi.resolveUserName(publicKey, timeoutMs)
|
||||
|
||||
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
|
||||
core.resolveUserInfo(publicKey, timeoutMs)
|
||||
directoryApi.resolveUserInfo(publicKey, timeoutMs)
|
||||
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> =
|
||||
core.searchUsers(query, timeoutMs)
|
||||
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
|
||||
directoryApi.searchUsers(query, timeoutMs)
|
||||
|
||||
override fun requestIceServers() = core.requestIceServers()
|
||||
override fun requestIceServers() = packetIoApi.requestIceServers()
|
||||
|
||||
override fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit =
|
||||
core.waitCallSignal(callback)
|
||||
packetIoApi.waitCallSignal(callback)
|
||||
|
||||
override fun unwaitCallSignal(callback: (Packet) -> Unit) =
|
||||
core.unwaitCallSignal(callback)
|
||||
packetIoApi.unwaitCallSignal(callback)
|
||||
|
||||
override fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit =
|
||||
core.waitWebRtcSignal(callback)
|
||||
packetIoApi.waitWebRtcSignal(callback)
|
||||
|
||||
override fun unwaitWebRtcSignal(callback: (Packet) -> Unit) =
|
||||
core.unwaitWebRtcSignal(callback)
|
||||
packetIoApi.unwaitWebRtcSignal(callback)
|
||||
|
||||
override fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit =
|
||||
core.waitIceServers(callback)
|
||||
packetIoApi.waitIceServers(callback)
|
||||
|
||||
override fun unwaitIceServers(callback: (Packet) -> Unit) =
|
||||
core.unwaitIceServers(callback)
|
||||
packetIoApi.unwaitIceServers(callback)
|
||||
|
||||
override fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
@@ -132,13 +141,13 @@ class ProtocolRuntime @Inject constructor(
|
||||
sharedPublic: String,
|
||||
callId: String,
|
||||
joinToken: String
|
||||
) = core.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
|
||||
) = packetIoApi.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
|
||||
|
||||
override fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) =
|
||||
core.sendWebRtcSignal(signalType, sdpOrCandidate)
|
||||
packetIoApi.sendWebRtcSignal(signalType, sdpOrCandidate)
|
||||
|
||||
private fun bindDependencies() {
|
||||
core.bindDependencies(
|
||||
runtimeComposition.bindDependencies(
|
||||
messageRepository = messageRepository,
|
||||
groupRepository = groupRepository,
|
||||
accountManager = accountManager
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||
import com.rosetta.messenger.network.connection.AuthBootstrapCoordinator
|
||||
import com.rosetta.messenger.network.connection.AuthRestoreService
|
||||
import com.rosetta.messenger.network.connection.ProtocolAccountSessionCoordinator
|
||||
import com.rosetta.messenger.network.connection.ConnectionOrchestrator
|
||||
import com.rosetta.messenger.network.connection.DeviceRuntimeService
|
||||
import com.rosetta.messenger.network.connection.OwnProfileSyncService
|
||||
import com.rosetta.messenger.network.connection.ProtocolDebugLogService
|
||||
import com.rosetta.messenger.network.connection.ProtocolLifecycleCoordinator
|
||||
import com.rosetta.messenger.network.connection.ProtocolPostAuthBootstrapCoordinator
|
||||
import com.rosetta.messenger.network.connection.ReadyPacketDispatchCoordinator
|
||||
import com.rosetta.messenger.network.connection.RuntimeInitializationCoordinator
|
||||
import com.rosetta.messenger.network.connection.RuntimeShutdownCoordinator
|
||||
import com.rosetta.messenger.session.IdentityStore
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Singleton manager for Protocol instance
|
||||
* Ensures single connection across the app
|
||||
*/
|
||||
class RuntimeComposition {
|
||||
private val TAG = "ProtocolRuntime"
|
||||
private val MANUAL_SYNC_BACKTRACK_MS = 120_000L
|
||||
private val SYNC_REQUEST_TIMEOUT_MS = 12_000L
|
||||
private val MAX_DEBUG_LOGS = 600
|
||||
private val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
||||
private val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
|
||||
private val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
|
||||
private val PROTOCOL_TRACE_FILE_NAME = "protocol_wire_log.txt"
|
||||
private val PROTOCOL_TRACE_MAX_BYTES = 2_000_000L
|
||||
private val PROTOCOL_TRACE_KEEP_BYTES = 1_200_000
|
||||
private val NETWORK_WAIT_TIMEOUT_MS = 20_000L
|
||||
private val BOOTSTRAP_OWN_PROFILE_FALLBACK_MS = 2_500L
|
||||
private val READY_PACKET_QUEUE_MAX = 500
|
||||
private val READY_PACKET_QUEUE_TTL_MS = 120_000L
|
||||
|
||||
// Desktop parity: use the same primary WebSocket endpoint as desktop client.
|
||||
private val SERVER_ADDRESS = "wss://wss.rosetta.im"
|
||||
|
||||
private var messageRepository: MessageRepository? = null
|
||||
private var groupRepository: GroupRepository? = null
|
||||
private var accountManager: AccountManager? = null
|
||||
private var appContext: Context? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val transportAssembly =
|
||||
RuntimeTransportAssembly(
|
||||
scope = scope,
|
||||
networkWaitTimeoutMs = NETWORK_WAIT_TIMEOUT_MS,
|
||||
serverAddress = SERVER_ADDRESS,
|
||||
getAppContext = { appContext },
|
||||
addLog = ::addLog,
|
||||
onFastReconnectRequested = ::onFastReconnectRequested
|
||||
)
|
||||
private val networkConnectivityFacade = transportAssembly.networkConnectivityFacade
|
||||
private val protocolInstanceManager = transportAssembly.protocolInstanceManager
|
||||
private val packetSubscriptionFacade = transportAssembly.packetSubscriptionFacade
|
||||
// Guard: prevent duplicate FCM token subscribe within a single session
|
||||
@Volatile
|
||||
private var lastSubscribedToken: String? = null
|
||||
private val stateAssembly =
|
||||
RuntimeStateAssembly(
|
||||
scope = scope,
|
||||
readyPacketQueueMax = READY_PACKET_QUEUE_MAX,
|
||||
readyPacketQueueTtlMs = READY_PACKET_QUEUE_TTL_MS,
|
||||
ownProfileFallbackTimeoutMs = BOOTSTRAP_OWN_PROFILE_FALLBACK_MS,
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = ::shortKeyForLog,
|
||||
sendPacketDirect = { packet -> getProtocol().sendPacket(packet) },
|
||||
onOwnProfileFallbackTimeout = ::onOwnProfileFallbackTimeoutEvent,
|
||||
clearLastSubscribedTokenValue = { lastSubscribedToken = null }
|
||||
)
|
||||
private val bootstrapCoordinator get() = stateAssembly.bootstrapCoordinator
|
||||
private val lifecycleStateMachine get() = stateAssembly.lifecycleStateMachine
|
||||
private val lifecycleStateStore get() = stateAssembly.lifecycleStateStore
|
||||
private val deviceRuntimeService =
|
||||
DeviceRuntimeService(
|
||||
getAppContext = { appContext },
|
||||
sendPacket = ::send
|
||||
)
|
||||
private val connectionOrchestrator =
|
||||
ConnectionOrchestrator(
|
||||
hasActiveInternet = networkConnectivityFacade::hasActiveInternet,
|
||||
waitForNetworkAndReconnect = networkConnectivityFacade::waitForNetworkAndReconnect,
|
||||
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
|
||||
getProtocol = ::getProtocol,
|
||||
persistHandshakeCredentials = { publicKey, privateHash ->
|
||||
accountManager?.setLastLoggedPublicKey(publicKey)
|
||||
accountManager?.setLastLoggedPrivateKeyHash(privateHash)
|
||||
},
|
||||
buildHandshakeDevice = deviceRuntimeService::buildHandshakeDevice
|
||||
)
|
||||
private val ownProfileSyncService =
|
||||
OwnProfileSyncService(
|
||||
isPlaceholderAccountName = ::isPlaceholderAccountName,
|
||||
updateAccountName = { publicKey, name ->
|
||||
accountManager?.updateAccountName(publicKey, name)
|
||||
},
|
||||
updateAccountUsername = { publicKey, username ->
|
||||
accountManager?.updateAccountUsername(publicKey, username)
|
||||
}
|
||||
)
|
||||
private val authBootstrapCoordinator =
|
||||
AuthBootstrapCoordinator(
|
||||
scope = scope,
|
||||
addLog = ::addLog
|
||||
)
|
||||
private val messagingAssembly by lazy {
|
||||
RuntimeMessagingAssembly(
|
||||
tag = TAG,
|
||||
scope = scope,
|
||||
typingIndicatorTimeoutMs = TYPING_INDICATOR_TIMEOUT_MS,
|
||||
syncRequestTimeoutMs = SYNC_REQUEST_TIMEOUT_MS,
|
||||
manualSyncBacktrackMs = MANUAL_SYNC_BACKTRACK_MS,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
ownProfileSyncService = ownProfileSyncService,
|
||||
isAuthenticated = ::isAuthenticated,
|
||||
getProtocolPublicKey = { getProtocol().getPublicKey().orEmpty() },
|
||||
getProtocolPrivateHash = {
|
||||
try {
|
||||
getProtocol().getPrivateHash()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
},
|
||||
getMessageRepository = { messageRepository },
|
||||
getGroupRepository = { groupRepository },
|
||||
sendSearchPacket = ::sendSearchPacketViaPacketIo,
|
||||
sendMessagePacket = ::sendMessagePacketViaPacketIo,
|
||||
sendSyncPacket = ::sendSyncPacketViaPacketIo,
|
||||
sendPacket = ::send,
|
||||
waitPacket = ::waitPacket,
|
||||
unwaitPacket = ::unwaitPacket,
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = ::shortKeyForLog,
|
||||
shortTextForLog = ::shortTextForLog,
|
||||
onSyncCompleted = ::finishSyncCycle,
|
||||
onInboundTaskQueued = ::onInboundTaskQueued,
|
||||
onInboundTaskFailure = ::markInboundProcessingFailure,
|
||||
resolveOutgoingRetry = ::resolveOutgoingRetry,
|
||||
isGroupDialogKey = ::isGroupDialogKey,
|
||||
setTransportServer = TransportManager::setTransportServer,
|
||||
onOwnProfileResolved = ::onOwnProfileResolvedEvent
|
||||
)
|
||||
}
|
||||
private val packetRouter get() = messagingAssembly.packetRouter
|
||||
private val outgoingMessagePipelineService get() = messagingAssembly.outgoingMessagePipelineService
|
||||
private val presenceTypingService get() = messagingAssembly.presenceTypingService
|
||||
private val inboundTaskQueueService get() = messagingAssembly.inboundTaskQueueService
|
||||
private val syncCoordinator get() = messagingAssembly.syncCoordinator
|
||||
private val callSignalBridge get() = messagingAssembly.callSignalBridge
|
||||
private val inboundPacketHandlerRegistrar get() = messagingAssembly.inboundPacketHandlerRegistrar
|
||||
|
||||
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
|
||||
stateAssembly.connectionLifecycleState
|
||||
private val postAuthBootstrapCoordinator =
|
||||
ProtocolPostAuthBootstrapCoordinator(
|
||||
tag = TAG,
|
||||
scope = scope,
|
||||
authBootstrapCoordinator = authBootstrapCoordinator,
|
||||
syncCoordinator = syncCoordinator,
|
||||
ownProfileSyncService = ownProfileSyncService,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
getMessageRepository = { messageRepository },
|
||||
getAppContext = { appContext },
|
||||
getProtocolPublicKey = { getProtocol().getPublicKey() },
|
||||
getProtocolPrivateHash = { getProtocol().getPrivateHash() },
|
||||
sendPacket = ::send,
|
||||
requestTransportServer = TransportManager::requestTransportServer,
|
||||
requestUpdateServer = { com.rosetta.messenger.update.UpdateManager.requestSduServer() },
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = { value -> shortKeyForLog(value) },
|
||||
getLastSubscribedToken = { lastSubscribedToken },
|
||||
setLastSubscribedToken = { token -> lastSubscribedToken = token }
|
||||
)
|
||||
private val lifecycleCoordinator =
|
||||
ProtocolLifecycleCoordinator(
|
||||
stateStore = lifecycleStateStore,
|
||||
syncCoordinator = syncCoordinator,
|
||||
authBootstrapCoordinator = authBootstrapCoordinator,
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = { value -> shortKeyForLog(value) },
|
||||
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
|
||||
cancelAllOutgoingRetries = ::cancelAllOutgoingRetries,
|
||||
recomputeConnectionLifecycleState = stateAssembly::recomputeConnectionLifecycleState,
|
||||
onAuthenticated = { postAuthBootstrapCoordinator.runPostAuthBootstrap("state_authenticated") },
|
||||
onSyncCompletedSideEffects = postAuthBootstrapCoordinator::handleSyncCompletedSideEffects,
|
||||
updateOwnProfileResolved = { publicKey, reason ->
|
||||
IdentityStore.updateOwnProfile(
|
||||
publicKey = publicKey,
|
||||
resolved = true,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
)
|
||||
private val readyPacketDispatchCoordinator =
|
||||
ReadyPacketDispatchCoordinator(
|
||||
bootstrapCoordinator = bootstrapCoordinator,
|
||||
getConnectionLifecycleState = lifecycleStateMachine::currentState,
|
||||
resolveAccountPublicKey = {
|
||||
messageRepository?.getCurrentAccountKey()?.trim().orEmpty().ifBlank {
|
||||
lifecycleStateMachine.bootstrapContext.accountPublicKey
|
||||
}
|
||||
},
|
||||
sendPacketDirect = { packet -> getProtocol().sendPacket(packet) },
|
||||
isAuthenticated = ::isAuthenticated,
|
||||
hasActiveInternet = networkConnectivityFacade::hasActiveInternet,
|
||||
waitForNetworkAndReconnect = networkConnectivityFacade::waitForNetworkAndReconnect,
|
||||
reconnectNowIfNeeded = { reason -> getProtocol().reconnectNowIfNeeded(reason) }
|
||||
)
|
||||
private val accountSessionCoordinator =
|
||||
ProtocolAccountSessionCoordinator(
|
||||
stateStore = lifecycleStateStore,
|
||||
syncCoordinator = syncCoordinator,
|
||||
authBootstrapCoordinator = authBootstrapCoordinator,
|
||||
presenceTypingService = presenceTypingService,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
getMessageRepository = { messageRepository },
|
||||
getProtocolState = { state.value },
|
||||
isProtocolAuthenticated = ::isAuthenticated,
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = { value -> shortKeyForLog(value) },
|
||||
clearReadyPacketQueue = readyPacketDispatchCoordinator::clearReadyPacketQueue,
|
||||
recomputeConnectionLifecycleState = stateAssembly::recomputeConnectionLifecycleState,
|
||||
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
|
||||
disconnectProtocol = protocolInstanceManager::disconnect,
|
||||
tryRunPostAuthBootstrap = postAuthBootstrapCoordinator::runPostAuthBootstrap,
|
||||
launchVersionUpdateCheck = {
|
||||
scope.launch {
|
||||
messageRepository?.checkAndSendVersionUpdateMessage()
|
||||
}
|
||||
}
|
||||
)
|
||||
private val initializationCoordinator =
|
||||
RuntimeInitializationCoordinator(
|
||||
ensureConnectionSupervisor = ::ensureConnectionSupervisor,
|
||||
setupPacketHandlers = ::setupPacketHandlers,
|
||||
setupStateMonitoring = ::setupStateMonitoring,
|
||||
setAppContext = { context -> appContext = context },
|
||||
hasBoundDependencies = {
|
||||
messageRepository != null && groupRepository != null && accountManager != null
|
||||
},
|
||||
addLog = ::addLog
|
||||
)
|
||||
private val authRestoreService =
|
||||
AuthRestoreService(
|
||||
getAccountManager = { accountManager },
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = ::shortKeyForLog,
|
||||
authenticate = ::authenticate
|
||||
)
|
||||
private val runtimeShutdownCoordinator =
|
||||
RuntimeShutdownCoordinator(
|
||||
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
|
||||
destroyPacketSubscriptionRegistry = transportAssembly::destroyPacketSubscriptions,
|
||||
destroyProtocolInstance = protocolInstanceManager::destroy,
|
||||
clearMessageRepositoryInitialization = {
|
||||
messageRepository?.clearInitialization()
|
||||
},
|
||||
clearPresenceTyping = presenceTypingService::clear,
|
||||
clearDeviceRuntime = deviceRuntimeService::clear,
|
||||
resetSyncCoordinator = syncCoordinator::resetForDisconnect,
|
||||
resetAuthBootstrap = authBootstrapCoordinator::reset,
|
||||
cancelRuntimeScope = scope::cancel
|
||||
)
|
||||
private val routingAssembly by lazy {
|
||||
RuntimeRoutingAssembly(
|
||||
scope = scope,
|
||||
tag = TAG,
|
||||
addLog = ::addLog,
|
||||
handleInitializeAccount = accountSessionCoordinator::handleInitializeAccount,
|
||||
handleConnect = connectionOrchestrator::handleConnect,
|
||||
handleFastReconnect = connectionOrchestrator::handleFastReconnect,
|
||||
handleDisconnect = accountSessionCoordinator::handleDisconnect,
|
||||
handleAuthenticate = connectionOrchestrator::handleAuthenticate,
|
||||
handleProtocolStateChanged = lifecycleCoordinator::handleProtocolStateChanged,
|
||||
handleSendPacket = readyPacketDispatchCoordinator::handleSendPacket,
|
||||
handleSyncCompleted = lifecycleCoordinator::handleSyncCompleted,
|
||||
handleOwnProfileResolved = lifecycleCoordinator::handleOwnProfileResolved,
|
||||
handleOwnProfileFallbackTimeout = lifecycleCoordinator::handleOwnProfileFallbackTimeout
|
||||
)
|
||||
}
|
||||
private val connectionControlFacade by lazy {
|
||||
RuntimeConnectionControlFacade(
|
||||
postConnectionEvent = ::postConnectionEvent,
|
||||
initializationCoordinator = initializationCoordinator,
|
||||
syncCoordinator = syncCoordinator,
|
||||
authRestoreService = authRestoreService,
|
||||
runtimeShutdownCoordinator = runtimeShutdownCoordinator,
|
||||
protocolInstanceManager = protocolInstanceManager,
|
||||
subscribePushTokenIfAvailable = postAuthBootstrapCoordinator::subscribePushTokenIfAvailable
|
||||
)
|
||||
}
|
||||
private val directoryFacade by lazy {
|
||||
RuntimeDirectoryFacade(
|
||||
packetRouter = packetRouter,
|
||||
ownProfileSyncService = ownProfileSyncService,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
presenceTypingService = presenceTypingService
|
||||
)
|
||||
}
|
||||
private val packetIoFacade by lazy {
|
||||
RuntimePacketIoFacade(
|
||||
postConnectionEvent = ::postConnectionEvent,
|
||||
outgoingMessagePipelineServiceProvider = { outgoingMessagePipelineService },
|
||||
callSignalBridge = callSignalBridge,
|
||||
packetSubscriptionFacade = packetSubscriptionFacade
|
||||
)
|
||||
}
|
||||
|
||||
private val debugLogService =
|
||||
ProtocolDebugLogService(
|
||||
scope = scope,
|
||||
maxDebugLogs = MAX_DEBUG_LOGS,
|
||||
debugLogFlushDelayMs = DEBUG_LOG_FLUSH_DELAY_MS,
|
||||
heartbeatOkLogMinIntervalMs = HEARTBEAT_OK_LOG_MIN_INTERVAL_MS,
|
||||
protocolTraceFileName = PROTOCOL_TRACE_FILE_NAME,
|
||||
protocolTraceMaxBytes = PROTOCOL_TRACE_MAX_BYTES,
|
||||
protocolTraceKeepBytes = PROTOCOL_TRACE_KEEP_BYTES,
|
||||
appContextProvider = { appContext }
|
||||
)
|
||||
val debugLogs: StateFlow<List<String>> = debugLogService.debugLogs
|
||||
val typingUsers: StateFlow<Set<String>> = presenceTypingService.typingUsers
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
|
||||
presenceTypingService.typingUsersByDialogSnapshot
|
||||
|
||||
val devices: StateFlow<List<DeviceEntry>> = deviceRuntimeService.devices
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?> =
|
||||
deviceRuntimeService.pendingDeviceVerification
|
||||
|
||||
// Сигнал обновления own profile (username/name загружены с сервера)
|
||||
val ownProfileUpdated: StateFlow<Long> = ownProfileSyncService.ownProfileUpdated
|
||||
val syncInProgress: StateFlow<Boolean> = syncCoordinator.syncInProgress
|
||||
|
||||
fun connectionControlApi(): RuntimeConnectionControlFacade = connectionControlFacade
|
||||
|
||||
fun directoryApi(): RuntimeDirectoryFacade = directoryFacade
|
||||
|
||||
fun packetIoApi(): RuntimePacketIoFacade = packetIoFacade
|
||||
|
||||
private fun sendSearchPacketViaPacketIo(packet: PacketSearch) {
|
||||
packetIoFacade.send(packet)
|
||||
}
|
||||
|
||||
private fun sendMessagePacketViaPacketIo(packet: PacketMessage) {
|
||||
packetIoFacade.send(packet)
|
||||
}
|
||||
|
||||
private fun sendSyncPacketViaPacketIo(packet: PacketSync) {
|
||||
packetIoFacade.send(packet)
|
||||
}
|
||||
|
||||
private fun ensureConnectionSupervisor() {
|
||||
routingAssembly.start()
|
||||
}
|
||||
|
||||
private fun postConnectionEvent(event: ConnectionEvent) {
|
||||
routingAssembly.post(event)
|
||||
}
|
||||
|
||||
private fun onFastReconnectRequested(reason: String) {
|
||||
postConnectionEvent(ConnectionEvent.FastReconnect(reason))
|
||||
}
|
||||
|
||||
private fun onOwnProfileResolvedEvent(publicKey: String) {
|
||||
postConnectionEvent(ConnectionEvent.OwnProfileResolved(publicKey))
|
||||
}
|
||||
|
||||
private fun onOwnProfileFallbackTimeoutEvent(generation: Long) {
|
||||
postConnectionEvent(ConnectionEvent.OwnProfileFallbackTimeout(generation))
|
||||
}
|
||||
|
||||
fun addLog(message: String) {
|
||||
debugLogService.addLog(message)
|
||||
}
|
||||
|
||||
fun enableUILogs(enabled: Boolean) {
|
||||
debugLogService.enableUILogs(enabled)
|
||||
MessageLogger.setEnabled(enabled)
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
debugLogService.clearLogs()
|
||||
}
|
||||
|
||||
private fun markInboundProcessingFailure(reason: String, error: Throwable? = null) {
|
||||
syncCoordinator.markInboundProcessingFailure()
|
||||
if (error != null) {
|
||||
android.util.Log.e(TAG, reason, error)
|
||||
addLog("❌ $reason: ${error.message ?: error.javaClass.simpleName}")
|
||||
} else {
|
||||
android.util.Log.w(TAG, reason)
|
||||
addLog("⚠️ $reason")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInboundTaskQueued() {
|
||||
syncCoordinator.trackInboundTaskQueued()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject process-wide dependencies from DI container.
|
||||
*/
|
||||
fun bindDependencies(
|
||||
messageRepository: MessageRepository,
|
||||
groupRepository: GroupRepository,
|
||||
accountManager: AccountManager
|
||||
) {
|
||||
this.messageRepository = messageRepository
|
||||
this.groupRepository = groupRepository
|
||||
this.accountManager = accountManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible alias kept while migrating call sites.
|
||||
*/
|
||||
fun bindRepositories(
|
||||
messageRepository: MessageRepository,
|
||||
groupRepository: GroupRepository
|
||||
) {
|
||||
this.messageRepository = messageRepository
|
||||
this.groupRepository = groupRepository
|
||||
}
|
||||
|
||||
private fun setupStateMonitoring() {
|
||||
scope.launch {
|
||||
state.collect { newState ->
|
||||
postConnectionEvent(ConnectionEvent.ProtocolStateChanged(newState))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupPacketHandlers() {
|
||||
inboundPacketHandlerRegistrar.register()
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun finishSyncCycle(reason: String) {
|
||||
postConnectionEvent(ConnectionEvent.SyncCompleted(reason))
|
||||
}
|
||||
|
||||
private fun getProtocol(): Protocol {
|
||||
return protocolInstanceManager.getOrCreateProtocol()
|
||||
}
|
||||
|
||||
val state: StateFlow<ProtocolState>
|
||||
get() = protocolInstanceManager.state
|
||||
|
||||
private fun send(packet: Packet) {
|
||||
packetIoFacade.send(packet)
|
||||
}
|
||||
|
||||
private fun resolveOutgoingRetry(messageId: String) {
|
||||
packetIoFacade.resolveOutgoingRetry(messageId)
|
||||
}
|
||||
|
||||
private fun cancelAllOutgoingRetries() {
|
||||
packetIoFacade.clearOutgoingRetries()
|
||||
}
|
||||
|
||||
private fun authenticate(publicKey: String, privateHash: String) {
|
||||
postConnectionEvent(
|
||||
ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash)
|
||||
)
|
||||
}
|
||||
|
||||
private fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetIoFacade.waitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
private fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetIoFacade.unwaitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
private fun shortKeyForLog(value: String, visible: Int = 8): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) return "<empty>"
|
||||
return if (trimmed.length <= visible) trimmed else "${trimmed.take(visible)}…"
|
||||
}
|
||||
|
||||
private fun shortTextForLog(value: String, limit: Int = 80): String {
|
||||
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
|
||||
if (normalized.isBlank()) return "<empty>"
|
||||
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…"
|
||||
}
|
||||
|
||||
private fun isAuthenticated(): Boolean = connectionControlFacade.isAuthenticated()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import com.rosetta.messenger.network.connection.DeviceRuntimeService
|
||||
import com.rosetta.messenger.network.connection.OwnProfileSyncService
|
||||
import com.rosetta.messenger.network.connection.PacketRouter
|
||||
import com.rosetta.messenger.network.connection.PresenceTypingService
|
||||
|
||||
class RuntimeDirectoryFacade(
|
||||
private val packetRouter: PacketRouter,
|
||||
private val ownProfileSyncService: OwnProfileSyncService,
|
||||
private val deviceRuntimeService: DeviceRuntimeService,
|
||||
private val presenceTypingService: PresenceTypingService
|
||||
) {
|
||||
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
|
||||
return presenceTypingService.getTypingUsersForDialog(dialogKey)
|
||||
}
|
||||
|
||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
|
||||
return packetRouter.resolveUserName(publicKey = publicKey, timeoutMs = timeoutMs)
|
||||
}
|
||||
|
||||
fun getCachedUserName(publicKey: String): String? {
|
||||
return packetRouter.getCachedUserName(publicKey)
|
||||
}
|
||||
|
||||
fun notifyOwnProfileUpdated() {
|
||||
ownProfileSyncService.notifyOwnProfileUpdated()
|
||||
}
|
||||
|
||||
fun getCachedUserInfo(publicKey: String): SearchUser? {
|
||||
return packetRouter.getCachedUserInfo(publicKey)
|
||||
}
|
||||
|
||||
fun getCachedUserByUsername(username: String): SearchUser? {
|
||||
return packetRouter.getCachedUserByUsername(username)
|
||||
}
|
||||
|
||||
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
|
||||
return packetRouter.resolveUserInfo(publicKey = publicKey, timeoutMs = timeoutMs)
|
||||
}
|
||||
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
|
||||
return packetRouter.searchUsers(query = query, timeoutMs = timeoutMs)
|
||||
}
|
||||
|
||||
fun acceptDevice(deviceId: String) {
|
||||
deviceRuntimeService.acceptDevice(deviceId)
|
||||
}
|
||||
|
||||
fun declineDevice(deviceId: String) {
|
||||
deviceRuntimeService.declineDevice(deviceId)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import com.rosetta.messenger.network.connection.CallSignalBridge
|
||||
import com.rosetta.messenger.network.connection.OutgoingMessagePipelineService
|
||||
import com.rosetta.messenger.network.connection.PacketSubscriptionFacade
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
class RuntimePacketIoFacade(
|
||||
private val postConnectionEvent: (ConnectionEvent) -> Unit,
|
||||
private val outgoingMessagePipelineServiceProvider: () -> OutgoingMessagePipelineService,
|
||||
private val callSignalBridge: CallSignalBridge,
|
||||
private val packetSubscriptionFacade: PacketSubscriptionFacade
|
||||
) {
|
||||
fun send(packet: Packet) {
|
||||
postConnectionEvent(ConnectionEvent.SendPacket(packet))
|
||||
}
|
||||
|
||||
fun sendMessageWithRetry(packet: PacketMessage) {
|
||||
outgoingMessagePipelineServiceProvider().sendWithRetry(packet)
|
||||
}
|
||||
|
||||
fun resolveOutgoingRetry(messageId: String) {
|
||||
outgoingMessagePipelineServiceProvider().resolveOutgoingRetry(messageId)
|
||||
}
|
||||
|
||||
fun clearOutgoingRetries() {
|
||||
outgoingMessagePipelineServiceProvider().clearRetryQueue()
|
||||
}
|
||||
|
||||
fun sendPacket(packet: Packet) {
|
||||
send(packet)
|
||||
}
|
||||
|
||||
fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String = "",
|
||||
dst: String = "",
|
||||
sharedPublic: String = "",
|
||||
callId: String = "",
|
||||
joinToken: String = ""
|
||||
) {
|
||||
callSignalBridge.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
|
||||
}
|
||||
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||
callSignalBridge.sendWebRtcSignal(signalType, sdpOrCandidate)
|
||||
}
|
||||
|
||||
fun requestIceServers() {
|
||||
callSignalBridge.requestIceServers()
|
||||
}
|
||||
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||
return callSignalBridge.waitCallSignal(callback)
|
||||
}
|
||||
|
||||
fun unwaitCallSignal(callback: (Packet) -> Unit) {
|
||||
callSignalBridge.unwaitCallSignal(callback)
|
||||
}
|
||||
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||
return callSignalBridge.waitWebRtcSignal(callback)
|
||||
}
|
||||
|
||||
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
|
||||
callSignalBridge.unwaitWebRtcSignal(callback)
|
||||
}
|
||||
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||
return callSignalBridge.waitIceServers(callback)
|
||||
}
|
||||
|
||||
fun unwaitIceServers(callback: (Packet) -> Unit) {
|
||||
callSignalBridge.unwaitIceServers(callback)
|
||||
}
|
||||
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetSubscriptionFacade.waitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetSubscriptionFacade.unwaitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
fun packetFlow(packetId: Int): SharedFlow<Packet> {
|
||||
return packetSubscriptionFacade.packetFlow(packetId)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import com.rosetta.messenger.network.connection.BootstrapCoordinator
|
||||
import com.rosetta.messenger.network.connection.OwnProfileFallbackTimerService
|
||||
import com.rosetta.messenger.network.connection.ProtocolLifecycleStateStoreImpl
|
||||
import com.rosetta.messenger.network.connection.RuntimeLifecycleStateMachine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
internal class RuntimeStateAssembly(
|
||||
scope: CoroutineScope,
|
||||
readyPacketQueueMax: Int,
|
||||
readyPacketQueueTtlMs: Long,
|
||||
ownProfileFallbackTimeoutMs: Long,
|
||||
addLog: (String) -> Unit,
|
||||
shortKeyForLog: (String) -> String,
|
||||
sendPacketDirect: (Packet) -> Unit,
|
||||
onOwnProfileFallbackTimeout: (Long) -> Unit,
|
||||
clearLastSubscribedTokenValue: () -> Unit
|
||||
) {
|
||||
private val readyPacketGate =
|
||||
ReadyPacketGate(
|
||||
maxSize = readyPacketQueueMax,
|
||||
ttlMs = readyPacketQueueTtlMs
|
||||
)
|
||||
|
||||
val bootstrapCoordinator =
|
||||
BootstrapCoordinator(
|
||||
readyPacketGate = readyPacketGate,
|
||||
addLog = addLog,
|
||||
shortKeyForLog = shortKeyForLog,
|
||||
sendPacketDirect = sendPacketDirect
|
||||
)
|
||||
|
||||
val lifecycleStateMachine =
|
||||
RuntimeLifecycleStateMachine(
|
||||
bootstrapCoordinator = bootstrapCoordinator,
|
||||
addLog = addLog
|
||||
)
|
||||
|
||||
private val ownProfileFallbackTimerService =
|
||||
OwnProfileFallbackTimerService(
|
||||
scope = scope,
|
||||
fallbackTimeoutMs = ownProfileFallbackTimeoutMs,
|
||||
onTimeout = onOwnProfileFallbackTimeout
|
||||
)
|
||||
|
||||
val lifecycleStateStore =
|
||||
ProtocolLifecycleStateStoreImpl(
|
||||
lifecycleStateMachine = lifecycleStateMachine,
|
||||
ownProfileFallbackTimerService = ownProfileFallbackTimerService,
|
||||
clearLastSubscribedTokenValue = clearLastSubscribedTokenValue
|
||||
)
|
||||
|
||||
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
|
||||
lifecycleStateMachine.connectionLifecycleState
|
||||
|
||||
fun recomputeConnectionLifecycleState(reason: String) {
|
||||
lifecycleStateMachine.recompute(reason)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class InboundTaskQueueService(
|
||||
private val scope: CoroutineScope,
|
||||
private val onTaskQueued: () -> Unit,
|
||||
private val onTaskFailure: (String, Throwable?) -> Unit
|
||||
) {
|
||||
private val inboundTaskChannel = Channel<suspend () -> Unit>(Channel.UNLIMITED)
|
||||
@Volatile private var inboundQueueDrainJob: Job? = null
|
||||
|
||||
fun enqueue(block: suspend () -> Unit): Boolean {
|
||||
ensureDrainRunning()
|
||||
onTaskQueued()
|
||||
val result = inboundTaskChannel.trySend(block)
|
||||
if (result.isFailure) {
|
||||
onTaskFailure("Failed to enqueue inbound task", result.exceptionOrNull())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun whenTasksFinish(): Boolean {
|
||||
val done = CompletableDeferred<Unit>()
|
||||
if (!enqueue { done.complete(Unit) }) {
|
||||
return false
|
||||
}
|
||||
done.await()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun ensureDrainRunning() {
|
||||
if (inboundQueueDrainJob?.isActive == true) return
|
||||
inboundQueueDrainJob = scope.launch {
|
||||
for (task in inboundTaskChannel) {
|
||||
try {
|
||||
task()
|
||||
} catch (t: Throwable) {
|
||||
onTaskFailure("Dialog queue error", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketSubscriptionRegistry
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
class PacketSubscriptionFacade(
|
||||
private val packetSubscriptionRegistry: PacketSubscriptionRegistry
|
||||
) {
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetSubscriptionRegistry.addCallback(packetId, callback)
|
||||
}
|
||||
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetSubscriptionRegistry.removeCallback(packetId, callback)
|
||||
}
|
||||
|
||||
fun packetFlow(packetId: Int): SharedFlow<Packet> {
|
||||
return packetSubscriptionRegistry.flow(packetId)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class ProtocolDebugLogService(
|
||||
private val scope: CoroutineScope,
|
||||
private val maxDebugLogs: Int,
|
||||
private val debugLogFlushDelayMs: Long,
|
||||
private val heartbeatOkLogMinIntervalMs: Long,
|
||||
private val protocolTraceFileName: String,
|
||||
private val protocolTraceMaxBytes: Long,
|
||||
private val protocolTraceKeepBytes: Int,
|
||||
private val appContextProvider: () -> Context?
|
||||
) {
|
||||
private var uiLogsEnabled = false
|
||||
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||
private val debugLogsBuffer = ArrayDeque<String>(maxDebugLogs)
|
||||
private val debugLogsLock = Any()
|
||||
private val protocolTraceLock = Any()
|
||||
@Volatile private var debugFlushJob: Job? = null
|
||||
private val debugFlushPending = AtomicBoolean(false)
|
||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
|
||||
|
||||
fun addLog(message: String) {
|
||||
var normalizedMessage = message
|
||||
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
||||
if (isHeartbeatOk) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastHeartbeatOkLogAtMs < heartbeatOkLogMinIntervalMs) {
|
||||
suppressedHeartbeatOkLogs++
|
||||
return
|
||||
}
|
||||
if (suppressedHeartbeatOkLogs > 0) {
|
||||
normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)"
|
||||
suppressedHeartbeatOkLogs = 0
|
||||
}
|
||||
lastHeartbeatOkLogAtMs = now
|
||||
}
|
||||
|
||||
val timestamp =
|
||||
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
val line = "[$timestamp] $normalizedMessage"
|
||||
if (shouldPersistProtocolTrace(normalizedMessage)) {
|
||||
persistProtocolTraceLine(line)
|
||||
}
|
||||
if (!uiLogsEnabled) return
|
||||
|
||||
synchronized(debugLogsLock) {
|
||||
if (debugLogsBuffer.size >= maxDebugLogs) {
|
||||
debugLogsBuffer.removeFirst()
|
||||
}
|
||||
debugLogsBuffer.addLast(line)
|
||||
}
|
||||
flushDebugLogsThrottled()
|
||||
}
|
||||
|
||||
fun enableUILogs(enabled: Boolean) {
|
||||
uiLogsEnabled = enabled
|
||||
if (enabled) {
|
||||
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||
_debugLogs.value = snapshot
|
||||
} else {
|
||||
_debugLogs.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
synchronized(debugLogsLock) {
|
||||
debugLogsBuffer.clear()
|
||||
}
|
||||
suppressedHeartbeatOkLogs = 0
|
||||
lastHeartbeatOkLogAtMs = 0L
|
||||
_debugLogs.value = emptyList()
|
||||
}
|
||||
|
||||
private fun shouldPersistProtocolTrace(message: String): Boolean {
|
||||
if (uiLogsEnabled) return true
|
||||
if (message.startsWith("❌") || message.startsWith("⚠️")) return true
|
||||
if (message.contains("STATE CHANGE")) return true
|
||||
if (message.contains("CONNECTION FULLY ESTABLISHED")) return true
|
||||
if (message.contains("HANDSHAKE COMPLETE")) return true
|
||||
if (message.contains("SYNC COMPLETE")) return true
|
||||
if (message.startsWith("🔌 CONNECT CALLED") || message.startsWith("🔌 Connecting to")) return true
|
||||
if (message.startsWith("✅ WebSocket OPEN")) return true
|
||||
if (message.startsWith("📡 NETWORK")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun persistProtocolTraceLine(line: String) {
|
||||
val context = appContextProvider() ?: return
|
||||
runCatching {
|
||||
val dir = File(context.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val traceFile = File(dir, protocolTraceFileName)
|
||||
synchronized(protocolTraceLock) {
|
||||
if (traceFile.exists() && traceFile.length() > protocolTraceMaxBytes) {
|
||||
val tail = runCatching {
|
||||
traceFile.readText(Charsets.UTF_8).takeLast(protocolTraceKeepBytes)
|
||||
}.getOrDefault("")
|
||||
traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8)
|
||||
}
|
||||
traceFile.appendText("$line\n", Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushDebugLogsThrottled() {
|
||||
debugFlushPending.set(true)
|
||||
if (debugFlushJob?.isActive == true) return
|
||||
debugFlushJob =
|
||||
scope.launch {
|
||||
while (debugFlushPending.getAndSet(false)) {
|
||||
delay(debugLogFlushDelayMs)
|
||||
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||
_debugLogs.value = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.Protocol
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class ProtocolInstanceManager(
|
||||
private val serverAddress: String,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val isNetworkAvailable: () -> Boolean,
|
||||
private val onNetworkUnavailable: () -> Unit
|
||||
) {
|
||||
@Volatile private var protocol: Protocol? = null
|
||||
private val protocolInstanceLock = Any()
|
||||
|
||||
fun getOrCreateProtocol(): Protocol {
|
||||
protocol?.let { return it }
|
||||
|
||||
synchronized(protocolInstanceLock) {
|
||||
protocol?.let { return it }
|
||||
|
||||
val created =
|
||||
Protocol(
|
||||
serverAddress = serverAddress,
|
||||
logger = { msg -> addLog(msg) },
|
||||
isNetworkAvailable = isNetworkAvailable,
|
||||
onNetworkUnavailable = onNetworkUnavailable
|
||||
)
|
||||
protocol = created
|
||||
addLog("🧩 Protocol singleton created: id=${System.identityHashCode(created)}")
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<ProtocolState>
|
||||
get() = getOrCreateProtocol().state
|
||||
|
||||
val lastError: StateFlow<String?>
|
||||
get() = getOrCreateProtocol().lastError
|
||||
|
||||
fun disconnect(clearCredentials: Boolean) {
|
||||
protocol?.disconnect()
|
||||
if (clearCredentials) {
|
||||
protocol?.clearCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
synchronized(protocolInstanceLock) {
|
||||
protocol?.destroy()
|
||||
protocol = null
|
||||
}
|
||||
}
|
||||
|
||||
fun isAuthenticated(): Boolean = protocol?.isAuthenticated() ?: false
|
||||
|
||||
fun isConnected(): Boolean = protocol?.isConnected() ?: false
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketPushNotification
|
||||
import com.rosetta.messenger.network.PushNotificationAction
|
||||
import com.rosetta.messenger.network.PushTokenType
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProtocolPostAuthBootstrapCoordinator(
|
||||
private val tag: String,
|
||||
private val scope: CoroutineScope,
|
||||
private val authBootstrapCoordinator: AuthBootstrapCoordinator,
|
||||
private val syncCoordinator: SyncCoordinator,
|
||||
private val ownProfileSyncService: OwnProfileSyncService,
|
||||
private val deviceRuntimeService: DeviceRuntimeService,
|
||||
private val getMessageRepository: () -> MessageRepository?,
|
||||
private val getAppContext: () -> Context?,
|
||||
private val getProtocolPublicKey: () -> String?,
|
||||
private val getProtocolPrivateHash: () -> String?,
|
||||
private val sendPacket: (Packet) -> Unit,
|
||||
private val requestTransportServer: () -> Unit,
|
||||
private val requestUpdateServer: () -> Unit,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val shortKeyForLog: (String) -> String,
|
||||
private val getLastSubscribedToken: () -> String?,
|
||||
private val setLastSubscribedToken: (String?) -> Unit
|
||||
) {
|
||||
fun runPostAuthBootstrap(trigger: String) {
|
||||
authBootstrapCoordinator.tryRun(
|
||||
trigger = trigger,
|
||||
canRun = ::canRunPostAuthBootstrap,
|
||||
onDeferred = {
|
||||
val repositoryAccount =
|
||||
getMessageRepository()?.getCurrentAccountKey()?.let { shortKeyForLog(it) }
|
||||
?: "<none>"
|
||||
val protocolAccount = getProtocolPublicKey()?.let { shortKeyForLog(it) } ?: "<none>"
|
||||
addLog(
|
||||
"⏳ AUTH bootstrap deferred trigger=$trigger repo=$repositoryAccount proto=$protocolAccount"
|
||||
)
|
||||
}
|
||||
) {
|
||||
syncCoordinator.markSyncInProgress(false)
|
||||
requestTransportServer()
|
||||
requestUpdateServer()
|
||||
fetchOwnProfile()
|
||||
syncCoordinator.requestSynchronize()
|
||||
subscribePushTokenIfAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSyncCompletedSideEffects() {
|
||||
retryWaitingMessages()
|
||||
requestMissingUserInfo()
|
||||
}
|
||||
|
||||
fun subscribePushTokenIfAvailable(forceToken: String? = null) {
|
||||
val context = getAppContext() ?: return
|
||||
val token =
|
||||
(forceToken
|
||||
?: context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
|
||||
.getString("fcm_token", null))
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (token.isEmpty()) return
|
||||
|
||||
if (token == getLastSubscribedToken()) {
|
||||
addLog("🔔 Push token already subscribed this session — skipped")
|
||||
return
|
||||
}
|
||||
|
||||
val deviceId = deviceRuntimeService.resolvePushDeviceId()
|
||||
val subPacket =
|
||||
PacketPushNotification().apply {
|
||||
notificationsToken = token
|
||||
action = PushNotificationAction.SUBSCRIBE
|
||||
tokenType = PushTokenType.FCM
|
||||
this.deviceId = deviceId
|
||||
}
|
||||
sendPacket(subPacket)
|
||||
setLastSubscribedToken(token)
|
||||
addLog("🔔 Push token SUBSCRIBE sent")
|
||||
|
||||
try {
|
||||
val dir = File(context.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val file = File(dir, "fcm_token.txt")
|
||||
val ts =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
|
||||
file.writeText(
|
||||
"=== FCM TOKEN ===\n\nTimestamp: $ts\nDeviceId: $deviceId\n\nToken:\n$token\n"
|
||||
)
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
private fun canRunPostAuthBootstrap(): Boolean {
|
||||
val repository = getMessageRepository() ?: return false
|
||||
if (!repository.isInitialized()) return false
|
||||
val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty()
|
||||
if (repositoryAccount.isBlank()) return false
|
||||
val protocolAccount = getProtocolPublicKey()?.trim().orEmpty()
|
||||
if (protocolAccount.isBlank()) return true
|
||||
return repositoryAccount.equals(protocolAccount, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun fetchOwnProfile() {
|
||||
val packet =
|
||||
ownProfileSyncService.buildOwnProfilePacket(
|
||||
publicKey = getProtocolPublicKey(),
|
||||
privateHash = getProtocolPrivateHash()
|
||||
) ?: return
|
||||
sendPacket(packet)
|
||||
}
|
||||
|
||||
private fun retryWaitingMessages() {
|
||||
scope.launch {
|
||||
val repository = getMessageRepository()
|
||||
if (repository == null || !repository.isInitialized()) return@launch
|
||||
try {
|
||||
repository.retryWaitingMessages()
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "retryWaitingMessages failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMissingUserInfo() {
|
||||
scope.launch {
|
||||
val repository = getMessageRepository()
|
||||
if (repository == null || !repository.isInitialized()) return@launch
|
||||
try {
|
||||
repository.clearUserInfoRequestCache()
|
||||
repository.requestMissingUserInfo()
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "requestMissingUserInfo failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.ConnectionBootstrapContext
|
||||
import com.rosetta.messenger.network.ConnectionLifecycleState
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class RuntimeLifecycleStateMachine(
|
||||
private val bootstrapCoordinator: BootstrapCoordinator,
|
||||
private val addLog: (String) -> Unit
|
||||
) {
|
||||
private val _connectionLifecycleState =
|
||||
MutableStateFlow(ConnectionLifecycleState.DISCONNECTED)
|
||||
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
|
||||
_connectionLifecycleState.asStateFlow()
|
||||
|
||||
var bootstrapContext: ConnectionBootstrapContext = ConnectionBootstrapContext()
|
||||
|
||||
fun currentState(): ConnectionLifecycleState = _connectionLifecycleState.value
|
||||
|
||||
fun recompute(reason: String) {
|
||||
val nextState =
|
||||
bootstrapCoordinator.recomputeLifecycleState(
|
||||
context = bootstrapContext,
|
||||
currentState = _connectionLifecycleState.value,
|
||||
reason = reason
|
||||
) { state, updateReason ->
|
||||
if (_connectionLifecycleState.value == state) return@recomputeLifecycleState
|
||||
addLog(
|
||||
"🧭 CONNECTION STATE: ${_connectionLifecycleState.value} -> $state ($updateReason)"
|
||||
)
|
||||
_connectionLifecycleState.value = state
|
||||
}
|
||||
_connectionLifecycleState.value = nextState
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.di.SessionCoordinator
|
||||
|
||||
enum class AuthScreen {
|
||||
@@ -28,6 +29,7 @@ fun AuthFlow(
|
||||
hasExistingAccount: Boolean,
|
||||
accounts: List<AccountInfo> = emptyList(),
|
||||
accountManager: AccountManager,
|
||||
protocolGateway: ProtocolGateway,
|
||||
sessionCoordinator: SessionCoordinator,
|
||||
startInCreateMode: Boolean = false,
|
||||
onAuthComplete: (DecryptedAccount?) -> Unit,
|
||||
@@ -212,6 +214,8 @@ fun AuthFlow(
|
||||
SetProfileScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
account = createdAccount,
|
||||
protocolGateway = protocolGateway,
|
||||
accountManager = accountManager,
|
||||
onComplete = { onAuthComplete(createdAccount) },
|
||||
onSkip = { onAuthComplete(createdAccount) }
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("") }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.EncodeVideoUriToHexCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.EncodeVideoUriToHexUseCase
|
||||
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.ResolveVideoCircleMetaCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.ResolveVideoCircleMetaUseCase
|
||||
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.ui.chats.models.ChatMessage
|
||||
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import com.rosetta.messenger.utils.MediaUtils
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class AttachmentsFeatureCoordinator(
|
||||
private val chatViewModel: ChatViewModel,
|
||||
private val resolveVideoCircleMetaUseCase: ResolveVideoCircleMetaUseCase,
|
||||
private val encodeVideoUriToHexUseCase: EncodeVideoUriToHexUseCase
|
||||
) {
|
||||
fun sendImageGroupFromUris(imageUris: List<Uri>, caption: String = "") {
|
||||
if (imageUris.isEmpty()) return
|
||||
if (imageUris.size == 1) {
|
||||
chatViewModel.attachmentsViewModel.sendImageFromUri(imageUris.first(), caption)
|
||||
return
|
||||
}
|
||||
|
||||
val context = chatViewModel.appContext()
|
||||
chatViewModel.launchBackgroundUpload {
|
||||
val prepared = mutableListOf<ChatViewModel.ImageData>()
|
||||
for ((index, uri) in imageUris.withIndex()) {
|
||||
val (width, height) = MediaUtils.getImageDimensions(context, uri)
|
||||
val imageBase64 = MediaUtils.uriToBase64Image(context, uri) ?: continue
|
||||
val blurhash = MediaUtils.generateBlurhash(context, uri)
|
||||
chatViewModel.addProtocolLog(
|
||||
"📸 IMG-GROUP convert item#$index: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
|
||||
)
|
||||
prepared.add(
|
||||
ChatViewModel.ImageData(
|
||||
base64 = imageBase64,
|
||||
blurhash = blurhash,
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
)
|
||||
}
|
||||
if (prepared.isEmpty()) return@launchBackgroundUpload
|
||||
withContext(Dispatchers.Main) {
|
||||
sendImageGroup(prepared, caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendImageGroup(images: List<ChatViewModel.ImageData>, caption: String = "") {
|
||||
if (images.isEmpty()) return
|
||||
if (images.size == 1) {
|
||||
val image = images.first()
|
||||
chatViewModel.attachmentsViewModel.sendImageMessage(
|
||||
imageBase64 = image.base64,
|
||||
blurhash = image.blurhash,
|
||||
caption = caption,
|
||||
width = image.width,
|
||||
height = image.height
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val text = caption.trim()
|
||||
chatViewModel.logPhotoEvent(
|
||||
messageId,
|
||||
"group start: count=${images.size}, captionLen=${text.length}"
|
||||
)
|
||||
|
||||
val attachmentsList =
|
||||
images.mapIndexed { index, imageData ->
|
||||
MessageAttachment(
|
||||
id = "img_${timestamp}_$index",
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = imageData.blurhash,
|
||||
blob = imageData.base64,
|
||||
width = imageData.width,
|
||||
height = imageData.height
|
||||
)
|
||||
}
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments = attachmentsList
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
|
||||
chatViewModel.launchBackgroundUpload {
|
||||
try {
|
||||
val groupStartedAt = System.currentTimeMillis()
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = sendContext.recipient,
|
||||
privateKey = sendContext.privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||
val isSavedMessages = sendContext.sender == sendContext.recipient
|
||||
|
||||
val networkAttachments = mutableListOf<MessageAttachment>()
|
||||
val attachmentsJsonArray = JSONArray()
|
||||
|
||||
for ((index, imageData) in images.withIndex()) {
|
||||
val attachmentId = "img_${timestamp}_$index"
|
||||
chatViewModel.logPhotoEvent(
|
||||
messageId,
|
||||
"group item#$index start: id=${chatViewModel.shortPhotoLogId(attachmentId)}, size=${imageData.width}x${imageData.height}"
|
||||
)
|
||||
|
||||
val uploadResult =
|
||||
chatViewModel.encryptAndUploadAttachment(
|
||||
EncryptAndUploadAttachmentCommand(
|
||||
payload = imageData.base64,
|
||||
attachmentPassword = encryptionContext.attachmentPassword,
|
||||
attachmentId = attachmentId,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
val uploadTag = uploadResult.transportTag
|
||||
val attachmentTransportServer = uploadResult.transportServer
|
||||
val previewValue = imageData.blurhash
|
||||
chatViewModel.logPhotoEvent(
|
||||
messageId,
|
||||
"group item#$index upload done: tag=${chatViewModel.shortPhotoLogId(uploadTag)}"
|
||||
)
|
||||
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = chatViewModel.appContext(),
|
||||
blob = imageData.base64,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
|
||||
networkAttachments.add(
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = if (uploadTag.isNotEmpty()) "" else uploadResult.encryptedBlob,
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = previewValue,
|
||||
width = imageData.width,
|
||||
height = imageData.height,
|
||||
transportTag = uploadTag,
|
||||
transportServer = attachmentTransportServer
|
||||
)
|
||||
)
|
||||
|
||||
attachmentsJsonArray.put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", previewValue)
|
||||
put("blob", "")
|
||||
put("width", imageData.width)
|
||||
put("height", imageData.height)
|
||||
put("transportTag", uploadTag)
|
||||
put("transportServer", attachmentTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
chatViewModel.sendMediaMessage(
|
||||
SendMediaMessageCommand(
|
||||
fromPublicKey = sendContext.sender,
|
||||
toPublicKey = sendContext.recipient,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
aesChachaKey = aesChachaKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
mediaAttachments = networkAttachments,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
val storedEncryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
sendContext.privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = storedEncryptedKey,
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJsonArray.toString(),
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
chatViewModel.logPhotoEvent(
|
||||
messageId,
|
||||
"group completed; totalElapsed=${System.currentTimeMillis() - groupStartedAt}ms"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
chatViewModel.logPhotoErrorEvent(messageId, "group-send", e)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
} finally {
|
||||
chatViewModel.releaseSendSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFileMessage(
|
||||
fileBase64: String,
|
||||
fileName: String,
|
||||
fileSize: Long,
|
||||
caption: String = ""
|
||||
) {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val text = caption.trim()
|
||||
val preview = "$fileSize::$fileName"
|
||||
val attachmentId = "file_$timestamp"
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
chatViewModel.createFileAttachment(
|
||||
CreateFileAttachmentCommand(
|
||||
attachmentId = attachmentId,
|
||||
preview = preview,
|
||||
blob = fileBase64
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
|
||||
chatViewModel.launchOnIo {
|
||||
try {
|
||||
runCatching {
|
||||
val appContext = chatViewModel.appContext()
|
||||
val downloadsDir =
|
||||
java.io.File(appContext.filesDir, "rosetta_downloads").apply { mkdirs() }
|
||||
val localFile = java.io.File(downloadsDir, fileName)
|
||||
if (!localFile.exists()) {
|
||||
val base64Data =
|
||||
if (fileBase64.contains(",")) fileBase64.substringAfter(",")
|
||||
else fileBase64
|
||||
val bytes = android.util.Base64.decode(base64Data, android.util.Base64.DEFAULT)
|
||||
localFile.writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = sendContext.recipient,
|
||||
privateKey = sendContext.privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||
val isSavedMessages = (sendContext.sender == sendContext.recipient)
|
||||
val uploadResult =
|
||||
chatViewModel.encryptAndUploadAttachment(
|
||||
EncryptAndUploadAttachmentCommand(
|
||||
payload = fileBase64,
|
||||
attachmentPassword = encryptionContext.attachmentPassword,
|
||||
attachmentId = attachmentId,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
val fileAttachment =
|
||||
chatViewModel.createFileAttachment(
|
||||
CreateFileAttachmentCommand(
|
||||
attachmentId = attachmentId,
|
||||
preview = preview,
|
||||
blob = "",
|
||||
transportTag = uploadResult.transportTag,
|
||||
transportServer = uploadResult.transportServer
|
||||
)
|
||||
)
|
||||
|
||||
chatViewModel.sendMediaMessage(
|
||||
SendMediaMessageCommand(
|
||||
fromPublicKey = sendContext.sender,
|
||||
toPublicKey = sendContext.recipient,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = encryptionContext.encryptedKey,
|
||||
aesChachaKey = encryptionContext.aesChachaKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
mediaAttachments = listOf(fileAttachment),
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
val attachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.FILE.value)
|
||||
put("preview", preview)
|
||||
put("blob", "")
|
||||
put("transportTag", uploadResult.transportTag)
|
||||
put("transportServer", uploadResult.transportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
val storedEncryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
sendContext.privateKey
|
||||
)
|
||||
} else {
|
||||
encryptionContext.encryptedKey
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = storedEncryptedKey,
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "file",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "file",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} finally {
|
||||
chatViewModel.releaseSendSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVideoCircleFromUri(videoUri: Uri) {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
return
|
||||
}
|
||||
|
||||
val fileSize = chatViewModel.resolveFileSizeForUri(videoUri)
|
||||
if (fileSize > 0L && fileSize > chatViewModel.maxMediaBytes()) {
|
||||
chatViewModel.releaseSendSlot()
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val attachmentId = "video_circle_$timestamp"
|
||||
val meta =
|
||||
resolveVideoCircleMetaUseCase(
|
||||
ResolveVideoCircleMetaCommand(
|
||||
context = chatViewModel.appContext(),
|
||||
videoUri = videoUri
|
||||
)
|
||||
)
|
||||
val preview = "${meta.durationSec}::${meta.mimeType}"
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = "",
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
chatViewModel.createVideoCircleAttachment(
|
||||
CreateVideoCircleAttachmentCommand(
|
||||
attachmentId = attachmentId,
|
||||
preview = preview,
|
||||
width = meta.width,
|
||||
height = meta.height,
|
||||
blob = "",
|
||||
localUri = videoUri.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
|
||||
chatViewModel.launchBackgroundUpload upload@{
|
||||
try {
|
||||
val optimisticAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.VIDEO_CIRCLE.value)
|
||||
put("preview", preview)
|
||||
put("blob", "")
|
||||
put("width", meta.width)
|
||||
put("height", meta.height)
|
||||
put("localUri", videoUri.toString())
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = "",
|
||||
encryptedKey = "",
|
||||
timestamp = timestamp,
|
||||
delivered = 0,
|
||||
attachmentsJson = optimisticAttachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = "Video message",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
try {
|
||||
val videoHex =
|
||||
encodeVideoUriToHexUseCase(
|
||||
EncodeVideoUriToHexCommand(
|
||||
context = chatViewModel.appContext(),
|
||||
videoUri = videoUri
|
||||
)
|
||||
)
|
||||
if (videoHex.isNullOrBlank()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
return@upload
|
||||
}
|
||||
|
||||
chatViewModel.sendVideoCircleMessageInternal(
|
||||
messageId = messageId,
|
||||
attachmentId = attachmentId,
|
||||
timestamp = timestamp,
|
||||
videoHex = videoHex,
|
||||
preview = preview,
|
||||
width = meta.width,
|
||||
height = meta.height,
|
||||
recipient = sendContext.recipient,
|
||||
sender = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
|
||||
} finally {
|
||||
chatViewModel.releaseSendSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAvatarMessage() {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val avatarAttachmentId = "avatar_$timestamp"
|
||||
|
||||
chatViewModel.launchOnIo sendAvatar@{
|
||||
try {
|
||||
val avatarDao = RosettaDatabase.getDatabase(chatViewModel.appContext()).avatarDao()
|
||||
val myAvatar = avatarDao.getLatestAvatar(sendContext.sender)
|
||||
if (myAvatar == null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
android.widget.Toast.makeText(
|
||||
chatViewModel.appContext(),
|
||||
"No avatar to send",
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
return@sendAvatar
|
||||
}
|
||||
|
||||
val avatarBlob = AvatarFileManager.readAvatar(chatViewModel.appContext(), myAvatar.avatar)
|
||||
if (avatarBlob.isNullOrEmpty()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
android.widget.Toast.makeText(
|
||||
chatViewModel.appContext(),
|
||||
"Failed to read avatar",
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
return@sendAvatar
|
||||
}
|
||||
|
||||
val avatarDataUrl =
|
||||
if (avatarBlob.startsWith("data:image")) {
|
||||
avatarBlob
|
||||
} else {
|
||||
"data:image/png;base64,$avatarBlob"
|
||||
}
|
||||
|
||||
val avatarBlurhash =
|
||||
runCatching {
|
||||
val cleanBase64 =
|
||||
if (avatarBlob.contains(",")) avatarBlob.substringAfter(",") else avatarBlob
|
||||
val bytes = android.util.Base64.decode(cleanBase64, android.util.Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
if (bitmap != null) {
|
||||
MediaUtils.generateBlurhashFromBitmap(bitmap)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}.getOrDefault("")
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = "",
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
chatViewModel.createAvatarAttachment(
|
||||
CreateAvatarAttachmentCommand(
|
||||
attachmentId = avatarAttachmentId,
|
||||
preview = avatarBlurhash,
|
||||
blob = avatarBlob
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = "",
|
||||
recipient = sendContext.recipient,
|
||||
privateKey = sendContext.privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||
val isSavedMessages = (sendContext.sender == sendContext.recipient)
|
||||
val uploadResult =
|
||||
chatViewModel.encryptAndUploadAttachment(
|
||||
EncryptAndUploadAttachmentCommand(
|
||||
payload = avatarDataUrl,
|
||||
attachmentPassword = encryptionContext.attachmentPassword,
|
||||
attachmentId = avatarAttachmentId,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
val avatarAttachment =
|
||||
chatViewModel.createAvatarAttachment(
|
||||
CreateAvatarAttachmentCommand(
|
||||
attachmentId = avatarAttachmentId,
|
||||
preview = avatarBlurhash,
|
||||
blob = "",
|
||||
transportTag = uploadResult.transportTag,
|
||||
transportServer = uploadResult.transportServer
|
||||
)
|
||||
)
|
||||
|
||||
chatViewModel.sendMediaMessage(
|
||||
SendMediaMessageCommand(
|
||||
fromPublicKey = sendContext.sender,
|
||||
toPublicKey = sendContext.recipient,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = encryptionContext.encryptedKey,
|
||||
aesChachaKey = encryptionContext.aesChachaKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
mediaAttachments = listOf(avatarAttachment),
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = chatViewModel.appContext(),
|
||||
blob = avatarBlob,
|
||||
attachmentId = avatarAttachmentId,
|
||||
publicKey = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
|
||||
val attachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", avatarAttachmentId)
|
||||
put("type", AttachmentType.AVATAR.value)
|
||||
put("preview", avatarBlurhash)
|
||||
put("blob", "")
|
||||
put("transportTag", uploadResult.transportTag)
|
||||
put("transportServer", uploadResult.transportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
val storedEncryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
sendContext.privateKey
|
||||
)
|
||||
} else {
|
||||
encryptionContext.encryptedKey
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = storedEncryptedKey,
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = "\$a=Avatar",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
android.widget.Toast.makeText(
|
||||
chatViewModel.appContext(),
|
||||
"Failed to send avatar: ${e.message}",
|
||||
android.widget.Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = "\$a=Avatar",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} finally {
|
||||
chatViewModel.releaseSendSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,15 +93,15 @@ import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.di.UiEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.PreferencesManager
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.CallManager
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
@@ -323,6 +323,10 @@ fun ChatDetailScreen(
|
||||
user: SearchUser,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChat: (SearchUser) -> Unit,
|
||||
protocolGateway: ProtocolGateway,
|
||||
preferencesManager: PreferencesManager,
|
||||
messageRepository: MessageRepository,
|
||||
groupRepository: GroupRepository,
|
||||
onCallClick: (SearchUser) -> Unit = {},
|
||||
onUserProfileClick: (SearchUser) -> Unit = {},
|
||||
onGroupInfoClick: (SearchUser) -> Unit = {},
|
||||
@@ -342,11 +346,11 @@ fun ChatDetailScreen(
|
||||
onVoiceWaveGestureChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val viewModel: ChatViewModel = hiltViewModel(key = "chat_${user.publicKey}")
|
||||
val messagesViewModel = remember(viewModel) { viewModel.messagesViewModel }
|
||||
val typingViewModel = remember(viewModel) { viewModel.typingViewModel }
|
||||
val voiceRecordingViewModel = remember(viewModel) { viewModel.voiceRecordingViewModel }
|
||||
val attachmentsViewModel = remember(viewModel) { viewModel.attachmentsViewModel }
|
||||
val context = LocalContext.current
|
||||
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
|
||||
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
|
||||
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
|
||||
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
|
||||
val hasNativeNavigationBar = remember(context) {
|
||||
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
|
||||
}
|
||||
@@ -437,9 +441,9 @@ fun ChatDetailScreen(
|
||||
var contextMenuIsPinned by remember { mutableStateOf(false) }
|
||||
|
||||
// 📌 PINNED MESSAGES
|
||||
val pinnedMessages by viewModel.pinnedMessages.collectAsState()
|
||||
val pinnedMessagePreviews by viewModel.pinnedMessagePreviews.collectAsState()
|
||||
val currentPinnedIndex by viewModel.currentPinnedIndex.collectAsState()
|
||||
val pinnedMessages by messagesViewModel.pinnedMessages.collectAsState()
|
||||
val pinnedMessagePreviews by messagesViewModel.pinnedMessagePreviews.collectAsState()
|
||||
val currentPinnedIndex by messagesViewModel.currentPinnedIndex.collectAsState()
|
||||
var isPinnedBannerDismissed by remember { mutableStateOf(false) }
|
||||
|
||||
// Логирование изменений selection mode
|
||||
@@ -547,7 +551,7 @@ fun ChatDetailScreen(
|
||||
|
||||
val resolvedVerified =
|
||||
runCatching {
|
||||
viewModel.resolveUserForProfile(normalizedPublicKey)?.verified
|
||||
messagesViewModel.resolveUserForProfile(normalizedPublicKey)?.verified
|
||||
?: 0
|
||||
}
|
||||
.getOrDefault(0)
|
||||
@@ -724,7 +728,7 @@ fun ChatDetailScreen(
|
||||
|
||||
val base64 = MediaUtils.uriToBase64File(context, uri)
|
||||
if (base64 != null) {
|
||||
viewModel.sendFileMessage(
|
||||
attachmentsViewModel.sendFileMessage(
|
||||
base64,
|
||||
fileName,
|
||||
fileSize
|
||||
@@ -737,7 +741,6 @@ fun ChatDetailScreen(
|
||||
// 📨 Forward: список диалогов для выбора (загружаем из базы)
|
||||
val chatsListViewModel: ChatsListViewModel = hiltViewModel()
|
||||
val dialogsList by chatsListViewModel.dialogs.collectAsState()
|
||||
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
|
||||
val groupMembersCacheKey =
|
||||
remember(user.publicKey, currentUserPublicKey) {
|
||||
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
|
||||
@@ -802,7 +805,7 @@ fun ChatDetailScreen(
|
||||
normalizedMembers
|
||||
.filter { !it.equals(currentUserPublicKey.trim(), ignoreCase = true) }
|
||||
.mapNotNull { memberKey ->
|
||||
val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null
|
||||
val resolvedUser = messagesViewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null
|
||||
val normalizedUsername = resolvedUser.username.trim().trimStart('@')
|
||||
MentionCandidate(
|
||||
username = normalizedUsername,
|
||||
@@ -839,17 +842,17 @@ fun ChatDetailScreen(
|
||||
.collectAsState(initial = false)
|
||||
|
||||
// Подключаем к ViewModel
|
||||
val messages by viewModel.messages.collectAsState()
|
||||
val isTyping by viewModel.opponentTyping.collectAsState()
|
||||
val typingDisplayName by viewModel.typingDisplayName.collectAsState()
|
||||
val typingDisplayPublicKey by viewModel.typingDisplayPublicKey.collectAsState()
|
||||
val messages by messagesViewModel.messages.collectAsState()
|
||||
val isTyping by typingViewModel.opponentTyping.collectAsState()
|
||||
val typingDisplayName by typingViewModel.typingDisplayName.collectAsState()
|
||||
val typingDisplayPublicKey by typingViewModel.typingDisplayPublicKey.collectAsState()
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val isLoadingMore by viewModel.isLoadingMore.collectAsState()
|
||||
val rawIsOnline by viewModel.opponentOnline.collectAsState()
|
||||
val isLoadingMore by messagesViewModel.isLoadingMore.collectAsState()
|
||||
val rawIsOnline by typingViewModel.opponentOnline.collectAsState()
|
||||
// If typing, the user is obviously online — never show "offline" while typing
|
||||
val isOnline = rawIsOnline || isTyping
|
||||
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
||||
val chatOpenMetrics by viewModel.chatOpenMetrics.collectAsState()
|
||||
val isLoading by messagesViewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
||||
val chatOpenMetrics by messagesViewModel.chatOpenMetrics.collectAsState()
|
||||
val performanceClass =
|
||||
remember(context.applicationContext) {
|
||||
DevicePerformanceClass.get(context.applicationContext)
|
||||
@@ -860,7 +863,7 @@ fun ChatDetailScreen(
|
||||
performanceClass == PerformanceClass.AVERAGE) &&
|
||||
chatOpenMetrics.firstListLayoutMs == null
|
||||
}
|
||||
val groupRequiresRejoin by viewModel.groupRequiresRejoin.collectAsState()
|
||||
val groupRequiresRejoin by messagesViewModel.groupRequiresRejoin.collectAsState()
|
||||
val showMessageSkeleton by
|
||||
produceState(initialValue = false, key1 = isLoading) {
|
||||
if (!isLoading) {
|
||||
@@ -872,9 +875,9 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// <20>🔥 Reply/Forward state
|
||||
val replyMessages by viewModel.replyMessages.collectAsState()
|
||||
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
||||
val pendingDeleteIds by viewModel.pendingDeleteIds.collectAsState()
|
||||
val replyMessages by messagesViewModel.replyMessages.collectAsState()
|
||||
val isForwardMode by messagesViewModel.isForwardMode.collectAsState()
|
||||
val pendingDeleteIds by messagesViewModel.pendingDeleteIds.collectAsState()
|
||||
|
||||
// Avatar-сообщения не должны попадать в selection ни при каких условиях.
|
||||
val avatarMessageIds =
|
||||
@@ -951,9 +954,9 @@ fun ChatDetailScreen(
|
||||
// Загружаем когда осталось 5 элементов до конца и не идёт загрузка
|
||||
if (total > 0 &&
|
||||
lastVisible >= total - 5 &&
|
||||
!viewModel.isLoadingMore.value
|
||||
!messagesViewModel.isLoadingMore.value
|
||||
) {
|
||||
viewModel.loadMoreMessages()
|
||||
messagesViewModel.loadMoreMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1010,7 +1013,7 @@ fun ChatDetailScreen(
|
||||
|
||||
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
|
||||
// (dedup + sort + date headers off the main thread)
|
||||
val messagesWithDates by viewModel.messagesWithDates.collectAsState()
|
||||
val messagesWithDates by messagesViewModel.messagesWithDates.collectAsState()
|
||||
val resolveSenderPublicKey: (ChatMessage?) -> String =
|
||||
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
|
||||
{ msg ->
|
||||
@@ -1256,7 +1259,7 @@ fun ChatDetailScreen(
|
||||
if (normalizedPublicKey.isNotBlank()) {
|
||||
scope.launch {
|
||||
val resolvedUser =
|
||||
viewModel.resolveUserForProfile(normalizedPublicKey)
|
||||
messagesViewModel.resolveUserForProfile(normalizedPublicKey)
|
||||
if (resolvedUser != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
@@ -1275,12 +1278,12 @@ fun ChatDetailScreen(
|
||||
// Находим индекс сообщения в списке
|
||||
var messageIndex = messagesWithDates.indexOfFirst { it.first.id == messageId }
|
||||
if (messageIndex == -1) {
|
||||
val loaded = viewModel.ensureMessageLoaded(messageId)
|
||||
val loaded = messagesViewModel.ensureMessageLoaded(messageId)
|
||||
if (loaded) {
|
||||
for (attempt in 0 until 8) {
|
||||
delay(16)
|
||||
messageIndex =
|
||||
viewModel.messagesWithDates.value.indexOfFirst {
|
||||
messagesViewModel.messagesWithDates.value.indexOfFirst {
|
||||
it.first.id == messageId
|
||||
}
|
||||
if (messageIndex != -1) break
|
||||
@@ -1420,15 +1423,15 @@ fun ChatDetailScreen(
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
isScreenActive = true
|
||||
viewModel.setDialogActive(true)
|
||||
viewModel.markVisibleMessagesAsRead()
|
||||
messagesViewModel.setDialogActive(true)
|
||||
messagesViewModel.markVisibleMessagesAsRead()
|
||||
// 🔥 Убираем уведомление этого чата из шторки
|
||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
||||
.cancelNotificationForChat(context, user.publicKey)
|
||||
}
|
||||
Lifecycle.Event.ON_PAUSE -> {
|
||||
isScreenActive = false
|
||||
viewModel.setDialogActive(false)
|
||||
messagesViewModel.setDialogActive(false)
|
||||
}
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
// Hard-stop camera/picker overlays when app goes background.
|
||||
@@ -1444,7 +1447,7 @@ fun ChatDetailScreen(
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
viewModel.closeDialog()
|
||||
messagesViewModel.closeDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1462,20 +1465,20 @@ fun ChatDetailScreen(
|
||||
) {
|
||||
val normalizedPublicKey = currentUserPublicKey.trim()
|
||||
val normalizedPrivateKey = currentUserPrivateKey.trim()
|
||||
viewModel.setUserKeys(normalizedPublicKey, normalizedPrivateKey)
|
||||
messagesViewModel.setUserKeys(normalizedPublicKey, normalizedPrivateKey)
|
||||
if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) {
|
||||
// Fresh registration path can render Chat UI before account keys arrive.
|
||||
// Avoid opening dialog with empty sender/private key.
|
||||
return@LaunchedEffect
|
||||
}
|
||||
viewModel.openDialog(user.publicKey, user.title, user.username, user.verified)
|
||||
viewModel.markVisibleMessagesAsRead()
|
||||
messagesViewModel.openDialog(user.publicKey, user.title, user.username, user.verified)
|
||||
messagesViewModel.markVisibleMessagesAsRead()
|
||||
// 🔥 Убираем уведомление этого чата из шторки при заходе
|
||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
||||
.cancelNotificationForChat(context, user.publicKey)
|
||||
// Подписываемся на онлайн статус собеседника
|
||||
if (!isSavedMessages && !isGroupChat) {
|
||||
viewModel.subscribeToOnlineStatus()
|
||||
messagesViewModel.subscribeToOnlineStatus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1488,7 +1491,7 @@ fun ChatDetailScreen(
|
||||
.distinctUntilChanged()
|
||||
.collect { isReady ->
|
||||
if (isReady) {
|
||||
viewModel.markFirstListLayoutReady()
|
||||
messagesViewModel.markFirstListLayoutReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1498,7 +1501,7 @@ fun ChatDetailScreen(
|
||||
if (!deferredEmojiPreloadStarted && chatOpenMetrics.firstListLayoutMs != null) {
|
||||
deferredEmojiPreloadStarted = true
|
||||
delay(300)
|
||||
viewModel.addChatOpenTraceEvent("deferred_emoji_preload_start")
|
||||
messagesViewModel.addChatOpenTraceEvent("deferred_emoji_preload_start")
|
||||
withContext(Dispatchers.Default) {
|
||||
com.rosetta.messenger.ui.components.EmojiCache.preload(context)
|
||||
}
|
||||
@@ -1509,14 +1512,14 @@ fun ChatDetailScreen(
|
||||
LaunchedEffect(user.publicKey, forwardTrigger) {
|
||||
val pendingForwards = ForwardManager.consumeForwardMessagesForChat(user.publicKey)
|
||||
if (pendingForwards.isNotEmpty()) {
|
||||
viewModel.sendForwardDirectly(user.publicKey, pendingForwards)
|
||||
messagesViewModel.sendForwardDirectly(user.publicKey, pendingForwards)
|
||||
}
|
||||
}
|
||||
|
||||
// Отмечаем сообщения как прочитанные только когда экран активен (RESUMED)
|
||||
LaunchedEffect(messages, isScreenActive) {
|
||||
if (messages.isNotEmpty() && isScreenActive) {
|
||||
viewModel.markVisibleMessagesAsRead()
|
||||
messagesViewModel.markVisibleMessagesAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1810,9 +1813,9 @@ fun ChatDetailScreen(
|
||||
selectedPinMessageId
|
||||
?: return@IconButton
|
||||
if (selectedPinMessageIsPinned) {
|
||||
viewModel.unpinMessage(targetId)
|
||||
messagesViewModel.unpinMessage(targetId)
|
||||
} else {
|
||||
viewModel.pinMessage(targetId)
|
||||
messagesViewModel.pinMessage(targetId)
|
||||
isPinnedBannerDismissed =
|
||||
false
|
||||
}
|
||||
@@ -1865,10 +1868,8 @@ fun ChatDetailScreen(
|
||||
.forEach {
|
||||
msg
|
||||
->
|
||||
viewModel
|
||||
.deleteMessage(
|
||||
msg.id
|
||||
)
|
||||
messagesViewModel
|
||||
.deleteMessage(msg.id)
|
||||
}
|
||||
selectedMessages =
|
||||
emptySet()
|
||||
@@ -2449,7 +2450,7 @@ fun ChatDetailScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBannerClick = {
|
||||
if (pinnedMessages.isNotEmpty()) {
|
||||
val messageId = viewModel.navigateToNextPinned()
|
||||
val messageId = messagesViewModel.navigateToNextPinned()
|
||||
if (messageId != null) {
|
||||
scrollToMessage(messageId)
|
||||
}
|
||||
@@ -2460,7 +2461,7 @@ fun ChatDetailScreen(
|
||||
// 📌 Открепляем текущий показанный пин
|
||||
val pinIdx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1)
|
||||
val pinToRemove = pinnedMessages[pinIdx]
|
||||
viewModel.unpinMessage(pinToRemove.messageId)
|
||||
messagesViewModel.unpinMessage(pinToRemove.messageId)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -2688,7 +2689,7 @@ fun ChatDetailScreen(
|
||||
{ it.id }
|
||||
)
|
||||
)
|
||||
viewModel
|
||||
messagesViewModel
|
||||
.setReplyMessages(
|
||||
selectedMsgs
|
||||
)
|
||||
@@ -2905,20 +2906,21 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
} else if (!isSystemAccount) {
|
||||
// INPUT BAR
|
||||
// INPUT BAR
|
||||
Column {
|
||||
ChatInputBarSection(
|
||||
viewModel = viewModel,
|
||||
messagesViewModel = messagesViewModel,
|
||||
typingViewModel = typingViewModel,
|
||||
isSavedMessages = isSavedMessages,
|
||||
onSend = {
|
||||
isSendingMessage = true
|
||||
viewModel.ensureSendContext(
|
||||
messagesViewModel.ensureSendContext(
|
||||
publicKey = user.publicKey,
|
||||
title = user.title,
|
||||
username = user.username,
|
||||
verified = user.verified
|
||||
)
|
||||
viewModel.sendMessage()
|
||||
messagesViewModel.sendMessage()
|
||||
scope.launch {
|
||||
delay(100)
|
||||
listState.animateScrollToItem(0)
|
||||
@@ -2928,13 +2930,13 @@ fun ChatDetailScreen(
|
||||
},
|
||||
onSendVoiceMessage = { voiceHex, durationSec, waves ->
|
||||
isSendingMessage = true
|
||||
viewModel.ensureSendContext(
|
||||
messagesViewModel.ensureSendContext(
|
||||
publicKey = user.publicKey,
|
||||
title = user.title,
|
||||
username = user.username,
|
||||
verified = user.verified
|
||||
)
|
||||
viewModel.sendVoiceMessage(
|
||||
voiceRecordingViewModel.sendVoiceMessage(
|
||||
voiceHex = voiceHex,
|
||||
durationSec = durationSec,
|
||||
waves = waves
|
||||
@@ -2954,7 +2956,7 @@ fun ChatDetailScreen(
|
||||
replyMessages = replyMessages,
|
||||
isForwardMode = isForwardMode,
|
||||
onCloseReply = {
|
||||
viewModel.clearReplyMessages()
|
||||
messagesViewModel.clearReplyMessages()
|
||||
},
|
||||
onShowForwardOptions = { panelMessages ->
|
||||
if (panelMessages.isEmpty()) {
|
||||
@@ -3011,7 +3013,7 @@ fun ChatDetailScreen(
|
||||
showMediaPicker = true
|
||||
},
|
||||
myPublicKey =
|
||||
viewModel.myPublicKey ?: "",
|
||||
messagesViewModel.myPublicKey ?: "",
|
||||
opponentPublicKey = user.publicKey,
|
||||
myPrivateKey = currentUserPrivateKey,
|
||||
isGroupChat = isGroupChat,
|
||||
@@ -3475,6 +3477,8 @@ fun ChatDetailScreen(
|
||||
currentUserPublicKey,
|
||||
currentUserUsername =
|
||||
currentUserUsername,
|
||||
groupRepository =
|
||||
groupRepository,
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
onLongClick = {
|
||||
@@ -3558,7 +3562,7 @@ fun ChatDetailScreen(
|
||||
contextMenuMessage = message
|
||||
showContextMenu = true
|
||||
scope.launch {
|
||||
contextMenuIsPinned = viewModel.isMessagePinned(message.id)
|
||||
contextMenuIsPinned = messagesViewModel.isMessagePinned(message.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3574,12 +3578,8 @@ fun ChatDetailScreen(
|
||||
if (!hasAvatar &&
|
||||
!isSystemAccount
|
||||
) {
|
||||
viewModel
|
||||
.setReplyMessages(
|
||||
listOf(
|
||||
message
|
||||
)
|
||||
)
|
||||
messagesViewModel
|
||||
.setReplyMessages(listOf(message))
|
||||
}
|
||||
},
|
||||
onVoiceWaveGestureActiveChanged = { active ->
|
||||
@@ -3593,20 +3593,16 @@ fun ChatDetailScreen(
|
||||
)
|
||||
},
|
||||
onRetry = {
|
||||
viewModel
|
||||
.retryMessage(
|
||||
message
|
||||
)
|
||||
messagesViewModel
|
||||
.retryMessage(message)
|
||||
},
|
||||
onDelete = {
|
||||
viewModel
|
||||
.deleteMessage(
|
||||
message.id
|
||||
)
|
||||
messagesViewModel
|
||||
.deleteMessage(message.id)
|
||||
},
|
||||
onCancelPhotoUpload = {
|
||||
attachmentId ->
|
||||
viewModel
|
||||
attachmentsViewModel
|
||||
.cancelOutgoingImageUpload(
|
||||
message.id,
|
||||
attachmentId
|
||||
@@ -3652,7 +3648,7 @@ fun ChatDetailScreen(
|
||||
onForwardedSenderClick = { senderPublicKey ->
|
||||
// Open profile of the forwarded message sender
|
||||
scope.launch {
|
||||
val resolvedUser = viewModel.resolveUserForProfile(senderPublicKey)
|
||||
val resolvedUser = messagesViewModel.resolveUserForProfile(senderPublicKey)
|
||||
if (resolvedUser != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
@@ -3693,7 +3689,7 @@ fun ChatDetailScreen(
|
||||
|
||||
if (targetPublicKey.isBlank()) {
|
||||
val resolvedByUsername =
|
||||
viewModel.resolveUserByUsername(normalizedUsername)
|
||||
messagesViewModel.resolveUserByUsername(normalizedUsername)
|
||||
if (resolvedByUsername != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
@@ -3717,7 +3713,7 @@ fun ChatDetailScreen(
|
||||
return@launch
|
||||
}
|
||||
|
||||
val resolvedUser = viewModel.resolveUserForProfile(targetPublicKey)
|
||||
val resolvedUser = messagesViewModel.resolveUserForProfile(targetPublicKey)
|
||||
if (resolvedUser != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
@@ -3744,7 +3740,7 @@ fun ChatDetailScreen(
|
||||
hasText = extractCopyableMessageText(msg).isNotBlank(),
|
||||
isSystemAccount = isSystemAccount,
|
||||
onReply = {
|
||||
viewModel.setReplyMessages(listOf(msg))
|
||||
messagesViewModel.setReplyMessages(listOf(msg))
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
},
|
||||
@@ -3797,16 +3793,16 @@ fun ChatDetailScreen(
|
||||
},
|
||||
onPin = {
|
||||
if (contextMenuIsPinned) {
|
||||
viewModel.unpinMessage(msg.id)
|
||||
messagesViewModel.unpinMessage(msg.id)
|
||||
} else {
|
||||
viewModel.pinMessage(msg.id)
|
||||
messagesViewModel.pinMessage(msg.id)
|
||||
isPinnedBannerDismissed = false
|
||||
}
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
},
|
||||
onDelete = {
|
||||
viewModel.deleteMessage(msg.id)
|
||||
messagesViewModel.deleteMessage(msg.id)
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
}
|
||||
@@ -4007,14 +4003,14 @@ fun ChatDetailScreen(
|
||||
showMediaPicker = false
|
||||
inputFocusTrigger++
|
||||
if (imageUris.isNotEmpty()) {
|
||||
viewModel.sendImageGroupFromUris(
|
||||
attachmentsViewModel.sendImageGroupFromUris(
|
||||
imageUris,
|
||||
caption
|
||||
)
|
||||
}
|
||||
if (videoUris.isNotEmpty()) {
|
||||
videoUris.forEach { uri ->
|
||||
viewModel.sendVideoCircleFromUri(uri)
|
||||
attachmentsViewModel.sendVideoCircleFromUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4023,9 +4019,9 @@ fun ChatDetailScreen(
|
||||
showMediaPicker = false
|
||||
inputFocusTrigger++
|
||||
if (mediaItem.isVideo) {
|
||||
viewModel.sendVideoCircleFromUri(mediaItem.uri)
|
||||
attachmentsViewModel.sendVideoCircleFromUri(mediaItem.uri)
|
||||
} else {
|
||||
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||
attachmentsViewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||
}
|
||||
},
|
||||
onOpenCamera = {
|
||||
@@ -4064,7 +4060,7 @@ fun ChatDetailScreen(
|
||||
}
|
||||
val base64 = MediaUtils.uriToBase64File(context, uri)
|
||||
if (base64 != null) {
|
||||
viewModel.sendFileMessage(base64, fileName, fileSize)
|
||||
attachmentsViewModel.sendFileMessage(base64, fileName, fileSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4083,12 +4079,12 @@ fun ChatDetailScreen(
|
||||
}
|
||||
val base64 = MediaUtils.uriToBase64File(context, uri)
|
||||
if (base64 != null) {
|
||||
viewModel.sendFileMessage(base64, fileName, fileSize)
|
||||
attachmentsViewModel.sendFileMessage(base64, fileName, fileSize)
|
||||
}
|
||||
}
|
||||
},
|
||||
onAvatarClick = {
|
||||
viewModel.sendAvatarMessage()
|
||||
attachmentsViewModel.sendAvatarMessage()
|
||||
},
|
||||
recipientName = user.title,
|
||||
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
|
||||
@@ -4124,14 +4120,14 @@ fun ChatDetailScreen(
|
||||
showMediaPicker = false
|
||||
inputFocusTrigger++
|
||||
if (imageUris.isNotEmpty()) {
|
||||
viewModel.sendImageGroupFromUris(
|
||||
attachmentsViewModel.sendImageGroupFromUris(
|
||||
imageUris,
|
||||
caption
|
||||
)
|
||||
}
|
||||
if (videoUris.isNotEmpty()) {
|
||||
videoUris.forEach { uri ->
|
||||
viewModel.sendVideoCircleFromUri(uri)
|
||||
attachmentsViewModel.sendVideoCircleFromUri(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4140,9 +4136,9 @@ fun ChatDetailScreen(
|
||||
showMediaPicker = false
|
||||
inputFocusTrigger++
|
||||
if (mediaItem.isVideo) {
|
||||
viewModel.sendVideoCircleFromUri(mediaItem.uri)
|
||||
attachmentsViewModel.sendVideoCircleFromUri(mediaItem.uri)
|
||||
} else {
|
||||
viewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||
attachmentsViewModel.sendImageFromUri(mediaItem.uri, caption)
|
||||
}
|
||||
},
|
||||
onOpenCamera = {
|
||||
@@ -4165,7 +4161,7 @@ fun ChatDetailScreen(
|
||||
filePickerLauncher.launch("*/*")
|
||||
},
|
||||
onAvatarClick = {
|
||||
viewModel.sendAvatarMessage()
|
||||
attachmentsViewModel.sendAvatarMessage()
|
||||
},
|
||||
recipientName = user.title,
|
||||
onPhotoPreviewRequested = { uri, sourceThumb, galleryUris, initialIndex ->
|
||||
@@ -4480,7 +4476,7 @@ fun ChatDetailScreen(
|
||||
|
||||
// Мультивыбор оставляем прямой отправкой как раньше.
|
||||
selectedDialogs.forEach { dialog ->
|
||||
viewModel.sendForwardDirectly(
|
||||
messagesViewModel.sendForwardDirectly(
|
||||
dialog.opponentKey,
|
||||
forwardMessages
|
||||
)
|
||||
@@ -4517,7 +4513,7 @@ fun ChatDetailScreen(
|
||||
onCaptionChange = { simplePickerPreviewCaption = it },
|
||||
isDarkTheme = isDarkTheme,
|
||||
onSend = { editedUri, caption ->
|
||||
viewModel.sendImageFromUri(editedUri, caption)
|
||||
attachmentsViewModel.sendImageFromUri(editedUri, caption)
|
||||
showMediaPicker = false
|
||||
simplePickerPreviewUri = null
|
||||
simplePickerPreviewSourceThumb = null
|
||||
@@ -4561,6 +4557,7 @@ fun ChatDetailScreen(
|
||||
// <20>📷 In-App Camera — FULLSCREEN поверх Scaffold (вне content lambda)
|
||||
if (showInAppCamera) {
|
||||
InAppCameraScreen(
|
||||
preferencesManager = preferencesManager,
|
||||
onDismiss = { showInAppCamera = false },
|
||||
onPhotoTaken = { photoUri ->
|
||||
// После камеры открываем тот же fullscreen-редактор,
|
||||
@@ -4581,7 +4578,7 @@ fun ChatDetailScreen(
|
||||
onCaptionChange = { pendingCameraPhotoCaption = it },
|
||||
isDarkTheme = isDarkTheme,
|
||||
onSend = { editedUri, caption ->
|
||||
viewModel.sendImageFromUri(editedUri, caption)
|
||||
attachmentsViewModel.sendImageFromUri(editedUri, caption)
|
||||
showMediaPicker = false
|
||||
pendingCameraPhotoUri = null
|
||||
pendingCameraPhotoCaption = ""
|
||||
@@ -4612,7 +4609,7 @@ fun ChatDetailScreen(
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
|
||||
viewModel.sendImageGroupFromUris(imageUris, groupCaption)
|
||||
attachmentsViewModel.sendImageGroupFromUris(imageUris, groupCaption)
|
||||
showMediaPicker = false
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -4625,7 +4622,8 @@ fun ChatDetailScreen(
|
||||
|
||||
@Composable
|
||||
private fun ChatInputBarSection(
|
||||
viewModel: ChatViewModel,
|
||||
messagesViewModel: MessagesViewModel,
|
||||
typingViewModel: TypingViewModel,
|
||||
isSavedMessages: Boolean,
|
||||
onSend: () -> Unit,
|
||||
onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List<Float>) -> Unit,
|
||||
@@ -4655,14 +4653,14 @@ private fun ChatInputBarSection(
|
||||
suppressKeyboard: Boolean,
|
||||
hasNativeNavigationBar: Boolean
|
||||
) {
|
||||
val inputText by viewModel.inputText.collectAsState()
|
||||
val inputText by messagesViewModel.inputText.collectAsState()
|
||||
|
||||
MessageInputBar(
|
||||
value = inputText,
|
||||
onValueChange = {
|
||||
viewModel.updateInputText(it)
|
||||
messagesViewModel.updateInputText(it)
|
||||
if (it.isNotEmpty() && !isSavedMessages) {
|
||||
viewModel.sendTypingIndicator()
|
||||
typingViewModel.sendTypingIndicator()
|
||||
}
|
||||
},
|
||||
onSend = onSend,
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.SendTypingIndicatorCommand
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.chats.models.ChatMessage
|
||||
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import com.rosetta.messenger.utils.MediaUtils
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class MessagesViewModel internal constructor(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
val messages: StateFlow<List<ChatMessage>> = chatViewModel.messages
|
||||
val messagesWithDates: StateFlow<List<Pair<ChatMessage, Boolean>>> = chatViewModel.messagesWithDates
|
||||
val isLoading: StateFlow<Boolean> = chatViewModel.isLoading
|
||||
val isLoadingMore: StateFlow<Boolean> = chatViewModel.isLoadingMore
|
||||
val groupRequiresRejoin: StateFlow<Boolean> = chatViewModel.groupRequiresRejoin
|
||||
val inputText: StateFlow<String> = chatViewModel.inputText
|
||||
val replyMessages: StateFlow<List<ChatViewModel.ReplyMessage>> = chatViewModel.replyMessages
|
||||
val isForwardMode: StateFlow<Boolean> = chatViewModel.isForwardMode
|
||||
val pendingDeleteIds: StateFlow<Set<String>> = chatViewModel.pendingDeleteIds
|
||||
val pinnedMessages = chatViewModel.pinnedMessages
|
||||
val pinnedMessagePreviews = chatViewModel.pinnedMessagePreviews
|
||||
val currentPinnedIndex = chatViewModel.currentPinnedIndex
|
||||
val chatOpenMetrics = chatViewModel.chatOpenMetrics
|
||||
val myPublicKey: String? get() = chatViewModel.myPublicKey
|
||||
|
||||
fun setUserKeys(publicKey: String, privateKey: String) = chatViewModel.setUserKeys(publicKey, privateKey)
|
||||
|
||||
fun ensureSendContext(
|
||||
publicKey: String,
|
||||
title: String = "",
|
||||
username: String = "",
|
||||
verified: Int = 0
|
||||
) = chatViewModel.ensureSendContext(publicKey, title, username, verified)
|
||||
|
||||
fun openDialog(
|
||||
publicKey: String,
|
||||
title: String = "",
|
||||
username: String = "",
|
||||
verified: Int = 0
|
||||
) = chatViewModel.openDialog(publicKey, title, username, verified)
|
||||
|
||||
fun closeDialog() = chatViewModel.closeDialog()
|
||||
|
||||
fun setDialogActive(active: Boolean) = chatViewModel.setDialogActive(active)
|
||||
|
||||
fun loadMoreMessages() = chatViewModel.loadMoreMessages()
|
||||
|
||||
fun updateInputText(text: String) = chatViewModel.updateInputText(text)
|
||||
|
||||
fun setReplyMessages(messages: List<ChatMessage>) = chatViewModel.setReplyMessages(messages)
|
||||
|
||||
fun setForwardMessages(messages: List<ChatMessage>) = chatViewModel.setForwardMessages(messages)
|
||||
|
||||
fun clearReplyMessages() = chatViewModel.clearReplyMessages()
|
||||
|
||||
suspend fun ensureMessageLoaded(messageId: String): Boolean = chatViewModel.ensureMessageLoaded(messageId)
|
||||
|
||||
fun pinMessage(messageId: String) = chatViewModel.pinMessage(messageId)
|
||||
|
||||
fun unpinMessage(messageId: String) = chatViewModel.unpinMessage(messageId)
|
||||
|
||||
suspend fun isMessagePinned(messageId: String): Boolean = chatViewModel.isMessagePinned(messageId)
|
||||
|
||||
fun navigateToNextPinned(): String? = chatViewModel.navigateToNextPinned()
|
||||
|
||||
fun unpinAllMessages() = chatViewModel.unpinAllMessages()
|
||||
|
||||
fun deleteMessage(messageId: String) = chatViewModel.deleteMessage(messageId)
|
||||
|
||||
suspend fun resolveUserForProfile(publicKey: String): SearchUser? =
|
||||
chatViewModel.resolveUserForProfile(publicKey)
|
||||
|
||||
suspend fun resolveUserByUsername(username: String, timeoutMs: Long = 3000): SearchUser? =
|
||||
chatViewModel.resolveUserByUsername(username, timeoutMs)
|
||||
|
||||
fun retryMessage(message: ChatMessage) {
|
||||
deleteMessage(message.id)
|
||||
updateInputText(message.text)
|
||||
chatViewModel.launchInViewModel {
|
||||
delay(100)
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
val hasPayload = inputText.value.trim().isNotEmpty() || replyMessages.value.isNotEmpty()
|
||||
if (!hasPayload) return
|
||||
chatViewModel.trySendTextMessage(allowPendingRecovery = true)
|
||||
}
|
||||
|
||||
fun sendForwardDirectly(
|
||||
targetPublicKey: String,
|
||||
forwardedMessages: List<ForwardManager.ForwardMessage>
|
||||
) = chatViewModel.sendForwardDirectly(targetPublicKey, forwardedMessages)
|
||||
|
||||
fun markVisibleMessagesAsRead() = chatViewModel.markVisibleMessagesAsRead()
|
||||
|
||||
fun subscribeToOnlineStatus() = chatViewModel.subscribeToOnlineStatus()
|
||||
|
||||
fun markFirstListLayoutReady() = chatViewModel.markFirstListLayoutReady()
|
||||
|
||||
fun addChatOpenTraceEvent(event: String, details: String = "") =
|
||||
chatViewModel.addChatOpenTraceEvent(event, details)
|
||||
}
|
||||
|
||||
class VoiceRecordingViewModel internal constructor(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
return
|
||||
}
|
||||
|
||||
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
|
||||
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
|
||||
chatViewModel.releaseSendSlot()
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val attachmentId = "voice_$timestamp"
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = "",
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
type = AttachmentType.VOICE,
|
||||
preview = voicePayload.preview,
|
||||
blob = voicePayload.normalizedVoiceHex
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
|
||||
chatViewModel.launchOnIo {
|
||||
try {
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = "",
|
||||
recipient = sendContext.recipient,
|
||||
privateKey = sendContext.privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||
val isSavedMessages = (sendContext.sender == sendContext.recipient)
|
||||
val uploadResult =
|
||||
chatViewModel.encryptAndUploadAttachment(
|
||||
EncryptAndUploadAttachmentCommand(
|
||||
payload = voicePayload.normalizedVoiceHex,
|
||||
attachmentPassword = encryptionContext.attachmentPassword,
|
||||
attachmentId = attachmentId,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
val voiceAttachment =
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "",
|
||||
type = AttachmentType.VOICE,
|
||||
preview = voicePayload.preview,
|
||||
transportTag = uploadResult.transportTag,
|
||||
transportServer = uploadResult.transportServer
|
||||
)
|
||||
|
||||
chatViewModel.sendMediaMessage(
|
||||
SendMediaMessageCommand(
|
||||
fromPublicKey = sendContext.sender,
|
||||
toPublicKey = sendContext.recipient,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = encryptionContext.encryptedKey,
|
||||
aesChachaKey = encryptionContext.aesChachaKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
mediaAttachments = listOf(voiceAttachment),
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
runCatching {
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = chatViewModel.appContext(),
|
||||
blob = voicePayload.normalizedVoiceHex,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
}
|
||||
|
||||
val attachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.VOICE.value)
|
||||
put("preview", voicePayload.preview)
|
||||
put("blob", "")
|
||||
put("transportTag", uploadResult.transportTag)
|
||||
put("transportServer", uploadResult.transportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
val storedEncryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
sendContext.privateKey
|
||||
)
|
||||
} else {
|
||||
encryptionContext.encryptedKey
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = storedEncryptedKey,
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = "Voice message",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = "Voice message",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} finally {
|
||||
chatViewModel.releaseSendSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentsViewModel internal constructor(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) =
|
||||
chatViewModel.cancelOutgoingImageUpload(messageId, attachmentId)
|
||||
|
||||
fun sendImageFromUri(imageUri: Uri, caption: String = "") {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext()
|
||||
if (sendContext == null) {
|
||||
chatViewModel.addProtocolLog("❌ IMG send aborted: missing keys or dialog")
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val text = caption.trim()
|
||||
val attachmentId = "img_$timestamp"
|
||||
val context = chatViewModel.appContext()
|
||||
|
||||
chatViewModel.logPhotoEvent(
|
||||
messageId,
|
||||
"start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${chatViewModel.shortPhotoLogId(attachmentId)}"
|
||||
)
|
||||
|
||||
val (imageWidth, imageHeight) = MediaUtils.getImageDimensions(context, imageUri)
|
||||
chatViewModel.logPhotoEvent(messageId, "dimensions: ${imageWidth}x$imageHeight")
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "",
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = "",
|
||||
width = imageWidth,
|
||||
height = imageHeight,
|
||||
localUri = imageUri.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
chatViewModel.logPhotoEvent(messageId, "optimistic UI added")
|
||||
|
||||
val uploadJob =
|
||||
chatViewModel.launchBackgroundUpload imageUpload@{
|
||||
try {
|
||||
chatViewModel.logPhotoEvent(messageId, "persist optimistic message in DB")
|
||||
val optimisticAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", "")
|
||||
put("blob", "")
|
||||
put("width", imageWidth)
|
||||
put("height", imageHeight)
|
||||
put("localUri", imageUri.toString())
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = "",
|
||||
encryptedKey = "",
|
||||
timestamp = timestamp,
|
||||
delivered = 0,
|
||||
attachmentsJson = optimisticAttachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
chatViewModel.logPhotoEvent(messageId, "optimistic dialog updated")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
chatViewModel.logPhotoEvent(messageId, "optimistic DB save skipped (non-fatal)")
|
||||
}
|
||||
|
||||
try {
|
||||
val convertStartedAt = System.currentTimeMillis()
|
||||
val (width, height) = MediaUtils.getImageDimensions(context, imageUri)
|
||||
val imageBase64 = MediaUtils.uriToBase64Image(context, imageUri)
|
||||
if (imageBase64 == null) {
|
||||
chatViewModel.logPhotoEvent(messageId, "base64 conversion returned null")
|
||||
if (!chatViewModel.isViewModelCleared()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
}
|
||||
return@imageUpload
|
||||
}
|
||||
chatViewModel.logPhotoEvent(
|
||||
messageId,
|
||||
"base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms"
|
||||
)
|
||||
|
||||
val blurhash = MediaUtils.generateBlurhash(context, imageUri)
|
||||
chatViewModel.logPhotoEvent(messageId, "blurhash ready: len=${blurhash.length}")
|
||||
|
||||
if (!chatViewModel.isViewModelCleared()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateOptimisticImageMessage(
|
||||
messageId = messageId,
|
||||
base64 = imageBase64,
|
||||
blurhash = blurhash,
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
}
|
||||
chatViewModel.logPhotoEvent(messageId, "optimistic payload updated in UI")
|
||||
}
|
||||
|
||||
chatViewModel.sendImageMessageInternal(
|
||||
messageId = messageId,
|
||||
imageBase64 = imageBase64,
|
||||
blurhash = blurhash,
|
||||
caption = text,
|
||||
width = width,
|
||||
height = height,
|
||||
timestamp = timestamp,
|
||||
recipient = sendContext.recipient,
|
||||
sender = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
chatViewModel.logPhotoEvent(messageId, "pipeline completed")
|
||||
} catch (e: CancellationException) {
|
||||
chatViewModel.logPhotoEvent(messageId, "pipeline cancelled by user")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
chatViewModel.logPhotoErrorEvent(messageId, "prepare+convert", e)
|
||||
if (!chatViewModel.isViewModelCleared()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.registerOutgoingImageUploadJob(messageId, uploadJob)
|
||||
}
|
||||
|
||||
fun sendImageMessage(
|
||||
imageBase64: String,
|
||||
blurhash: String,
|
||||
caption: String = "",
|
||||
width: Int = 0,
|
||||
height: Int = 0
|
||||
) {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val text = caption.trim()
|
||||
val attachmentId = "img_$timestamp"
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = blurhash,
|
||||
blob = imageBase64,
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
|
||||
chatViewModel.launchBackgroundUpload prepareGroup@{
|
||||
try {
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = sendContext.recipient,
|
||||
privateKey = sendContext.privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||
val isSavedMessages = sendContext.sender == sendContext.recipient
|
||||
val uploadResult =
|
||||
chatViewModel.encryptAndUploadAttachment(
|
||||
EncryptAndUploadAttachmentCommand(
|
||||
payload = imageBase64,
|
||||
attachmentPassword = encryptionContext.attachmentPassword,
|
||||
attachmentId = attachmentId,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
val imageAttachment =
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "",
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = blurhash,
|
||||
width = width,
|
||||
height = height,
|
||||
transportTag = uploadResult.transportTag,
|
||||
transportServer = uploadResult.transportServer
|
||||
)
|
||||
|
||||
chatViewModel.sendMediaMessage(
|
||||
SendMediaMessageCommand(
|
||||
fromPublicKey = sendContext.sender,
|
||||
toPublicKey = sendContext.recipient,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = encryptionContext.encryptedKey,
|
||||
aesChachaKey = encryptionContext.aesChachaKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
mediaAttachments = listOf(imageAttachment),
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = chatViewModel.appContext(),
|
||||
blob = imageBase64,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
|
||||
val attachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", blurhash)
|
||||
put("blob", "")
|
||||
put("width", width)
|
||||
put("height", height)
|
||||
put("transportTag", uploadResult.transportTag)
|
||||
put("transportServer", uploadResult.transportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
val storedEncryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
sendContext.privateKey
|
||||
)
|
||||
} else {
|
||||
encryptionContext.encryptedKey
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = storedEncryptedKey,
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
} finally {
|
||||
chatViewModel.releaseSendSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendImageGroupFromUris(imageUris: List<Uri>, caption: String = "") {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendImageGroupFromUris(
|
||||
imageUris = imageUris,
|
||||
caption = caption
|
||||
)
|
||||
}
|
||||
|
||||
fun sendImageGroup(images: List<ChatViewModel.ImageData>, caption: String = "") {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendImageGroup(
|
||||
images = images,
|
||||
caption = caption
|
||||
)
|
||||
}
|
||||
|
||||
fun sendFileMessage(
|
||||
fileBase64: String,
|
||||
fileName: String,
|
||||
fileSize: Long,
|
||||
caption: String = ""
|
||||
) {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendFileMessage(
|
||||
fileBase64 = fileBase64,
|
||||
fileName = fileName,
|
||||
fileSize = fileSize,
|
||||
caption = caption
|
||||
)
|
||||
}
|
||||
|
||||
fun sendVideoCircleFromUri(videoUri: Uri) {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendVideoCircleFromUri(videoUri)
|
||||
}
|
||||
|
||||
fun sendAvatarMessage() {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendAvatarMessage()
|
||||
}
|
||||
}
|
||||
|
||||
class TypingViewModel internal constructor(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
val opponentTyping: StateFlow<Boolean> = chatViewModel.opponentTyping
|
||||
val typingDisplayName: StateFlow<String> = chatViewModel.typingDisplayName
|
||||
val typingDisplayPublicKey: StateFlow<String> = chatViewModel.typingDisplayPublicKey
|
||||
val opponentOnline: StateFlow<Boolean> = chatViewModel.opponentOnline
|
||||
|
||||
fun sendTypingIndicator() {
|
||||
val now = System.currentTimeMillis()
|
||||
val context = chatViewModel.resolveTypingSendContext() ?: return
|
||||
val decision =
|
||||
chatViewModel.decideTypingSend(
|
||||
SendTypingIndicatorCommand(
|
||||
nowMs = now,
|
||||
lastSentMs = chatViewModel.lastTypingSentTimeMs(),
|
||||
throttleMs = chatViewModel.typingThrottleMs(),
|
||||
opponentPublicKey = context.opponent,
|
||||
senderPublicKey = context.sender,
|
||||
isGroupDialog = context.isGroupDialog,
|
||||
isOpponentOnline = context.isOpponentOnline
|
||||
)
|
||||
)
|
||||
if (!decision.shouldSend) return
|
||||
chatViewModel.setLastTypingSentTimeMs(decision.nextLastSentMs)
|
||||
chatViewModel.sendTypingPacket(
|
||||
privateKey = context.privateKey,
|
||||
sender = context.sender,
|
||||
opponent = context.opponent
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.update.UpdateState
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.ChevronDown
|
||||
import compose.icons.tablericons.Download
|
||||
import compose.icons.tablericons.X
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ChatsDrawerHeaderAndAccounts(
|
||||
isDarkTheme: Boolean,
|
||||
drawerBackgroundColor: Color,
|
||||
accountPublicKey: String,
|
||||
accountVerified: Int,
|
||||
allAccounts: List<EncryptedAccount>,
|
||||
effectiveCurrentPublicKey: String,
|
||||
sidebarAccountName: String,
|
||||
sidebarAccountUsername: String,
|
||||
avatarRepository: AvatarRepository?,
|
||||
accountsSectionExpanded: Boolean,
|
||||
onAccountsSectionExpandedChange: (Boolean) -> Unit,
|
||||
onProfileClick: () -> Unit,
|
||||
onCurrentAccountLongPress: () -> Unit,
|
||||
onSwitchAccount: (String) -> Unit,
|
||||
onAccountLongPress: (EncryptedAccount) -> Unit,
|
||||
onAddAccountClick: () -> Unit,
|
||||
onThemeToggleClick: () -> Unit,
|
||||
onThemeToggleCenterChanged: (Offset) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.matchParentSize().background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6)
|
||||
)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(top = 16.dp, start = 20.dp, end = 20.dp, bottom = 12.dp)
|
||||
) {
|
||||
val isOfficialByKey = MessageRepository.isSystemAccount(accountPublicKey)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(72.dp).clip(CircleShape).combinedClickable(
|
||||
onClick = onProfileClick,
|
||||
onLongClick = onCurrentAccountLongPress
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey = effectiveCurrentPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 72.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
displayName = sidebarAccountName.ifEmpty { sidebarAccountUsername }
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onThemeToggleClick,
|
||||
modifier =
|
||||
Modifier.size(48.dp).onGloballyPositioned {
|
||||
val pos = it.positionInRoot()
|
||||
onThemeToggleCenterChanged(
|
||||
Offset(
|
||||
x = pos.x + it.size.width / 2f,
|
||||
y = pos.y + it.size.height / 2f
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter =
|
||||
painterResource(
|
||||
id =
|
||||
if (isDarkTheme) R.drawable.day_theme_filled
|
||||
else R.drawable.night_mode
|
||||
),
|
||||
contentDescription = if (isDarkTheme) "Light Mode" else "Dark Mode",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (sidebarAccountName.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = sidebarAccountName,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
if (accountVerified > 0 || isOfficialByKey) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(
|
||||
verified = if (accountVerified > 0) accountVerified else 1,
|
||||
size = 15,
|
||||
modifier = Modifier.offset(y = 1.dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
badgeTint = if (isDarkTheme) PrimaryBlue else Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sidebarAccountUsername.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "@$sidebarAccountUsername",
|
||||
fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val arrowRotation by animateFloatAsState(
|
||||
targetValue = if (accountsSectionExpanded) 180f else 0f,
|
||||
animationSpec = tween(300),
|
||||
label = "arrowRotation"
|
||||
)
|
||||
IconButton(
|
||||
onClick = { onAccountsSectionExpandedChange(!accountsSectionExpanded) },
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronDown,
|
||||
contentDescription = if (accountsSectionExpanded) "Collapse" else "Expand",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(20.dp).graphicsLayer { rotationZ = arrowRotation }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = accountsSectionExpanded,
|
||||
enter = expandVertically(animationSpec = tween(250)) + fadeIn(animationSpec = tween(250)),
|
||||
exit = shrinkVertically(animationSpec = tween(250)) + fadeOut(animationSpec = tween(200))
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
allAccounts.take(3).forEach { account ->
|
||||
val isCurrentAccount = account.publicKey == effectiveCurrentPublicKey
|
||||
val displayName =
|
||||
resolveAccountDisplayName(
|
||||
account.publicKey,
|
||||
account.name,
|
||||
account.username
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().height(48.dp).combinedClickable(
|
||||
onClick = {
|
||||
if (!isCurrentAccount) {
|
||||
onSwitchAccount(account.publicKey)
|
||||
}
|
||||
},
|
||||
onLongClick = { onAccountLongPress(account) }
|
||||
).padding(start = 14.dp, end = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
||||
AvatarImage(
|
||||
publicKey = account.publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 36.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
displayName = displayName
|
||||
)
|
||||
if (isCurrentAccount) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomEnd).size(14.dp).background(
|
||||
color = drawerBackgroundColor,
|
||||
shape = CircleShape
|
||||
).padding(1.5.dp).background(
|
||||
color = Color(0xFF50A7EA),
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(8.dp).offset(x = 0.3.dp, y = 0.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(22.dp))
|
||||
|
||||
Text(
|
||||
text = displayName,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color =
|
||||
if (isDarkTheme) Color(0xFFF4FFFFFF.toInt()) else Color(0xFF444444),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().height(48.dp).clickable(onClick = onAddAccountClick)
|
||||
.padding(start = 14.dp, end = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Add,
|
||||
contentDescription = "Add Account",
|
||||
tint = if (isDarkTheme) Color(0xFF828282) else Color(0xFF889198),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(22.dp))
|
||||
|
||||
Text(
|
||||
text = "Add Account",
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isDarkTheme) Color(0xFFF4FFFFFF.toInt()) else Color(0xFF444444),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Divider(
|
||||
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatsDrawerFooter(
|
||||
isDarkTheme: Boolean,
|
||||
appVersion: String,
|
||||
updateState: UpdateState,
|
||||
onUpdateClick: (UpdateState) -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().windowInsetsPadding(
|
||||
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
|
||||
)
|
||||
) {
|
||||
val showUpdateBanner =
|
||||
updateState is UpdateState.UpdateAvailable ||
|
||||
updateState is UpdateState.Downloading ||
|
||||
updateState is UpdateState.ReadyToInstall
|
||||
|
||||
Divider(
|
||||
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8),
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
|
||||
if (!showUpdateBanner) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Rosetta v$appVersion",
|
||||
fontSize = 12.sp,
|
||||
color = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (showUpdateBanner) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth().height(50.dp).background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(Color(0xFF69BF72), Color(0xFF53B3AD))
|
||||
)
|
||||
).clickable { onUpdateClick(updateState) },
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector =
|
||||
when (updateState) {
|
||||
is UpdateState.Downloading -> TablerIcons.X
|
||||
else -> TablerIcons.Download
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text =
|
||||
when (updateState) {
|
||||
is UpdateState.Downloading -> "Downloading... ${updateState.progress}%"
|
||||
is UpdateState.ReadyToInstall -> "Install Update"
|
||||
is UpdateState.UpdateAvailable -> "Update Rosetta"
|
||||
else -> ""
|
||||
},
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (updateState is UpdateState.UpdateAvailable) {
|
||||
Text(
|
||||
text = updateState.version,
|
||||
color = Color.White.copy(alpha = 0.8f),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
if (updateState is UpdateState.Downloading) {
|
||||
CircularProgressIndicator(
|
||||
progress = updateState.progress / 100f,
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White,
|
||||
trackColor = Color.White.copy(alpha = 0.3f),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.airbnb.lottie.compose.LottieAnimation
|
||||
import com.airbnb.lottie.compose.LottieCompositionSpec
|
||||
import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
|
||||
@Composable
|
||||
internal fun EmptyRequestsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
|
||||
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.no_requests))
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = LottieConstants.IterateForever
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().background(backgroundColor),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(160.dp)
|
||||
)
|
||||
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = "No Requests",
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = secondaryTextColor,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "New message requests will appear here",
|
||||
fontSize = 15.sp,
|
||||
color = secondaryTextColor.copy(alpha = 0.7f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 📬 Экран со списком Requests (без хедера - хедер в основном TopAppBar) */
|
||||
@Composable
|
||||
fun RequestsScreen(
|
||||
requests: List<DialogUiModel>,
|
||||
isDarkTheme: Boolean,
|
||||
onRequestClick: (DialogUiModel) -> Unit,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
blockedUsers: Set<String> = emptySet(),
|
||||
pinnedChats: Set<String> = emptySet(),
|
||||
isDrawerOpen: Boolean = false,
|
||||
onTogglePin: (String) -> Unit = {},
|
||||
onDeleteDialog: (String) -> Unit = {},
|
||||
onBlockUser: (String) -> Unit = {},
|
||||
onUnblockUser: (String) -> Unit = {}
|
||||
) {
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
|
||||
var swipedItemKey by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(isDrawerOpen) {
|
||||
if (isDrawerOpen) {
|
||||
swipedItemKey = null
|
||||
}
|
||||
}
|
||||
|
||||
var dialogToDelete by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
|
||||
if (requests.isEmpty()) {
|
||||
EmptyRequestsState(isDarkTheme = isDarkTheme)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(
|
||||
requests,
|
||||
key = { it.opponentKey },
|
||||
contentType = { "request" }
|
||||
) { request ->
|
||||
val isBlocked = blockedUsers.contains(request.opponentKey)
|
||||
val isPinned = pinnedChats.contains(request.opponentKey)
|
||||
|
||||
SwipeableDialogItem(
|
||||
dialog = request,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isTyping = false,
|
||||
isBlocked = isBlocked,
|
||||
isSavedMessages = false,
|
||||
avatarRepository = avatarRepository,
|
||||
isDrawerOpen = isDrawerOpen,
|
||||
isSwipedOpen = swipedItemKey == request.opponentKey,
|
||||
onSwipeStarted = { swipedItemKey = request.opponentKey },
|
||||
onSwipeClosed = {
|
||||
if (swipedItemKey == request.opponentKey) swipedItemKey = null
|
||||
},
|
||||
onClick = {
|
||||
swipedItemKey = null
|
||||
onRequestClick(request)
|
||||
},
|
||||
onDelete = { dialogToDelete = request },
|
||||
onBlock = { dialogToBlock = request },
|
||||
onUnblock = { dialogToUnblock = request },
|
||||
isPinned = isPinned,
|
||||
onPin = { onTogglePin(request.opponentKey) }
|
||||
)
|
||||
|
||||
if (request != requests.last()) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(start = 84.dp),
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialogToDelete?.let { dialog ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogToDelete = null },
|
||||
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||
title = {
|
||||
Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"Are you sure you want to delete this chat? This action cannot be undone.",
|
||||
color = secondaryTextColor
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val opponentKey = dialog.opponentKey
|
||||
dialogToDelete = null
|
||||
onDeleteDialog(opponentKey)
|
||||
}
|
||||
) { Text("Delete", color = Color(0xFFFF3B30)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { dialogToDelete = null }) {
|
||||
Text("Cancel", color = PrimaryBlue)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
dialogToBlock?.let { dialog ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogToBlock = null },
|
||||
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||
title = {
|
||||
Text(
|
||||
"Block ${dialog.opponentTitle.ifEmpty { "User" }}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"Are you sure you want to block this user? They won't be able to send you messages.",
|
||||
color = secondaryTextColor
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val opponentKey = dialog.opponentKey
|
||||
dialogToBlock = null
|
||||
onBlockUser(opponentKey)
|
||||
}
|
||||
) { Text("Block", color = Color(0xFFFF3B30)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { dialogToBlock = null }) {
|
||||
Text("Cancel", color = PrimaryBlue)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
dialogToUnblock?.let { dialog ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogToUnblock = null },
|
||||
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
|
||||
title = {
|
||||
Text(
|
||||
"Unblock ${dialog.opponentTitle.ifEmpty { "User" }}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"Are you sure you want to unblock this user? They will be able to send you messages again.",
|
||||
color = secondaryTextColor
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val opponentKey = dialog.opponentKey
|
||||
dialogToUnblock = null
|
||||
onUnblockUser(opponentKey)
|
||||
}
|
||||
) { Text("Unblock", color = Color(0xFF34C759)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { dialogToUnblock = null }) {
|
||||
Text("Cancel", color = PrimaryBlue)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.R
|
||||
|
||||
@Composable
|
||||
fun RequestsSection(
|
||||
count: Int,
|
||||
requests: List<DialogUiModel> = emptyList(),
|
||||
isDarkTheme: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val titleColor = remember(isDarkTheme) {
|
||||
if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF525252)
|
||||
}
|
||||
val subtitleColor =
|
||||
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF6E6E6E) else Color(0xFF919191) }
|
||||
val iconBgColor =
|
||||
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFB8C2CC) }
|
||||
val badgeColor =
|
||||
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF4E4E4E) else Color(0xFFC6C9CC) }
|
||||
|
||||
val lastRequest = remember(requests) { requests.firstOrNull() }
|
||||
val subtitle = remember(lastRequest) {
|
||||
when {
|
||||
lastRequest == null -> ""
|
||||
lastRequest.opponentTitle.isNotEmpty() &&
|
||||
lastRequest.opponentTitle != lastRequest.opponentKey &&
|
||||
lastRequest.opponentTitle != lastRequest.opponentKey.take(7) -> lastRequest.opponentTitle
|
||||
lastRequest.opponentUsername.isNotEmpty() -> "@${lastRequest.opponentUsername}"
|
||||
else -> lastRequest.opponentKey.take(7)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(
|
||||
horizontal = TELEGRAM_DIALOG_AVATAR_START,
|
||||
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
|
||||
.background(iconBgColor, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.archive_filled),
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Requests",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = titleColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 14.sp,
|
||||
color = subtitleColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
|
||||
.background(badgeColor, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (count > 99) "99+" else count.toString(),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
lineHeight = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,7 @@ class ChatsListViewModel @Inject constructor(
|
||||
// Количество requests
|
||||
private val _requestsCount = MutableStateFlow(0)
|
||||
val requestsCount: StateFlow<Int> = _requestsCount.asStateFlow()
|
||||
val syncInProgress: StateFlow<Boolean> = protocolGateway.syncInProgress
|
||||
|
||||
// Заблокированные пользователи (реактивный Set из Room Flow)
|
||||
private val _blockedUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Base64
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.domain.chats.usecase.ForwardPayloadMessage
|
||||
import com.rosetta.messenger.domain.chats.usecase.SendForwardUseCase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import com.rosetta.messenger.network.TransportManager
|
||||
import com.rosetta.messenger.ui.chats.models.ChatMessage
|
||||
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
||||
import com.rosetta.messenger.ui.chats.models.ReplyData
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class ForwardCoordinator(
|
||||
private val chatViewModel: ChatViewModel,
|
||||
private val sendForwardUseCase: SendForwardUseCase
|
||||
) {
|
||||
private val forwardUuidRegex =
|
||||
Regex(
|
||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
private data class ForwardSourceMessage(
|
||||
val messageId: String,
|
||||
val senderPublicKey: String,
|
||||
val chachaKeyPlainHex: String,
|
||||
val attachments: List<MessageAttachment>
|
||||
)
|
||||
|
||||
private data class ForwardRewriteResult(
|
||||
val rewrittenAttachments: Map<String, MessageAttachment>,
|
||||
val rewrittenMessageIds: Set<String>
|
||||
)
|
||||
|
||||
fun sendForwardDirectly(
|
||||
recipientPublicKey: String,
|
||||
forwardMessages: List<ForwardManager.ForwardMessage>
|
||||
) {
|
||||
val sender = chatViewModel.currentSenderPublicKeyForSend() ?: return
|
||||
val privateKey = chatViewModel.currentSenderPrivateKeyForSend() ?: return
|
||||
if (forwardMessages.isEmpty()) return
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val isCurrentDialogTarget = chatViewModel.isCurrentDialogTarget(recipientPublicKey)
|
||||
|
||||
chatViewModel.launchOnIo {
|
||||
try {
|
||||
val context = chatViewModel.appContext()
|
||||
val isSavedMessages = (sender == recipientPublicKey)
|
||||
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = "",
|
||||
recipient = recipientPublicKey,
|
||||
privateKey = privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
val replyAttachmentId = "reply_$timestamp"
|
||||
|
||||
val forwardSources =
|
||||
forwardMessages.map { message ->
|
||||
ForwardSourceMessage(
|
||||
messageId = message.messageId,
|
||||
senderPublicKey = message.senderPublicKey,
|
||||
chachaKeyPlainHex = message.chachaKeyPlain,
|
||||
attachments = message.attachments
|
||||
)
|
||||
}
|
||||
val forwardRewriteResult =
|
||||
prepareForwardAttachmentRewrites(
|
||||
context = context,
|
||||
sourceMessages = forwardSources,
|
||||
encryptionContext = encryptionContext,
|
||||
privateKey = privateKey,
|
||||
isSavedMessages = isSavedMessages,
|
||||
timestamp = timestamp
|
||||
)
|
||||
val forwardedAttMap = forwardRewriteResult.rewrittenAttachments
|
||||
val rewrittenForwardMessageIds = forwardRewriteResult.rewrittenMessageIds
|
||||
val outgoingForwardPlainKeyHex =
|
||||
encryptionContext.plainKeyAndNonce
|
||||
?.joinToString("") { "%02x".format(it) }
|
||||
.orEmpty()
|
||||
val forwardPayloadMessages =
|
||||
forwardMessages.map { message ->
|
||||
ForwardPayloadMessage(
|
||||
messageId = message.messageId,
|
||||
senderPublicKey = message.senderPublicKey,
|
||||
senderName = message.senderName,
|
||||
text = message.text,
|
||||
timestamp = message.timestamp,
|
||||
chachaKeyPlain = message.chachaKeyPlain,
|
||||
attachments = message.attachments
|
||||
)
|
||||
}
|
||||
|
||||
fun buildForwardReplyJson(includeLocalUri: Boolean): JSONArray {
|
||||
return sendForwardUseCase.buildForwardReplyJson(
|
||||
messages = forwardPayloadMessages,
|
||||
rewrittenAttachments = forwardedAttMap,
|
||||
rewrittenMessageIds = rewrittenForwardMessageIds,
|
||||
outgoingForwardPlainKeyHex = outgoingForwardPlainKeyHex,
|
||||
includeLocalUri = includeLocalUri,
|
||||
rewriteKey = ::forwardAttachmentRewriteKey
|
||||
)
|
||||
}
|
||||
|
||||
if (isCurrentDialogTarget) {
|
||||
val optimisticForwardedMessages =
|
||||
forwardMessages.map { message ->
|
||||
val senderDisplayName =
|
||||
message.senderName.ifEmpty {
|
||||
if (message.senderPublicKey == sender) "You" else "User"
|
||||
}
|
||||
ReplyData(
|
||||
messageId = message.messageId,
|
||||
senderName = senderDisplayName,
|
||||
text = message.text,
|
||||
isFromMe = message.senderPublicKey == sender,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
attachments = message.attachments.filter { it.type != AttachmentType.MESSAGES },
|
||||
senderPublicKey = message.senderPublicKey,
|
||||
recipientPrivateKey = privateKey
|
||||
)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = "",
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
forwardedMessages = optimisticForwardedMessages
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val optimisticReplyBlobPlaintext = buildForwardReplyJson(includeLocalUri = true).toString()
|
||||
val optimisticReplyBlobForDatabase =
|
||||
CryptoManager.encryptWithPassword(optimisticReplyBlobPlaintext, privateKey)
|
||||
|
||||
val optimisticAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", replyAttachmentId)
|
||||
put("type", AttachmentType.MESSAGES.value)
|
||||
put("preview", "")
|
||||
put("width", 0)
|
||||
put("height", 0)
|
||||
put("blob", optimisticReplyBlobForDatabase)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
privateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = optimisticAttachmentsJson,
|
||||
accountPublicKey = sender,
|
||||
accountPrivateKey = privateKey,
|
||||
opponentPublicKey = recipientPublicKey
|
||||
)
|
||||
chatViewModel.refreshDialogFromMessagesForForward(sender, recipientPublicKey)
|
||||
|
||||
if (isSavedMessages && isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
val replyBlobPlaintext = buildForwardReplyJson(includeLocalUri = false).toString()
|
||||
val encryptedReplyBlob = encryptAttachmentPayload(replyBlobPlaintext, encryptionContext)
|
||||
val replyBlobForDatabase =
|
||||
CryptoManager.encryptWithPassword(replyBlobPlaintext, privateKey)
|
||||
|
||||
val finalMessageAttachments =
|
||||
listOf(
|
||||
sendForwardUseCase.buildForwardAttachment(
|
||||
replyAttachmentId = replyAttachmentId,
|
||||
encryptedReplyBlob = encryptedReplyBlob
|
||||
)
|
||||
)
|
||||
|
||||
val packet =
|
||||
PacketMessage().apply {
|
||||
fromPublicKey = sender
|
||||
toPublicKey = recipientPublicKey
|
||||
content = encryptedContent
|
||||
chachaKey = encryptedKey
|
||||
this.aesChachaKey = aesChachaKey
|
||||
this.timestamp = timestamp
|
||||
this.privateKey = privateKeyHash
|
||||
this.messageId = messageId
|
||||
attachments = finalMessageAttachments
|
||||
}
|
||||
sendForwardUseCase.dispatch(packet, isSavedMessages)
|
||||
|
||||
val finalAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
finalMessageAttachments.forEach { attachment ->
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachment.id)
|
||||
put("type", attachment.type.value)
|
||||
put("preview", attachment.preview)
|
||||
put("width", attachment.width)
|
||||
put("height", attachment.height)
|
||||
put(
|
||||
"blob",
|
||||
when (attachment.type) {
|
||||
AttachmentType.MESSAGES -> replyBlobForDatabase
|
||||
else -> ""
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.toString()
|
||||
|
||||
chatViewModel.updateMessageStatusAndAttachmentsDb(
|
||||
messageId = messageId,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = finalAttachmentsJson
|
||||
)
|
||||
|
||||
if (isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
chatViewModel.refreshDialogFromMessagesForForward(sender, recipientPublicKey)
|
||||
} catch (_: Exception) {
|
||||
if (isCurrentDialogTarget) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
}
|
||||
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun encryptAttachmentPayloadForTextSend(
|
||||
payload: String,
|
||||
context: OutgoingEncryptionContext
|
||||
): String {
|
||||
return encryptAttachmentPayload(payload, context)
|
||||
}
|
||||
|
||||
fun forwardAttachmentRewriteKeyForTextSend(messageId: String, attachmentId: String): String {
|
||||
return forwardAttachmentRewriteKey(messageId, attachmentId)
|
||||
}
|
||||
|
||||
suspend fun prepareForwardAttachmentRewritesForTextSend(
|
||||
sourceMessages: List<ChatViewModel.ReplyMessage>,
|
||||
encryptionContext: OutgoingEncryptionContext,
|
||||
privateKey: String,
|
||||
isSavedMessages: Boolean,
|
||||
timestamp: Long
|
||||
): Pair<Map<String, MessageAttachment>, Set<String>> {
|
||||
val forwardSources =
|
||||
sourceMessages.map { message ->
|
||||
ForwardSourceMessage(
|
||||
messageId = message.messageId,
|
||||
senderPublicKey = message.publicKey,
|
||||
chachaKeyPlainHex = message.chachaKeyPlainHex,
|
||||
attachments = message.attachments
|
||||
)
|
||||
}
|
||||
|
||||
val result =
|
||||
prepareForwardAttachmentRewrites(
|
||||
context = chatViewModel.appContext(),
|
||||
sourceMessages = forwardSources,
|
||||
encryptionContext = encryptionContext,
|
||||
privateKey = privateKey,
|
||||
isSavedMessages = isSavedMessages,
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
return result.rewrittenAttachments to result.rewrittenMessageIds
|
||||
}
|
||||
|
||||
private fun encryptAttachmentPayload(payload: String, context: OutgoingEncryptionContext): String {
|
||||
return CryptoManager.encryptWithPassword(payload, context.attachmentPassword)
|
||||
}
|
||||
|
||||
private fun forwardAttachmentRewriteKey(messageId: String, attachmentId: String): String {
|
||||
return "$messageId::$attachmentId"
|
||||
}
|
||||
|
||||
private fun shouldReuploadForwardAttachment(type: AttachmentType): Boolean {
|
||||
return type == AttachmentType.IMAGE ||
|
||||
type == AttachmentType.FILE ||
|
||||
type == AttachmentType.VOICE ||
|
||||
type == AttachmentType.VIDEO_CIRCLE
|
||||
}
|
||||
|
||||
private fun decodeHexBytes(value: String): ByteArray? {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
if (normalized.isEmpty() || normalized.length % 2 != 0) return null
|
||||
return runCatching {
|
||||
ByteArray(normalized.length / 2) { index ->
|
||||
normalized.substring(index * 2, index * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun extractForwardFileName(preview: String): String {
|
||||
val normalized = preview.trim()
|
||||
if (normalized.isEmpty()) return ""
|
||||
val parts = normalized.split("::")
|
||||
return when {
|
||||
parts.size >= 3 && forwardUuidRegex.matches(parts[0]) -> {
|
||||
parts.drop(2).joinToString("::").trim()
|
||||
}
|
||||
|
||||
parts.size >= 2 -> {
|
||||
parts.drop(1).joinToString("::").trim()
|
||||
}
|
||||
|
||||
else -> normalized
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBase64PayloadForForward(value: String): ByteArray? {
|
||||
val normalized = value.trim()
|
||||
if (normalized.isEmpty()) return null
|
||||
val payload =
|
||||
when {
|
||||
normalized.contains("base64,", ignoreCase = true) -> {
|
||||
normalized.substringAfter("base64,", "")
|
||||
}
|
||||
|
||||
normalized.substringBefore(",").contains("base64", ignoreCase = true) -> {
|
||||
normalized.substringAfter(",", "")
|
||||
}
|
||||
|
||||
else -> normalized
|
||||
}
|
||||
if (payload.isEmpty()) return null
|
||||
return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun resolveForwardAttachmentPayload(
|
||||
context: Application,
|
||||
sourceMessage: ForwardSourceMessage,
|
||||
attachment: MessageAttachment,
|
||||
privateKey: String
|
||||
): String? {
|
||||
if (attachment.blob.isNotBlank()) {
|
||||
return attachment.blob
|
||||
}
|
||||
if (attachment.id.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val normalizedPublicKey =
|
||||
sourceMessage.senderPublicKey.ifBlank {
|
||||
chatViewModel.currentSenderPublicKeyForSend()?.trim().orEmpty()
|
||||
}
|
||||
if (normalizedPublicKey.isNotBlank()) {
|
||||
val cachedPayload =
|
||||
AttachmentFileManager.readAttachment(
|
||||
context = context,
|
||||
attachmentId = attachment.id,
|
||||
publicKey = normalizedPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
if (!cachedPayload.isNullOrBlank()) {
|
||||
return cachedPayload
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.type == AttachmentType.FILE) {
|
||||
val fileName = extractForwardFileName(attachment.preview)
|
||||
if (fileName.isNotBlank()) {
|
||||
val localFile = File(context.filesDir, "rosetta_downloads/$fileName")
|
||||
if (localFile.exists() && localFile.length() > 0L) {
|
||||
return runCatching {
|
||||
Base64.encodeToString(localFile.readBytes(), Base64.NO_WRAP)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val downloadTag = attachment.transportTag.trim()
|
||||
val plainKey = decodeHexBytes(sourceMessage.chachaKeyPlainHex)
|
||||
if (downloadTag.isBlank() || plainKey == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val encrypted =
|
||||
runCatching {
|
||||
TransportManager.downloadFile(
|
||||
attachment.id,
|
||||
downloadTag,
|
||||
attachment.transportServer.ifBlank { null }
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
.orEmpty()
|
||||
if (encrypted.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return MessageCrypto.decryptAttachmentBlobWithPlainKey(encrypted, plainKey)
|
||||
?: MessageCrypto.decryptReplyBlob(encrypted, plainKey).takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private suspend fun prepareForwardAttachmentRewrites(
|
||||
context: Application,
|
||||
sourceMessages: List<ForwardSourceMessage>,
|
||||
encryptionContext: OutgoingEncryptionContext,
|
||||
privateKey: String,
|
||||
isSavedMessages: Boolean,
|
||||
timestamp: Long
|
||||
): ForwardRewriteResult {
|
||||
if (sourceMessages.isEmpty()) {
|
||||
return ForwardRewriteResult(emptyMap(), emptySet())
|
||||
}
|
||||
|
||||
val rewritten = mutableMapOf<String, MessageAttachment>()
|
||||
val rewrittenMessageIds = mutableSetOf<String>()
|
||||
var forwardAttachmentIndex = 0
|
||||
|
||||
for (sourceMessage in sourceMessages) {
|
||||
val candidates =
|
||||
sourceMessage.attachments.filter { shouldReuploadForwardAttachment(it.type) }
|
||||
if (candidates.isEmpty()) continue
|
||||
|
||||
val stagedForMessage = mutableMapOf<String, MessageAttachment>()
|
||||
var allRewritten = true
|
||||
|
||||
for (attachment in candidates) {
|
||||
val payload =
|
||||
resolveForwardAttachmentPayload(
|
||||
context = context,
|
||||
sourceMessage = sourceMessage,
|
||||
attachment = attachment,
|
||||
privateKey = privateKey
|
||||
)
|
||||
if (payload.isNullOrBlank()) {
|
||||
allRewritten = false
|
||||
break
|
||||
}
|
||||
|
||||
val encryptedBlob = encryptAttachmentPayload(payload, encryptionContext)
|
||||
val newAttachmentId = "fwd_${timestamp}_${forwardAttachmentIndex++}"
|
||||
val uploadTag =
|
||||
if (!isSavedMessages) {
|
||||
runCatching { TransportManager.uploadFile(newAttachmentId, encryptedBlob) }
|
||||
.getOrDefault("")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val transportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val normalizedPreview =
|
||||
if (attachment.type == AttachmentType.IMAGE) {
|
||||
attachment.preview.substringAfter("::", attachment.preview)
|
||||
} else {
|
||||
attachment.preview
|
||||
}
|
||||
|
||||
stagedForMessage[forwardAttachmentRewriteKey(sourceMessage.messageId, attachment.id)] =
|
||||
attachment.copy(
|
||||
id = newAttachmentId,
|
||||
preview = normalizedPreview,
|
||||
blob = "",
|
||||
localUri = "",
|
||||
transportTag = uploadTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
|
||||
if (attachment.type == AttachmentType.IMAGE ||
|
||||
attachment.type == AttachmentType.VOICE ||
|
||||
attachment.type == AttachmentType.VIDEO_CIRCLE
|
||||
) {
|
||||
runCatching {
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = payload,
|
||||
attachmentId = newAttachmentId,
|
||||
publicKey =
|
||||
sourceMessage.senderPublicKey.ifBlank {
|
||||
chatViewModel.currentSenderPublicKeyForSend()?.trim().orEmpty()
|
||||
},
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isSavedMessages && attachment.type == AttachmentType.FILE) {
|
||||
val fileName = extractForwardFileName(attachment.preview)
|
||||
val payloadBytes = decodeBase64PayloadForForward(payload)
|
||||
if (fileName.isNotBlank() && payloadBytes != null) {
|
||||
runCatching {
|
||||
val downloadsDir =
|
||||
File(context.filesDir, "rosetta_downloads").apply { mkdirs() }
|
||||
File(downloadsDir, fileName).writeBytes(payloadBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allRewritten && stagedForMessage.size == candidates.size) {
|
||||
rewritten.putAll(stagedForMessage)
|
||||
rewrittenMessageIds.add(sourceMessage.messageId)
|
||||
}
|
||||
}
|
||||
|
||||
return ForwardRewriteResult(
|
||||
rewrittenAttachments = rewritten,
|
||||
rewrittenMessageIds = rewrittenMessageIds
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.DraftManager
|
||||
import com.rosetta.messenger.domain.chats.usecase.SendTextMessageCommand
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.ui.chats.models.ChatMessage
|
||||
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
||||
import com.rosetta.messenger.ui.chats.models.ReplyData
|
||||
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class MessagesCoordinator(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
private var pendingTextSendRequested = false
|
||||
private var pendingTextSendReason: String = ""
|
||||
private var pendingSendRecoveryJob: Job? = null
|
||||
|
||||
private data class SendCommand(
|
||||
val messageId: String,
|
||||
val timestamp: Long,
|
||||
val text: String,
|
||||
val replyMessages: List<ChatViewModel.ReplyMessage>,
|
||||
val isForward: Boolean,
|
||||
val senderPublicKey: String,
|
||||
val senderPrivateKey: String,
|
||||
val recipientPublicKey: String
|
||||
) {
|
||||
val dialogThrottleKey: String
|
||||
get() = "$senderPublicKey:$recipientPublicKey"
|
||||
}
|
||||
|
||||
fun onSendContextChanged(trigger: String) {
|
||||
triggerPendingTextSendIfReady(trigger)
|
||||
}
|
||||
|
||||
fun onCleared() {
|
||||
clearPendingRecovery(cancelJob = true)
|
||||
}
|
||||
|
||||
fun trySendMessage(allowPendingRecovery: Boolean) {
|
||||
val text = chatViewModel.inputText.value.trim()
|
||||
val replyMsgsToSend = chatViewModel.replyMessages.value.toList()
|
||||
val isForward = chatViewModel.isForwardMode.value
|
||||
val hasPayload = text.isNotEmpty() || replyMsgsToSend.isNotEmpty()
|
||||
|
||||
if (!hasPayload) return
|
||||
|
||||
if (!chatViewModel.hasRuntimeKeysForSend()) {
|
||||
recoverRuntimeKeysIfMissing()
|
||||
}
|
||||
|
||||
val recipient = chatViewModel.currentRecipientForSend()
|
||||
val sender = chatViewModel.currentSenderPublicKeyForSend()
|
||||
val privateKey = chatViewModel.currentSenderPrivateKeyForSend()
|
||||
|
||||
if (recipient == null) {
|
||||
logSendBlocked(
|
||||
reason = "no_dialog",
|
||||
textLength = text.length,
|
||||
hasReply = replyMsgsToSend.isNotEmpty(),
|
||||
recipient = null,
|
||||
sender = sender,
|
||||
hasPrivateKey = privateKey != null
|
||||
)
|
||||
if (allowPendingRecovery) {
|
||||
schedulePendingTextSendRecovery(reason = "no_dialog", hasPayload = hasPayload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sender == null || privateKey == null) {
|
||||
logSendBlocked(
|
||||
reason = "no_keys",
|
||||
textLength = text.length,
|
||||
hasReply = replyMsgsToSend.isNotEmpty(),
|
||||
recipient = recipient,
|
||||
sender = sender,
|
||||
hasPrivateKey = privateKey != null
|
||||
)
|
||||
if (allowPendingRecovery) {
|
||||
schedulePendingTextSendRecovery(reason = "no_keys", hasPayload = hasPayload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (chatViewModel.isSendSlotBusy()) {
|
||||
logSendBlocked(
|
||||
reason = "is_sending",
|
||||
textLength = text.length,
|
||||
hasReply = replyMsgsToSend.isNotEmpty(),
|
||||
recipient = recipient,
|
||||
sender = sender,
|
||||
hasPrivateKey = true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val command =
|
||||
SendCommand(
|
||||
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
|
||||
timestamp = System.currentTimeMillis(),
|
||||
text = text,
|
||||
replyMessages = replyMsgsToSend,
|
||||
isForward = isForward,
|
||||
senderPublicKey = sender,
|
||||
senderPrivateKey = privateKey,
|
||||
recipientPublicKey = recipient
|
||||
)
|
||||
|
||||
if (!MessageThrottleManager.canSendWithContent(command.dialogThrottleKey, command.text.hashCode())) {
|
||||
logSendBlocked(
|
||||
reason = "throttle",
|
||||
textLength = command.text.length,
|
||||
hasReply = command.replyMessages.isNotEmpty(),
|
||||
recipient = command.recipientPublicKey,
|
||||
sender = command.senderPublicKey,
|
||||
hasPrivateKey = true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
logSendBlocked(
|
||||
reason = "is_sending",
|
||||
textLength = command.text.length,
|
||||
hasReply = command.replyMessages.isNotEmpty(),
|
||||
recipient = command.recipientPublicKey,
|
||||
sender = command.senderPublicKey,
|
||||
hasPrivateKey = true
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = command.messageId
|
||||
val timestamp = command.timestamp
|
||||
val fallbackName = chatViewModel.replyFallbackName()
|
||||
val currentMessages = chatViewModel.messages.value
|
||||
|
||||
val replyData: ReplyData? =
|
||||
if (command.replyMessages.isNotEmpty()) {
|
||||
val firstReply = command.replyMessages.first()
|
||||
val replyAttachments =
|
||||
currentMessages.find { it.id == firstReply.messageId }?.attachments
|
||||
?: firstReply.attachments.filter { it.type != AttachmentType.MESSAGES }
|
||||
val firstReplySenderName =
|
||||
if (firstReply.isOutgoing) {
|
||||
"You"
|
||||
} else {
|
||||
firstReply.senderName.ifEmpty { fallbackName }
|
||||
}
|
||||
|
||||
ReplyData(
|
||||
messageId = firstReply.messageId,
|
||||
senderName = firstReplySenderName,
|
||||
text = chatViewModel.resolveReplyPreviewTextForSend(firstReply.text, replyAttachments),
|
||||
isFromMe = firstReply.isOutgoing,
|
||||
isForwarded = command.isForward,
|
||||
forwardedFromName = if (command.isForward) firstReplySenderName else "",
|
||||
attachments = replyAttachments,
|
||||
senderPublicKey =
|
||||
firstReply.publicKey.ifEmpty {
|
||||
if (firstReply.isOutgoing) command.senderPublicKey else command.recipientPublicKey
|
||||
},
|
||||
recipientPrivateKey = command.senderPrivateKey
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val optimisticForwardedMessages: List<ReplyData> =
|
||||
if (command.isForward && command.replyMessages.isNotEmpty()) {
|
||||
command.replyMessages.map { message ->
|
||||
val senderDisplayName =
|
||||
if (message.isOutgoing) {
|
||||
"You"
|
||||
} else {
|
||||
message.senderName.ifEmpty { fallbackName }
|
||||
}
|
||||
val resolvedAttachments =
|
||||
currentMessages.find { it.id == message.messageId }?.attachments
|
||||
?: message.attachments.filter { it.type != AttachmentType.MESSAGES }
|
||||
|
||||
ReplyData(
|
||||
messageId = message.messageId,
|
||||
senderName = senderDisplayName,
|
||||
text = chatViewModel.resolveReplyPreviewTextForSend(message.text, resolvedAttachments),
|
||||
isFromMe = message.isOutgoing,
|
||||
isForwarded = true,
|
||||
forwardedFromName = senderDisplayName,
|
||||
attachments = resolvedAttachments,
|
||||
senderPublicKey =
|
||||
message.publicKey.ifEmpty {
|
||||
if (message.isOutgoing) command.senderPublicKey
|
||||
else command.recipientPublicKey
|
||||
},
|
||||
recipientPrivateKey = command.senderPrivateKey
|
||||
)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val isForwardToSend = command.isForward
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = command.text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
replyData = if (isForwardToSend) null else replyData,
|
||||
forwardedMessages = optimisticForwardedMessages
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
DraftManager.clearDraft(command.recipientPublicKey)
|
||||
chatViewModel.clearReplyMessages()
|
||||
chatViewModel.cacheDecryptedText(messageId, command.text)
|
||||
|
||||
chatViewModel.launchOnIo {
|
||||
try {
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = command.text,
|
||||
recipient = command.recipientPublicKey,
|
||||
privateKey = command.senderPrivateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
val encryptedContent = encryptionContext.encryptedContent
|
||||
val encryptedKey = encryptionContext.encryptedKey
|
||||
val aesChachaKey = encryptionContext.aesChachaKey
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(command.senderPrivateKey)
|
||||
val messageAttachments = mutableListOf<MessageAttachment>()
|
||||
var replyBlobForDatabase = ""
|
||||
|
||||
val isSavedMessages = (command.senderPublicKey == command.recipientPublicKey)
|
||||
val (forwardedAttachments, rewrittenForwardMessageIds) =
|
||||
if (isForwardToSend && command.replyMessages.isNotEmpty()) {
|
||||
chatViewModel.prepareForwardAttachmentRewritesForTextSend(
|
||||
sourceMessages = command.replyMessages,
|
||||
encryptionContext = encryptionContext,
|
||||
privateKey = command.senderPrivateKey,
|
||||
isSavedMessages = isSavedMessages,
|
||||
timestamp = timestamp
|
||||
)
|
||||
} else {
|
||||
emptyMap<String, MessageAttachment>() to emptySet<String>()
|
||||
}
|
||||
val outgoingForwardPlainKeyHex =
|
||||
encryptionContext.plainKeyAndNonce
|
||||
?.joinToString("") { "%02x".format(it) }
|
||||
.orEmpty()
|
||||
|
||||
if (command.replyMessages.isNotEmpty()) {
|
||||
val replyJsonArray = JSONArray()
|
||||
command.replyMessages.forEach { message ->
|
||||
val attachmentsArray = JSONArray()
|
||||
message.attachments.forEach { attachment ->
|
||||
val rewrittenAttachment =
|
||||
forwardedAttachments[
|
||||
chatViewModel.forwardAttachmentRewriteKeyForTextSend(
|
||||
message.messageId,
|
||||
attachment.id
|
||||
)
|
||||
]
|
||||
val attachmentId = rewrittenAttachment?.id ?: attachment.id
|
||||
val attachmentPreview = rewrittenAttachment?.preview ?: attachment.preview
|
||||
val attachmentTransportTag =
|
||||
rewrittenAttachment?.transportTag ?: attachment.transportTag
|
||||
val attachmentTransportServer =
|
||||
rewrittenAttachment?.transportServer ?: attachment.transportServer
|
||||
|
||||
attachmentsArray.put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", attachment.type.value)
|
||||
put("preview", attachmentPreview)
|
||||
put("width", attachment.width)
|
||||
put("height", attachment.height)
|
||||
put(
|
||||
"blob",
|
||||
if (attachment.type == AttachmentType.MESSAGES) attachment.blob else ""
|
||||
)
|
||||
put("transportTag", attachmentTransportTag)
|
||||
put("transportServer", attachmentTransportServer)
|
||||
put(
|
||||
"transport",
|
||||
JSONObject().apply {
|
||||
put("transport_tag", attachmentTransportTag)
|
||||
put("transport_server", attachmentTransportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val replyJson =
|
||||
JSONObject().apply {
|
||||
put("message_id", message.messageId)
|
||||
put("publicKey", message.publicKey)
|
||||
put("message", message.text)
|
||||
put("timestamp", message.timestamp)
|
||||
put("attachments", attachmentsArray)
|
||||
if (isForwardToSend) {
|
||||
put("forwarded", true)
|
||||
put("senderName", message.senderName)
|
||||
val effectiveForwardPlainKey =
|
||||
if (message.messageId in rewrittenForwardMessageIds &&
|
||||
outgoingForwardPlainKeyHex.isNotEmpty()
|
||||
) {
|
||||
outgoingForwardPlainKeyHex
|
||||
} else {
|
||||
message.chachaKeyPlainHex
|
||||
}
|
||||
if (effectiveForwardPlainKey.isNotEmpty()) {
|
||||
put("chacha_key_plain", effectiveForwardPlainKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
replyJsonArray.put(replyJson)
|
||||
}
|
||||
|
||||
val replyBlobPlaintext = replyJsonArray.toString()
|
||||
val encryptedReplyBlob =
|
||||
chatViewModel.encryptAttachmentPayloadForTextSend(
|
||||
payload = replyBlobPlaintext,
|
||||
context = encryptionContext
|
||||
)
|
||||
replyBlobForDatabase =
|
||||
CryptoManager.encryptWithPassword(replyBlobPlaintext, command.senderPrivateKey)
|
||||
|
||||
val replyAttachmentId = "reply_$timestamp"
|
||||
messageAttachments.add(
|
||||
MessageAttachment(
|
||||
id = replyAttachmentId,
|
||||
blob = encryptedReplyBlob,
|
||||
type = AttachmentType.MESSAGES,
|
||||
preview = ""
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
chatViewModel.sendTextMessage(
|
||||
SendTextMessageCommand(
|
||||
fromPublicKey = command.senderPublicKey,
|
||||
toPublicKey = command.recipientPublicKey,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey = encryptedKey,
|
||||
aesChachaKey = aesChachaKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
attachments = messageAttachments,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
val attachmentsJson =
|
||||
if (messageAttachments.isNotEmpty()) {
|
||||
JSONArray()
|
||||
.apply {
|
||||
messageAttachments.forEach { attachment ->
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachment.id)
|
||||
put("type", attachment.type.value)
|
||||
put("preview", attachment.preview)
|
||||
put("width", attachment.width)
|
||||
put("height", attachment.height)
|
||||
put("transportTag", attachment.transportTag)
|
||||
put("transportServer", attachment.transportServer)
|
||||
val blobToSave =
|
||||
when (attachment.type) {
|
||||
AttachmentType.MESSAGES -> replyBlobForDatabase
|
||||
else -> ""
|
||||
}
|
||||
put("blob", blobToSave)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.toString()
|
||||
} else {
|
||||
"[]"
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptedContent,
|
||||
encryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
command.senderPrivateKey
|
||||
)
|
||||
} else {
|
||||
encryptedKey
|
||||
},
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
accountPublicKey = command.senderPublicKey,
|
||||
accountPrivateKey = command.senderPrivateKey,
|
||||
opponentPublicKey = command.recipientPublicKey
|
||||
)
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = command.text,
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = command.senderPublicKey,
|
||||
accountPrivateKey = command.senderPrivateKey,
|
||||
opponentPublicKey = command.recipientPublicKey
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = command.text,
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = command.senderPublicKey,
|
||||
accountPrivateKey = command.senderPrivateKey,
|
||||
opponentPublicKey = command.recipientPublicKey
|
||||
)
|
||||
} finally {
|
||||
chatViewModel.releaseSendSlot()
|
||||
triggerPendingTextSendIfReady("send_finished")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shortSendKey(value: String?): String {
|
||||
val normalized = value?.trim().orEmpty()
|
||||
if (normalized.isEmpty()) return "<empty>"
|
||||
return if (normalized.length <= 12) normalized else "${normalized.take(12)}…"
|
||||
}
|
||||
|
||||
private fun logSendBlocked(
|
||||
reason: String,
|
||||
textLength: Int,
|
||||
hasReply: Boolean,
|
||||
recipient: String?,
|
||||
sender: String?,
|
||||
hasPrivateKey: Boolean
|
||||
) {
|
||||
chatViewModel.addProtocolLog(
|
||||
"⚠️ SEND_BLOCKED reason=$reason textLen=$textLength hasReply=$hasReply recipient=${shortSendKey(recipient)} sender=${shortSendKey(sender)} hasPriv=$hasPrivateKey isSending=${chatViewModel.isSendSlotBusy()}"
|
||||
)
|
||||
}
|
||||
|
||||
private fun recoverRuntimeKeysIfMissing(): Boolean {
|
||||
if (chatViewModel.hasRuntimeKeysForSend()) return true
|
||||
|
||||
val repositoryKeys = chatViewModel.resolveRepositoryRuntimeKeys()
|
||||
if (repositoryKeys != null) {
|
||||
chatViewModel.setUserKeys(repositoryKeys.first, repositoryKeys.second)
|
||||
chatViewModel.addProtocolLog(
|
||||
"🔄 SEND_RECOVERY restored_keys pk=${shortSendKey(repositoryKeys.first)}"
|
||||
)
|
||||
}
|
||||
|
||||
return chatViewModel.hasRuntimeKeysForSend()
|
||||
}
|
||||
|
||||
private fun schedulePendingTextSendRecovery(reason: String, hasPayload: Boolean) {
|
||||
if (!hasPayload) return
|
||||
pendingTextSendRequested = true
|
||||
pendingTextSendReason = reason
|
||||
|
||||
if (pendingSendRecoveryJob?.isActive == true) return
|
||||
|
||||
chatViewModel.addProtocolLog("⏳ SEND_RECOVERY queued reason=$reason")
|
||||
pendingSendRecoveryJob =
|
||||
chatViewModel.launchInViewModel {
|
||||
repeat(10) { attempt ->
|
||||
delay(if (attempt < 4) 180L else 350L)
|
||||
recoverRuntimeKeysIfMissing()
|
||||
triggerPendingTextSendIfReady("timer_${attempt + 1}")
|
||||
if (!pendingTextSendRequested) return@launchInViewModel
|
||||
}
|
||||
|
||||
if (pendingTextSendRequested) {
|
||||
chatViewModel.addProtocolLog(
|
||||
"⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason"
|
||||
)
|
||||
}
|
||||
clearPendingRecovery(cancelJob = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerPendingTextSendIfReady(trigger: String) {
|
||||
if (!pendingTextSendRequested) return
|
||||
|
||||
val hasPayload =
|
||||
chatViewModel.inputText.value.trim().isNotEmpty() ||
|
||||
chatViewModel.replyMessages.value.isNotEmpty()
|
||||
if (!hasPayload) {
|
||||
clearPendingRecovery(cancelJob = true)
|
||||
return
|
||||
}
|
||||
|
||||
val recipientReady = chatViewModel.currentRecipientForSend() != null
|
||||
val keysReady = chatViewModel.hasRuntimeKeysForSend()
|
||||
if (!recipientReady || !keysReady || chatViewModel.isSendSlotBusy()) return
|
||||
|
||||
chatViewModel.addProtocolLog("🚀 SEND_RECOVERY flush trigger=$trigger")
|
||||
clearPendingRecovery(cancelJob = true)
|
||||
trySendMessage(allowPendingRecovery = false)
|
||||
}
|
||||
|
||||
private fun clearPendingRecovery(cancelJob: Boolean) {
|
||||
if (cancelJob) {
|
||||
pendingSendRecoveryJob?.cancel()
|
||||
}
|
||||
pendingSendRecoveryJob = null
|
||||
pendingTextSendRequested = false
|
||||
pendingTextSendReason = ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -57,8 +57,8 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.airbnb.lottie.compose.*
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.di.UiEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
@@ -96,6 +96,8 @@ private enum class SearchTab(val title: String) {
|
||||
fun SearchScreen(
|
||||
privateKeyHash: String,
|
||||
currentUserPublicKey: String,
|
||||
accountManager: AccountManager,
|
||||
messageRepository: MessageRepository,
|
||||
isDarkTheme: Boolean,
|
||||
protocolState: ProtocolState,
|
||||
onBackClick: () -> Unit,
|
||||
@@ -105,8 +107,6 @@ fun SearchScreen(
|
||||
) {
|
||||
// Context и View для мгновенного закрытия клавиатуры
|
||||
val context = LocalContext.current
|
||||
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
|
||||
val accountManager = remember(uiDeps) { uiDeps.accountManager() }
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
if (!view.isInEditMode) {
|
||||
@@ -398,6 +398,7 @@ fun SearchScreen(
|
||||
MessagesTabContent(
|
||||
searchQuery = searchQuery,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
messageRepository = messageRepository,
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
@@ -409,6 +410,7 @@ fun SearchScreen(
|
||||
SearchTab.MEDIA -> {
|
||||
MediaTabContent(
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
messageRepository = messageRepository,
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
@@ -988,6 +990,7 @@ private data class MessageSearchResult(
|
||||
private fun MessagesTabContent(
|
||||
searchQuery: String,
|
||||
currentUserPublicKey: String,
|
||||
messageRepository: MessageRepository,
|
||||
isDarkTheme: Boolean,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
@@ -996,8 +999,6 @@ private fun MessagesTabContent(
|
||||
onUserSelect: (SearchUser) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
|
||||
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
|
||||
var results by remember { mutableStateOf<List<MessageSearchResult>>(emptyList()) }
|
||||
var isSearching by remember { mutableStateOf(false) }
|
||||
val dividerColor = remember(isDarkTheme) {
|
||||
@@ -1479,14 +1480,13 @@ private data class MediaItem(
|
||||
@Composable
|
||||
private fun MediaTabContent(
|
||||
currentUserPublicKey: String,
|
||||
messageRepository: MessageRepository,
|
||||
isDarkTheme: Boolean,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
onOpenImageViewer: (images: List<com.rosetta.messenger.ui.chats.components.ViewableImage>, initialIndex: Int, privateKey: String) -> Unit = { _, _, _ -> }
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
|
||||
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
|
||||
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -85,8 +85,7 @@ import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.di.UiEntryPoint
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import com.rosetta.messenger.data.PreferencesManager
|
||||
import com.rosetta.messenger.database.MessageEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
@@ -186,6 +185,8 @@ private fun calculateAverageColor(bitmap: android.graphics.Bitmap): Color {
|
||||
fun OtherProfileScreen(
|
||||
user: SearchUser,
|
||||
isDarkTheme: Boolean,
|
||||
preferencesManager: PreferencesManager,
|
||||
messageRepository: MessageRepository,
|
||||
onBack: () -> Unit,
|
||||
onSwipeBackEnabledChanged: (Boolean) -> Unit = {},
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
@@ -257,8 +258,6 @@ fun OtherProfileScreen(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
// 🔕 Mute state
|
||||
val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) }
|
||||
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
|
||||
var notificationsEnabled by remember { mutableStateOf(true) }
|
||||
|
||||
// 🔥 Загружаем статус блокировки при открытии экрана
|
||||
@@ -359,7 +358,6 @@ fun OtherProfileScreen(
|
||||
}
|
||||
|
||||
// <20>🟢 Наблюдаем за онлайн статусом пользователя в реальном времени
|
||||
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
|
||||
val onlineStatus by
|
||||
messageRepository
|
||||
.observeUserOnlineStatus(user.publicKey)
|
||||
|
||||
Reference in New Issue
Block a user