Compare commits
34 Commits
ad08af7f0c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d048a6f7 | |||
| 2e5dcfc99d | |||
| b32d8ed061 | |||
| 5e6d66b762 | |||
| 15bca1ec34 | |||
| aa0fa3fdb1 | |||
| cedbd204c2 | |||
| 660ba12c8c | |||
| 7f4684082e | |||
| 1a57d8f4d0 | |||
| 1cf645ea3f | |||
| 17f37b06ec | |||
| d008485a9d | |||
| 95ec00547c | |||
| edd0e73de9 | |||
| 7199e174f1 | |||
| 7521b9a11b | |||
| 484c02c867 | |||
| 53e2119feb | |||
| 664f9fd7ae | |||
| 103ae134a5 | |||
| 2066eb9f03 | |||
| 2fc652cacb | |||
| 6242e3c34f | |||
| 0c150a3113 | |||
| ab9145c77a | |||
| 45134665b3 | |||
| 38ae9bca66 | |||
| 0d21769399 | |||
| 060d0cbd12 | |||
| 4396611355 | |||
| ce7f913de7 | |||
| cb920b490d | |||
| b1fc623f5e |
593
Architecture.md
Normal file
593
Architecture.md
Normal file
@@ -0,0 +1,593 @@
|
||||
# Rosetta Android — Architecture
|
||||
|
||||
> Документ отражает текущее состояние `rosetta-android` (ветка `dev`) по коду на 2026-04-19.
|
||||
|
||||
## 1. Архитектурный профиль
|
||||
|
||||
Приложение сейчас устроено как layered + service-oriented архитектура:
|
||||
- UI: `MainActivity` + Compose-экраны + ViewModel.
|
||||
- Chat feature orchestration: `ChatViewModel` (host-state) + feature-facade VM + coordinators.
|
||||
- DI: Hilt (`@HiltAndroidApp`, `@AndroidEntryPoint`, модули в `di/AppContainer.kt`).
|
||||
- Runtime orchestration: `ProtocolGateway`/`ProtocolRuntime` -> `RuntimeComposition` (+ legacy facade `ProtocolManager`), `CallManager`, `TransportManager`, `UpdateManager`.
|
||||
- Session/Identity runtime state: `SessionStore`, `SessionReducer`, `IdentityStore`.
|
||||
- Domain сценарии отправки чата: `domain/chats/usecase/*` (text/media/forward/voice/typing/read-receipt/attachments/upload).
|
||||
- Data: `MessageRepository`, `GroupRepository`, `AccountManager`, `PreferencesManager`.
|
||||
- Persistence: Room (`RosettaDatabase`) + DataStore/SharedPreferences.
|
||||
|
||||
Основная runtime-логика сети вынесена в `RuntimeComposition`, а DI-вход в runtime идет напрямую через `ProtocolRuntime`.
|
||||
`ProtocolManager` переведен в минимальный legacy compatibility facade поверх `ProtocolRuntimeAccess`.
|
||||
DI-вход в network core идет через `ProtocolRuntime` (Hilt singleton).
|
||||
|
||||
---
|
||||
|
||||
## 2. Слои и границы
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph ENTRY["Android Entry Points"]
|
||||
E1["RosettaApplication"]
|
||||
E2["MainActivity"]
|
||||
E3["RosettaFirebaseMessagingService"]
|
||||
E4["IncomingCallActivity / CallForegroundService"]
|
||||
end
|
||||
|
||||
subgraph DI["Hilt Singleton Graph"]
|
||||
D1["ProtocolGateway -> ProtocolRuntime"]
|
||||
D2["SessionCoordinator"]
|
||||
D3["IdentityGateway"]
|
||||
D4["AccountManager / PreferencesManager"]
|
||||
D5["MessageRepository / GroupRepository"]
|
||||
end
|
||||
|
||||
subgraph CHAT_UI["Chat UI Orchestration"]
|
||||
C1["ChatDetailScreen / ChatsListScreen"]
|
||||
C2["ChatViewModel (host-state)"]
|
||||
C3["Feature VM: Messages/Voice/Attachments/Typing"]
|
||||
C4["Coordinators: Messages/Forward/Attachments"]
|
||||
end
|
||||
|
||||
subgraph CHAT_DOMAIN["Chat Domain UseCases"]
|
||||
U1["SendText / SendMedia / SendForward"]
|
||||
U2["SendVoice / SendTyping / SendReadReceipt"]
|
||||
U3["CreateAttachment / EncryptAndUpload / VideoCircle"]
|
||||
end
|
||||
|
||||
subgraph SESSION["Session / Identity Runtime"]
|
||||
S1["SessionStore / SessionReducer"]
|
||||
S2["IdentityStore / AppSessionCoordinator"]
|
||||
end
|
||||
|
||||
subgraph NET["Network Runtime"]
|
||||
N0["ProtocolRuntime"]
|
||||
N1["RuntimeComposition (wiring only)"]
|
||||
N2["RuntimeConnectionControlFacade"]
|
||||
N3["RuntimeDirectoryFacade"]
|
||||
N4["RuntimePacketIoFacade"]
|
||||
N5["Assemblies: Transport / Messaging / State / Routing"]
|
||||
N6["ProtocolInstanceManager -> Protocol"]
|
||||
N7["ProtocolManager (legacy compat)"]
|
||||
end
|
||||
|
||||
subgraph DATA["Data + Persistence"]
|
||||
R1["MessageRepository / GroupRepository"]
|
||||
R2["Room: RosettaDatabase"]
|
||||
end
|
||||
|
||||
ENTRY --> DI
|
||||
DI --> SESSION
|
||||
DI --> DATA
|
||||
DI --> CHAT_UI
|
||||
DI --> N0
|
||||
CHAT_UI --> CHAT_DOMAIN
|
||||
CHAT_UI --> R1
|
||||
CHAT_DOMAIN --> D1
|
||||
D1 --> N0
|
||||
N0 --> N1
|
||||
N1 --> N2
|
||||
N1 --> N3
|
||||
N1 --> N4
|
||||
N1 --> N5
|
||||
N5 --> N6
|
||||
N7 --> N0
|
||||
SESSION --> N0
|
||||
R1 --> N0
|
||||
R1 --> R2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DI и composition root
|
||||
|
||||
### 3.1 Hilt
|
||||
- `RosettaApplication` помечен `@HiltAndroidApp`.
|
||||
- Entry points уровня Android-компонентов: `MainActivity`, `IncomingCallActivity`, `CallForegroundService`, `RosettaFirebaseMessagingService`.
|
||||
- Основные модули:
|
||||
- `AppDataModule`: `AccountManager`, `PreferencesManager`.
|
||||
- `AppGatewayModule`: биндинги `ProtocolGateway`, `SessionCoordinator`, `IdentityGateway`, `ProtocolClient`.
|
||||
- `ProtocolGateway` теперь биндится напрямую на `ProtocolRuntime` (без отдельного `ProtocolGatewayImpl` proxy-класса).
|
||||
- `ProtocolClientImpl` остается узким техническим adapter-слоем для repository (`send/sendWithRetry/addLog/wait/unwait`) и делегирует в `ProtocolRuntime` через `Provider<ProtocolRuntime>`.
|
||||
|
||||
### 3.2 UI bridge для composable-слоя
|
||||
UI-композаблы больше не получают runtime-зависимости через `UiEntryPoint`/`EntryPointAccessors`.
|
||||
`UiDependencyAccess.get(...)` из `ui/*` удален (DoD: 0 вхождений).
|
||||
|
||||
Для non-Hilt `object`-ов (`CallManager`, `TransportManager`, `UpdateManager`, utils)
|
||||
используется `ProtocolRuntimeAccess` + `ProtocolRuntimePort`:
|
||||
- runtime ставится в `RosettaApplication` через `ProtocolRuntimeAccess.install(protocolRuntime)`;
|
||||
- доступ до install запрещен (fail-fast), чтобы не было тихого отката в legacy facade.
|
||||
|
||||
### 3.3 Разрыв DI-cycle (Hilt)
|
||||
После перехода на `ProtocolRuntime` был закрыт цикл зависимостей:
|
||||
`MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository`.
|
||||
|
||||
Текущее решение:
|
||||
- `ProtocolClientImpl` получает `Provider<ProtocolRuntime>` (ленивая резолюция).
|
||||
- `ProtocolRuntime` остается singleton-композицией для `MessageRepository/GroupRepository/AccountManager`.
|
||||
- На `assembleDebug/assembleRelease` больше нет `Dagger/DependencyCycle`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Session lifecycle: единый source of truth
|
||||
|
||||
### 4.1 Модель состояния
|
||||
`SessionState`:
|
||||
- `LoggedOut`
|
||||
- `AuthInProgress(publicKey?, reason)`
|
||||
- `Ready(account, reason)`
|
||||
|
||||
### 4.2 Модель событий
|
||||
`SessionAction`:
|
||||
- `LoggedOut`
|
||||
- `AuthInProgress`
|
||||
- `Ready`
|
||||
- `SyncFromCachedAccount`
|
||||
|
||||
### 4.3 Контур изменения состояния
|
||||
- Только `SessionStore` владеет `MutableStateFlow<SessionState>`.
|
||||
- Только `SessionReducer` вычисляет next-state.
|
||||
- `SessionCoordinator`/`AppSessionCoordinator` больше не мутируют состояние напрямую, а делают `dispatch(action)`.
|
||||
- `SessionStore.dispatch(...)` синхронно обновляет `IdentityStore` для консистентности account/profile/auth-runtime.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["AuthFlow / MainActivity / Unlock / SetPassword"] --> B["SessionCoordinator.dispatch(action)"]
|
||||
B --> C["SessionStore.dispatch(action)"]
|
||||
C --> D["SessionReducer.reduce(current, action)"]
|
||||
D --> E["StateFlow<SessionState>"]
|
||||
C --> F["IdentityStore sync"]
|
||||
```
|
||||
|
||||
### 4.4 State machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> LoggedOut
|
||||
LoggedOut --> AuthInProgress: dispatch(AuthInProgress)
|
||||
AuthInProgress --> Ready: dispatch(Ready)
|
||||
AuthInProgress --> LoggedOut: dispatch(LoggedOut)
|
||||
Ready --> LoggedOut: dispatch(LoggedOut)
|
||||
Ready --> Ready: dispatch(SyncFromCachedAccount(account))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Network orchestration после декомпозиции
|
||||
|
||||
`ProtocolRuntime` — DI-фасад runtime слоя и реализация `ProtocolGateway`/`ProtocolRuntimePort`.
|
||||
`RuntimeComposition` — composition-root runtime слоя (сборка service graph + orchestration wiring) и делегирует отдельные зоны ответственности:
|
||||
- Публичные runtime API proxy-методы (connect/auth/directory/packet I/O) убраны из `RuntimeComposition`; публичный runtime surface теперь удерживается в `ProtocolRuntime` + `Runtime*Facade`.
|
||||
- `RuntimeTransportAssembly`: отдельный assembly-блок transport/network wiring (`NetworkReconnectWatcher`, `NetworkConnectivityFacade`, `ProtocolInstanceManager`, `PacketSubscriptionRegistry/Facade`).
|
||||
- `RuntimeMessagingAssembly`: отдельный assembly-блок packet/message/sync wiring (`PacketRouter`, `OutgoingMessagePipelineService`, `PresenceTypingService`, `SyncCoordinator`, `CallSignalBridge`, `InboundPacketHandlerRegistrar`).
|
||||
- `RuntimeStateAssembly`: отдельный assembly-блок connection-state wiring (`ReadyPacketGate`, `BootstrapCoordinator`, `RuntimeLifecycleStateMachine`, `OwnProfileFallbackTimerService`, `ProtocolLifecycleStateStoreImpl`).
|
||||
- `RuntimeRoutingAssembly`: отдельный assembly-блок event-routing wiring (`ConnectionEventRouter` + `ProtocolConnectionSupervisor` как единый orchestration-шаг).
|
||||
- `RuntimeConnectionControlFacade`: high-level connection/session control API (`initialize*`, `connect/reconnect/sync/auth`, `disconnect/destroy`, auth/connect checks).
|
||||
- `RuntimeDirectoryFacade`: directory/device/typing API (`resolve/search user`, cached user lookup, own-profile signal, device accept/decline, typing snapshot by dialog).
|
||||
- `RuntimePacketIoFacade`: packet I/O API (`send/sendWithRetry/resolveRetry`, call/webrtc/ice bridge, `wait/unwait/packetFlow`).
|
||||
- `ProtocolInstanceManager`: singleton lifecycle `Protocol` (create/state/lastError/disconnect/destroy/isAuthenticated/isConnected).
|
||||
- `RuntimeLifecycleStateMachine`: runtime lifecycle state (`ConnectionLifecycleState` + `ConnectionBootstrapContext`) и пересчет transition-логики через `BootstrapCoordinator`.
|
||||
- `RuntimeInitializationCoordinator`: one-time bootstrap runtime (`initialize`, регистрация packet handlers, старт state monitoring, проверка bound DI dependencies).
|
||||
- `ProtocolLifecycleStateStoreImpl`: отдельное lifecycle-state хранилище (`bootstrapContext`, `sessionGeneration`, last-subscribed-token clear hooks, own-profile fallback timer hooks).
|
||||
- `OwnProfileFallbackTimerService`: управление таймером own-profile fallback (`schedule/cancel`) с генерацией timeout-события.
|
||||
- `AuthRestoreService`: восстановление auth-handshake credentials из локального кеша аккаунта (`preferredPublicKey`/fallback + validation + authenticate trigger).
|
||||
- `RuntimeShutdownCoordinator`: централизованный graceful runtime shutdown (`stop watcher`, `destroy subscriptions/protocol`, `clear runtime state/services`, `cancel scope`).
|
||||
- `ConnectionEventRouter`: маршрутизация `ConnectionEvent` к соответствующим coordinator/service handlers без `when(event)` внутри core.
|
||||
- `NetworkConnectivityFacade`: единая обертка network-availability/wait/stop policy поверх `NetworkReconnectWatcher`.
|
||||
- `ConnectionOrchestrator`: connect/reconnect/authenticate + network-aware поведение.
|
||||
- `ProtocolLifecycleCoordinator`: lifecycle/auth/bootstrap transitions (`ProtocolStateChanged`, `SyncCompleted`, own-profile resolved/fallback).
|
||||
- `ProtocolAccountSessionCoordinator`: account-bound transitions (`InitializeAccount`, `Disconnect`) и reset account/session state.
|
||||
- `ReadyPacketDispatchCoordinator`: обработка `SendPacket` через ready-gate (`bypass/enqueue/flush trigger + reconnect policy`).
|
||||
- `ProtocolPostAuthBootstrapCoordinator`: post-auth orchestration (`canRun/tryRun bootstrap`, own profile fetch, push subscribe, post-sync retry/missing-user-info).
|
||||
- `BootstrapCoordinator`: пересчет lifecycle (`AUTHENTICATED`/`BOOTSTRAPPING`/`READY`) и работа с `ReadyPacketGate`.
|
||||
- `SyncCoordinator`: sync state machine (request/timeout, BATCH_START/BATCH_END/NOT_NEEDED, foreground/manual sync).
|
||||
- `PresenceTypingService`: in-memory typing presence с TTL и snapshot `StateFlow`.
|
||||
- `PacketRouter`: user/search cache + resolve/search continuation routing.
|
||||
- `OwnProfileSyncService`: применение собственного профиля из search и синхронизация `IdentityStore`.
|
||||
- `RetryQueueService`: retry очереди отправки `PacketMessage`.
|
||||
- `AuthBootstrapCoordinator`: session-aware post-auth bootstrap (transport/update/profile/sync/push).
|
||||
- `NetworkReconnectWatcher`: единый watcher ожидания сети и fast-reconnect триггеры.
|
||||
- `DeviceVerificationService`: состояние списка устройств + pending verification + resolve packets.
|
||||
- `DeviceRuntimeService`: device-id/handshake device + device verification orchestration.
|
||||
- `CallSignalBridge`: call/webrtc/ice signal send+subscribe bridge.
|
||||
- `PacketSubscriptionFacade`: thin bridge `waitPacket/unwaitPacket/packetFlow` API поверх `PacketSubscriptionRegistry`.
|
||||
- `PacketSubscriptionRegistry`: централизованные подписки на пакеты и fan-out.
|
||||
- `InboundPacketHandlerRegistrar`: централизованная регистрация inbound packet handlers (`0x03/0x05/0x06/0x07/0x08/0x09/0x0B/0x0F/0x14/0x17/0x19`) и делегирование в sync/repository/device/typing/profile сервисы.
|
||||
- `InboundTaskQueueService`: sequential inbound task queue (`enqueue` + `whenTasksFinish`) для Desktop parity (`dialogQueue` semantics).
|
||||
- `OutgoingMessagePipelineService`: отправка `PacketMessage` с retry/error policy.
|
||||
- `ProtocolDebugLogService`: буферизация UI-логов, throttle flush и персистентный protocol trace.
|
||||
|
||||
На hot-path `ProtocolRuntime` берет runtime API (`RuntimeConnectionControlFacade`/`RuntimeDirectoryFacade`/`RuntimePacketIoFacade`) напрямую из `RuntimeComposition`, поэтому лишний proxy-hop через публичные методы composition не используется.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["ProtocolRuntime (ProtocolGateway impl)"] --> RC["RuntimeComposition"]
|
||||
RC --> RCC["RuntimeConnectionControlFacade"]
|
||||
RC --> RDF["RuntimeDirectoryFacade"]
|
||||
RC --> RPF["RuntimePacketIoFacade"]
|
||||
|
||||
RC --> RTA["RuntimeTransportAssembly"]
|
||||
RC --> RMA["RuntimeMessagingAssembly"]
|
||||
RC --> RSA["RuntimeStateAssembly"]
|
||||
RC --> RRA["RuntimeRoutingAssembly"]
|
||||
|
||||
RTA --> PIM["ProtocolInstanceManager"]
|
||||
RTA --> PSF["PacketSubscriptionFacade"]
|
||||
RTA --> NCF["NetworkConnectivityFacade"]
|
||||
|
||||
RMA --> SC["SyncCoordinator"]
|
||||
RMA --> PROUTER["PacketRouter"]
|
||||
RMA --> OMPS["OutgoingMessagePipelineService"]
|
||||
RMA --> CSB["CallSignalBridge"]
|
||||
RMA --> IPR["InboundPacketHandlerRegistrar"]
|
||||
|
||||
RSA --> RLSM["RuntimeLifecycleStateMachine"]
|
||||
RSA --> BC["BootstrapCoordinator"]
|
||||
RSA --> RPG["ReadyPacketGate"]
|
||||
RSA --> PLSS["ProtocolLifecycleStateStoreImpl"]
|
||||
|
||||
RRA --> SUP["ProtocolConnectionSupervisor"]
|
||||
RRA --> CER["ConnectionEventRouter"]
|
||||
|
||||
CER --> CO["ConnectionOrchestrator"]
|
||||
CER --> PLC["ProtocolLifecycleCoordinator"]
|
||||
CER --> PAC["ProtocolAccountSessionCoordinator"]
|
||||
CER --> RPDC["ReadyPacketDispatchCoordinator"]
|
||||
|
||||
PIM --> P["Protocol (WebSocket + packet codec)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Централизация packet-subscriptions
|
||||
|
||||
Проблема дублирующихся low-level подписок закрыта через `PacketSubscriptionRegistry`:
|
||||
- На каждый `packetId` создается один bus и один bridge на `Protocol.waitPacket(...)`.
|
||||
- Дальше packet fan-out идет в:
|
||||
- callback API (`waitPacket/unwaitPacket`),
|
||||
- `SharedFlow` (`packetFlow(packetId)`).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Feature as Feature/Service
|
||||
participant PR as ProtocolRuntime
|
||||
participant RPF as RuntimePacketIoFacade
|
||||
participant PSF as PacketSubscriptionFacade
|
||||
participant REG as PacketSubscriptionRegistry
|
||||
participant P as Protocol
|
||||
|
||||
Feature->>PR: waitPacket(0x03, callback)
|
||||
PR->>RPF: waitPacket(0x03, callback)
|
||||
RPF->>PSF: waitPacket(0x03, callback)
|
||||
PSF->>REG: addCallback(0x03, callback)
|
||||
REG->>P: waitPacket(0x03, protocolBridge) [once per packetId]
|
||||
|
||||
P-->>REG: Packet(0x03)
|
||||
REG-->>Feature: callback(packet)
|
||||
REG-->>Feature: packetFlow(0x03).emit(packet)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Чат-модуль: декомпозиция и message pipeline
|
||||
|
||||
### 7.1 Domain слой для сценариев отправки
|
||||
|
||||
Use-case слой вынесен из UI-пакета в `domain/chats/usecase`:
|
||||
- `SendTextMessageUseCase`
|
||||
- `SendMediaMessageUseCase`
|
||||
- `SendForwardUseCase`
|
||||
- `SendVoiceMessageUseCase`
|
||||
- `SendTypingIndicatorUseCase`
|
||||
- `SendReadReceiptUseCase`
|
||||
- `CreateFileAttachmentUseCase`
|
||||
- `CreateAvatarAttachmentUseCase`
|
||||
- `CreateVideoCircleAttachmentUseCase`
|
||||
- `EncryptAndUploadAttachmentUseCase`
|
||||
|
||||
Роли use-case слоя:
|
||||
- `SendTextMessageUseCase`/`SendMediaMessageUseCase`: сборка `PacketMessage` + dispatch через `ProtocolGateway` (с учетом `isSavedMessages`).
|
||||
- `SendForwardUseCase`: сборка forward-reply JSON, сборка forward attachment и dispatch.
|
||||
- `SendVoiceMessageUseCase`/`SendTypingIndicatorUseCase`: normalization/decision логика (preview waveform, throttle/guard).
|
||||
- `SendReadReceiptUseCase`: отдельный сценарий отправки `PacketRead`.
|
||||
- `Create*AttachmentUseCase`: типобезопасная сборка attachment-моделей.
|
||||
- `EncryptAndUploadAttachmentUseCase`: общий шаг `encrypt + upload` с возвратом `transportTag/transportServer`.
|
||||
|
||||
Текущий поток отправки:
|
||||
1. Feature VM/Coordinator через `ChatViewModel`-host формирует command + encryption context.
|
||||
2. UseCase строит payload/decision (`PacketMessage` или typed decision model).
|
||||
3. `ProtocolGateway.sendMessageWithRetry(...)` уводит пакет в network runtime.
|
||||
4. `RuntimeComposition` (через `ProtocolRuntime`) регистрирует пакет в `RetryQueueService` и отправляет в сеть.
|
||||
5. До `READY` пакет буферизуется через `ReadyPacketGate`, затем flush.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FVM["Feature ViewModel"] --> CVM["ChatViewModel (host)"]
|
||||
CVM --> COORD["Messages/Forward/Attachments Coordinator"]
|
||||
CVM --> UC["domain/chats/usecase/*"]
|
||||
COORD --> UC
|
||||
UC --> GW["ProtocolGateway.send / sendMessageWithRetry"]
|
||||
GW --> PR["ProtocolRuntime"]
|
||||
PR --> RPF["RuntimePacketIoFacade"]
|
||||
RPF --> OMP["OutgoingMessagePipelineService"]
|
||||
OMP --> RQ["RetryQueueService"]
|
||||
OMP --> RR["RuntimeRoutingAssembly"]
|
||||
RR --> RG["ReadyPacketGate / ReadyPacketDispatchCoordinator"]
|
||||
RG --> P["Protocol.sendPacket"]
|
||||
```
|
||||
|
||||
### 7.2 Декомпозиция ChatViewModel (host + feature/coordinator слой)
|
||||
|
||||
Для UI-слоя введены feature-facade viewmodel-классы:
|
||||
- `MessagesViewModel`
|
||||
- `VoiceRecordingViewModel`
|
||||
- `AttachmentsViewModel`
|
||||
- `TypingViewModel`
|
||||
|
||||
Они живут в `ui/chats/ChatFeatureViewModels.kt` и компонуются внутри `ChatViewModel`.
|
||||
Текущий статус:
|
||||
- `VoiceRecordingViewModel` содержит реальный send-pipeline голосовых сообщений.
|
||||
- `TypingViewModel` содержит реальную отправку typing indicator (throttle + packet send).
|
||||
- `MessagesViewModel` содержит orchestration-level entrypoint (`sendMessage`, `retryMessage`), а core text send pipeline вынесен в `MessagesCoordinator` (pending recovery/throttle + reply/forward packet assembly).
|
||||
- `ForwardCoordinator` вынесен из `ChatViewModel`: `sendForwardDirectly` + forward rewrite/re-upload helper-ветка (включая payload resolve из cache/download).
|
||||
- `AttachmentsCoordinator` вынесен из `ChatViewModel`: `updateOptimisticImageMessage`, `sendImageMessageInternal`, `sendVideoCircleMessageInternal` + local cache/update (`localUri` cleanup после отправки).
|
||||
- `AttachmentsFeatureCoordinator` вынесен из `AttachmentsViewModel`: high-level media orchestration для `sendImageGroup*`, `sendFileMessage`, `sendVideoCircleFromUri`, `sendAvatarMessage`.
|
||||
- `AttachmentsViewModel` теперь концентрируется на facade-методах и `sendImageFromUri`/`sendImageMessage`, делегируя крупные media-ветки в coordinator-слой.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
CD["ChatDetailScreen"] --> MVM["MessagesViewModel"]
|
||||
CD --> TVM["TypingViewModel"]
|
||||
CD --> VVM["VoiceRecordingViewModel"]
|
||||
CD --> AVM["AttachmentsViewModel"]
|
||||
MVM --> CVM["ChatViewModel (host-state)"]
|
||||
TVM --> CVM
|
||||
VVM --> CVM
|
||||
AVM --> CVM
|
||||
CVM --> MCO["MessagesCoordinator"]
|
||||
CVM --> FCO["ForwardCoordinator"]
|
||||
CVM --> ACO["AttachmentsCoordinator"]
|
||||
AVM --> AFCO["AttachmentsFeatureCoordinator"]
|
||||
CVM --> U["domain/chats/usecase/*"]
|
||||
MCO --> U
|
||||
FCO --> U
|
||||
ACO --> U
|
||||
AFCO --> U
|
||||
```
|
||||
|
||||
Важно: после вынесения `MessagesCoordinator`, `ForwardCoordinator` и `AttachmentsCoordinator` `ChatViewModel` выступает как host-state и bridge для feature/coordinator подсистем.
|
||||
|
||||
### 7.3 Декомпозиция ChatsListScreen
|
||||
|
||||
Из `ChatsListScreen.kt` вынесены отдельные composable-секции:
|
||||
- `ChatItem` -> `ChatsListChatItem.kt`
|
||||
- `RequestsSection` -> `ChatsListRequestsSection.kt`
|
||||
- `DrawerContent` -> `ChatsListDrawerContent.kt`
|
||||
|
||||
Результат:
|
||||
- основной файл экрана меньше и проще для навигации;
|
||||
- повторно используемые куски UI имеют явные file boundaries;
|
||||
- дальнейший рефакторинг drawer/request/chat list можно делать независимо.
|
||||
|
||||
---
|
||||
|
||||
## 8. Auth/bootstrap: фактический runtime flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Auth UI (SetPassword/Unlock)
|
||||
participant SC as SessionCoordinatorImpl
|
||||
participant SS as SessionStore
|
||||
participant PG as ProtocolGateway
|
||||
participant PR as ProtocolRuntime
|
||||
participant RCC as RuntimeConnectionControlFacade
|
||||
participant RRA as RuntimeRoutingAssembly
|
||||
participant RSA as RuntimeStateAssembly
|
||||
participant AM as AccountManager
|
||||
|
||||
UI->>SC: bootstrapAuthenticatedSession(account, reason)
|
||||
SC->>SS: dispatch(AuthInProgress)
|
||||
SC->>PG: initializeAccount(public, private)
|
||||
SC->>PG: connect()
|
||||
SC->>PG: authenticate(public, privateHash)
|
||||
SC->>PG: reconnectNowIfNeeded(...)
|
||||
SC->>AM: setCurrentAccount(public)
|
||||
SC->>SS: dispatch(Ready)
|
||||
|
||||
PG->>PR: runtime API calls
|
||||
PR->>RCC: connection/auth commands
|
||||
RCC->>RRA: post(ConnectionEvent.*)
|
||||
RRA-->>RRA: Supervisor + Router route events
|
||||
RRA-->>RSA: apply lifecycle transitions
|
||||
RSA-->>RSA: AUTHENTICATED -> BOOTSTRAPPING -> READY
|
||||
```
|
||||
|
||||
Важно: `SessionState.Ready` (app-session готова) и `connectionLifecycleState = READY` (сеть готова) — это разные state-модели.
|
||||
|
||||
---
|
||||
|
||||
## 9. Состояния соединения (network lifecycle)
|
||||
|
||||
`RuntimeComposition.connectionLifecycleState`:
|
||||
- `DISCONNECTED`
|
||||
- `CONNECTING`
|
||||
- `HANDSHAKING`
|
||||
- `AUTHENTICATED`
|
||||
- `BOOTSTRAPPING`
|
||||
- `READY`
|
||||
- `DEVICE_VERIFICATION_REQUIRED`
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DISCONNECTED
|
||||
DISCONNECTED --> CONNECTING
|
||||
CONNECTING --> HANDSHAKING
|
||||
HANDSHAKING --> DEVICE_VERIFICATION_REQUIRED
|
||||
HANDSHAKING --> AUTHENTICATED
|
||||
AUTHENTICATED --> BOOTSTRAPPING
|
||||
BOOTSTRAPPING --> READY
|
||||
READY --> HANDSHAKING
|
||||
AUTHENTICATED --> DISCONNECTED
|
||||
BOOTSTRAPPING --> DISCONNECTED
|
||||
READY --> DISCONNECTED
|
||||
DEVICE_VERIFICATION_REQUIRED --> CONNECTING
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Ключевые файлы новой архитектуры
|
||||
|
||||
- `app/src/main/java/com/rosetta/messenger/di/AppContainer.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeComposition.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeTransportAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeMessagingAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeStateAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeRoutingAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeDirectoryFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimePacketIoFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeAccess.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/AppSessionCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/SessionStore.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/SessionReducer.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/SessionAction.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/IdentityStore.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/PacketSubscriptionRegistry.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolConnectionModels.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolConnectionSupervisor.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ReadyPacketGate.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ConnectionOrchestrator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolInstanceManager.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeLifecycleStateMachine.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeInitializationCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleStateStoreImpl.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileFallbackTimerService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/AuthRestoreService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeShutdownCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ConnectionEventRouter.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/NetworkConnectivityFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PacketSubscriptionFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolAccountSessionCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ReadyPacketDispatchCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolPostAuthBootstrapCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/BootstrapCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/SyncCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PresenceTypingService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/AuthBootstrapCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/NetworkReconnectWatcher.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/DeviceVerificationService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/DeviceRuntimeService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/CallSignalBridge.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/InboundPacketHandlerRegistrar.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/InboundTaskQueueService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PacketRouter.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileSyncService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RetryQueueService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ForwardCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsFeatureCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingEncryptionContext.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingSendContext.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListChatItem.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsSection.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerContent.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerSections.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTextMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendMediaMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendForwardUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendVoiceMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTypingIndicatorUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendReadReceiptUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/CreateAttachmentUseCases.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/EncryptAndUploadAttachmentUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/VideoCircleMediaUseCases.kt`
|
||||
|
||||
---
|
||||
|
||||
## 11. Что осталось как технический долг
|
||||
|
||||
Актуальные открытые хвосты:
|
||||
- `RuntimeComposition` остается composition-root (около 501 строки): публичные proxy-методы уже убраны, но внутри все еще смешаны wiring и часть helper-логики (`setupStateMonitoring`, event-bridge, log helpers). Следующий шаг: вынести эти helper-блоки в отдельные adapters/services.
|
||||
- `ProtocolRuntime` + `ProtocolRuntimePort` все еще имеют широкий API surface (connection + directory + packet IO + call signaling + debug). Нужен audit и сужение публичных контрактов по use-case группам.
|
||||
- `ChatViewModel` остается очень крупным host-классом (около 4391 строки) с большим bridge/proxy surface к feature/coordinator/use-case слоям.
|
||||
- `AttachmentsFeatureCoordinator` остается крупным (около 761 строки): high-level media сценарии стоит резать на более узкие upload/transform/packet-assembly сервисы.
|
||||
- Тестовое покрытие архитектурно-критичных слоев недостаточно: `app/src/test` = 7, `app/src/androidTest` = 1; не покрыты runtime-routing/lifecycle компоненты (`RuntimeRoutingAssembly`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat coordinators (`Messages/Forward/Attachments*`).
|
||||
- В runtime все еще несколько точек входа (`ProtocolRuntime`, `ProtocolRuntimeAccess`, `ProtocolManager` legacy), что повышает cognitive load; целевой шаг — дальнейшее сокращение legacy/static call-sites.
|
||||
|
||||
Уже закрыто и больше не считается техдолгом:
|
||||
- `UiDependencyAccess.get(...)` удален из `ui/*`.
|
||||
- `UiEntryPoint`/`EntryPointAccessors` убраны из UI-экранов (явная передача зависимостей через `MainActivity`/`ViewModel`).
|
||||
- DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` закрыт через `Provider<ProtocolRuntime>`.
|
||||
- `ProtocolManager` переведен в минимальный legacy compatibility API (тонкие прокси к `ProtocolRuntimeAccess`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Guardrails против переусложнения
|
||||
|
||||
Чтобы декомпозиция не превращалась в «архитектуру ради архитектуры», применяются следующие правила:
|
||||
|
||||
1. Лимит глубины runtime-цепочки вызова: не более 3 логических слоев после DI-entry (`ProtocolRuntime -> Runtime*Facade -> service`; `RuntimeComposition` остается composition-root/wiring-слоем, а не обязательным proxy-hop).
|
||||
2. Новый слой/класс допускается только если он дает измеримый выигрыш:
|
||||
- убирает минимум 80-120 строк связанной orchestration-логики из текущего класса, или
|
||||
- убирает минимум 2 внешние зависимости из текущего класса.
|
||||
3. Каждый шаг рефакторинга считается завершенным только после: `compileDebugKotlin` + минимум одного smoke-сценария по затронутому флоу + обновления `Architecture.md`.
|
||||
4. Если после выноса сложность чтения/изменения не снизилась (по факту код не стал проще), такой вынос считается кандидатом на откат/консолидацию.
|
||||
5. Для event-driven runtime-chain (`ProtocolConnectionSupervisor` + `ConnectionEventRouter`) эти два элемента считаются одним orchestration-этапом при анализе hop-depth.
|
||||
6. `ProtocolClientImpl` трактуется как инфраструктурный DI-adapter и учитывается отдельно от business-flow hop budget.
|
||||
|
||||
---
|
||||
|
||||
## 13. Плюсы и минусы текущей архитектуры
|
||||
|
||||
### 13.1 Плюсы
|
||||
- Четко выделены слои: UI, domain use-cases, network runtime, session/identity, data/persistence.
|
||||
- DI через Hilt и `ProtocolGateway`/`SessionCoordinator` снижает прямую связанность между UI и transport/runtime.
|
||||
- Убраны `UiEntryPoint`/`EntryPointAccessors` из UI-экранов, что улучшило явность зависимостей.
|
||||
- Закрыт критичный DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` через `Provider<ProtocolRuntime>`.
|
||||
- Network runtime декомпозирован на отдельные сервисы/coordinator-ы с более узкими зонами ответственности.
|
||||
- Сокращен DI runtime path: `ProtocolGateway` биндится напрямую на `ProtocolRuntime`, runtime работает напрямую с `RuntimeComposition`.
|
||||
- Централизован packet subscription fan-out (`PacketSubscriptionRegistry` + `PacketSubscriptionFacade`), что снижает риск дублирующих low-level подписок.
|
||||
- В chat-модуле выделен domain use-case слой и вынесены крупные сценарии в coordinators.
|
||||
|
||||
### 13.2 Минусы
|
||||
- `RuntimeComposition` и `ChatViewModel` остаются очень крупными hotspot-классами и концентрируют много связей.
|
||||
- Runtime API-слой пока широкий: много proxy-методов усложняют контроль границ и эволюцию surface API.
|
||||
- В части chat/media orchestration (`AttachmentsFeatureCoordinator`, `MessagesCoordinator`, `ForwardCoordinator`) сохраняются большие high-level сценарии.
|
||||
- Мало unit/integration тестов на архитектурно-критичные runtime/chat orchestration компоненты.
|
||||
- В проекте остаются несколько точек доступа к runtime (`ProtocolRuntime`, `ProtocolRuntimePort`, `ProtocolManager` legacy), что повышает cognitive load для новых разработчиков.
|
||||
- Стоимость входа в кодовую базу выросла: для трассировки одного бизнес-флоу нужно проходить больше слоев, чем раньше.
|
||||
|
||||
### 13.3 Итог оценки
|
||||
- Текущая архитектура стала заметно лучше по управляемости зависимостей и изоляции ответственности.
|
||||
- Главные риски сместились из “монолитного класса” в “размер composition/API surface и недотестированность orchestration”.
|
||||
- При соблюдении guardrails (секция 12) и фокусе на тестах/дальнейшей локальной декомпозиции архитектура остается управляемой и не уходит в избыточную сложность.
|
||||
@@ -2,6 +2,7 @@ plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
@@ -23,8 +24,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.5.0"
|
||||
val rosettaVersionCode = 52 // Increment on each release
|
||||
val rosettaVersionName = "1.5.5"
|
||||
val rosettaVersionCode = 57 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
@@ -119,6 +120,10 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||
@@ -182,6 +187,11 @@ dependencies {
|
||||
implementation("androidx.room:room-ktx:2.6.1")
|
||||
kapt("androidx.room:room-compiler:2.6.1")
|
||||
|
||||
// Hilt DI
|
||||
implementation("com.google.dagger:hilt-android:2.51.1")
|
||||
kapt("com.google.dagger:hilt-compiler:2.51.1")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
||||
|
||||
// Biometric authentication
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
@@ -26,10 +27,8 @@
|
||||
|
||||
<application
|
||||
android:name=".RosettaApplication"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -47,10 +46,7 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -63,8 +59,80 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="rosetta.im" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- App Icon Aliases: only one enabled at a time -->
|
||||
<activity-alias
|
||||
android:name=".MainActivityDefault"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityCalculator"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_calc"
|
||||
android:roundIcon="@mipmap/ic_launcher_calc"
|
||||
android:label="Calculator">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityWeather"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_weather"
|
||||
android:roundIcon="@mipmap/ic_launcher_weather"
|
||||
android:label="Weather">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityNotes"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_notes"
|
||||
android:roundIcon="@mipmap/ic_launcher_notes"
|
||||
android:label="Notes">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name=".IncomingCallActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition(
|
||||
wasEmojiShown = true
|
||||
}
|
||||
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
|
||||
// Emoji закрылся после того как был открыт = переход emoji→keyboard
|
||||
isTransitioningToKeyboard = true
|
||||
// Keep reserved space only if keyboard is actually opening.
|
||||
// For back-swipe/back-press close there is no keyboard open request,
|
||||
// so we must drop the emoji box immediately to avoid an empty gap.
|
||||
val keyboardIsComing =
|
||||
coordinator.currentState == KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD ||
|
||||
coordinator.isKeyboardVisible ||
|
||||
coordinator.keyboardHeight > 0.dp
|
||||
isTransitioningToKeyboard = keyboardIsComing
|
||||
if (!keyboardIsComing) {
|
||||
wasEmojiShown = false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji
|
||||
@@ -63,6 +72,19 @@ fun AnimatedKeyboardTransition(
|
||||
isTransitioningToKeyboard = false
|
||||
wasEmojiShown = false
|
||||
}
|
||||
|
||||
// Failsafe for interrupted gesture/back navigation: if keyboard never started opening,
|
||||
// don't keep an invisible fixed-height box.
|
||||
if (
|
||||
isTransitioningToKeyboard &&
|
||||
!showEmojiPicker &&
|
||||
coordinator.currentState != KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD &&
|
||||
!coordinator.isKeyboardVisible &&
|
||||
coordinator.keyboardHeight == 0.dp
|
||||
) {
|
||||
isTransitioningToKeyboard = false
|
||||
wasEmojiShown = false
|
||||
}
|
||||
|
||||
// 🎯 Целевая прозрачность
|
||||
val targetAlpha = if (showEmojiPicker) 1f else 0f
|
||||
@@ -109,4 +131,4 @@ fun AnimatedKeyboardTransition(
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,19 @@ import com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.calls.CallOverlay
|
||||
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Лёгкая Activity для показа входящего звонка на lock screen.
|
||||
* Показывается поверх экрана блокировки, без auth/splash.
|
||||
* При Accept → переходит в MainActivity. При Decline → закрывается.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class IncomingCallActivity : ComponentActivity() {
|
||||
|
||||
@Inject lateinit var accountManager: AccountManager
|
||||
|
||||
companion object {
|
||||
private const val TAG = "IncomingCallActivity"
|
||||
}
|
||||
@@ -119,7 +124,7 @@ class IncomingCallActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
val avatarRepository = remember {
|
||||
val accountKey = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
|
||||
val accountKey = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||
if (accountKey.isNotBlank()) {
|
||||
val db = RosettaDatabase.getDatabase(applicationContext)
|
||||
AvatarRepository(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,29 @@ package com.rosetta.messenger
|
||||
|
||||
import android.app.Application
|
||||
import com.airbnb.lottie.L
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.DraftManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.CallManager
|
||||
import com.rosetta.messenger.network.ProtocolRuntime
|
||||
import com.rosetta.messenger.network.ProtocolRuntimeAccess
|
||||
import com.rosetta.messenger.network.TransportManager
|
||||
import com.rosetta.messenger.update.UpdateManager
|
||||
import com.rosetta.messenger.utils.CrashReportManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Application класс для инициализации глобальных компонентов приложения
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class RosettaApplication : Application() {
|
||||
|
||||
@Inject lateinit var messageRepository: MessageRepository
|
||||
@Inject lateinit var groupRepository: GroupRepository
|
||||
@Inject lateinit var accountManager: AccountManager
|
||||
@Inject lateinit var protocolRuntime: ProtocolRuntime
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RosettaApplication"
|
||||
@@ -24,6 +38,9 @@ class RosettaApplication : Application() {
|
||||
|
||||
// Инициализируем crash reporter
|
||||
initCrashReporting()
|
||||
|
||||
// Install instance-based protocol runtime for non-Hilt singleton objects.
|
||||
ProtocolRuntimeAccess.install(protocolRuntime)
|
||||
|
||||
// Инициализируем менеджер черновиков
|
||||
DraftManager.init(this)
|
||||
@@ -33,6 +50,11 @@ class RosettaApplication : Application() {
|
||||
|
||||
// Инициализируем менеджер обновлений (SDU)
|
||||
UpdateManager.init(this)
|
||||
|
||||
CallManager.bindDependencies(
|
||||
messageRepository = messageRepository,
|
||||
accountManager = accountManager
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ object CryptoManager {
|
||||
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
||||
// расшифровке
|
||||
private const val DECRYPTION_CACHE_SIZE = 2000
|
||||
// Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key
|
||||
// и хранения гигантских plaintext в памяти.
|
||||
private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024
|
||||
private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024
|
||||
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
||||
|
||||
init {
|
||||
@@ -298,17 +302,21 @@ object CryptoManager {
|
||||
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
||||
*/
|
||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
||||
val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS
|
||||
val cacheKey = if (useCache) "$password:$encryptedData" else null
|
||||
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||
val cacheKey = "$password:$encryptedData"
|
||||
decryptionCache[cacheKey]?.let {
|
||||
return it
|
||||
if (cacheKey != null) {
|
||||
decryptionCache[cacheKey]?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||
|
||||
// 🚀 Сохраняем в кэш (lock-free)
|
||||
if (result != null) {
|
||||
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
|
||||
// Ограничиваем размер кэша
|
||||
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||
// Удаляем ~10% самых старых записей
|
||||
|
||||
@@ -14,17 +14,24 @@ import com.rosetta.messenger.network.PacketGroupInfo
|
||||
import com.rosetta.messenger.network.PacketGroupInviteInfo
|
||||
import com.rosetta.messenger.network.PacketGroupJoin
|
||||
import com.rosetta.messenger.network.PacketGroupLeave
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class GroupRepository private constructor(context: Context) {
|
||||
@Singleton
|
||||
class GroupRepository @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val messageRepository: MessageRepository,
|
||||
private val protocolClient: ProtocolClient
|
||||
) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val db = RosettaDatabase.getDatabase(context.applicationContext)
|
||||
private val groupDao = db.groupDao()
|
||||
private val messageDao = db.messageDao()
|
||||
@@ -38,15 +45,6 @@ class GroupRepository private constructor(context: Context) {
|
||||
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
||||
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
|
||||
private const val GROUP_CREATED_MARKER = "\$a=Group created"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: GroupRepository? = null
|
||||
|
||||
fun getInstance(context: Context): GroupRepository {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: GroupRepository(context).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ParsedGroupInvite(
|
||||
@@ -155,7 +153,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
this.groupId = groupId
|
||||
this.members = emptyList()
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupInfo>(
|
||||
packetId = 0x12,
|
||||
@@ -189,7 +187,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
this.membersCount = 0
|
||||
this.groupStatus = GroupStatus.NOT_JOINED
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupInviteInfo>(
|
||||
packetId = 0x13,
|
||||
@@ -217,7 +215,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
}
|
||||
|
||||
val createPacket = PacketCreateGroup()
|
||||
ProtocolManager.send(createPacket)
|
||||
protocolClient.send(createPacket)
|
||||
|
||||
val response = awaitPacketOnce<PacketCreateGroup>(
|
||||
packetId = 0x11,
|
||||
@@ -268,7 +266,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
groupString = encodedGroupStringForServer
|
||||
groupStatus = GroupStatus.NOT_JOINED
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupJoin>(
|
||||
packetId = 0x14,
|
||||
@@ -376,7 +374,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
val packet = PacketGroupLeave().apply {
|
||||
this.groupId = groupId
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupLeave>(
|
||||
packetId = 0x15,
|
||||
@@ -402,7 +400,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
this.groupId = groupId
|
||||
this.publicKey = targetPublicKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupBan>(
|
||||
packetId = 0x16,
|
||||
@@ -479,9 +477,8 @@ class GroupRepository private constructor(context: Context) {
|
||||
dialogPublicKey: String
|
||||
) {
|
||||
try {
|
||||
val messages = MessageRepository.getInstance(appContext)
|
||||
messages.initialize(accountPublicKey, accountPrivateKey)
|
||||
messages.sendMessage(
|
||||
messageRepository.initialize(accountPublicKey, accountPrivateKey)
|
||||
messageRepository.sendMessage(
|
||||
toPublicKey = dialogPublicKey,
|
||||
text = GROUP_CREATED_MARKER
|
||||
)
|
||||
@@ -512,13 +509,13 @@ class GroupRepository private constructor(context: Context) {
|
||||
callback = { packet ->
|
||||
val typedPacket = packet as? T
|
||||
if (typedPacket != null && predicate(typedPacket)) {
|
||||
ProtocolManager.unwaitPacket(packetId, callback)
|
||||
protocolClient.unwaitPacket(packetId, callback)
|
||||
continuation.resume(typedPacket)
|
||||
}
|
||||
}
|
||||
ProtocolManager.waitPacket(packetId, callback)
|
||||
protocolClient.waitPacket(packetId, callback)
|
||||
continuation.invokeOnCancellation {
|
||||
ProtocolManager.unwaitPacket(packetId, callback)
|
||||
protocolClient.unwaitPacket(packetId, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.data
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.BuildConfig
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.database.*
|
||||
@@ -8,8 +9,11 @@ import com.rosetta.messenger.network.*
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import org.json.JSONArray
|
||||
@@ -30,7 +34,6 @@ data class Message(
|
||||
val replyToMessageId: String? = null
|
||||
)
|
||||
|
||||
/** UI модель диалога */
|
||||
data class Dialog(
|
||||
val opponentKey: String,
|
||||
val opponentTitle: String,
|
||||
@@ -44,7 +47,11 @@ data class Dialog(
|
||||
)
|
||||
|
||||
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */
|
||||
class MessageRepository private constructor(private val context: Context) {
|
||||
@Singleton
|
||||
class MessageRepository @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val protocolClient: ProtocolClient
|
||||
) {
|
||||
|
||||
private val database = RosettaDatabase.getDatabase(context)
|
||||
private val messageDao = database.messageDao()
|
||||
@@ -97,8 +104,6 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private var currentPrivateKey: String? = null
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: MessageRepository? = null
|
||||
|
||||
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
|
||||
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
|
||||
|
||||
@@ -136,16 +141,6 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
/** Очистка кэша (вызывается при logout) */
|
||||
fun clearProcessedCache() = processedMessageIds.clear()
|
||||
|
||||
fun getInstance(context: Context): MessageRepository {
|
||||
return INSTANCE
|
||||
?: synchronized(this) {
|
||||
INSTANCE
|
||||
?: MessageRepository(context.applicationContext).also {
|
||||
INSTANCE = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
|
||||
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
|
||||
@@ -245,6 +240,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
opponentUsername =
|
||||
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
|
||||
?: SYSTEM_SAFE_USERNAME,
|
||||
lastMessage = encryptedPlainMessage,
|
||||
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
|
||||
hasContent = 1,
|
||||
lastMessageFromMe = 0,
|
||||
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
|
||||
lastMessageRead = 0,
|
||||
lastMessageAttachments = "[]",
|
||||
isOnline = existing?.isOnline ?: 0,
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = maxOf(existing?.verified ?: 0, 1),
|
||||
@@ -265,7 +267,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
try {
|
||||
CryptoManager.encryptWithPassword(messageText, privateKey)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
|
||||
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -324,6 +326,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
opponentUsername =
|
||||
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
|
||||
?: SYSTEM_UPDATES_USERNAME,
|
||||
lastMessage = encryptedPlainMessage,
|
||||
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
|
||||
hasContent = 1,
|
||||
lastMessageFromMe = 0,
|
||||
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
|
||||
lastMessageRead = 0,
|
||||
lastMessageAttachments = "[]",
|
||||
isOnline = existing?.isOnline ?: 0,
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = maxOf(existing?.verified ?: 0, 1),
|
||||
@@ -343,12 +352,12 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
suspend fun checkAndSendVersionUpdateMessage() {
|
||||
val account = currentAccount
|
||||
if (account == null) {
|
||||
android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
|
||||
return
|
||||
}
|
||||
val privateKey = currentPrivateKey
|
||||
if (privateKey == null) {
|
||||
android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
|
||||
return
|
||||
}
|
||||
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
|
||||
@@ -356,7 +365,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
|
||||
val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}"
|
||||
|
||||
android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
|
||||
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
|
||||
|
||||
if (lastNoticeKey != currentKey) {
|
||||
// Delete the previous message for this version (if any)
|
||||
@@ -367,15 +376,15 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion))
|
||||
android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
|
||||
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
|
||||
if (messageId != null) {
|
||||
prefs.edit()
|
||||
.putString("lastNoticeKey", currentKey)
|
||||
.putString("lastNoticeMessageId_$currentVersion", messageId)
|
||||
.apply()
|
||||
android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
|
||||
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
|
||||
} else {
|
||||
android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
|
||||
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -599,6 +608,12 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||
dialogDao.updateDialogFromMessages(account, toPublicKey)
|
||||
|
||||
// Notify listeners (ChatViewModel) that a new message was persisted
|
||||
// so the chat UI reloads from DB. Without this, messages produced by
|
||||
// non-input flows (e.g. CallManager's missed-call attachment) only
|
||||
// appear after the user re-enters the chat.
|
||||
_newMessageEvents.tryEmit(dialogKey)
|
||||
|
||||
// 📁 Для saved messages - гарантируем создание/обновление dialog
|
||||
if (isSavedMessages) {
|
||||
val existing = dialogDao.getDialog(account, account)
|
||||
@@ -674,7 +689,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
|
||||
|
||||
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
|
||||
ProtocolManager.sendMessageWithRetry(packet)
|
||||
protocolClient.sendMessageWithRetry(packet)
|
||||
|
||||
// 📝 LOG: Успешная отправка
|
||||
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||
@@ -814,11 +829,19 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
if (isGroupMessage && groupKey.isNullOrBlank()) {
|
||||
MessageLogger.debug(
|
||||
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
|
||||
val requiresGroupKey =
|
||||
(packet.content.isNotBlank() && isProbablyEncryptedPayload(packet.content)) ||
|
||||
packet.attachments.any { it.blob.isNotBlank() }
|
||||
if (requiresGroupKey) {
|
||||
MessageLogger.debug(
|
||||
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
|
||||
)
|
||||
processedMessageIds.remove(messageId)
|
||||
return false
|
||||
}
|
||||
protocolClient.addLog(
|
||||
"⚠️ GROUP fallback without key: ${messageId.take(8)}..., contentLikelyPlain=true"
|
||||
)
|
||||
processedMessageIds.remove(messageId)
|
||||
return false
|
||||
}
|
||||
|
||||
val plainKeyAndNonce =
|
||||
@@ -830,7 +853,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||
ProtocolManager.addLog(
|
||||
protocolClient.addLog(
|
||||
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
|
||||
)
|
||||
}
|
||||
@@ -849,8 +872,9 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
if (isAttachmentOnly) {
|
||||
""
|
||||
} else if (isGroupMessage) {
|
||||
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
||||
?: throw IllegalStateException("Failed to decrypt group payload")
|
||||
val decryptedGroupPayload =
|
||||
groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
|
||||
decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
|
||||
} else if (plainKeyAndNonce != null) {
|
||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||
} else {
|
||||
@@ -858,7 +882,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
} catch (e: Exception) {
|
||||
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
|
||||
android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -998,8 +1022,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
} catch (e: Exception) {
|
||||
// 📝 LOG: Ошибка обработки
|
||||
MessageLogger.logDecryptionError(messageId, e)
|
||||
ProtocolManager.addLog(
|
||||
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
|
||||
protocolClient.addLog(
|
||||
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " +
|
||||
"group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " +
|
||||
"aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: "<no-message>"}"
|
||||
)
|
||||
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
|
||||
processedMessageIds.remove(messageId)
|
||||
@@ -1236,7 +1262,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.toPublicKey = toPublicKey
|
||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,7 +1327,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
syncedOpponentsWithWrongStatus.forEach { opponentKey ->
|
||||
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
|
||||
}
|
||||
android.util.Log.i(
|
||||
if (BuildConfig.DEBUG) android.util.Log.i(
|
||||
"MessageRepository",
|
||||
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
|
||||
)
|
||||
@@ -1310,14 +1336,14 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// Mark expired messages as ERROR (older than 80 seconds)
|
||||
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
||||
if (expiredCount > 0) {
|
||||
android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
|
||||
}
|
||||
|
||||
// Get remaining WAITING messages (younger than 80s)
|
||||
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
||||
if (waitingMessages.isEmpty()) return
|
||||
|
||||
android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
|
||||
if (BuildConfig.DEBUG) android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
|
||||
|
||||
for (entity in waitingMessages) {
|
||||
// Skip saved messages (should not happen, but guard)
|
||||
@@ -1341,7 +1367,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
privateKey
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -1367,10 +1393,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
// iOS parity: use retry mechanism for reconnect-resent messages too
|
||||
ProtocolManager.sendMessageWithRetry(packet)
|
||||
android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
|
||||
protocolClient.sendMessageWithRetry(packet)
|
||||
if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
|
||||
if (BuildConfig.DEBUG) android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
|
||||
// Mark as ERROR if retry fails
|
||||
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
|
||||
val dialogKey = getDialogKey(entity.toPublicKey)
|
||||
@@ -1471,7 +1497,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API for ProtocolManager to update delivery status (e.g., marking as ERROR on retry timeout).
|
||||
* Runtime API to update delivery status (e.g., marking as ERROR on retry timeout).
|
||||
*/
|
||||
suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
|
||||
val account = currentAccount ?: return
|
||||
@@ -1632,7 +1658,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.privateKey = privateKeyHash
|
||||
this.search = dialog.opponentKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
// Small delay to avoid flooding the server with search requests
|
||||
kotlinx.coroutines.delay(50)
|
||||
}
|
||||
@@ -1669,7 +1695,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.privateKey = privateKeyHash
|
||||
this.search = publicKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1755,6 +1781,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
put("preview", attachment.preview)
|
||||
put("width", attachment.width)
|
||||
put("height", attachment.height)
|
||||
put("localUri", attachment.localUri)
|
||||
put("transportTag", attachment.transportTag)
|
||||
put("transportServer", attachment.transportServer)
|
||||
}
|
||||
@@ -1999,6 +2026,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
jsonObj.put("preview", attachment.preview)
|
||||
jsonObj.put("width", attachment.width)
|
||||
jsonObj.put("height", attachment.height)
|
||||
jsonObj.put("localUri", attachment.localUri)
|
||||
jsonObj.put("transportTag", attachment.transportTag)
|
||||
jsonObj.put("transportServer", attachment.transportServer)
|
||||
} else {
|
||||
@@ -2009,6 +2037,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
jsonObj.put("preview", attachment.preview)
|
||||
jsonObj.put("width", attachment.width)
|
||||
jsonObj.put("height", attachment.height)
|
||||
jsonObj.put("localUri", attachment.localUri)
|
||||
jsonObj.put("transportTag", attachment.transportTag)
|
||||
jsonObj.put("transportServer", attachment.transportServer)
|
||||
}
|
||||
@@ -2020,6 +2049,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
jsonObj.put("preview", attachment.preview)
|
||||
jsonObj.put("width", attachment.width)
|
||||
jsonObj.put("height", attachment.height)
|
||||
jsonObj.put("localUri", attachment.localUri)
|
||||
jsonObj.put("transportTag", attachment.transportTag)
|
||||
jsonObj.put("transportServer", attachment.transportServer)
|
||||
}
|
||||
@@ -2031,6 +2061,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
jsonObj.put("preview", attachment.preview)
|
||||
jsonObj.put("width", attachment.width)
|
||||
jsonObj.put("height", attachment.height)
|
||||
jsonObj.put("localUri", attachment.localUri)
|
||||
jsonObj.put("transportTag", attachment.transportTag)
|
||||
jsonObj.put("transportServer", attachment.transportServer)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ class PreferencesManager(private val context: Context) {
|
||||
val BACKGROUND_BLUR_COLOR_ID =
|
||||
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
||||
|
||||
// App Icon disguise: "default", "calculator", "weather", "notes"
|
||||
val APP_ICON = stringPreferencesKey("app_icon")
|
||||
|
||||
// Pinned Chats (max 3)
|
||||
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
||||
|
||||
@@ -333,6 +336,19 @@ class PreferencesManager(private val context: Context) {
|
||||
return wasPinned
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🎨 APP ICON
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
val appIcon: Flow<String> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
preferences[APP_ICON] ?: "default"
|
||||
}
|
||||
|
||||
suspend fun setAppIcon(value: String) {
|
||||
context.dataStore.edit { preferences -> preferences[APP_ICON] = value }
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🔕 MUTED CHATS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -17,12 +17,14 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
- Исправлена расшифровка фото в группах (совместимость с Desktop v1.2.1)
|
||||
- Исправлен статус доставки: галочки больше не откатываются на часики
|
||||
- Исправлен просмотр фото из медиа-галереи профиля
|
||||
- Зашифрованные ключи больше не отображаются как подпись к фото
|
||||
- Анимация удаления сообщений (плавное сжатие + fade)
|
||||
- Фильтрация пустых push-уведомлений
|
||||
- Выполнен крупный рефакторинг runtime сети и сессии: SessionStore/Reducer, декомпозиция ProtocolManager и выделение профильных coordinator/service слоев
|
||||
- Стабилизированы первичное подключение, авторизация и восстановление после сбоев навигации в auth-flow
|
||||
- Улучшены sync/send-потоки и retry-пайплайн: меньше регрессий при переподключениях и фоновых отправках
|
||||
- Текстовые сообщения теперь отправляются параллельно с загрузкой голосовых и вложений
|
||||
- Панель записи ГС приведена к Telegram-поведению: lock flow, анимации, blob-эффекты в lock, корректные pause/play и центрирование иконок
|
||||
- Исправлена анимация waveform после перемотки: прогресс продолжается с текущей позиции без отката к началу
|
||||
- Улучшены QR-сканер и in-app camera: более плавный выход и стабильнее обработка UI-состояний
|
||||
- Добавлен расширенный сетевой debug-канал rosettadev1 с выводом в crash logs для ускоренной диагностики
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
200
app/src/main/java/com/rosetta/messenger/di/AppContainer.kt
Normal file
200
app/src/main/java/com/rosetta/messenger/di/AppContainer.kt
Normal file
@@ -0,0 +1,200 @@
|
||||
package com.rosetta.messenger.di
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.data.PreferencesManager
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import com.rosetta.messenger.network.ProtocolClient
|
||||
import com.rosetta.messenger.network.ProtocolRuntime
|
||||
import com.rosetta.messenger.network.ProtocolRuntimePort
|
||||
import com.rosetta.messenger.network.RuntimeComposition
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.session.SessionAction
|
||||
import com.rosetta.messenger.session.IdentityStateSnapshot
|
||||
import com.rosetta.messenger.session.IdentityStore
|
||||
import com.rosetta.messenger.session.SessionState
|
||||
import com.rosetta.messenger.session.SessionStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import javax.inject.Provider
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ProtocolGateway : ProtocolRuntimePort {
|
||||
val syncInProgress: StateFlow<Boolean>
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?>
|
||||
val typingUsers: StateFlow<Set<String>>
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>>
|
||||
val ownProfileUpdated: StateFlow<Long>
|
||||
|
||||
fun initialize(context: Context)
|
||||
fun initializeAccount(publicKey: String, privateKey: String)
|
||||
fun connect()
|
||||
fun authenticate(publicKey: String, privateHash: String)
|
||||
fun disconnect()
|
||||
fun getPrivateHash(): String?
|
||||
fun subscribePushTokenIfAvailable(forceToken: String? = null)
|
||||
fun enableUILogs(enabled: Boolean)
|
||||
fun clearLogs()
|
||||
fun resolveOutgoingRetry(messageId: String)
|
||||
fun getCachedUserByUsername(username: String): SearchUser?
|
||||
fun getCachedUserName(publicKey: String): String?
|
||||
fun acceptDevice(deviceId: String)
|
||||
fun declineDevice(deviceId: String)
|
||||
fun sendMessageWithRetry(packet: PacketMessage)
|
||||
fun packetFlow(packetId: Int): SharedFlow<Packet>
|
||||
fun notifyOwnProfileUpdated()
|
||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
|
||||
fun setAppInForeground(foreground: Boolean)
|
||||
}
|
||||
|
||||
interface SessionCoordinator {
|
||||
val sessionState: StateFlow<SessionState>
|
||||
|
||||
fun dispatch(action: SessionAction)
|
||||
fun markLoggedOut(reason: String = "") =
|
||||
dispatch(SessionAction.LoggedOut(reason = reason))
|
||||
fun markAuthInProgress(publicKey: String? = null, reason: String = "") =
|
||||
dispatch(
|
||||
SessionAction.AuthInProgress(
|
||||
publicKey = publicKey,
|
||||
reason = reason
|
||||
)
|
||||
)
|
||||
fun markReady(account: DecryptedAccount, reason: String = "") =
|
||||
dispatch(SessionAction.Ready(account = account, reason = reason))
|
||||
fun syncFromCachedAccount(account: DecryptedAccount?) =
|
||||
dispatch(SessionAction.SyncFromCachedAccount(account = account))
|
||||
suspend fun bootstrapAuthenticatedSession(account: DecryptedAccount, reason: String)
|
||||
}
|
||||
|
||||
interface IdentityGateway {
|
||||
val state: StateFlow<IdentityStateSnapshot>
|
||||
|
||||
fun updateOwnProfile(
|
||||
publicKey: String,
|
||||
displayName: String? = null,
|
||||
username: String? = null,
|
||||
verified: Int? = null,
|
||||
resolved: Boolean = true,
|
||||
reason: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
/**
|
||||
* Thin infrastructure adapter for repositories.
|
||||
*
|
||||
* This bridge is intentionally excluded from business-flow hop-depth accounting and exists
|
||||
* to keep lazy runtime access (`Provider<ProtocolRuntime>`) and avoid DI cycles.
|
||||
*/
|
||||
class ProtocolClientImpl @Inject constructor(
|
||||
private val runtimeProvider: Provider<ProtocolRuntime>
|
||||
) : ProtocolClient {
|
||||
override fun send(packet: Packet) = runtimeProvider.get().send(packet)
|
||||
|
||||
override fun sendMessageWithRetry(packet: PacketMessage) =
|
||||
runtimeProvider.get().sendMessageWithRetry(packet)
|
||||
|
||||
override fun addLog(message: String) = runtimeProvider.get().addLog(message)
|
||||
|
||||
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
runtimeProvider.get().waitPacket(packetId, callback)
|
||||
|
||||
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
runtimeProvider.get().unwaitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class SessionCoordinatorImpl @Inject constructor(
|
||||
private val accountManager: AccountManager,
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) : SessionCoordinator {
|
||||
override val sessionState: StateFlow<SessionState> = SessionStore.state
|
||||
|
||||
override fun dispatch(action: SessionAction) {
|
||||
SessionStore.dispatch(action)
|
||||
}
|
||||
|
||||
override suspend fun bootstrapAuthenticatedSession(account: DecryptedAccount, reason: String) {
|
||||
dispatch(SessionAction.AuthInProgress(publicKey = account.publicKey, reason = reason))
|
||||
protocolGateway.initializeAccount(account.publicKey, account.privateKey)
|
||||
protocolGateway.connect()
|
||||
protocolGateway.authenticate(account.publicKey, account.privateKeyHash)
|
||||
protocolGateway.reconnectNowIfNeeded("session_bootstrap_$reason")
|
||||
accountManager.setCurrentAccount(account.publicKey)
|
||||
dispatch(SessionAction.Ready(account = account, reason = reason))
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class IdentityGatewayImpl @Inject constructor() : IdentityGateway {
|
||||
override val state: StateFlow<IdentityStateSnapshot> = IdentityStore.state
|
||||
|
||||
override fun updateOwnProfile(
|
||||
publicKey: String,
|
||||
displayName: String?,
|
||||
username: String?,
|
||||
verified: Int?,
|
||||
resolved: Boolean,
|
||||
reason: String
|
||||
) {
|
||||
IdentityStore.updateOwnProfile(
|
||||
publicKey = publicKey,
|
||||
displayName = displayName,
|
||||
username = username,
|
||||
verified = verified,
|
||||
resolved = resolved,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppDataModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAccountManager(@ApplicationContext context: Context): AccountManager =
|
||||
AccountManager(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePreferencesManager(@ApplicationContext context: Context): PreferencesManager =
|
||||
PreferencesManager(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRuntimeComposition(): RuntimeComposition = RuntimeComposition()
|
||||
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AppGatewayModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProtocolGateway(runtime: ProtocolRuntime): ProtocolGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSessionCoordinator(impl: SessionCoordinatorImpl): SessionCoordinator
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindIdentityGateway(impl: IdentityGatewayImpl): IdentityGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProtocolClient(impl: ProtocolClientImpl): ProtocolClient
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ForwardPayloadMessage(
|
||||
val messageId: String,
|
||||
val senderPublicKey: String,
|
||||
val senderName: String,
|
||||
val text: String,
|
||||
val timestamp: Long,
|
||||
val chachaKeyPlain: String,
|
||||
val attachments: List<MessageAttachment>
|
||||
)
|
||||
|
||||
class SendForwardUseCase @Inject constructor(
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) {
|
||||
fun buildForwardReplyJson(
|
||||
messages: List<ForwardPayloadMessage>,
|
||||
rewrittenAttachments: Map<String, MessageAttachment>,
|
||||
rewrittenMessageIds: Set<String>,
|
||||
outgoingForwardPlainKeyHex: String,
|
||||
includeLocalUri: Boolean,
|
||||
rewriteKey: (messageId: String, attachmentId: String) -> String
|
||||
): JSONArray {
|
||||
val replyJsonArray = JSONArray()
|
||||
messages.forEach { message ->
|
||||
val attachmentsArray = JSONArray()
|
||||
message.attachments.forEach { attachment ->
|
||||
val rewritten =
|
||||
rewrittenAttachments[rewriteKey(message.messageId, attachment.id)]
|
||||
val effectiveAttachment = rewritten ?: attachment
|
||||
attachmentsArray.put(
|
||||
JSONObject().apply {
|
||||
put("id", effectiveAttachment.id)
|
||||
put("type", effectiveAttachment.type.value)
|
||||
put("preview", effectiveAttachment.preview)
|
||||
put("width", effectiveAttachment.width)
|
||||
put("height", effectiveAttachment.height)
|
||||
put("blob", "")
|
||||
put("transportTag", effectiveAttachment.transportTag)
|
||||
put("transportServer", effectiveAttachment.transportServer)
|
||||
put(
|
||||
"transport",
|
||||
JSONObject().apply {
|
||||
put("transport_tag", effectiveAttachment.transportTag)
|
||||
put("transport_server", effectiveAttachment.transportServer)
|
||||
}
|
||||
)
|
||||
if (includeLocalUri && effectiveAttachment.localUri.isNotEmpty()) {
|
||||
put("localUri", effectiveAttachment.localUri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val effectiveForwardPlainKey =
|
||||
if (message.messageId in rewrittenMessageIds && outgoingForwardPlainKeyHex.isNotEmpty()) {
|
||||
outgoingForwardPlainKeyHex
|
||||
} else {
|
||||
message.chachaKeyPlain
|
||||
}
|
||||
|
||||
replyJsonArray.put(
|
||||
JSONObject().apply {
|
||||
put("message_id", message.messageId)
|
||||
put("publicKey", message.senderPublicKey)
|
||||
put("message", message.text)
|
||||
put("timestamp", message.timestamp)
|
||||
put("attachments", attachmentsArray)
|
||||
put("forwarded", true)
|
||||
put("senderName", message.senderName)
|
||||
if (effectiveForwardPlainKey.isNotEmpty()) {
|
||||
put("chacha_key_plain", effectiveForwardPlainKey)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
return replyJsonArray
|
||||
}
|
||||
|
||||
fun buildForwardAttachment(
|
||||
replyAttachmentId: String,
|
||||
encryptedReplyBlob: String
|
||||
): MessageAttachment =
|
||||
MessageAttachment(
|
||||
id = replyAttachmentId,
|
||||
blob = encryptedReplyBlob,
|
||||
type = AttachmentType.MESSAGES,
|
||||
preview = ""
|
||||
)
|
||||
|
||||
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
|
||||
if (!isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SendMediaMessageCommand(
|
||||
val fromPublicKey: String,
|
||||
val toPublicKey: String,
|
||||
val encryptedContent: String,
|
||||
val encryptedKey: String,
|
||||
val aesChachaKey: String,
|
||||
val privateKeyHash: String,
|
||||
val messageId: String,
|
||||
val timestamp: Long,
|
||||
val mediaAttachments: List<MessageAttachment>,
|
||||
val isSavedMessages: Boolean
|
||||
)
|
||||
|
||||
class SendMediaMessageUseCase @Inject constructor(
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) {
|
||||
operator fun invoke(command: SendMediaMessageCommand): PacketMessage {
|
||||
val packet =
|
||||
PacketMessage().apply {
|
||||
fromPublicKey = command.fromPublicKey
|
||||
toPublicKey = command.toPublicKey
|
||||
content = command.encryptedContent
|
||||
chachaKey = command.encryptedKey
|
||||
aesChachaKey = command.aesChachaKey
|
||||
privateKey = command.privateKeyHash
|
||||
messageId = command.messageId
|
||||
timestamp = command.timestamp
|
||||
attachments = command.mediaAttachments
|
||||
}
|
||||
|
||||
if (!command.isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
return packet
|
||||
}
|
||||
|
||||
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
|
||||
if (!isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SendTextMessageCommand(
|
||||
val fromPublicKey: String,
|
||||
val toPublicKey: String,
|
||||
val encryptedContent: String,
|
||||
val encryptedKey: String,
|
||||
val aesChachaKey: String,
|
||||
val privateKeyHash: String,
|
||||
val messageId: String,
|
||||
val timestamp: Long,
|
||||
val attachments: List<MessageAttachment> = emptyList(),
|
||||
val isSavedMessages: Boolean
|
||||
)
|
||||
|
||||
class SendTextMessageUseCase @Inject constructor(
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) {
|
||||
operator fun invoke(command: SendTextMessageCommand): PacketMessage {
|
||||
val packet =
|
||||
PacketMessage().apply {
|
||||
fromPublicKey = command.fromPublicKey
|
||||
toPublicKey = command.toPublicKey
|
||||
content = command.encryptedContent
|
||||
chachaKey = command.encryptedKey
|
||||
aesChachaKey = command.aesChachaKey
|
||||
privateKey = command.privateKeyHash
|
||||
messageId = command.messageId
|
||||
timestamp = command.timestamp
|
||||
attachments = command.attachments
|
||||
}
|
||||
|
||||
if (!command.isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
return packet
|
||||
}
|
||||
|
||||
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
|
||||
if (!isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,11 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.rosetta.messenger.MainActivity
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.PreferencesManager
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -34,8 +37,11 @@ import kotlinx.coroutines.runBlocking
|
||||
* Keeps call alive while app goes to background.
|
||||
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class CallForegroundService : Service() {
|
||||
|
||||
@Inject lateinit var preferencesManager: PreferencesManager
|
||||
|
||||
private data class Snapshot(
|
||||
val phase: CallPhase,
|
||||
val displayName: String,
|
||||
@@ -469,8 +475,7 @@ class CallForegroundService : Service() {
|
||||
// Проверяем настройку
|
||||
val avatarEnabled = runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
com.rosetta.messenger.data.PreferencesManager(applicationContext)
|
||||
.notificationAvatarEnabled.first()
|
||||
preferencesManager.notificationAvatarEnabled.first()
|
||||
}
|
||||
}.getOrDefault(true)
|
||||
if (!avatarEnabled) return null
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.BuildConfig
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
@@ -95,7 +96,11 @@ object CallManager {
|
||||
private const val TAIL_LINES = 300
|
||||
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||
private const val MAX_LOG_PREFIX = 180
|
||||
private const val INCOMING_RING_TIMEOUT_MS = 45_000L
|
||||
// Backend's CallManager.java uses RINGING_TIMEOUT = 30s. Local timeouts are
|
||||
// slightly larger so the server's RINGING_TIMEOUT signal takes precedence when
|
||||
// the network is healthy; local jobs are a fallback when the signal is lost.
|
||||
private const val INCOMING_RING_TIMEOUT_MS = 35_000L
|
||||
private const val OUTGOING_RING_TIMEOUT_MS = 35_000L
|
||||
private const val CONNECTING_TIMEOUT_MS = 30_000L
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
@@ -107,6 +112,8 @@ object CallManager {
|
||||
@Volatile
|
||||
private var initialized = false
|
||||
private var appContext: Context? = null
|
||||
private var messageRepository: MessageRepository? = null
|
||||
private var accountManager: AccountManager? = null
|
||||
private var ownPublicKey: String = ""
|
||||
|
||||
private var role: CallRole? = null
|
||||
@@ -127,6 +134,7 @@ object CallManager {
|
||||
private var protocolStateJob: Job? = null
|
||||
private var disconnectResetJob: Job? = null
|
||||
private var incomingRingTimeoutJob: Job? = null
|
||||
private var outgoingRingTimeoutJob: Job? = null
|
||||
private var connectingTimeoutJob: Job? = null
|
||||
|
||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||
@@ -157,24 +165,25 @@ object CallManager {
|
||||
initialized = true
|
||||
appContext = context.applicationContext
|
||||
CallSoundManager.initialize(context)
|
||||
CallProximityManager.initialize(context)
|
||||
XChaCha20E2EE.initWithContext(context)
|
||||
|
||||
signalWaiter = ProtocolManager.waitCallSignal { packet ->
|
||||
signalWaiter = ProtocolRuntimeAccess.get().waitCallSignal { packet ->
|
||||
scope.launch { handleSignalPacket(packet) }
|
||||
}
|
||||
webRtcWaiter = ProtocolManager.waitWebRtcSignal { packet ->
|
||||
webRtcWaiter = ProtocolRuntimeAccess.get().waitWebRtcSignal { packet ->
|
||||
scope.launch { handleWebRtcPacket(packet) }
|
||||
}
|
||||
iceWaiter = ProtocolManager.waitIceServers { packet ->
|
||||
iceWaiter = ProtocolRuntimeAccess.get().waitIceServers { packet ->
|
||||
handleIceServersPacket(packet)
|
||||
}
|
||||
|
||||
protocolStateJob =
|
||||
scope.launch {
|
||||
ProtocolManager.state.collect { protocolState ->
|
||||
ProtocolRuntimeAccess.get().state.collect { protocolState ->
|
||||
when (protocolState) {
|
||||
ProtocolState.AUTHENTICATED -> {
|
||||
ProtocolManager.requestIceServers()
|
||||
ProtocolRuntimeAccess.get().requestIceServers()
|
||||
}
|
||||
ProtocolState.DISCONNECTED -> {
|
||||
// Не сбрасываем звонок при переподключении WebSocket —
|
||||
@@ -204,7 +213,15 @@ object CallManager {
|
||||
}
|
||||
}
|
||||
|
||||
ProtocolManager.requestIceServers()
|
||||
ProtocolRuntimeAccess.get().requestIceServers()
|
||||
}
|
||||
|
||||
fun bindDependencies(
|
||||
messageRepository: MessageRepository,
|
||||
accountManager: AccountManager
|
||||
) {
|
||||
this.messageRepository = messageRepository
|
||||
this.accountManager = accountManager
|
||||
}
|
||||
|
||||
fun bindAccount(publicKey: String) {
|
||||
@@ -238,7 +255,7 @@ object CallManager {
|
||||
beginCallSession("incoming-push:${peer.take(8)}")
|
||||
role = CallRole.CALLEE
|
||||
resetRtcObjects()
|
||||
val cachedInfo = ProtocolManager.getCachedUserInfo(peer)
|
||||
val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(peer)
|
||||
val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() }
|
||||
val username = cachedInfo?.username.orEmpty()
|
||||
setPeer(peer, title, username)
|
||||
@@ -269,7 +286,7 @@ object CallManager {
|
||||
if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
|
||||
if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL
|
||||
if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND
|
||||
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
|
||||
if (!ProtocolRuntimeAccess.get().isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
|
||||
|
||||
resetSession(reason = null, notifyPeer = false)
|
||||
beginCallSession("outgoing:${targetKey.take(8)}")
|
||||
@@ -283,13 +300,25 @@ object CallManager {
|
||||
)
|
||||
}
|
||||
|
||||
ProtocolManager.sendCallSignal(
|
||||
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||
signalType = SignalType.CALL,
|
||||
src = ownPublicKey,
|
||||
dst = targetKey
|
||||
)
|
||||
breadcrumbState("startOutgoingCall")
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
||||
|
||||
// Local fallback for caller: if RINGING_TIMEOUT signal from the server is lost,
|
||||
// stop ringing after the same window the server uses (~30s + small buffer).
|
||||
outgoingRingTimeoutJob?.cancel()
|
||||
outgoingRingTimeoutJob = scope.launch {
|
||||
delay(OUTGOING_RING_TIMEOUT_MS)
|
||||
val snap = _state.value
|
||||
if (snap.phase == CallPhase.OUTGOING && snap.peerPublicKey == targetKey) {
|
||||
breadcrumb("startOutgoingCall: local ring timeout (${OUTGOING_RING_TIMEOUT_MS}ms) → reset")
|
||||
resetSession(reason = "No answer", notifyPeer = true)
|
||||
}
|
||||
}
|
||||
return CallActionResult.STARTED
|
||||
}
|
||||
|
||||
@@ -300,7 +329,7 @@ object CallManager {
|
||||
|
||||
// Если ownPublicKey пустой (push разбудил но аккаунт не привязан) — попробуем привязать
|
||||
if (ownPublicKey.isBlank()) {
|
||||
val lastPk = appContext?.let { com.rosetta.messenger.data.AccountManager(it).getLastLoggedPublicKey() }.orEmpty()
|
||||
val lastPk = accountManager?.getLastLoggedPublicKey().orEmpty()
|
||||
if (lastPk.isNotBlank()) {
|
||||
bindAccount(lastPk)
|
||||
breadcrumb("acceptIncomingCall: auto-bind account pk=${lastPk.take(8)}…")
|
||||
@@ -308,12 +337,12 @@ object CallManager {
|
||||
return CallActionResult.ACCOUNT_NOT_BOUND
|
||||
}
|
||||
}
|
||||
val restoredAuth = ProtocolManager.restoreAuthFromStoredCredentials(
|
||||
val restoredAuth = ProtocolRuntimeAccess.get().restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey = ownPublicKey,
|
||||
reason = "accept_incoming_call"
|
||||
)
|
||||
if (restoredAuth) {
|
||||
ProtocolManager.reconnectNowIfNeeded("accept_incoming_call")
|
||||
ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_incoming_call")
|
||||
breadcrumb("acceptIncomingCall: auth restore requested")
|
||||
}
|
||||
|
||||
@@ -343,7 +372,7 @@ object CallManager {
|
||||
kotlinx.coroutines.delay(200)
|
||||
continue
|
||||
}
|
||||
ProtocolManager.sendCallSignal(
|
||||
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||
signalType = SignalType.ACCEPT,
|
||||
src = ownPublicKey,
|
||||
dst = snapshot.peerPublicKey,
|
||||
@@ -352,7 +381,7 @@ object CallManager {
|
||||
)
|
||||
// ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен
|
||||
// сразу при открытии сокета (или останется в очереди до onOpen).
|
||||
ProtocolManager.reconnectNowIfNeeded("accept_send_$attempt")
|
||||
ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_send_$attempt")
|
||||
breadcrumb(
|
||||
"acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " +
|
||||
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
|
||||
@@ -378,7 +407,7 @@ object CallManager {
|
||||
val callIdNow = serverCallId.trim()
|
||||
val joinTokenNow = serverJoinToken.trim()
|
||||
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank() && callIdNow.isNotBlank() && joinTokenNow.isNotBlank()) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||
signalType = SignalType.END_CALL,
|
||||
src = ownPublicKey,
|
||||
dst = snapshot.peerPublicKey,
|
||||
@@ -478,7 +507,7 @@ object CallManager {
|
||||
if (_state.value.phase != CallPhase.IDLE) {
|
||||
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
|
||||
if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||
signalType = SignalType.END_CALL_BECAUSE_BUSY,
|
||||
src = ownPublicKey,
|
||||
dst = incomingPeer
|
||||
@@ -494,7 +523,7 @@ object CallManager {
|
||||
role = CallRole.CALLEE
|
||||
resetRtcObjects()
|
||||
// Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
|
||||
val cachedInfo = ProtocolManager.getCachedUserInfo(incomingPeer)
|
||||
val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(incomingPeer)
|
||||
val cachedTitle = cachedInfo?.title.orEmpty()
|
||||
val cachedUsername = cachedInfo?.username.orEmpty()
|
||||
setPeer(incomingPeer, cachedTitle, cachedUsername)
|
||||
@@ -551,12 +580,15 @@ object CallManager {
|
||||
breadcrumb("SIG: ACCEPT ignored — role=$role")
|
||||
return
|
||||
}
|
||||
// Callee answered before timeout — cancel outgoing ring timer
|
||||
outgoingRingTimeoutJob?.cancel()
|
||||
outgoingRingTimeoutJob = null
|
||||
if (localPrivateKey == null || localPublicKey == null) {
|
||||
breadcrumb("SIG: ACCEPT — generating local session keys")
|
||||
generateSessionKeys()
|
||||
}
|
||||
val localPublic = localPublicKey ?: return
|
||||
ProtocolManager.sendCallSignal(
|
||||
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||
signalType = SignalType.KEY_EXCHANGE,
|
||||
src = ownPublicKey,
|
||||
dst = _state.value.peerPublicKey,
|
||||
@@ -628,7 +660,7 @@ object CallManager {
|
||||
breadcrumb("KE: CALLER — E2EE ready, notifying ACTIVE")
|
||||
updateState { it.copy(keyCast = sharedKey, statusText = "Connecting...") }
|
||||
if (!activeSignalSent) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||
signalType = SignalType.ACTIVE,
|
||||
src = ownPublicKey,
|
||||
dst = peerKey
|
||||
@@ -653,7 +685,7 @@ object CallManager {
|
||||
setupE2EE(sharedKey)
|
||||
if (!keyExchangeSent) {
|
||||
val localPublic = localPublicKey ?: return
|
||||
ProtocolManager.sendCallSignal(
|
||||
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||
signalType = SignalType.KEY_EXCHANGE,
|
||||
src = ownPublicKey,
|
||||
dst = peerKey,
|
||||
@@ -754,7 +786,7 @@ object CallManager {
|
||||
|
||||
val answer = pc.createAnswerAwait()
|
||||
pc.setLocalDescriptionAwait(answer)
|
||||
ProtocolManager.sendWebRtcSignal(
|
||||
ProtocolRuntimeAccess.get().sendWebRtcSignal(
|
||||
signalType = WebRTCSignalType.ANSWER,
|
||||
sdpOrCandidate = serializeSessionDescription(answer)
|
||||
)
|
||||
@@ -842,7 +874,7 @@ object CallManager {
|
||||
pc.setLocalDescriptionAwait(offer)
|
||||
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
|
||||
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
|
||||
ProtocolManager.sendWebRtcSignal(
|
||||
ProtocolRuntimeAccess.get().sendWebRtcSignal(
|
||||
signalType = WebRTCSignalType.OFFER,
|
||||
sdpOrCandidate = serializeSessionDescription(offer)
|
||||
)
|
||||
@@ -883,7 +915,7 @@ object CallManager {
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
if (candidate == null) return
|
||||
breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}…")
|
||||
ProtocolManager.sendWebRtcSignal(
|
||||
ProtocolRuntimeAccess.get().sendWebRtcSignal(
|
||||
signalType = WebRTCSignalType.ICE_CANDIDATE,
|
||||
sdpOrCandidate = serializeIceCandidate(candidate)
|
||||
)
|
||||
@@ -1002,7 +1034,7 @@ object CallManager {
|
||||
|
||||
private fun resolvePeerIdentity(publicKey: String) {
|
||||
scope.launch {
|
||||
val resolved = ProtocolManager.resolveUserInfo(publicKey)
|
||||
val resolved = ProtocolRuntimeAccess.get().resolveUserInfo(publicKey)
|
||||
if (resolved != null && _state.value.peerPublicKey == publicKey) {
|
||||
setPeer(publicKey, resolved.title, resolved.username)
|
||||
}
|
||||
@@ -1021,7 +1053,6 @@ object CallManager {
|
||||
|
||||
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
|
||||
val peerPublicKey = snapshot.peerPublicKey.trim()
|
||||
val context = appContext ?: return
|
||||
if (peerPublicKey.isBlank()) return
|
||||
|
||||
val durationSec = snapshot.durationSec.coerceAtLeast(0)
|
||||
@@ -1033,22 +1064,30 @@ object CallManager {
|
||||
preview = durationSec.toString()
|
||||
)
|
||||
|
||||
// Capture role synchronously before the coroutine launches, because
|
||||
// resetSession() sets role = null right after calling this function —
|
||||
// otherwise the async check below would fall through to the callee branch.
|
||||
val capturedRole = role
|
||||
|
||||
scope.launch {
|
||||
runCatching {
|
||||
if (role == CallRole.CALLER) {
|
||||
val repository = messageRepository
|
||||
if (repository == null) {
|
||||
breadcrumb("CALL ATTACHMENT: MessageRepository not bound")
|
||||
return@runCatching
|
||||
}
|
||||
if (capturedRole == CallRole.CALLER) {
|
||||
// CALLER: send call attachment as a message (peer will receive it)
|
||||
MessageRepository.getInstance(context).sendMessage(
|
||||
repository.sendMessage(
|
||||
toPublicKey = peerPublicKey,
|
||||
text = "",
|
||||
attachments = listOf(callAttachment)
|
||||
)
|
||||
} else {
|
||||
// CALLEE: save call event locally (incoming from peer)
|
||||
// CALLER will send their own message which may arrive later
|
||||
MessageRepository.getInstance(context).saveIncomingCallEvent(
|
||||
fromPublicKey = peerPublicKey,
|
||||
durationSec = durationSec
|
||||
)
|
||||
// CALLEE: do not create local fallback call message.
|
||||
// Caller sends a single canonical CALL attachment; local fallback here
|
||||
// caused duplicates (local + remote) in direct dialogs.
|
||||
breadcrumb("CALL ATTACHMENT: CALLEE skip local fallback, waiting caller message")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.w(TAG, "Failed to emit call attachment", error)
|
||||
@@ -1061,11 +1100,12 @@ object CallManager {
|
||||
disarmConnectingTimeout("resetSession")
|
||||
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||
breadcrumbState("resetSession")
|
||||
appContext?.let { CallProximityManager.setEnabled(it, false) }
|
||||
val snapshot = _state.value
|
||||
val wasActive = snapshot.phase != CallPhase.IDLE
|
||||
val peerToNotify = snapshot.peerPublicKey
|
||||
if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||
signalType = SignalType.END_CALL,
|
||||
src = ownPublicKey,
|
||||
dst = peerToNotify,
|
||||
@@ -1082,6 +1122,8 @@ object CallManager {
|
||||
disconnectResetJob = null
|
||||
incomingRingTimeoutJob?.cancel()
|
||||
incomingRingTimeoutJob = null
|
||||
outgoingRingTimeoutJob?.cancel()
|
||||
outgoingRingTimeoutJob = null
|
||||
// Play end call sound, then stop all
|
||||
if (wasActive) {
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
||||
@@ -1286,7 +1328,7 @@ object CallManager {
|
||||
val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES)
|
||||
val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES)
|
||||
val protocolTail =
|
||||
ProtocolManager.debugLogs.value
|
||||
ProtocolRuntimeAccess.get().debugLogs.value
|
||||
.takeLast(PROTOCOL_LOG_TAIL_LINES)
|
||||
.joinToString("\n")
|
||||
f.writeText(
|
||||
@@ -1589,6 +1631,13 @@ object CallManager {
|
||||
val old = _state.value
|
||||
_state.update(reducer)
|
||||
val newState = _state.value
|
||||
// Proximity is needed only while call is connecting/active and speaker is off.
|
||||
appContext?.let { context ->
|
||||
val shouldEnableProximity =
|
||||
(newState.phase == CallPhase.CONNECTING || newState.phase == CallPhase.ACTIVE) &&
|
||||
!newState.isSpeakerOn
|
||||
CallProximityManager.setEnabled(context, shouldEnableProximity)
|
||||
}
|
||||
// Синхронизируем ForegroundService при смене фазы или имени
|
||||
if (newState.phase != CallPhase.IDLE &&
|
||||
(newState.phase != old.phase || newState.displayName != old.displayName)) {
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Controls proximity screen-off behavior during active calls.
|
||||
* Uses PROXIMITY_SCREEN_OFF_WAKE_LOCK to mimic phone-call UX.
|
||||
*/
|
||||
object CallProximityManager : SensorEventListener {
|
||||
|
||||
private const val TAG = "CallProximityManager"
|
||||
private const val WAKE_LOCK_TAG = "Rosetta:CallProximity"
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
private var sensorManager: SensorManager? = null
|
||||
private var proximitySensor: Sensor? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
private var enabled: Boolean = false
|
||||
private var listenerRegistered: Boolean = false
|
||||
private var lastNearState: Boolean? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
synchronized(lock) {
|
||||
if (sensorManager != null) return
|
||||
val app = context.applicationContext
|
||||
|
||||
sensorManager = app.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
||||
proximitySensor = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY)
|
||||
|
||||
val powerManager = app.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
val wakeSupported =
|
||||
powerManager?.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) == true
|
||||
wakeLock =
|
||||
if (wakeSupported) {
|
||||
powerManager
|
||||
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, WAKE_LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"initialize: sensor=${proximitySensor != null} wakeLockSupported=$wakeSupported"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(context: Context, shouldEnable: Boolean) {
|
||||
initialize(context)
|
||||
synchronized(lock) {
|
||||
if (enabled == shouldEnable) return
|
||||
enabled = shouldEnable
|
||||
if (shouldEnable) {
|
||||
registerListenerLocked()
|
||||
} else {
|
||||
unregisterListenerLocked()
|
||||
releaseWakeLockLocked()
|
||||
lastNearState = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
synchronized(lock) {
|
||||
enabled = false
|
||||
unregisterListenerLocked()
|
||||
releaseWakeLockLocked()
|
||||
lastNearState = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
val ev = event ?: return
|
||||
val near = isNear(ev)
|
||||
synchronized(lock) {
|
||||
if (!enabled) return
|
||||
if (lastNearState == near) return
|
||||
lastNearState = near
|
||||
if (near) {
|
||||
acquireWakeLockLocked()
|
||||
} else {
|
||||
releaseWakeLockLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
|
||||
private fun registerListenerLocked() {
|
||||
if (listenerRegistered) return
|
||||
val sm = sensorManager
|
||||
val sensor = proximitySensor
|
||||
if (sm == null || sensor == null) {
|
||||
Log.w(TAG, "register skipped: no proximity sensor")
|
||||
return
|
||||
}
|
||||
listenerRegistered = sm.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
Log.i(TAG, "registerListener: ok=$listenerRegistered")
|
||||
}
|
||||
|
||||
private fun unregisterListenerLocked() {
|
||||
if (!listenerRegistered) return
|
||||
runCatching { sensorManager?.unregisterListener(this) }
|
||||
listenerRegistered = false
|
||||
Log.i(TAG, "unregisterListener")
|
||||
}
|
||||
|
||||
private fun acquireWakeLockLocked() {
|
||||
val wl = wakeLock ?: return
|
||||
if (wl.isHeld) return
|
||||
runCatching { wl.acquire() }
|
||||
.onSuccess { Log.i(TAG, "wakeLock acquired (near)") }
|
||||
.onFailure { Log.w(TAG, "wakeLock acquire failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun releaseWakeLockLocked() {
|
||||
val wl = wakeLock ?: return
|
||||
if (!wl.isHeld) return
|
||||
runCatching { wl.release() }
|
||||
.onSuccess { Log.i(TAG, "wakeLock released (far/disabled)") }
|
||||
.onFailure { Log.w(TAG, "wakeLock release failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun isNear(event: SensorEvent): Boolean {
|
||||
val value = event.values.firstOrNull() ?: return false
|
||||
val maxRange = event.sensor.maximumRange
|
||||
// Treat as "near" if below max range and below a common 5cm threshold.
|
||||
return value < maxRange && value < 5f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,22 @@ object CallSoundManager {
|
||||
stop()
|
||||
currentSound = sound
|
||||
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
val ringerMode = audioManager?.ringerMode ?: AudioManager.RINGER_MODE_NORMAL
|
||||
val allowAudible = ringerMode == AudioManager.RINGER_MODE_NORMAL
|
||||
val allowVibration =
|
||||
sound == CallSound.RINGTONE &&
|
||||
(ringerMode == AudioManager.RINGER_MODE_NORMAL ||
|
||||
ringerMode == AudioManager.RINGER_MODE_VIBRATE)
|
||||
|
||||
if (!allowAudible) {
|
||||
if (allowVibration) {
|
||||
startVibration()
|
||||
}
|
||||
Log.i(TAG, "Skip audible $sound due to ringerMode=$ringerMode")
|
||||
return
|
||||
}
|
||||
|
||||
val resId = when (sound) {
|
||||
CallSound.RINGTONE -> R.raw.call_ringtone
|
||||
CallSound.CALLING -> R.raw.call_calling
|
||||
@@ -86,7 +102,7 @@ object CallSoundManager {
|
||||
mediaPlayer = player
|
||||
|
||||
// Vibrate for incoming calls
|
||||
if (sound == CallSound.RINGTONE) {
|
||||
if (allowVibration) {
|
||||
startVibration()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Centralized packet subscription registry.
|
||||
*
|
||||
* Guarantees exactly one low-level Protocol.waitPacket subscription per packet id
|
||||
* and fans out packets to:
|
||||
* 1) legacy callback listeners (waitPacket/unwaitPacket API),
|
||||
* 2) SharedFlow collectors in network/UI layers.
|
||||
*/
|
||||
class PacketSubscriptionRegistry(
|
||||
private val protocolProvider: () -> Protocol,
|
||||
private val scope: CoroutineScope,
|
||||
private val addLog: (String) -> Unit
|
||||
) {
|
||||
|
||||
private data class PacketBus(
|
||||
val packetId: Int,
|
||||
val callbacks: CopyOnWriteArrayList<(Packet) -> Unit>,
|
||||
val sharedFlow: MutableSharedFlow<Packet>,
|
||||
val protocolBridge: (Packet) -> Unit
|
||||
)
|
||||
|
||||
private val buses = ConcurrentHashMap<Int, PacketBus>()
|
||||
|
||||
private fun ensureBus(packetId: Int): PacketBus {
|
||||
buses[packetId]?.let { return it }
|
||||
|
||||
val callbacks = CopyOnWriteArrayList<(Packet) -> Unit>()
|
||||
val sharedFlow =
|
||||
MutableSharedFlow<Packet>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 128,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val bridge: (Packet) -> Unit = { packet ->
|
||||
if (!sharedFlow.tryEmit(packet)) {
|
||||
scope.launch { sharedFlow.emit(packet) }
|
||||
}
|
||||
callbacks.forEach { callback ->
|
||||
runCatching { callback(packet) }
|
||||
.onFailure { error ->
|
||||
addLog("❌ PacketSubscriptionRegistry callback error: ${error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val created =
|
||||
PacketBus(
|
||||
packetId = packetId,
|
||||
callbacks = callbacks,
|
||||
sharedFlow = sharedFlow,
|
||||
protocolBridge = bridge
|
||||
)
|
||||
|
||||
val existing = buses.putIfAbsent(packetId, created)
|
||||
if (existing == null) {
|
||||
protocolProvider().waitPacket(packetId, bridge)
|
||||
addLog(
|
||||
"🧭 PacketSubscriptionRegistry attached id=0x${packetId.toString(16).uppercase()}"
|
||||
)
|
||||
return created
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
fun flow(packetId: Int): SharedFlow<Packet> = ensureBus(packetId).sharedFlow.asSharedFlow()
|
||||
|
||||
fun addCallback(packetId: Int, callback: (Packet) -> Unit) {
|
||||
val bus = ensureBus(packetId)
|
||||
if (bus.callbacks.contains(callback)) {
|
||||
addLog(
|
||||
"📝 registry waitPacket(0x${packetId.toString(16)}) skipped duplicate callback; callbacks=${bus.callbacks.size}"
|
||||
)
|
||||
return
|
||||
}
|
||||
bus.callbacks.add(callback)
|
||||
addLog(
|
||||
"📝 registry waitPacket(0x${packetId.toString(16)}) callback registered; callbacks=${bus.callbacks.size}"
|
||||
)
|
||||
}
|
||||
|
||||
fun removeCallback(packetId: Int, callback: (Packet) -> Unit) {
|
||||
val bus = buses[packetId] ?: return
|
||||
bus.callbacks.remove(callback)
|
||||
addLog(
|
||||
"📝 registry unwaitPacket(0x${packetId.toString(16)}) callback removed; callbacks=${bus.callbacks.size}"
|
||||
)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
buses.forEach { (packetId, bus) ->
|
||||
runCatching {
|
||||
protocolProvider().unwaitPacket(packetId, bus.protocolBridge)
|
||||
}
|
||||
}
|
||||
buses.clear()
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import okhttp3.*
|
||||
import okio.ByteString
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Protocol connection states
|
||||
@@ -35,12 +39,16 @@ class Protocol(
|
||||
private const val TAG = "RosettaProtocol"
|
||||
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
||||
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
||||
private const val CONNECTING_STUCK_TIMEOUT_MS = 15_000L
|
||||
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||
private const val BACKGROUND_HEARTBEAT_INTERVAL_MS = 30_000L
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 10
|
||||
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||
private const val HEX_PREVIEW_BYTES = 64
|
||||
private const val TEXT_PREVIEW_CHARS = 80
|
||||
private val INSTANCE_COUNTER = AtomicInteger(0)
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
@@ -181,9 +189,103 @@ class Protocol(
|
||||
private var lastStateChangeTime = System.currentTimeMillis()
|
||||
private var lastSuccessfulConnection = 0L
|
||||
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
|
||||
private var connectingTimeoutJob: Job? = null
|
||||
private var isConnecting = false // Флаг для защиты от одновременных подключений
|
||||
private var connectingSinceMs = 0L
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val connectionGeneration = AtomicLong(0L)
|
||||
@Volatile private var activeConnectionGeneration: Long = 0L
|
||||
private val instanceId = INSTANCE_COUNTER.incrementAndGet()
|
||||
|
||||
/**
|
||||
* Single-writer session loop for all lifecycle mutations.
|
||||
* Replaces ad-hoc Mutex locking and guarantees strict FIFO ordering.
|
||||
*/
|
||||
private sealed interface SessionEvent {
|
||||
data class Connect(val trigger: String = "api_connect") : SessionEvent
|
||||
data class HandleDisconnect(val source: String) : SessionEvent
|
||||
data class Disconnect(val manual: Boolean, val reason: String) : SessionEvent
|
||||
data class FastReconnect(val reason: String) : SessionEvent
|
||||
data class AccountSwitchReconnect(val reason: String = "Account switch reconnect") : SessionEvent
|
||||
data class HandshakeResponse(val packet: PacketHandshake) : SessionEvent
|
||||
data class DeviceVerificationAccepted(val deviceId: String) : SessionEvent
|
||||
data class DeviceVerificationDeclined(
|
||||
val deviceId: String,
|
||||
val observedState: ProtocolState
|
||||
) : SessionEvent
|
||||
data class SocketOpened(
|
||||
val generation: Long,
|
||||
val socket: WebSocket,
|
||||
val responseCode: Int
|
||||
) : SessionEvent
|
||||
data class SocketClosed(
|
||||
val generation: Long,
|
||||
val socket: WebSocket,
|
||||
val code: Int,
|
||||
val reason: String
|
||||
) : SessionEvent
|
||||
data class SocketFailure(
|
||||
val generation: Long,
|
||||
val socket: WebSocket,
|
||||
val throwable: Throwable,
|
||||
val responseCode: Int?,
|
||||
val responseMessage: String?
|
||||
) : SessionEvent
|
||||
data class ConnectingTimeout(val generation: Long) : SessionEvent
|
||||
}
|
||||
|
||||
private val sessionEvents = Channel<SessionEvent>(Channel.UNLIMITED)
|
||||
private val sessionLoopJob =
|
||||
scope.launch {
|
||||
for (event in sessionEvents) {
|
||||
try {
|
||||
when (event) {
|
||||
is SessionEvent.Connect -> connectLocked()
|
||||
is SessionEvent.HandleDisconnect -> handleDisconnectLocked(event.source)
|
||||
is SessionEvent.Disconnect ->
|
||||
disconnectLocked(manual = event.manual, reason = event.reason)
|
||||
is SessionEvent.FastReconnect -> reconnectNowIfNeededLocked(event.reason)
|
||||
is SessionEvent.AccountSwitchReconnect -> {
|
||||
disconnectLocked(manual = false, reason = event.reason)
|
||||
connectLocked()
|
||||
}
|
||||
is SessionEvent.HandshakeResponse -> handleHandshakeResponse(event.packet)
|
||||
is SessionEvent.DeviceVerificationAccepted ->
|
||||
handleDeviceVerificationAccepted(event.deviceId)
|
||||
is SessionEvent.DeviceVerificationDeclined -> {
|
||||
handshakeComplete = false
|
||||
handshakeJob?.cancel()
|
||||
packetQueue.clear()
|
||||
if (webSocket != null) {
|
||||
setState(
|
||||
ProtocolState.CONNECTED,
|
||||
"Device verification declined, waiting for retry"
|
||||
)
|
||||
} else {
|
||||
setState(
|
||||
ProtocolState.DISCONNECTED,
|
||||
"Device verification declined without active socket"
|
||||
)
|
||||
}
|
||||
log(
|
||||
"⛔ DEVICE DECLINE APPLIED: deviceId=${shortKey(event.deviceId, 12)} " +
|
||||
"observed=${event.observedState} current=${_state.value}"
|
||||
)
|
||||
}
|
||||
is SessionEvent.SocketOpened -> handleSocketOpened(event)
|
||||
is SessionEvent.SocketClosed -> handleSocketClosed(event)
|
||||
is SessionEvent.SocketFailure -> handleSocketFailure(event)
|
||||
is SessionEvent.ConnectingTimeout -> handleConnectingTimeout(event.generation)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("❌ Session event failed: ${event::class.java.simpleName} ${e.message}")
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(ProtocolState.DISCONNECTED)
|
||||
val state: StateFlow<ProtocolState> = _state.asStateFlow()
|
||||
@@ -215,12 +317,209 @@ class Protocol(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enqueueSessionEvent(event: SessionEvent) {
|
||||
val result = sessionEvents.trySend(event)
|
||||
if (result.isFailure) {
|
||||
log(
|
||||
"⚠️ Session event dropped: ${event::class.java.simpleName} " +
|
||||
"reason=${result.exceptionOrNull()?.message ?: "channel_closed"}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelConnectingTimeout(reason: String) {
|
||||
if (connectingTimeoutJob != null) {
|
||||
log("⏱️ CONNECTING watchdog disarmed ($reason)")
|
||||
}
|
||||
connectingTimeoutJob?.cancel()
|
||||
connectingTimeoutJob = null
|
||||
}
|
||||
|
||||
private fun armConnectingTimeout(generation: Long) {
|
||||
cancelConnectingTimeout(reason = "re-arm")
|
||||
connectingTimeoutJob = scope.launch {
|
||||
delay(CONNECTING_STUCK_TIMEOUT_MS)
|
||||
enqueueSessionEvent(SessionEvent.ConnectingTimeout(generation))
|
||||
}
|
||||
log("⏱️ CONNECTING watchdog armed gen=$generation timeout=${CONNECTING_STUCK_TIMEOUT_MS}ms")
|
||||
}
|
||||
|
||||
private fun handleSocketOpened(event: SessionEvent.SocketOpened) {
|
||||
if (isStaleSocketEvent("onOpen", event.generation, event.socket)) return
|
||||
log(
|
||||
"✅ WebSocket OPEN: response=${event.responseCode}, " +
|
||||
"hasCredentials=${lastPublicKey != null}, gen=${event.generation}"
|
||||
)
|
||||
|
||||
cancelConnectingTimeout(reason = "socket_opened")
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
|
||||
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
|
||||
// Flush queue as soon as socket is open.
|
||||
// Auth-required packets will remain queued until handshake completes.
|
||||
flushPacketQueue()
|
||||
|
||||
if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) {
|
||||
lastPublicKey?.let { publicKey ->
|
||||
lastPrivateHash?.let { privateHash ->
|
||||
log("🤝 Auto-starting handshake with saved credentials")
|
||||
startHandshake(publicKey, privateHash, lastDevice)
|
||||
}
|
||||
} ?: log("⚠️ No saved credentials, waiting for manual handshake")
|
||||
} else {
|
||||
log("⚠️ Skipping auto-handshake: already in state ${_state.value}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSocketClosed(event: SessionEvent.SocketClosed) {
|
||||
if (isStaleSocketEvent("onClosed", event.generation, event.socket)) return
|
||||
log(
|
||||
"❌ WebSocket CLOSED: code=${event.code} reason='${event.reason}' state=${_state.value} " +
|
||||
"manuallyClosed=$isManuallyClosed gen=${event.generation}"
|
||||
)
|
||||
cancelConnectingTimeout(reason = "socket_closed")
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
handleDisconnectLocked("onClosed")
|
||||
}
|
||||
|
||||
private fun handleSocketFailure(event: SessionEvent.SocketFailure) {
|
||||
if (isStaleSocketEvent("onFailure", event.generation, event.socket)) return
|
||||
log("❌ WebSocket FAILURE: ${event.throwable.message}")
|
||||
log(" Response: ${event.responseCode} ${event.responseMessage}")
|
||||
log(" State: ${_state.value}")
|
||||
log(" Manually closed: $isManuallyClosed")
|
||||
log(" Reconnect attempts: $reconnectAttempts")
|
||||
log(" Generation: ${event.generation}")
|
||||
event.throwable.printStackTrace()
|
||||
cancelConnectingTimeout(reason = "socket_failure")
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
_lastError.value = event.throwable.message
|
||||
handleDisconnectLocked("onFailure")
|
||||
}
|
||||
|
||||
private fun handleConnectingTimeout(generation: Long) {
|
||||
val currentState = _state.value
|
||||
if (generation != activeConnectionGeneration) {
|
||||
log(
|
||||
"⏱️ CONNECTING watchdog ignored for stale generation " +
|
||||
"(event=$generation active=$activeConnectionGeneration)"
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!isConnecting || currentState != ProtocolState.CONNECTING) {
|
||||
return
|
||||
}
|
||||
|
||||
val elapsed = if (connectingSinceMs > 0L) {
|
||||
System.currentTimeMillis() - connectingSinceMs
|
||||
} else {
|
||||
CONNECTING_STUCK_TIMEOUT_MS
|
||||
}
|
||||
log("🧯 CONNECTING TIMEOUT fired (elapsed=${elapsed}ms) -> forcing disconnect/reconnect")
|
||||
|
||||
cancelConnectingTimeout(reason = "timeout_fired")
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
runCatching { webSocket?.cancel() }
|
||||
webSocket = null
|
||||
handleDisconnectLocked("connecting_timeout")
|
||||
}
|
||||
|
||||
private fun handleHandshakeResponse(packet: PacketHandshake) {
|
||||
handshakeJob?.cancel()
|
||||
|
||||
when (packet.handshakeState) {
|
||||
HandshakeState.COMPLETED -> {
|
||||
log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
|
||||
handshakeComplete = true
|
||||
setState(ProtocolState.AUTHENTICATED, "Handshake completed")
|
||||
flushPacketQueue()
|
||||
}
|
||||
|
||||
HandshakeState.NEED_DEVICE_VERIFICATION -> {
|
||||
log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION")
|
||||
handshakeComplete = false
|
||||
setState(
|
||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED,
|
||||
"Handshake requires device verification"
|
||||
)
|
||||
packetQueue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Keep heartbeat in both handshake states to maintain server session.
|
||||
startHeartbeat(packet.heartbeatInterval)
|
||||
}
|
||||
|
||||
private fun handleDeviceVerificationAccepted(deviceId: String) {
|
||||
log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(deviceId, 12)})")
|
||||
val stateAtAccept = _state.value
|
||||
if (stateAtAccept == ProtocolState.AUTHENTICATED) {
|
||||
log("✅ ACCEPT ignored: already authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
if (stateAtAccept == ProtocolState.DEVICE_VERIFICATION_REQUIRED) {
|
||||
setState(ProtocolState.CONNECTED, "Device verification accepted")
|
||||
}
|
||||
|
||||
val publicKey = lastPublicKey
|
||||
val privateHash = lastPrivateHash
|
||||
if (publicKey.isNullOrBlank() || privateHash.isNullOrBlank()) {
|
||||
log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect")
|
||||
return
|
||||
}
|
||||
|
||||
when (_state.value) {
|
||||
ProtocolState.DISCONNECTED -> {
|
||||
log("🔄 ACCEPT while disconnected -> reconnecting")
|
||||
connectLocked()
|
||||
}
|
||||
|
||||
ProtocolState.CONNECTING -> {
|
||||
log("⏳ ACCEPT while connecting -> waiting for onOpen auto-handshake")
|
||||
}
|
||||
|
||||
else -> {
|
||||
startHandshake(publicKey, privateHash, lastDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rotateConnectionGeneration(reason: String): Long {
|
||||
val generation = connectionGeneration.incrementAndGet()
|
||||
activeConnectionGeneration = generation
|
||||
log("🧬 CONNECTION GENERATION: #$generation ($reason, instance=$instanceId)")
|
||||
return generation
|
||||
}
|
||||
|
||||
private fun isStaleSocketEvent(event: String, generation: Long, socket: WebSocket): Boolean {
|
||||
val currentGeneration = activeConnectionGeneration
|
||||
val activeSocket = webSocket
|
||||
val staleByGeneration = generation != currentGeneration
|
||||
val staleBySocket = activeSocket != null && activeSocket !== socket
|
||||
if (!staleByGeneration && !staleBySocket) {
|
||||
return false
|
||||
}
|
||||
|
||||
log(
|
||||
"🧊 STALE SOCKET EVENT ignored: event=$event gen=$generation activeGen=$currentGeneration " +
|
||||
"sameSocket=${activeSocket === socket} instance=$instanceId"
|
||||
)
|
||||
runCatching { socket.close(1000, "Stale socket event") }
|
||||
return true
|
||||
}
|
||||
|
||||
private val _lastError = MutableStateFlow<String?>(null)
|
||||
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||
|
||||
// Packet waiters - callbacks for specific packet types (thread-safe)
|
||||
private val packetWaiters = java.util.concurrent.ConcurrentHashMap<Int, MutableList<(Packet) -> Unit>>()
|
||||
private val packetWaiters =
|
||||
java.util.concurrent.ConcurrentHashMap<Int, CopyOnWriteArrayList<(Packet) -> Unit>>()
|
||||
|
||||
// Packet queue for packets sent before handshake complete (thread-safe)
|
||||
private val packetQueue = java.util.Collections.synchronizedList(mutableListOf<Packet>())
|
||||
@@ -230,13 +529,15 @@ class Protocol(
|
||||
private var lastPrivateHash: String? = null
|
||||
private var lastDevice: HandshakeDevice = HandshakeDevice()
|
||||
|
||||
// Getters for ProtocolManager to fetch own profile
|
||||
// Getters for runtime layers to fetch own profile
|
||||
fun getPublicKey(): String? = lastPublicKey
|
||||
fun getPrivateHash(): String? = lastPrivateHash
|
||||
|
||||
// Heartbeat
|
||||
private var heartbeatJob: Job? = null
|
||||
@Volatile private var heartbeatPeriodMs: Long = 0L
|
||||
@Volatile private var isAppInForeground: Boolean = true
|
||||
private var serverHeartbeatIntervalSec: Int = DEFAULT_HEARTBEAT_INTERVAL_SECONDS
|
||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||
@Volatile private var heartbeatOkSuppressedCount: Int = 0
|
||||
|
||||
@@ -271,69 +572,96 @@ class Protocol(
|
||||
)
|
||||
|
||||
init {
|
||||
log("🧩 Protocol init: instance=$instanceId")
|
||||
|
||||
// Register handshake response handler
|
||||
waitPacket(0x00) { packet ->
|
||||
if (packet is PacketHandshake) {
|
||||
handshakeJob?.cancel()
|
||||
enqueueSessionEvent(SessionEvent.HandshakeResponse(packet))
|
||||
}
|
||||
}
|
||||
|
||||
when (packet.handshakeState) {
|
||||
HandshakeState.COMPLETED -> {
|
||||
log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
|
||||
handshakeComplete = true
|
||||
setState(ProtocolState.AUTHENTICATED, "Handshake completed")
|
||||
flushPacketQueue()
|
||||
}
|
||||
// Device verification resolution from primary device.
|
||||
// Desktop typically continues after next handshake response; here we also
|
||||
// add a safety re-handshake trigger on ACCEPT to avoid being stuck in
|
||||
// DEVICE_VERIFICATION_REQUIRED if server doesn't immediately push 0x00.
|
||||
waitPacket(0x18) { packet ->
|
||||
val resolve = packet as? PacketDeviceResolve ?: return@waitPacket
|
||||
when (resolve.solution) {
|
||||
DeviceResolveSolution.ACCEPT -> {
|
||||
enqueueSessionEvent(
|
||||
SessionEvent.DeviceVerificationAccepted(deviceId = resolve.deviceId)
|
||||
)
|
||||
}
|
||||
DeviceResolveSolution.DECLINE -> {
|
||||
val stateAtDecline = _state.value
|
||||
log(
|
||||
"⛔ DEVICE VERIFICATION DECLINED (deviceId=${shortKey(resolve.deviceId, 12)}, state=$stateAtDecline)"
|
||||
)
|
||||
|
||||
HandshakeState.NEED_DEVICE_VERIFICATION -> {
|
||||
log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION")
|
||||
handshakeComplete = false
|
||||
setState(
|
||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED,
|
||||
"Handshake requires device verification"
|
||||
// Critical recovery: after DECLINE user may retry login without app restart.
|
||||
// Keep socket session alive when possible, but leave DEVICE_VERIFICATION_REQUIRED
|
||||
// state so next authenticate() is not ignored by startHandshake guards.
|
||||
if (
|
||||
stateAtDecline == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||
stateAtDecline == ProtocolState.HANDSHAKING
|
||||
) {
|
||||
enqueueSessionEvent(
|
||||
SessionEvent.DeviceVerificationDeclined(
|
||||
deviceId = resolve.deviceId,
|
||||
observedState = stateAtDecline
|
||||
)
|
||||
)
|
||||
packetQueue.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Keep heartbeat in both handshake states to maintain server session.
|
||||
startHeartbeat(packet.heartbeatInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat to keep connection alive
|
||||
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
|
||||
* Start adaptive heartbeat to keep connection alive.
|
||||
* Foreground: serverInterval / 2 (like desktop).
|
||||
* Background: 30s to save battery.
|
||||
*/
|
||||
private fun startHeartbeat(intervalSeconds: Int) {
|
||||
val normalizedServerIntervalSec =
|
||||
serverHeartbeatIntervalSec =
|
||||
if (intervalSeconds > 0) intervalSeconds else DEFAULT_HEARTBEAT_INTERVAL_SECONDS
|
||||
// Отправляем чаще - каждые 1/3 интервала, но с нижним лимитом чтобы исключить tight-loop.
|
||||
val intervalMs =
|
||||
((normalizedServerIntervalSec * 1000L) / 3).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
|
||||
|
||||
if (heartbeatJob?.isActive == true && heartbeatPeriodMs == intervalMs) {
|
||||
return
|
||||
}
|
||||
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatPeriodMs = intervalMs
|
||||
lastHeartbeatOkLogAtMs = 0L
|
||||
heartbeatOkSuppressedCount = 0
|
||||
log(
|
||||
"💓 HEARTBEAT START: server=${intervalSeconds}s(normalized=${normalizedServerIntervalSec}s), " +
|
||||
"sending=${intervalMs / 1000}s, state=${_state.value}"
|
||||
)
|
||||
|
||||
|
||||
heartbeatJob = scope.launch {
|
||||
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
|
||||
sendHeartbeat()
|
||||
|
||||
|
||||
while (isActive) {
|
||||
val intervalMs = if (isAppInForeground) {
|
||||
((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
|
||||
} else {
|
||||
BACKGROUND_HEARTBEAT_INTERVAL_MS
|
||||
}
|
||||
heartbeatPeriodMs = intervalMs
|
||||
delay(intervalMs)
|
||||
sendHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
val fgMs = ((serverHeartbeatIntervalSec * 1000L) / 2).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
|
||||
log(
|
||||
"💓 HEARTBEAT START: server=${intervalSeconds}s, " +
|
||||
"foreground=${fgMs / 1000}s, background=${BACKGROUND_HEARTBEAT_INTERVAL_MS / 1000}s, " +
|
||||
"appForeground=$isAppInForeground, state=${_state.value}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify protocol about app foreground/background state.
|
||||
* Adjusts heartbeat interval to save battery in background.
|
||||
*/
|
||||
fun setAppInForeground(foreground: Boolean) {
|
||||
if (isAppInForeground == foreground) return
|
||||
isAppInForeground = foreground
|
||||
log("💓 App foreground=$foreground, heartbeat will adjust on next tick")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,7 +694,7 @@ class Protocol(
|
||||
// Триггерим reconnect если heartbeat не прошёл
|
||||
if (!isManuallyClosed) {
|
||||
log("🔄 TRIGGERING RECONNECT due to failed heartbeat")
|
||||
handleDisconnect()
|
||||
handleDisconnect("heartbeat_failed")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -384,8 +712,13 @@ class Protocol(
|
||||
* Initialize connection to server
|
||||
*/
|
||||
fun connect() {
|
||||
enqueueSessionEvent(SessionEvent.Connect())
|
||||
}
|
||||
|
||||
private fun connectLocked() {
|
||||
val currentState = _state.value
|
||||
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
|
||||
val now = System.currentTimeMillis()
|
||||
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting, instance=$instanceId")
|
||||
|
||||
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
|
||||
if (
|
||||
@@ -403,10 +736,21 @@ class Protocol(
|
||||
return
|
||||
}
|
||||
|
||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
|
||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
|
||||
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
|
||||
if (isConnecting || currentState == ProtocolState.CONNECTING) {
|
||||
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
|
||||
return
|
||||
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
|
||||
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
|
||||
log("⚠️ Already connecting, skipping... (elapsed=${elapsed}ms)")
|
||||
return
|
||||
}
|
||||
log("🧯 CONNECTING STUCK detected (elapsed=${elapsed}ms) -> forcing reconnect reset")
|
||||
cancelConnectingTimeout(reason = "connect_stuck_reset")
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
runCatching { webSocket?.cancel() }
|
||||
webSocket = null
|
||||
setState(ProtocolState.DISCONNECTED, "Reset stuck CONNECTING (${elapsed}ms)")
|
||||
}
|
||||
|
||||
val networkReady = isNetworkAvailable?.invoke() ?: true
|
||||
@@ -424,9 +768,11 @@ class Protocol(
|
||||
|
||||
// Устанавливаем флаг ПЕРЕД любыми операциями
|
||||
isConnecting = true
|
||||
connectingSinceMs = now
|
||||
|
||||
reconnectAttempts++
|
||||
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
|
||||
val generation = rotateConnectionGeneration("connect_attempt_$reconnectAttempts")
|
||||
|
||||
// Закрываем старый сокет если есть (как в Архиве)
|
||||
webSocket?.let { oldSocket ->
|
||||
@@ -442,6 +788,7 @@ class Protocol(
|
||||
isManuallyClosed = false
|
||||
setState(ProtocolState.CONNECTING, "Starting new connection attempt #$reconnectAttempts")
|
||||
_lastError.value = null
|
||||
armConnectingTimeout(generation)
|
||||
|
||||
log("🔌 Connecting to: $serverAddress (attempt #$reconnectAttempts)")
|
||||
|
||||
@@ -451,40 +798,28 @@ class Protocol(
|
||||
|
||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
log("✅ WebSocket OPEN: response=${response.code}, hasCredentials=${lastPublicKey != null}")
|
||||
|
||||
// Сбрасываем флаг подключения
|
||||
isConnecting = false
|
||||
|
||||
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
|
||||
// Flush queue as soon as socket is open.
|
||||
// Auth-required packets will remain queued until handshake completes.
|
||||
flushPacketQueue()
|
||||
|
||||
// КРИТИЧНО: проверяем что не идет уже handshake
|
||||
if (_state.value != ProtocolState.HANDSHAKING && _state.value != ProtocolState.AUTHENTICATED) {
|
||||
// If we have saved credentials, start handshake automatically
|
||||
lastPublicKey?.let { publicKey ->
|
||||
lastPrivateHash?.let { privateHash ->
|
||||
log("🤝 Auto-starting handshake with saved credentials")
|
||||
startHandshake(publicKey, privateHash, lastDevice)
|
||||
}
|
||||
} ?: log("⚠️ No saved credentials, waiting for manual handshake")
|
||||
} else {
|
||||
log("⚠️ Skipping auto-handshake: already in state ${_state.value}")
|
||||
}
|
||||
enqueueSessionEvent(
|
||||
SessionEvent.SocketOpened(
|
||||
generation = generation,
|
||||
socket = webSocket,
|
||||
responseCode = response.code
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||
if (isStaleSocketEvent("onMessage(bytes)", generation, webSocket)) return
|
||||
log("📥 onMessage called - ${bytes.size} bytes")
|
||||
handleMessage(bytes.toByteArray())
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (isStaleSocketEvent("onMessage(text)", generation, webSocket)) return
|
||||
log("Received text message (unexpected): $text")
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
if (isStaleSocketEvent("onClosing", generation, webSocket)) return
|
||||
log("⚠️ WebSocket CLOSING: code=$code reason='$reason' state=${_state.value}")
|
||||
// Must respond with close() so OkHttp transitions to onClosed.
|
||||
// Without this, the socket stays in a half-closed "zombie" state —
|
||||
@@ -498,21 +833,26 @@ class Protocol(
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
handleDisconnect()
|
||||
enqueueSessionEvent(
|
||||
SessionEvent.SocketClosed(
|
||||
generation = generation,
|
||||
socket = webSocket,
|
||||
code = code,
|
||||
reason = reason
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
log("❌ WebSocket FAILURE: ${t.message}")
|
||||
log(" Response: ${response?.code} ${response?.message}")
|
||||
log(" State: ${_state.value}")
|
||||
log(" Manually closed: $isManuallyClosed")
|
||||
log(" Reconnect attempts: $reconnectAttempts")
|
||||
t.printStackTrace()
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
_lastError.value = t.message
|
||||
handleDisconnect()
|
||||
enqueueSessionEvent(
|
||||
SessionEvent.SocketFailure(
|
||||
generation = generation,
|
||||
socket = webSocket,
|
||||
throwable = t,
|
||||
responseCode = response?.code,
|
||||
responseMessage = response?.message
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -542,8 +882,9 @@ class Protocol(
|
||||
// If switching accounts, force disconnect and reconnect with new credentials
|
||||
if (switchingAccount) {
|
||||
log("🔄 Account switch detected, forcing reconnect with new credentials")
|
||||
disconnect()
|
||||
connect() // Will auto-handshake with saved credentials (publicKey, privateHash) on connect
|
||||
enqueueSessionEvent(
|
||||
SessionEvent.AccountSwitchReconnect(reason = "Account switch reconnect")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -601,7 +942,14 @@ class Protocol(
|
||||
val currentState = _state.value
|
||||
val socket = webSocket
|
||||
val socketReady = socket != null
|
||||
val authReady = handshakeComplete && currentState == ProtocolState.AUTHENTICATED
|
||||
val authReady = currentState == ProtocolState.AUTHENTICATED
|
||||
if (authReady && !handshakeComplete) {
|
||||
// Defensive self-heal:
|
||||
// AUTHENTICATED state must imply completed handshake.
|
||||
// If these flags diverge, message sending can be stuck in queue forever.
|
||||
log("⚠️ AUTHENTICATED with handshakeComplete=false -> self-heal handshakeComplete=true")
|
||||
handshakeComplete = true
|
||||
}
|
||||
val preAuthAllowedPacket =
|
||||
packet is PacketSignalPeer || packet is PacketWebRTC || packet is PacketIceServers
|
||||
val preAuthReady =
|
||||
@@ -726,15 +1074,32 @@ class Protocol(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDisconnect() {
|
||||
private fun handleDisconnect(source: String = "unknown") {
|
||||
enqueueSessionEvent(SessionEvent.HandleDisconnect(source))
|
||||
}
|
||||
|
||||
private fun handleDisconnectLocked(source: String) {
|
||||
val previousState = _state.value
|
||||
log("🔌 DISCONNECT HANDLER: previousState=$previousState, manuallyClosed=$isManuallyClosed, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
|
||||
log(
|
||||
"🔌 DISCONNECT HANDLER: source=$source previousState=$previousState, manuallyClosed=$isManuallyClosed, " +
|
||||
"reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting, instance=$instanceId"
|
||||
)
|
||||
cancelConnectingTimeout(reason = "handle_disconnect:$source")
|
||||
|
||||
// Duplicate callbacks are possible (e.g. heartbeat failure + onFailure/onClosed).
|
||||
// If we are already disconnected and a reconnect is pending, avoid scheduling another one.
|
||||
if (previousState == ProtocolState.DISCONNECTED && reconnectJob?.isActive == true) {
|
||||
log("⚠️ DISCONNECT DUPLICATE: reconnect already scheduled, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// КРИТИЧНО: если уже идет подключение, не делаем ничего
|
||||
if (isConnecting) {
|
||||
log("⚠️ DISCONNECT IGNORED: connection already in progress")
|
||||
return
|
||||
}
|
||||
|
||||
rotateConnectionGeneration("disconnect:$source")
|
||||
|
||||
setState(ProtocolState.DISCONNECTED, "Disconnect handler called from $previousState")
|
||||
handshakeComplete = false
|
||||
@@ -751,18 +1116,22 @@ class Protocol(
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматический reconnect с защитой от бесконечных попыток
|
||||
// Автоматический reconnect с лимитом попыток
|
||||
if (!isManuallyClosed) {
|
||||
// КРИТИЧНО: отменяем предыдущий reconnect job если есть
|
||||
reconnectJob?.cancel()
|
||||
|
||||
// Экспоненциальная задержка: 1s, 2s, 4s, 8s, максимум 30s
|
||||
val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempts - 1, 4)), 30000L)
|
||||
log("🔄 SCHEDULING RECONNECT: attempt #$reconnectAttempts, delay=${delayMs}ms")
|
||||
|
||||
if (reconnectAttempts > 20) {
|
||||
log("⚠️ WARNING: Too many reconnect attempts ($reconnectAttempts), may be stuck in loop")
|
||||
|
||||
val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1)
|
||||
|
||||
// После 10 попыток — останавливаемся, ждём NetworkCallback или foreground resume
|
||||
if (nextAttemptNumber > MAX_RECONNECT_ATTEMPTS) {
|
||||
log("🛑 RECONNECT STOPPED: $nextAttemptNumber attempts exhausted, waiting for network change or foreground resume")
|
||||
onNetworkUnavailable?.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
val exponent = (nextAttemptNumber - 1).coerceIn(0, 4)
|
||||
val delayMs = minOf(1000L * (1L shl exponent), 30000L)
|
||||
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber/$MAX_RECONNECT_ATTEMPTS, delay=${delayMs}ms")
|
||||
|
||||
reconnectJob = scope.launch {
|
||||
delay(delayMs)
|
||||
@@ -782,33 +1151,58 @@ class Protocol(
|
||||
* Register callback for specific packet type
|
||||
*/
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetWaiters.getOrPut(packetId) { mutableListOf() }.add(callback)
|
||||
val count = packetWaiters[packetId]?.size ?: 0
|
||||
log("📝 waitPacket(0x${Integer.toHexString(packetId)}) registered. Total handlers for 0x${Integer.toHexString(packetId)}: $count")
|
||||
val waiters = packetWaiters.computeIfAbsent(packetId) { CopyOnWriteArrayList() }
|
||||
if (waiters.contains(callback)) {
|
||||
log(
|
||||
"📝 waitPacket(0x${Integer.toHexString(packetId)}) skipped duplicate callback. " +
|
||||
"Total handlers for 0x${Integer.toHexString(packetId)}: ${waiters.size}"
|
||||
)
|
||||
return
|
||||
}
|
||||
waiters.add(callback)
|
||||
log(
|
||||
"📝 waitPacket(0x${Integer.toHexString(packetId)}) registered. " +
|
||||
"Total handlers for 0x${Integer.toHexString(packetId)}: ${waiters.size}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister callback for specific packet type
|
||||
*/
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetWaiters[packetId]?.remove(callback)
|
||||
val waiters = packetWaiters[packetId] ?: return
|
||||
waiters.remove(callback)
|
||||
if (waiters.isEmpty()) {
|
||||
packetWaiters.remove(packetId, waiters)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server
|
||||
*/
|
||||
fun disconnect() {
|
||||
log("🔌 Manual disconnect requested")
|
||||
isManuallyClosed = true
|
||||
enqueueSessionEvent(
|
||||
SessionEvent.Disconnect(manual = true, reason = "User disconnected")
|
||||
)
|
||||
}
|
||||
|
||||
private fun disconnectLocked(manual: Boolean, reason: String) {
|
||||
log("🔌 Disconnect requested: manual=$manual reason='$reason' instance=$instanceId")
|
||||
isManuallyClosed = manual
|
||||
cancelConnectingTimeout(reason = "disconnect_locked")
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
connectingSinceMs = 0L
|
||||
reconnectJob?.cancel() // Отменяем запланированные переподключения
|
||||
reconnectJob = null
|
||||
handshakeJob?.cancel()
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatPeriodMs = 0L
|
||||
webSocket?.close(1000, "User disconnected")
|
||||
rotateConnectionGeneration("disconnect_locked:${if (manual) "manual" else "internal"}")
|
||||
|
||||
val socket = webSocket
|
||||
webSocket = null
|
||||
_state.value = ProtocolState.DISCONNECTED
|
||||
runCatching { socket?.close(1000, reason) }
|
||||
setState(ProtocolState.DISCONNECTED, "disconnectLocked(manual=$manual, reason=$reason)")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -821,21 +1215,46 @@ class Protocol(
|
||||
* on app resume we should not wait scheduled exponential backoff.
|
||||
*/
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground") {
|
||||
enqueueSessionEvent(SessionEvent.FastReconnect(reason))
|
||||
}
|
||||
|
||||
private fun reconnectNowIfNeededLocked(reason: String) {
|
||||
val currentState = _state.value
|
||||
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
log(
|
||||
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
|
||||
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, attempts=$reconnectAttempts, reason=$reason"
|
||||
)
|
||||
|
||||
// Reset attempt counter — network changed or user returned to app
|
||||
reconnectAttempts = 0
|
||||
|
||||
if (isManuallyClosed) {
|
||||
log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason")
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasCredentials) return
|
||||
|
||||
if (
|
||||
if (currentState == ProtocolState.CONNECTING && isConnecting) {
|
||||
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
|
||||
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
|
||||
return
|
||||
}
|
||||
log("🧯 FAST RECONNECT: stuck CONNECTING (${elapsed}ms) -> reset and reconnect")
|
||||
cancelConnectingTimeout(reason = "fast_reconnect_reset")
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
runCatching { webSocket?.cancel() }
|
||||
webSocket = null
|
||||
rotateConnectionGeneration("fast_reconnect_reset:$reason")
|
||||
setState(ProtocolState.DISCONNECTED, "Fast reconnect reset stuck CONNECTING")
|
||||
} else if (
|
||||
currentState == ProtocolState.AUTHENTICATED ||
|
||||
currentState == ProtocolState.HANDSHAKING ||
|
||||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||
currentState == ProtocolState.CONNECTED ||
|
||||
(currentState == ProtocolState.CONNECTING && isConnecting)
|
||||
currentState == ProtocolState.CONNECTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -844,7 +1263,7 @@ class Protocol(
|
||||
reconnectAttempts = 0
|
||||
reconnectJob?.cancel()
|
||||
reconnectJob = null
|
||||
connect()
|
||||
connectLocked()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -867,7 +1286,20 @@ class Protocol(
|
||||
* Release resources
|
||||
*/
|
||||
fun destroy() {
|
||||
disconnect()
|
||||
enqueueSessionEvent(
|
||||
SessionEvent.Disconnect(manual = true, reason = "Destroy protocol")
|
||||
)
|
||||
runCatching { sessionEvents.close() }
|
||||
runBlocking {
|
||||
val drained = withTimeoutOrNull(2_000L) {
|
||||
sessionLoopJob.join()
|
||||
true
|
||||
} ?: false
|
||||
if (!drained) {
|
||||
sessionLoopJob.cancelAndJoin()
|
||||
}
|
||||
}
|
||||
connectingTimeoutJob?.cancel()
|
||||
heartbeatJob?.cancel()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Infrastructure adapter contract used by repositories.
|
||||
*
|
||||
* Kept intentionally narrow and transport-oriented to avoid direct repository -> runtime wiring
|
||||
* while preserving lazy runtime resolution through Provider in DI.
|
||||
*/
|
||||
interface ProtocolClient {
|
||||
fun send(packet: Packet)
|
||||
fun sendMessageWithRetry(packet: PacketMessage)
|
||||
fun addLog(message: String)
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
enum class ConnectionLifecycleState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
HANDSHAKING,
|
||||
AUTHENTICATED,
|
||||
BOOTSTRAPPING,
|
||||
READY,
|
||||
DEVICE_VERIFICATION_REQUIRED
|
||||
}
|
||||
|
||||
sealed interface ConnectionEvent {
|
||||
data class InitializeAccount(val publicKey: String, val privateKey: String) : ConnectionEvent
|
||||
data class Connect(val reason: String) : ConnectionEvent
|
||||
data class FastReconnect(val reason: String) : ConnectionEvent
|
||||
data class Disconnect(val reason: String, val clearCredentials: Boolean) : ConnectionEvent
|
||||
data class Authenticate(val publicKey: String, val privateHash: String) : ConnectionEvent
|
||||
data class ProtocolStateChanged(val state: ProtocolState) : ConnectionEvent
|
||||
data class SendPacket(val packet: Packet) : ConnectionEvent
|
||||
data class SyncCompleted(val reason: String) : ConnectionEvent
|
||||
data class OwnProfileResolved(val publicKey: String) : ConnectionEvent
|
||||
data class OwnProfileFallbackTimeout(val sessionGeneration: Long) : ConnectionEvent
|
||||
}
|
||||
|
||||
data class ConnectionBootstrapContext(
|
||||
val accountPublicKey: String = "",
|
||||
val accountInitialized: Boolean = false,
|
||||
val protocolState: ProtocolState = ProtocolState.DISCONNECTED,
|
||||
val authenticated: Boolean = false,
|
||||
val syncCompleted: Boolean = false,
|
||||
val ownProfileResolved: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProtocolConnectionSupervisor(
|
||||
private val scope: CoroutineScope,
|
||||
private val onEvent: suspend (ConnectionEvent) -> Unit,
|
||||
private val onError: (Throwable) -> Unit,
|
||||
private val addLog: (String) -> Unit
|
||||
) {
|
||||
private val eventChannel = Channel<ConnectionEvent>(Channel.UNLIMITED)
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile private var job: Job? = null
|
||||
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
synchronized(lock) {
|
||||
if (job?.isActive == true) return
|
||||
job =
|
||||
scope.launch {
|
||||
for (event in eventChannel) {
|
||||
try {
|
||||
onEvent(event)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
addLog("❌ ConnectionSupervisor event failed: ${e.message}")
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
addLog("🧠 ConnectionSupervisor started")
|
||||
}
|
||||
}
|
||||
|
||||
fun post(event: ConnectionEvent) {
|
||||
start()
|
||||
val result = eventChannel.trySend(event)
|
||||
if (result.isFailure) {
|
||||
scope.launch { eventChannel.send(event) }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ProtocolRuntime @Inject constructor(
|
||||
private val runtimeComposition: RuntimeComposition,
|
||||
private val messageRepository: MessageRepository,
|
||||
private val groupRepository: GroupRepository,
|
||||
private val accountManager: AccountManager
|
||||
) : ProtocolRuntimePort, ProtocolGateway {
|
||||
init {
|
||||
bindDependencies()
|
||||
}
|
||||
|
||||
private val connectionControlApi by lazy { runtimeComposition.connectionControlApi() }
|
||||
private val directoryApi by lazy { runtimeComposition.directoryApi() }
|
||||
private val packetIoApi by lazy { runtimeComposition.packetIoApi() }
|
||||
|
||||
override val state: StateFlow<ProtocolState> get() = runtimeComposition.state
|
||||
override val syncInProgress: StateFlow<Boolean> get() = runtimeComposition.syncInProgress
|
||||
override val pendingDeviceVerification: StateFlow<DeviceEntry?> get() =
|
||||
runtimeComposition.pendingDeviceVerification
|
||||
override val typingUsers: StateFlow<Set<String>> get() = runtimeComposition.typingUsers
|
||||
override val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> get() =
|
||||
runtimeComposition.typingUsersByDialogSnapshot
|
||||
override val debugLogs: StateFlow<List<String>> get() = runtimeComposition.debugLogs
|
||||
override val ownProfileUpdated: StateFlow<Long> get() = runtimeComposition.ownProfileUpdated
|
||||
|
||||
override fun initialize(context: Context) {
|
||||
bindDependencies()
|
||||
connectionControlApi.initialize(context)
|
||||
}
|
||||
|
||||
override fun initializeAccount(publicKey: String, privateKey: String) =
|
||||
connectionControlApi.initializeAccount(publicKey, privateKey)
|
||||
|
||||
override fun connect() = connectionControlApi.connect()
|
||||
|
||||
override fun authenticate(publicKey: String, privateHash: String) =
|
||||
connectionControlApi.authenticate(publicKey, privateHash)
|
||||
|
||||
override fun reconnectNowIfNeeded(reason: String) =
|
||||
connectionControlApi.reconnectNowIfNeeded(reason)
|
||||
|
||||
override fun disconnect() = connectionControlApi.disconnect()
|
||||
|
||||
override fun setAppInForeground(foreground: Boolean) =
|
||||
connectionControlApi.setAppInForeground(foreground)
|
||||
|
||||
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
|
||||
|
||||
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()
|
||||
|
||||
override fun subscribePushTokenIfAvailable(forceToken: String?) =
|
||||
connectionControlApi.subscribePushToken(forceToken)
|
||||
|
||||
override fun addLog(message: String) = runtimeComposition.addLog(message)
|
||||
|
||||
override fun enableUILogs(enabled: Boolean) = runtimeComposition.enableUILogs(enabled)
|
||||
|
||||
override fun clearLogs() = runtimeComposition.clearLogs()
|
||||
|
||||
override fun resolveOutgoingRetry(messageId: String) =
|
||||
packetIoApi.resolveOutgoingRetry(messageId)
|
||||
|
||||
override fun getCachedUserByUsername(username: String): SearchUser? =
|
||||
directoryApi.getCachedUserByUsername(username)
|
||||
|
||||
override fun getCachedUserName(publicKey: String): String? =
|
||||
directoryApi.getCachedUserName(publicKey)
|
||||
|
||||
override fun getCachedUserInfo(publicKey: String): SearchUser? =
|
||||
directoryApi.getCachedUserInfo(publicKey)
|
||||
|
||||
override fun acceptDevice(deviceId: String) = directoryApi.acceptDevice(deviceId)
|
||||
|
||||
override fun declineDevice(deviceId: String) = directoryApi.declineDevice(deviceId)
|
||||
|
||||
override fun send(packet: Packet) = packetIoApi.send(packet)
|
||||
|
||||
override fun sendPacket(packet: Packet) = packetIoApi.sendPacket(packet)
|
||||
|
||||
override fun sendMessageWithRetry(packet: PacketMessage) =
|
||||
packetIoApi.sendMessageWithRetry(packet)
|
||||
|
||||
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
packetIoApi.waitPacket(packetId, callback)
|
||||
|
||||
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
packetIoApi.unwaitPacket(packetId, callback)
|
||||
|
||||
override fun packetFlow(packetId: Int): SharedFlow<Packet> =
|
||||
packetIoApi.packetFlow(packetId)
|
||||
|
||||
override fun notifyOwnProfileUpdated() = directoryApi.notifyOwnProfileUpdated()
|
||||
|
||||
override fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String?,
|
||||
reason: String
|
||||
): Boolean = connectionControlApi.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||
|
||||
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
|
||||
directoryApi.resolveUserName(publicKey, timeoutMs)
|
||||
|
||||
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
|
||||
directoryApi.resolveUserInfo(publicKey, timeoutMs)
|
||||
|
||||
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
|
||||
directoryApi.searchUsers(query, timeoutMs)
|
||||
|
||||
override fun requestIceServers() = packetIoApi.requestIceServers()
|
||||
|
||||
override fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit =
|
||||
packetIoApi.waitCallSignal(callback)
|
||||
|
||||
override fun unwaitCallSignal(callback: (Packet) -> Unit) =
|
||||
packetIoApi.unwaitCallSignal(callback)
|
||||
|
||||
override fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit =
|
||||
packetIoApi.waitWebRtcSignal(callback)
|
||||
|
||||
override fun unwaitWebRtcSignal(callback: (Packet) -> Unit) =
|
||||
packetIoApi.unwaitWebRtcSignal(callback)
|
||||
|
||||
override fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit =
|
||||
packetIoApi.waitIceServers(callback)
|
||||
|
||||
override fun unwaitIceServers(callback: (Packet) -> Unit) =
|
||||
packetIoApi.unwaitIceServers(callback)
|
||||
|
||||
override fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String,
|
||||
dst: String,
|
||||
sharedPublic: String,
|
||||
callId: String,
|
||||
joinToken: String
|
||||
) = packetIoApi.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
|
||||
|
||||
override fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) =
|
||||
packetIoApi.sendWebRtcSignal(signalType, sdpOrCandidate)
|
||||
|
||||
private fun bindDependencies() {
|
||||
runtimeComposition.bindDependencies(
|
||||
messageRepository = messageRepository,
|
||||
groupRepository = groupRepository,
|
||||
accountManager = accountManager
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Stable runtime port for layers that are not created by Hilt (object managers/services).
|
||||
*/
|
||||
interface ProtocolRuntimePort {
|
||||
val state: StateFlow<ProtocolState>
|
||||
val debugLogs: StateFlow<List<String>>
|
||||
|
||||
fun addLog(message: String)
|
||||
fun send(packet: Packet)
|
||||
fun sendPacket(packet: Packet)
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
|
||||
fun requestIceServers()
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit
|
||||
fun unwaitCallSignal(callback: (Packet) -> Unit)
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit
|
||||
fun unwaitWebRtcSignal(callback: (Packet) -> Unit)
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit
|
||||
fun unwaitIceServers(callback: (Packet) -> Unit)
|
||||
|
||||
fun getCachedUserInfo(publicKey: String): SearchUser?
|
||||
fun isAuthenticated(): Boolean
|
||||
fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String? = null,
|
||||
reason: String = "background_restore"
|
||||
): Boolean
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground_resume")
|
||||
fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String = "",
|
||||
dst: String = "",
|
||||
sharedPublic: String = "",
|
||||
callId: String = "",
|
||||
joinToken: String = ""
|
||||
)
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String)
|
||||
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser?
|
||||
}
|
||||
|
||||
object ProtocolRuntimeAccess {
|
||||
@Volatile private var runtime: ProtocolRuntimePort? = null
|
||||
|
||||
fun install(runtime: ProtocolRuntimePort) {
|
||||
this.runtime = runtime
|
||||
}
|
||||
|
||||
fun get(): ProtocolRuntimePort {
|
||||
return runtime
|
||||
?: error(
|
||||
"ProtocolRuntimeAccess is not installed. Install runtime in RosettaApplication.onCreate() before using singleton managers."
|
||||
)
|
||||
}
|
||||
|
||||
fun isInstalled(): Boolean = runtime != null
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
class ReadyPacketGate(
|
||||
private val maxSize: Int,
|
||||
private val ttlMs: Long
|
||||
) {
|
||||
private data class QueuedPacket(
|
||||
val packet: Packet,
|
||||
val accountPublicKey: String,
|
||||
val queuedAtMs: Long
|
||||
)
|
||||
|
||||
private val queue = ArrayDeque<QueuedPacket>()
|
||||
|
||||
fun clear(reason: String, addLog: (String) -> Unit) {
|
||||
val clearedCount =
|
||||
synchronized(queue) {
|
||||
val count = queue.size
|
||||
queue.clear()
|
||||
count
|
||||
}
|
||||
if (clearedCount > 0) {
|
||||
addLog("🧹 READY-GATE queue cleared: $clearedCount packet(s), reason=$reason")
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueue(
|
||||
packet: Packet,
|
||||
accountPublicKey: String,
|
||||
state: ConnectionLifecycleState,
|
||||
shortKeyForLog: (String) -> String,
|
||||
addLog: (String) -> Unit
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
val packetId = packet.getPacketId()
|
||||
synchronized(queue) {
|
||||
while (queue.isNotEmpty()) {
|
||||
val oldest = queue.first()
|
||||
if (now - oldest.queuedAtMs <= ttlMs) break
|
||||
queue.removeFirst()
|
||||
}
|
||||
while (queue.size >= maxSize) {
|
||||
queue.removeFirst()
|
||||
}
|
||||
queue.addLast(
|
||||
QueuedPacket(
|
||||
packet = packet,
|
||||
accountPublicKey = accountPublicKey,
|
||||
queuedAtMs = now
|
||||
)
|
||||
)
|
||||
}
|
||||
addLog(
|
||||
"📦 READY-GATE queued id=0x${packetId.toString(16)} state=$state account=${shortKeyForLog(accountPublicKey)}"
|
||||
)
|
||||
}
|
||||
|
||||
fun drainForAccount(
|
||||
activeAccountKey: String,
|
||||
reason: String,
|
||||
addLog: (String) -> Unit
|
||||
): List<Packet> {
|
||||
if (activeAccountKey.isBlank()) return emptyList()
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val packetsToSend = mutableListOf<Packet>()
|
||||
|
||||
synchronized(queue) {
|
||||
val iterator = queue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val queued = iterator.next()
|
||||
val isExpired = now - queued.queuedAtMs > ttlMs
|
||||
val accountMatches =
|
||||
queued.accountPublicKey.isBlank() ||
|
||||
queued.accountPublicKey.equals(activeAccountKey, ignoreCase = true)
|
||||
if (!isExpired && accountMatches) {
|
||||
packetsToSend += queued.packet
|
||||
}
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
if (packetsToSend.isNotEmpty()) {
|
||||
addLog("📬 READY-GATE flush: ${packetsToSend.size} packet(s), reason=$reason")
|
||||
}
|
||||
return packetsToSend
|
||||
}
|
||||
}
|
||||
@@ -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,90 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.network.connection.AuthRestoreService
|
||||
import com.rosetta.messenger.network.connection.ProtocolInstanceManager
|
||||
import com.rosetta.messenger.network.connection.RuntimeInitializationCoordinator
|
||||
import com.rosetta.messenger.network.connection.RuntimeShutdownCoordinator
|
||||
import com.rosetta.messenger.network.connection.SyncCoordinator
|
||||
|
||||
class RuntimeConnectionControlFacade(
|
||||
private val postConnectionEvent: (ConnectionEvent) -> Unit,
|
||||
private val initializationCoordinator: RuntimeInitializationCoordinator,
|
||||
private val syncCoordinator: SyncCoordinator,
|
||||
private val authRestoreService: AuthRestoreService,
|
||||
private val runtimeShutdownCoordinator: RuntimeShutdownCoordinator,
|
||||
private val protocolInstanceManager: ProtocolInstanceManager,
|
||||
private val subscribePushTokenIfAvailable: (String?) -> Unit
|
||||
) {
|
||||
fun initialize(context: Context) {
|
||||
initializationCoordinator.initialize(context)
|
||||
}
|
||||
|
||||
fun initializeAccount(publicKey: String, privateKey: String) {
|
||||
postConnectionEvent(
|
||||
ConnectionEvent.InitializeAccount(publicKey = publicKey, privateKey = privateKey)
|
||||
)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
postConnectionEvent(ConnectionEvent.Connect(reason = "api_connect"))
|
||||
}
|
||||
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground_resume") {
|
||||
postConnectionEvent(ConnectionEvent.FastReconnect(reason = reason))
|
||||
}
|
||||
|
||||
fun syncOnForeground() {
|
||||
syncCoordinator.syncOnForeground()
|
||||
}
|
||||
|
||||
fun forceSynchronize(backtrackMs: Long) {
|
||||
if (!isAuthenticated()) {
|
||||
reconnectNowIfNeeded("manual_sync_button")
|
||||
return
|
||||
}
|
||||
syncCoordinator.forceSynchronize(backtrackMs)
|
||||
}
|
||||
|
||||
fun authenticate(publicKey: String, privateHash: String) {
|
||||
postConnectionEvent(
|
||||
ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash)
|
||||
)
|
||||
}
|
||||
|
||||
fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String? = null,
|
||||
reason: String = "background_restore"
|
||||
): Boolean {
|
||||
return authRestoreService.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||
}
|
||||
|
||||
fun subscribePushToken(forceToken: String? = null) {
|
||||
subscribePushTokenIfAvailable(forceToken)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
postConnectionEvent(
|
||||
ConnectionEvent.Disconnect(
|
||||
reason = "manual_disconnect",
|
||||
clearCredentials = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
runtimeShutdownCoordinator.destroy()
|
||||
}
|
||||
|
||||
fun isAuthenticated(): Boolean = protocolInstanceManager.isAuthenticated()
|
||||
|
||||
fun isConnected(): Boolean = protocolInstanceManager.isConnected()
|
||||
|
||||
fun setAppInForeground(foreground: Boolean) {
|
||||
runCatching { protocolInstanceManager.getOrCreateProtocol().setAppInForeground(foreground) }
|
||||
}
|
||||
|
||||
fun getPrivateHashOrNull(): String? {
|
||||
return runCatching { protocolInstanceManager.getOrCreateProtocol().getPrivateHash() }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,14 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.rosetta.messenger.utils.RosettaDev1Log
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
@@ -37,6 +40,7 @@ data class TransportState(
|
||||
object TransportManager {
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val INITIAL_BACKOFF_MS = 1000L
|
||||
private const val UPLOAD_ATTEMPT_TIMEOUT_MS = 45_000L
|
||||
|
||||
private var transportServer: String? = null
|
||||
private var appContext: Context? = null
|
||||
@@ -68,6 +72,7 @@ object TransportManager {
|
||||
fun setTransportServer(server: String) {
|
||||
val normalized = server.trim().trimEnd('/')
|
||||
transportServer = normalized.ifBlank { null }
|
||||
RosettaDev1Log.d("net/transport-server set=${transportServer.orEmpty()}")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,15 +104,37 @@ object TransportManager {
|
||||
/**
|
||||
* Retry с exponential backoff: 1с, 2с, 4с
|
||||
*/
|
||||
private suspend fun <T> withRetry(block: suspend () -> T): T {
|
||||
private suspend fun <T> withRetry(
|
||||
operation: String = "transport",
|
||||
id: String = "-",
|
||||
block: suspend () -> T
|
||||
): T {
|
||||
var lastException: Exception? = null
|
||||
repeat(MAX_RETRIES) { attempt ->
|
||||
try {
|
||||
return block()
|
||||
} catch (e: CancellationException) {
|
||||
RosettaDev1Log.w(
|
||||
"net/$operation cancelled id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES",
|
||||
e
|
||||
)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
||||
val shouldRetry = attempt < MAX_RETRIES - 1
|
||||
if (shouldRetry) {
|
||||
val backoffMs = INITIAL_BACKOFF_MS shl attempt
|
||||
RosettaDev1Log.w(
|
||||
"net/$operation retry id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
|
||||
"backoff=${backoffMs}ms reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}"
|
||||
)
|
||||
} else {
|
||||
RosettaDev1Log.e(
|
||||
"net/$operation failed id=${id.take(12)} attempt=${attempt + 1}/$MAX_RETRIES " +
|
||||
"reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
|
||||
e
|
||||
)
|
||||
}
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
delay(INITIAL_BACKOFF_MS shl attempt) // 1s, 2s, 4s
|
||||
}
|
||||
@@ -121,7 +148,8 @@ object TransportManager {
|
||||
*/
|
||||
fun requestTransportServer() {
|
||||
val packet = PacketRequestTransport()
|
||||
ProtocolManager.sendPacket(packet)
|
||||
RosettaDev1Log.d("net/transport-server request packet=0x0F")
|
||||
ProtocolRuntimeAccess.get().sendPacket(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,6 +200,37 @@ object TransportManager {
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun awaitUploadResponse(id: String, request: Request): Response =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val call = client.newCall(request)
|
||||
activeUploadCalls[id] = call
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
activeUploadCalls.remove(id, call)
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
activeUploadCalls.remove(id, call)
|
||||
if (call.isCanceled()) {
|
||||
cont.cancel(CancellationException("Upload cancelled"))
|
||||
} else {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
activeUploadCalls.remove(id, call)
|
||||
if (cont.isCancelled) {
|
||||
response.close()
|
||||
return
|
||||
}
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun parseContentRangeTotal(value: String?): Long? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
// Example: "bytes 100-999/12345"
|
||||
@@ -188,13 +247,16 @@ object TransportManager {
|
||||
*/
|
||||
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer()
|
||||
ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
||||
RosettaDev1Log.i(
|
||||
"net/upload start id=${id.take(12)} server=$server bytes=${content.length}"
|
||||
)
|
||||
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
||||
|
||||
// Добавляем в список загрузок
|
||||
_uploading.value = _uploading.value + TransportState(id, 0)
|
||||
|
||||
try {
|
||||
withRetry {
|
||||
withRetry(operation = "upload", id = id) {
|
||||
val contentBytes = content.toByteArray(Charsets.UTF_8)
|
||||
val totalSize = contentBytes.size.toLong()
|
||||
|
||||
@@ -206,6 +268,7 @@ object TransportManager {
|
||||
val source = okio.Buffer().write(contentBytes)
|
||||
var uploaded = 0L
|
||||
val bufferSize = 8 * 1024L
|
||||
var lastProgressUpdateMs = 0L
|
||||
|
||||
while (true) {
|
||||
val read = source.read(sink.buffer, bufferSize)
|
||||
@@ -214,9 +277,14 @@ object TransportManager {
|
||||
uploaded += read
|
||||
sink.flush()
|
||||
|
||||
val progress = ((uploaded * 100) / totalSize).toInt()
|
||||
_uploading.value = _uploading.value.map {
|
||||
if (it.id == id) it.copy(progress = progress) else it
|
||||
val now = System.currentTimeMillis()
|
||||
val isLast = uploaded >= totalSize
|
||||
if (isLast || now - lastProgressUpdateMs >= 200) {
|
||||
lastProgressUpdateMs = now
|
||||
val progress = ((uploaded * 100) / totalSize).toInt()
|
||||
_uploading.value = _uploading.value.map {
|
||||
if (it.id == id) it.copy(progress = progress) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,61 +299,61 @@ object TransportManager {
|
||||
.url("$server/u")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = suspendCancellableCoroutine<Response> { cont ->
|
||||
val call = client.newCall(request)
|
||||
activeUploadCalls[id] = call
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
activeUploadCalls.remove(id, call)
|
||||
call.cancel()
|
||||
val response =
|
||||
try {
|
||||
withTimeout(UPLOAD_ATTEMPT_TIMEOUT_MS) {
|
||||
awaitUploadResponse(id, request)
|
||||
}
|
||||
} catch (timeout: CancellationException) {
|
||||
if (timeout is kotlinx.coroutines.TimeoutCancellationException) {
|
||||
activeUploadCalls.remove(id)?.cancel()
|
||||
RosettaDev1Log.w(
|
||||
"net/upload attempt-timeout id=${id.take(12)} timeoutMs=$UPLOAD_ATTEMPT_TIMEOUT_MS"
|
||||
)
|
||||
throw SocketTimeoutException(
|
||||
"Upload timeout after ${UPLOAD_ATTEMPT_TIMEOUT_MS}ms"
|
||||
)
|
||||
}
|
||||
throw timeout
|
||||
}
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
activeUploadCalls.remove(id, call)
|
||||
if (call.isCanceled()) {
|
||||
cont.cancel(CancellationException("Upload cancelled"))
|
||||
} else {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
val tag =
|
||||
response.use { uploadResponse ->
|
||||
if (!uploadResponse.isSuccessful) {
|
||||
val errorBody = uploadResponse.body?.string()?.take(240).orEmpty()
|
||||
RosettaDev1Log.e(
|
||||
"net/upload http-fail id=${id.take(12)} code=${uploadResponse.code} body=$errorBody"
|
||||
)
|
||||
throw IOException("Upload failed: ${uploadResponse.code}")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
activeUploadCalls.remove(id, call)
|
||||
if (cont.isCancelled) {
|
||||
response.close()
|
||||
return
|
||||
}
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Upload failed: ${response.code}")
|
||||
}
|
||||
|
||||
val responseBody = response.body?.string()
|
||||
?: throw IOException("Empty response")
|
||||
val tag = org.json.JSONObject(responseBody).getString("t")
|
||||
val responseBody = uploadResponse.body?.string()
|
||||
?: throw IOException("Empty response")
|
||||
org.json.JSONObject(responseBody).getString("t")
|
||||
}
|
||||
|
||||
// Обновляем прогресс до 100%
|
||||
_uploading.value = _uploading.value.map {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
|
||||
ProtocolManager.addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
|
||||
ProtocolRuntimeAccess.get().addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
|
||||
RosettaDev1Log.i("net/upload success id=${id.take(12)} tag=${tag.take(16)}")
|
||||
|
||||
tag
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
||||
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
||||
RosettaDev1Log.w("net/upload cancelled id=${id.take(12)}", e)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog(
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
RosettaDev1Log.e(
|
||||
"net/upload failed id=${id.take(12)} reason=${e.javaClass.simpleName}:${e.message ?: "unknown"}",
|
||||
e
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeUploadCalls.remove(id)?.cancel()
|
||||
@@ -309,13 +377,13 @@ object TransportManager {
|
||||
transportServer: String? = null
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer(transportServer)
|
||||
ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
|
||||
ProtocolRuntimeAccess.get().addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
|
||||
|
||||
// Добавляем в список скачиваний
|
||||
_downloading.value = _downloading.value + TransportState(id, 0)
|
||||
|
||||
try {
|
||||
withRetry {
|
||||
withRetry(operation = "download", id = id) {
|
||||
val request = Request.Builder()
|
||||
.url("$server/d/$tag")
|
||||
.get()
|
||||
@@ -336,7 +404,7 @@ object TransportManager {
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
ProtocolManager.addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}")
|
||||
ProtocolRuntimeAccess.get().addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}")
|
||||
return@withRetry content
|
||||
}
|
||||
|
||||
@@ -383,14 +451,14 @@ object TransportManager {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
|
||||
ProtocolManager.addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
|
||||
ProtocolRuntimeAccess.get().addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
|
||||
content
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog(
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
throw e
|
||||
@@ -457,14 +525,14 @@ object TransportManager {
|
||||
transportServer: String? = null
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer(transportServer)
|
||||
ProtocolManager.addLog(
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
|
||||
)
|
||||
|
||||
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
|
||||
|
||||
try {
|
||||
withRetry {
|
||||
withRetry(operation = "download-raw-resume", id = id) {
|
||||
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
|
||||
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
|
||||
.coerceAtMost(existingBytes)
|
||||
@@ -541,13 +609,13 @@ object TransportManager {
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
ProtocolManager.addLog(
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
|
||||
)
|
||||
targetFile
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog(
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
throw e
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class AuthBootstrapCoordinator(
|
||||
private val scope: CoroutineScope,
|
||||
private val addLog: (String) -> Unit
|
||||
) {
|
||||
private val sessionCounter = AtomicLong(0L)
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile private var activeAuthenticatedSessionId = 0L
|
||||
@Volatile private var lastBootstrappedSessionId = 0L
|
||||
@Volatile private var deferredAuthBootstrap = false
|
||||
|
||||
fun onAuthenticatedSessionStarted(): Long {
|
||||
val sessionId = sessionCounter.incrementAndGet()
|
||||
activeAuthenticatedSessionId = sessionId
|
||||
deferredAuthBootstrap = false
|
||||
return sessionId
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
deferredAuthBootstrap = false
|
||||
activeAuthenticatedSessionId = 0L
|
||||
lastBootstrappedSessionId = 0L
|
||||
}
|
||||
|
||||
fun isBootstrapPending(): Boolean {
|
||||
return activeAuthenticatedSessionId > 0L &&
|
||||
lastBootstrappedSessionId != activeAuthenticatedSessionId
|
||||
}
|
||||
|
||||
fun tryRun(
|
||||
trigger: String,
|
||||
canRun: () -> Boolean,
|
||||
onDeferred: () -> Unit,
|
||||
runBootstrap: suspend () -> Unit
|
||||
) {
|
||||
val sessionId = activeAuthenticatedSessionId
|
||||
if (sessionId <= 0L) return
|
||||
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
if (sessionId != activeAuthenticatedSessionId) return@withLock
|
||||
if (sessionId == lastBootstrappedSessionId) return@withLock
|
||||
if (!canRun()) {
|
||||
deferredAuthBootstrap = true
|
||||
onDeferred()
|
||||
return@withLock
|
||||
}
|
||||
|
||||
deferredAuthBootstrap = false
|
||||
addLog("🚀 AUTH bootstrap start session=$sessionId trigger=$trigger")
|
||||
runCatching { runBootstrap() }
|
||||
.onSuccess {
|
||||
lastBootstrappedSessionId = sessionId
|
||||
addLog("✅ AUTH bootstrap complete session=$sessionId trigger=$trigger")
|
||||
}
|
||||
.onFailure { error ->
|
||||
addLog(
|
||||
"❌ AUTH bootstrap failed session=$sessionId trigger=$trigger: ${error.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,92 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.*
|
||||
|
||||
class BootstrapCoordinator(
|
||||
private val readyPacketGate: ReadyPacketGate,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val shortKeyForLog: (String) -> String,
|
||||
private val sendPacketDirect: (Packet) -> Unit
|
||||
) {
|
||||
fun protocolToLifecycleState(state: ProtocolState): ConnectionLifecycleState =
|
||||
when (state) {
|
||||
ProtocolState.DISCONNECTED -> ConnectionLifecycleState.DISCONNECTED
|
||||
ProtocolState.CONNECTING -> ConnectionLifecycleState.CONNECTING
|
||||
ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> ConnectionLifecycleState.HANDSHAKING
|
||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
|
||||
ConnectionLifecycleState.DEVICE_VERIFICATION_REQUIRED
|
||||
ProtocolState.AUTHENTICATED -> ConnectionLifecycleState.AUTHENTICATED
|
||||
}
|
||||
|
||||
fun packetCanBypassReadyGate(packet: Packet): Boolean =
|
||||
when (packet) {
|
||||
is PacketHandshake,
|
||||
is PacketSync,
|
||||
is PacketSearch,
|
||||
is PacketPushNotification,
|
||||
is PacketRequestTransport,
|
||||
is PacketRequestUpdate,
|
||||
is PacketSignalPeer,
|
||||
is PacketWebRTC,
|
||||
is PacketIceServers,
|
||||
is PacketDeviceResolve -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun recomputeLifecycleState(
|
||||
context: ConnectionBootstrapContext,
|
||||
currentState: ConnectionLifecycleState,
|
||||
reason: String,
|
||||
onStateChanged: (ConnectionLifecycleState, String) -> Unit
|
||||
): ConnectionLifecycleState {
|
||||
val nextState =
|
||||
if (context.authenticated) {
|
||||
if (context.accountInitialized && context.syncCompleted && context.ownProfileResolved) {
|
||||
ConnectionLifecycleState.READY
|
||||
} else {
|
||||
ConnectionLifecycleState.BOOTSTRAPPING
|
||||
}
|
||||
} else {
|
||||
protocolToLifecycleState(context.protocolState)
|
||||
}
|
||||
|
||||
if (currentState != nextState) {
|
||||
onStateChanged(nextState, reason)
|
||||
}
|
||||
|
||||
if (nextState == ConnectionLifecycleState.READY) {
|
||||
flushReadyPacketQueue(context.accountPublicKey, reason)
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
fun clearReadyPacketQueue(reason: String) {
|
||||
readyPacketGate.clear(reason = reason, addLog = addLog)
|
||||
}
|
||||
|
||||
fun enqueueReadyPacket(
|
||||
packet: Packet,
|
||||
accountPublicKey: String,
|
||||
state: ConnectionLifecycleState
|
||||
) {
|
||||
readyPacketGate.enqueue(
|
||||
packet = packet,
|
||||
accountPublicKey = accountPublicKey,
|
||||
state = state,
|
||||
shortKeyForLog = shortKeyForLog,
|
||||
addLog = addLog
|
||||
)
|
||||
}
|
||||
|
||||
fun flushReadyPacketQueue(activeAccountKey: String, reason: String) {
|
||||
val packetsToSend =
|
||||
readyPacketGate.drainForAccount(
|
||||
activeAccountKey = activeAccountKey,
|
||||
reason = reason,
|
||||
addLog = addLog
|
||||
)
|
||||
if (packetsToSend.isEmpty()) return
|
||||
packetsToSend.forEach(sendPacketDirect)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketIceServers
|
||||
import com.rosetta.messenger.network.PacketSignalPeer
|
||||
import com.rosetta.messenger.network.PacketWebRTC
|
||||
import com.rosetta.messenger.network.SignalType
|
||||
import com.rosetta.messenger.network.WebRTCSignalType
|
||||
|
||||
class CallSignalBridge(
|
||||
private val sendPacket: (Packet) -> Unit,
|
||||
private val waitPacket: (packetId: Int, callback: (Packet) -> Unit) -> Unit,
|
||||
private val unwaitPacket: (packetId: Int, callback: (Packet) -> Unit) -> Unit,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val shortKeyForLog: (String, Int) -> String,
|
||||
private val shortTextForLog: (String, Int) -> String
|
||||
) {
|
||||
private companion object {
|
||||
const val PACKET_SIGNAL_PEER = 0x1A
|
||||
const val PACKET_WEB_RTC = 0x1B
|
||||
const val PACKET_ICE_SERVERS = 0x1C
|
||||
}
|
||||
|
||||
fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String = "",
|
||||
dst: String = "",
|
||||
sharedPublic: String = "",
|
||||
callId: String = "",
|
||||
joinToken: String = ""
|
||||
) {
|
||||
addLog(
|
||||
"📡 CALL TX type=$signalType src=${shortKeyForLog(src, 8)} dst=${shortKeyForLog(dst, 8)} " +
|
||||
"sharedLen=${sharedPublic.length} callId=${shortKeyForLog(callId, 12)} join=${shortKeyForLog(joinToken, 12)}"
|
||||
)
|
||||
sendPacket(
|
||||
PacketSignalPeer().apply {
|
||||
this.signalType = signalType
|
||||
this.src = src
|
||||
this.dst = dst
|
||||
this.sharedPublic = sharedPublic
|
||||
this.callId = callId
|
||||
this.joinToken = joinToken
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||
addLog(
|
||||
"📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " +
|
||||
"preview='${shortTextForLog(sdpOrCandidate, 56)}'"
|
||||
)
|
||||
sendPacket(
|
||||
PacketWebRTC().apply {
|
||||
this.signalType = signalType
|
||||
this.sdpOrCandidate = sdpOrCandidate
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestIceServers() {
|
||||
addLog("📡 ICE TX request")
|
||||
sendPacket(PacketIceServers())
|
||||
}
|
||||
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketSignalPeer)?.let {
|
||||
addLog(
|
||||
"📡 CALL RX type=${it.signalType} src=${shortKeyForLog(it.src, 8)} dst=${shortKeyForLog(it.dst, 8)} " +
|
||||
"sharedLen=${it.sharedPublic.length} callId=${shortKeyForLog(it.callId, 12)} join=${shortKeyForLog(it.joinToken, 12)}"
|
||||
)
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_SIGNAL_PEER, wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
fun unwaitCallSignal(callback: (Packet) -> Unit) {
|
||||
unwaitPacket(PACKET_SIGNAL_PEER, callback)
|
||||
}
|
||||
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketWebRTC)?.let {
|
||||
addLog(
|
||||
"📡 WEBRTC RX type=${it.signalType} sdpLen=${it.sdpOrCandidate.length} " +
|
||||
"preview='${shortTextForLog(it.sdpOrCandidate, 56)}'"
|
||||
)
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_WEB_RTC, wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
|
||||
unwaitPacket(PACKET_WEB_RTC, callback)
|
||||
}
|
||||
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketIceServers)?.let {
|
||||
val firstUrl = it.iceServers.firstOrNull()?.url.orEmpty()
|
||||
addLog("📡 ICE RX count=${it.iceServers.size} first='${shortTextForLog(firstUrl, 56)}'")
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_ICE_SERVERS, wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
fun unwaitIceServers(callback: (Packet) -> Unit) {
|
||||
unwaitPacket(PACKET_ICE_SERVERS, callback)
|
||||
}
|
||||
}
|
||||
@@ -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,37 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.HandshakeDevice
|
||||
import com.rosetta.messenger.network.Protocol
|
||||
|
||||
class ConnectionOrchestrator(
|
||||
private val hasActiveInternet: () -> Boolean,
|
||||
private val waitForNetworkAndReconnect: (String) -> Unit,
|
||||
private val stopWaitingForNetwork: (String) -> Unit,
|
||||
private val getProtocol: () -> Protocol,
|
||||
private val persistHandshakeCredentials: (publicKey: String, privateHash: String) -> Unit,
|
||||
private val buildHandshakeDevice: () -> HandshakeDevice
|
||||
) {
|
||||
fun handleConnect(reason: String) {
|
||||
if (!hasActiveInternet()) {
|
||||
waitForNetworkAndReconnect("connect:$reason")
|
||||
return
|
||||
}
|
||||
stopWaitingForNetwork("connect:$reason")
|
||||
getProtocol().connect()
|
||||
}
|
||||
|
||||
fun handleFastReconnect(reason: String) {
|
||||
if (!hasActiveInternet()) {
|
||||
waitForNetworkAndReconnect("reconnect:$reason")
|
||||
return
|
||||
}
|
||||
stopWaitingForNetwork("reconnect:$reason")
|
||||
getProtocol().reconnectNowIfNeeded(reason)
|
||||
}
|
||||
|
||||
fun handleAuthenticate(publicKey: String, privateHash: String) {
|
||||
runCatching { persistHandshakeCredentials(publicKey, privateHash) }
|
||||
val device = buildHandshakeDevice()
|
||||
getProtocol().startHandshake(publicKey, privateHash, device)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.DeviceResolveSolution
|
||||
import com.rosetta.messenger.network.HandshakeDevice
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketDeviceList
|
||||
import java.security.SecureRandom
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class DeviceRuntimeService(
|
||||
private val getAppContext: () -> Context?,
|
||||
private val sendPacket: (Packet) -> Unit,
|
||||
private val devicePrefsName: String = "rosetta_protocol",
|
||||
private val deviceIdKey: String = "device_id",
|
||||
private val deviceIdLength: Int = 128
|
||||
) {
|
||||
private val verificationService = DeviceVerificationService()
|
||||
|
||||
val devices: StateFlow<List<DeviceEntry>> = verificationService.devices
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?> =
|
||||
verificationService.pendingDeviceVerification
|
||||
|
||||
fun handleDeviceList(packet: PacketDeviceList) {
|
||||
verificationService.handleDeviceList(packet)
|
||||
}
|
||||
|
||||
fun acceptDevice(deviceId: String) {
|
||||
sendPacket(
|
||||
verificationService.buildResolvePacket(
|
||||
deviceId = deviceId,
|
||||
solution = DeviceResolveSolution.ACCEPT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun declineDevice(deviceId: String) {
|
||||
sendPacket(
|
||||
verificationService.buildResolvePacket(
|
||||
deviceId = deviceId,
|
||||
solution = DeviceResolveSolution.DECLINE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun resolvePushDeviceId(): String {
|
||||
return getAppContext()?.let(::getOrCreateDeviceId).orEmpty()
|
||||
}
|
||||
|
||||
fun buildHandshakeDevice(): HandshakeDevice {
|
||||
val context = getAppContext()
|
||||
val deviceId = if (context != null) getOrCreateDeviceId(context) else generateDeviceId()
|
||||
val manufacturer = Build.MANUFACTURER.orEmpty().trim()
|
||||
val model = Build.MODEL.orEmpty().trim()
|
||||
val name =
|
||||
listOf(manufacturer, model)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(" ")
|
||||
.ifBlank { "Android Device" }
|
||||
val os = "Android ${Build.VERSION.RELEASE ?: "Unknown"}"
|
||||
|
||||
return HandshakeDevice(
|
||||
deviceId = deviceId,
|
||||
deviceName = name,
|
||||
deviceOs = os
|
||||
)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
verificationService.clear()
|
||||
}
|
||||
|
||||
private fun getOrCreateDeviceId(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(devicePrefsName, Context.MODE_PRIVATE)
|
||||
val cached = prefs.getString(deviceIdKey, null)
|
||||
if (!cached.isNullOrBlank()) {
|
||||
return cached
|
||||
}
|
||||
val newId = generateDeviceId()
|
||||
prefs.edit().putString(deviceIdKey, newId).apply()
|
||||
return newId
|
||||
}
|
||||
|
||||
private fun generateDeviceId(): String {
|
||||
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
val random = SecureRandom()
|
||||
return buildString(deviceIdLength) {
|
||||
repeat(deviceIdLength) {
|
||||
append(chars[random.nextInt(chars.length)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.DeviceResolveSolution
|
||||
import com.rosetta.messenger.network.DeviceVerifyState
|
||||
import com.rosetta.messenger.network.PacketDeviceList
|
||||
import com.rosetta.messenger.network.PacketDeviceResolve
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class DeviceVerificationService {
|
||||
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
|
||||
val devices: StateFlow<List<DeviceEntry>> = _devices.asStateFlow()
|
||||
|
||||
private val _pendingDeviceVerification = MutableStateFlow<DeviceEntry?>(null)
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?> = _pendingDeviceVerification.asStateFlow()
|
||||
|
||||
fun handleDeviceList(packet: PacketDeviceList) {
|
||||
val parsedDevices = packet.devices
|
||||
_devices.value = parsedDevices
|
||||
_pendingDeviceVerification.value =
|
||||
parsedDevices.firstOrNull { device ->
|
||||
device.deviceVerify == DeviceVerifyState.NOT_VERIFIED
|
||||
}
|
||||
}
|
||||
|
||||
fun buildResolvePacket(
|
||||
deviceId: String,
|
||||
solution: DeviceResolveSolution
|
||||
): PacketDeviceResolve {
|
||||
return PacketDeviceResolve().apply {
|
||||
this.deviceId = deviceId
|
||||
this.solution = solution
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
}
|
||||
}
|
||||
@@ -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,131 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NetworkReconnectWatcher(
|
||||
private val scope: CoroutineScope,
|
||||
private val networkWaitTimeoutMs: Long,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val onReconnectRequested: (String) -> Unit
|
||||
) {
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile private var registered = false
|
||||
@Volatile private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
@Volatile private var timeoutJob: Job? = null
|
||||
|
||||
fun hasActiveInternet(context: Context?): Boolean {
|
||||
if (context == null) return true
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return true
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
fun stop(context: Context?, reason: String? = null) {
|
||||
if (context == null) return
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return
|
||||
|
||||
val currentCallback = synchronized(lock) {
|
||||
val current = callback
|
||||
callback = null
|
||||
registered = false
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
current
|
||||
}
|
||||
|
||||
if (currentCallback != null) {
|
||||
runCatching { cm.unregisterNetworkCallback(currentCallback) }
|
||||
if (!reason.isNullOrBlank()) {
|
||||
addLog("📡 NETWORK WATCH STOP: $reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun waitForNetwork(context: Context?, reason: String) {
|
||||
if (context == null) return
|
||||
if (hasActiveInternet(context)) {
|
||||
stop(context, "network already available")
|
||||
return
|
||||
}
|
||||
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return
|
||||
|
||||
val alreadyRegistered = synchronized(lock) {
|
||||
if (registered) {
|
||||
true
|
||||
} else {
|
||||
val callback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
if (hasActiveInternet(context)) {
|
||||
addLog("📡 NETWORK AVAILABLE → reconnect")
|
||||
stop(context, "available")
|
||||
onReconnectRequested("network_available")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||
addLog("📡 NETWORK CAPABILITIES READY → reconnect")
|
||||
stop(context, "capabilities_changed")
|
||||
onReconnectRequested("network_capabilities_changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
this.callback = callback
|
||||
registered = true
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyRegistered) {
|
||||
addLog("📡 NETWORK WAIT already active (reason=$reason)")
|
||||
return
|
||||
}
|
||||
|
||||
addLog("📡 NETWORK WAIT start (reason=$reason)")
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
cm.registerDefaultNetworkCallback(callback!!)
|
||||
} else {
|
||||
val request =
|
||||
NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, callback!!)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
addLog("⚠️ NETWORK WAIT register failed: ${error.message}")
|
||||
stop(context, "register_failed")
|
||||
onReconnectRequested("network_wait_register_failed")
|
||||
}
|
||||
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob =
|
||||
scope.launch {
|
||||
delay(networkWaitTimeoutMs)
|
||||
if (!hasActiveInternet(context)) {
|
||||
addLog("⏱️ NETWORK WAIT timeout (${networkWaitTimeoutMs}ms), reconnect fallback")
|
||||
stop(context, "timeout")
|
||||
onReconnectRequested("network_wait_timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import com.rosetta.messenger.utils.RosettaDev1Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class OutgoingMessagePipelineService(
|
||||
scope: CoroutineScope,
|
||||
private val getRepository: () -> MessageRepository?,
|
||||
private val sendPacket: (PacketMessage) -> Unit,
|
||||
isAuthenticated: () -> Boolean,
|
||||
addLog: (String) -> Unit
|
||||
) {
|
||||
private val retryQueueService =
|
||||
RetryQueueService(
|
||||
scope = scope,
|
||||
sendPacket = sendPacket,
|
||||
isAuthenticated = isAuthenticated,
|
||||
addLog = addLog,
|
||||
markOutgoingAsError = ::markOutgoingAsError
|
||||
)
|
||||
|
||||
fun sendWithRetry(packet: PacketMessage) {
|
||||
RosettaDev1Log.d(
|
||||
"net/pipeline sendWithRetry msg=${packet.messageId.take(8)} " +
|
||||
"to=${packet.toPublicKey.take(12)} from=${packet.fromPublicKey.take(12)}"
|
||||
)
|
||||
sendPacket(packet)
|
||||
retryQueueService.register(packet)
|
||||
}
|
||||
|
||||
fun resolveOutgoingRetry(messageId: String) {
|
||||
RosettaDev1Log.d("net/pipeline resolveRetry msg=${messageId.take(8)}")
|
||||
retryQueueService.resolve(messageId)
|
||||
}
|
||||
|
||||
fun clearRetryQueue() {
|
||||
RosettaDev1Log.d("net/pipeline clearRetryQueue")
|
||||
retryQueueService.clear()
|
||||
}
|
||||
|
||||
private suspend fun markOutgoingAsError(messageId: String, packet: PacketMessage) {
|
||||
val repository = getRepository() ?: return
|
||||
val opponentKey =
|
||||
if (packet.fromPublicKey == repository.getCurrentAccountKey()) {
|
||||
packet.toPublicKey
|
||||
} else {
|
||||
packet.fromPublicKey
|
||||
}
|
||||
val dialogKey = repository.getDialogKey(opponentKey)
|
||||
RosettaDev1Log.w("net/pipeline markError msg=${messageId.take(8)} dialog=${dialogKey.take(16)}")
|
||||
repository.updateMessageDeliveryStatus(dialogKey, messageId, DeliveryStatus.ERROR)
|
||||
}
|
||||
}
|
||||
@@ -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,50 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.session.IdentityStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class OwnProfileSyncService(
|
||||
private val isPlaceholderAccountName: (String?) -> Boolean,
|
||||
private val updateAccountName: suspend (publicKey: String, name: String) -> Unit,
|
||||
private val updateAccountUsername: suspend (publicKey: String, username: String) -> Unit
|
||||
) {
|
||||
private val _ownProfileUpdated = MutableStateFlow(0L)
|
||||
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
|
||||
|
||||
fun notifyOwnProfileUpdated() {
|
||||
_ownProfileUpdated.value = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
suspend fun applyOwnProfileFromSearch(
|
||||
ownPublicKey: String,
|
||||
user: SearchUser
|
||||
): Boolean {
|
||||
if (ownPublicKey.isBlank()) return false
|
||||
if (!user.publicKey.equals(ownPublicKey, ignoreCase = true)) return false
|
||||
|
||||
if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) {
|
||||
updateAccountName(ownPublicKey, user.title)
|
||||
}
|
||||
if (user.username.isNotBlank()) {
|
||||
updateAccountUsername(ownPublicKey, user.username)
|
||||
}
|
||||
IdentityStore.updateOwnProfile(user, reason = "protocol_search_own_profile")
|
||||
_ownProfileUpdated.value = System.currentTimeMillis()
|
||||
return true
|
||||
}
|
||||
|
||||
fun buildOwnProfilePacket(publicKey: String?, privateHash: String?): PacketSearch? {
|
||||
val normalizedPublicKey = publicKey?.trim().orEmpty()
|
||||
val normalizedPrivateHash = privateHash?.trim().orEmpty()
|
||||
if (normalizedPublicKey.isEmpty() || normalizedPrivateHash.isEmpty()) return null
|
||||
|
||||
return PacketSearch().apply {
|
||||
this.privateKey = normalizedPrivateHash
|
||||
this.search = normalizedPublicKey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.LinkedHashSet
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PacketRouter(
|
||||
private val sendSearchPacket: (PacketSearch) -> Unit,
|
||||
private val privateHashProvider: () -> String?
|
||||
) {
|
||||
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
|
||||
private val pendingResolves =
|
||||
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
|
||||
private val pendingSearchQueries =
|
||||
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>>()
|
||||
|
||||
private fun normalizeSearchQuery(value: String): String =
|
||||
value.trim().removePrefix("@").lowercase(Locale.ROOT)
|
||||
|
||||
suspend fun onSearchPacket(packet: PacketSearch, onUserDiscovered: suspend (SearchUser) -> Unit) {
|
||||
if (packet.users.isNotEmpty()) {
|
||||
packet.users.forEach { user ->
|
||||
val normalizedUserPublicKey = user.publicKey.trim()
|
||||
userInfoCache[normalizedUserPublicKey] = user
|
||||
|
||||
pendingResolves
|
||||
.keys
|
||||
.filter { it.equals(normalizedUserPublicKey, ignoreCase = true) }
|
||||
.forEach { key ->
|
||||
pendingResolves.remove(key)?.forEach { cont ->
|
||||
try {
|
||||
cont.resume(user)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
onUserDiscovered(user)
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.search.isNotEmpty() && packet.users.none { it.publicKey == packet.search }) {
|
||||
pendingResolves.remove(packet.search)?.forEach { cont ->
|
||||
try {
|
||||
cont.resume(null)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.search.isNotEmpty()) {
|
||||
val rawQuery = packet.search.trim()
|
||||
val normalizedQuery = normalizeSearchQuery(rawQuery)
|
||||
val continuations =
|
||||
LinkedHashSet<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>()
|
||||
|
||||
fun collectByKey(key: String) {
|
||||
if (key.isEmpty()) return
|
||||
pendingSearchQueries.remove(key)?.let { continuations.addAll(it) }
|
||||
}
|
||||
|
||||
collectByKey(rawQuery)
|
||||
if (normalizedQuery.isNotEmpty() && normalizedQuery != rawQuery) {
|
||||
collectByKey(normalizedQuery)
|
||||
}
|
||||
|
||||
if (continuations.isEmpty()) {
|
||||
val matchedByQuery =
|
||||
pendingSearchQueries.keys.firstOrNull { pendingKey ->
|
||||
pendingKey.equals(rawQuery, ignoreCase = true) ||
|
||||
normalizeSearchQuery(pendingKey) == normalizedQuery
|
||||
}
|
||||
if (matchedByQuery != null) collectByKey(matchedByQuery)
|
||||
}
|
||||
|
||||
if (continuations.isEmpty() && packet.users.isNotEmpty()) {
|
||||
val responseUsernames =
|
||||
packet.users
|
||||
.map { normalizeSearchQuery(it.username) }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
if (responseUsernames.isNotEmpty()) {
|
||||
val matchedByUsers =
|
||||
pendingSearchQueries.keys.firstOrNull { pendingKey ->
|
||||
val normalizedPending = normalizeSearchQuery(pendingKey)
|
||||
normalizedPending.isNotEmpty() &&
|
||||
responseUsernames.contains(normalizedPending)
|
||||
}
|
||||
if (matchedByUsers != null) collectByKey(matchedByUsers)
|
||||
}
|
||||
}
|
||||
|
||||
continuations.forEach { cont ->
|
||||
try {
|
||||
cont.resume(packet.users)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedUserName(publicKey: String): String? {
|
||||
val cached = userInfoCache[publicKey] ?: return null
|
||||
return cached.title.ifEmpty { cached.username }.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun getCachedUserInfo(publicKey: String): SearchUser? = userInfoCache[publicKey]
|
||||
|
||||
fun getCachedUserByUsername(username: String): SearchUser? {
|
||||
val normalizedUsername = normalizeSearchQuery(username)
|
||||
if (normalizedUsername.isEmpty()) return null
|
||||
return userInfoCache.values.firstOrNull { cached ->
|
||||
normalizeSearchQuery(cached.username) == normalizedUsername
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
|
||||
if (publicKey.isEmpty()) return null
|
||||
|
||||
userInfoCache[publicKey]?.let { cached ->
|
||||
val name = cached.title.ifEmpty { cached.username }
|
||||
if (name.isNotEmpty()) return name
|
||||
}
|
||||
|
||||
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return null
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
pendingResolves[publicKey]?.remove(cont)
|
||||
if (pendingResolves[publicKey]?.isEmpty() == true) {
|
||||
pendingResolves.remove(publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateHash
|
||||
this.search = publicKey
|
||||
}
|
||||
sendSearchPacket(packet)
|
||||
}
|
||||
}?.let { user -> user.title.ifEmpty { user.username }.ifEmpty { null } }
|
||||
} catch (_: Exception) {
|
||||
pendingResolves.remove(publicKey)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
|
||||
if (publicKey.isEmpty()) return null
|
||||
|
||||
userInfoCache[publicKey]?.let { return it }
|
||||
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return null
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
|
||||
cont.invokeOnCancellation {
|
||||
pendingResolves[publicKey]?.remove(cont)
|
||||
if (pendingResolves[publicKey]?.isEmpty() == true) {
|
||||
pendingResolves.remove(publicKey)
|
||||
}
|
||||
}
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateHash
|
||||
this.search = publicKey
|
||||
}
|
||||
sendSearchPacket(packet)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pendingResolves.remove(publicKey)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
|
||||
val normalizedQuery = normalizeSearchQuery(query)
|
||||
if (normalizedQuery.isEmpty()) return emptyList()
|
||||
|
||||
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return emptyList()
|
||||
|
||||
val cachedMatches =
|
||||
userInfoCache.values.filter { cached ->
|
||||
normalizeSearchQuery(cached.username) == normalizedQuery && cached.publicKey.isNotBlank()
|
||||
}
|
||||
if (cachedMatches.isNotEmpty()) {
|
||||
return cachedMatches.distinctBy { it.publicKey }
|
||||
}
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingSearchQueries.getOrPut(normalizedQuery) { mutableListOf() }.add(cont)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
pendingSearchQueries[normalizedQuery]?.remove(cont)
|
||||
if (pendingSearchQueries[normalizedQuery]?.isEmpty() == true) {
|
||||
pendingSearchQueries.remove(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateHash
|
||||
this.search = normalizedQuery
|
||||
}
|
||||
sendSearchPacket(packet)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pendingSearchQueries.remove(normalizedQuery)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,142 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.PacketTyping
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PresenceTypingService(
|
||||
private val scope: CoroutineScope,
|
||||
private val typingIndicatorTimeoutMs: Long
|
||||
) {
|
||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||
|
||||
private val _typingUsersByDialogSnapshot =
|
||||
MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
|
||||
_typingUsersByDialogSnapshot.asStateFlow()
|
||||
|
||||
private val typingStateLock = Any()
|
||||
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
|
||||
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
|
||||
val normalizedDialogKey =
|
||||
if (isGroupDialogKey(dialogKey)) {
|
||||
normalizeGroupDialogKey(dialogKey)
|
||||
} else {
|
||||
dialogKey.trim()
|
||||
}
|
||||
if (normalizedDialogKey.isBlank()) return emptySet()
|
||||
|
||||
synchronized(typingStateLock) {
|
||||
return typingUsersByDialog[normalizedDialogKey]?.toSet() ?: emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleTypingPacket(
|
||||
packet: PacketTyping,
|
||||
ownPublicKeyProvider: () -> String
|
||||
) {
|
||||
val fromPublicKey = packet.fromPublicKey.trim()
|
||||
val toPublicKey = packet.toPublicKey.trim()
|
||||
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return
|
||||
|
||||
val ownPublicKey = ownPublicKeyProvider().trim()
|
||||
if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val dialogKey =
|
||||
resolveTypingDialogKey(
|
||||
fromPublicKey = fromPublicKey,
|
||||
toPublicKey = toPublicKey,
|
||||
ownPublicKey = ownPublicKey
|
||||
) ?: return
|
||||
|
||||
rememberTypingEvent(dialogKey, fromPublicKey)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
typingTimeoutJobs.values.forEach { it.cancel() }
|
||||
typingTimeoutJobs.clear()
|
||||
synchronized(typingStateLock) {
|
||||
typingUsersByDialog.clear()
|
||||
_typingUsers.value = emptySet()
|
||||
_typingUsersByDialogSnapshot.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun normalizeGroupDialogKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
val normalized = trimmed.lowercase(Locale.ROOT)
|
||||
return when {
|
||||
normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}"
|
||||
normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}"
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveTypingDialogKey(
|
||||
fromPublicKey: String,
|
||||
toPublicKey: String,
|
||||
ownPublicKey: String
|
||||
): String? {
|
||||
return when {
|
||||
isGroupDialogKey(toPublicKey) -> normalizeGroupDialogKey(toPublicKey)
|
||||
ownPublicKey.isNotBlank() && toPublicKey.equals(ownPublicKey, ignoreCase = true) ->
|
||||
fromPublicKey.trim()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeTypingTimeoutKey(dialogKey: String, fromPublicKey: String): String {
|
||||
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
|
||||
val normalizedDialogKey =
|
||||
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
|
||||
val normalizedFrom = fromPublicKey.trim()
|
||||
if (normalizedDialogKey.isBlank() || normalizedFrom.isBlank()) return
|
||||
|
||||
synchronized(typingStateLock) {
|
||||
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
|
||||
users.add(normalizedFrom)
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
_typingUsersByDialogSnapshot.value =
|
||||
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||
}
|
||||
|
||||
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
|
||||
typingTimeoutJobs.remove(timeoutKey)?.cancel()
|
||||
typingTimeoutJobs[timeoutKey] =
|
||||
scope.launch {
|
||||
delay(typingIndicatorTimeoutMs)
|
||||
synchronized(typingStateLock) {
|
||||
val users = typingUsersByDialog[normalizedDialogKey]
|
||||
users?.remove(normalizedFrom)
|
||||
if (users.isNullOrEmpty()) {
|
||||
typingUsersByDialog.remove(normalizedDialogKey)
|
||||
}
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
_typingUsersByDialogSnapshot.value =
|
||||
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||
}
|
||||
typingTimeoutJobs.remove(timeoutKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,133 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class ProtocolDebugLogService(
|
||||
private val scope: CoroutineScope,
|
||||
private val maxDebugLogs: Int,
|
||||
private val debugLogFlushDelayMs: Long,
|
||||
private val heartbeatOkLogMinIntervalMs: Long,
|
||||
private val protocolTraceFileName: String,
|
||||
private val protocolTraceMaxBytes: Long,
|
||||
private val protocolTraceKeepBytes: Int,
|
||||
private val appContextProvider: () -> Context?
|
||||
) {
|
||||
private var uiLogsEnabled = false
|
||||
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||
private val debugLogsBuffer = ArrayDeque<String>(maxDebugLogs)
|
||||
private val debugLogsLock = Any()
|
||||
private val protocolTraceLock = Any()
|
||||
@Volatile private var debugFlushJob: Job? = null
|
||||
private val debugFlushPending = AtomicBoolean(false)
|
||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
|
||||
|
||||
fun addLog(message: String) {
|
||||
var normalizedMessage = message
|
||||
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
||||
if (isHeartbeatOk) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastHeartbeatOkLogAtMs < heartbeatOkLogMinIntervalMs) {
|
||||
suppressedHeartbeatOkLogs++
|
||||
return
|
||||
}
|
||||
if (suppressedHeartbeatOkLogs > 0) {
|
||||
normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)"
|
||||
suppressedHeartbeatOkLogs = 0
|
||||
}
|
||||
lastHeartbeatOkLogAtMs = now
|
||||
}
|
||||
|
||||
val timestamp =
|
||||
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
val line = "[$timestamp] $normalizedMessage"
|
||||
if (shouldPersistProtocolTrace(normalizedMessage)) {
|
||||
persistProtocolTraceLine(line)
|
||||
}
|
||||
if (!uiLogsEnabled) return
|
||||
|
||||
synchronized(debugLogsLock) {
|
||||
if (debugLogsBuffer.size >= maxDebugLogs) {
|
||||
debugLogsBuffer.removeFirst()
|
||||
}
|
||||
debugLogsBuffer.addLast(line)
|
||||
}
|
||||
flushDebugLogsThrottled()
|
||||
}
|
||||
|
||||
fun enableUILogs(enabled: Boolean) {
|
||||
uiLogsEnabled = enabled
|
||||
if (enabled) {
|
||||
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||
_debugLogs.value = snapshot
|
||||
} else {
|
||||
_debugLogs.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
synchronized(debugLogsLock) {
|
||||
debugLogsBuffer.clear()
|
||||
}
|
||||
suppressedHeartbeatOkLogs = 0
|
||||
lastHeartbeatOkLogAtMs = 0L
|
||||
_debugLogs.value = emptyList()
|
||||
}
|
||||
|
||||
private fun shouldPersistProtocolTrace(message: String): Boolean {
|
||||
if (uiLogsEnabled) return true
|
||||
if (message.contains("rosettadev1", ignoreCase = true)) return true
|
||||
if (message.startsWith("❌") || message.startsWith("⚠️")) return true
|
||||
if (message.contains("STATE CHANGE")) return true
|
||||
if (message.contains("CONNECTION FULLY ESTABLISHED")) return true
|
||||
if (message.contains("HANDSHAKE COMPLETE")) return true
|
||||
if (message.contains("SYNC COMPLETE")) return true
|
||||
if (message.startsWith("🔌 CONNECT CALLED") || message.startsWith("🔌 Connecting to")) return true
|
||||
if (message.startsWith("✅ WebSocket OPEN")) return true
|
||||
if (message.startsWith("📡 NETWORK")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun persistProtocolTraceLine(line: String) {
|
||||
val context = appContextProvider() ?: return
|
||||
runCatching {
|
||||
val dir = File(context.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val traceFile = File(dir, protocolTraceFileName)
|
||||
synchronized(protocolTraceLock) {
|
||||
if (traceFile.exists() && traceFile.length() > protocolTraceMaxBytes) {
|
||||
val tail = runCatching {
|
||||
traceFile.readText(Charsets.UTF_8).takeLast(protocolTraceKeepBytes)
|
||||
}.getOrDefault("")
|
||||
traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8)
|
||||
}
|
||||
traceFile.appendText("$line\n", Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushDebugLogsThrottled() {
|
||||
debugFlushPending.set(true)
|
||||
if (debugFlushJob?.isActive == true) return
|
||||
debugFlushJob =
|
||||
scope.launch {
|
||||
while (debugFlushPending.getAndSet(false)) {
|
||||
delay(debugLogFlushDelayMs)
|
||||
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||
_debugLogs.value = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,110 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import com.rosetta.messenger.utils.RosettaDev1Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Outgoing retry queue for PacketMessage delivery.
|
||||
*
|
||||
* Mirrors iOS behavior:
|
||||
* - retry every 4s,
|
||||
* - max 3 attempts,
|
||||
* - max 80s lifetime.
|
||||
*/
|
||||
class RetryQueueService(
|
||||
private val scope: CoroutineScope,
|
||||
private val sendPacket: (PacketMessage) -> Unit,
|
||||
private val isAuthenticated: () -> Boolean,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val markOutgoingAsError: suspend (messageId: String, packet: PacketMessage) -> Unit,
|
||||
private val retryIntervalMs: Long = 4_000L,
|
||||
private val maxRetryAttempts: Int = 3,
|
||||
private val maxLifetimeMs: Long = 80_000L
|
||||
) {
|
||||
private val pendingOutgoingPackets = ConcurrentHashMap<String, PacketMessage>()
|
||||
private val pendingOutgoingAttempts = ConcurrentHashMap<String, Int>()
|
||||
private val pendingOutgoingRetryJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun register(packet: PacketMessage) {
|
||||
val messageId = packet.messageId
|
||||
RosettaDev1Log.d("net/retry register msg=${messageId.take(8)}")
|
||||
pendingOutgoingRetryJobs[messageId]?.cancel()
|
||||
pendingOutgoingPackets[messageId] = packet
|
||||
pendingOutgoingAttempts[messageId] = 0
|
||||
schedule(messageId)
|
||||
}
|
||||
|
||||
fun resolve(messageId: String) {
|
||||
RosettaDev1Log.d("net/retry resolve msg=${messageId.take(8)}")
|
||||
pendingOutgoingRetryJobs[messageId]?.cancel()
|
||||
pendingOutgoingRetryJobs.remove(messageId)
|
||||
pendingOutgoingPackets.remove(messageId)
|
||||
pendingOutgoingAttempts.remove(messageId)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
RosettaDev1Log.d("net/retry clear size=${pendingOutgoingRetryJobs.size}")
|
||||
pendingOutgoingRetryJobs.values.forEach { it.cancel() }
|
||||
pendingOutgoingRetryJobs.clear()
|
||||
pendingOutgoingPackets.clear()
|
||||
pendingOutgoingAttempts.clear()
|
||||
}
|
||||
|
||||
private fun schedule(messageId: String) {
|
||||
pendingOutgoingRetryJobs[messageId]?.cancel()
|
||||
pendingOutgoingRetryJobs[messageId] =
|
||||
scope.launch {
|
||||
delay(retryIntervalMs)
|
||||
|
||||
val packet = pendingOutgoingPackets[messageId] ?: return@launch
|
||||
val attempts = pendingOutgoingAttempts[messageId] ?: 0
|
||||
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val ageMs = nowMs - packet.timestamp
|
||||
if (ageMs >= maxLifetimeMs) {
|
||||
RosettaDev1Log.w(
|
||||
"net/retry expired msg=${messageId.take(8)} age=${ageMs}ms"
|
||||
)
|
||||
addLog(
|
||||
"⚠️ Message ${messageId.take(8)} expired after ${ageMs}ms — marking as error"
|
||||
)
|
||||
scope.launch { markOutgoingAsError(messageId, packet) }
|
||||
resolve(messageId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (attempts >= maxRetryAttempts) {
|
||||
RosettaDev1Log.w(
|
||||
"net/retry exhausted msg=${messageId.take(8)} attempts=$attempts"
|
||||
)
|
||||
addLog(
|
||||
"⚠️ Message ${messageId.take(8)} exhausted $attempts retries — marking as error"
|
||||
)
|
||||
scope.launch { markOutgoingAsError(messageId, packet) }
|
||||
resolve(messageId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
RosettaDev1Log.w("net/retry deferred-not-auth msg=${messageId.take(8)}")
|
||||
addLog("⏳ Message ${messageId.take(8)} retry deferred — not authenticated")
|
||||
resolve(messageId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val nextAttempt = attempts + 1
|
||||
pendingOutgoingAttempts[messageId] = nextAttempt
|
||||
RosettaDev1Log.i(
|
||||
"net/retry resend msg=${messageId.take(8)} attempt=$nextAttempt/$maxRetryAttempts"
|
||||
)
|
||||
addLog("🔄 Retrying message ${messageId.take(8)}, attempt $nextAttempt")
|
||||
sendPacket(packet)
|
||||
schedule(messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.PacketSync
|
||||
import com.rosetta.messenger.network.SyncStatus
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SyncCoordinator(
|
||||
private val scope: CoroutineScope,
|
||||
private val syncRequestTimeoutMs: Long,
|
||||
private val manualSyncBacktrackMs: Long,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val isAuthenticated: () -> Boolean,
|
||||
private val getRepository: () -> MessageRepository?,
|
||||
private val getProtocolPublicKey: () -> String,
|
||||
private val sendPacket: (PacketSync) -> Unit,
|
||||
private val onSyncCompleted: (String) -> Unit,
|
||||
private val whenInboundTasksFinish: suspend () -> Boolean
|
||||
) {
|
||||
private val _syncInProgress = MutableStateFlow(false)
|
||||
val syncInProgress: StateFlow<Boolean> = _syncInProgress.asStateFlow()
|
||||
|
||||
@Volatile private var syncBatchInProgress = false
|
||||
@Volatile private var syncRequestInFlight = false
|
||||
@Volatile private var resyncRequiredAfterAccountInit = false
|
||||
@Volatile private var lastForegroundSyncTime = 0L
|
||||
@Volatile private var syncRequestTimeoutJob: Job? = null
|
||||
|
||||
private val inboundProcessingFailures = AtomicInteger(0)
|
||||
private val inboundTasksInCurrentBatch = AtomicInteger(0)
|
||||
private val fullFailureBatchStreak = AtomicInteger(0)
|
||||
private val syncBatchEndMutex = Mutex()
|
||||
|
||||
fun isBatchInProgress(): Boolean = syncBatchInProgress
|
||||
|
||||
fun isRequestInFlight(): Boolean = syncRequestInFlight
|
||||
|
||||
fun markSyncInProgress(value: Boolean) {
|
||||
syncBatchInProgress = value
|
||||
if (_syncInProgress.value != value) {
|
||||
_syncInProgress.value = value
|
||||
}
|
||||
}
|
||||
|
||||
fun clearRequestState() {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
}
|
||||
|
||||
fun clearResyncRequired() {
|
||||
resyncRequiredAfterAccountInit = false
|
||||
}
|
||||
|
||||
fun shouldResyncAfterAccountInit(): Boolean = resyncRequiredAfterAccountInit
|
||||
|
||||
fun requireResyncAfterAccountInit(reason: String) {
|
||||
if (!resyncRequiredAfterAccountInit) {
|
||||
addLog(reason)
|
||||
}
|
||||
resyncRequiredAfterAccountInit = true
|
||||
}
|
||||
|
||||
fun markInboundProcessingFailure() {
|
||||
inboundProcessingFailures.incrementAndGet()
|
||||
}
|
||||
|
||||
fun trackInboundTaskQueued() {
|
||||
if (syncBatchInProgress) {
|
||||
inboundTasksInCurrentBatch.incrementAndGet()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSynchronize() {
|
||||
if (syncBatchInProgress) {
|
||||
addLog("⚠️ SYNC request skipped: sync already in progress")
|
||||
return
|
||||
}
|
||||
if (syncRequestInFlight) {
|
||||
addLog("⚠️ SYNC request skipped: previous request still in flight")
|
||||
return
|
||||
}
|
||||
syncRequestInFlight = true
|
||||
addLog("🔄 SYNC requested — fetching last sync timestamp...")
|
||||
scope.launch {
|
||||
val repository = getRepository()
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
|
||||
return@launch
|
||||
}
|
||||
val protocolAccount = getProtocolPublicKey().trim()
|
||||
val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty()
|
||||
if (
|
||||
protocolAccount.isNotBlank() &&
|
||||
repositoryAccount.isNotBlank() &&
|
||||
!repositoryAccount.equals(protocolAccount, ignoreCase = true)
|
||||
) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
requireResyncAfterAccountInit(
|
||||
"⏳ Sync postponed: repository bound to another account"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val lastSync = repository.getLastSyncTimestamp()
|
||||
addLog("🔄 SYNC sending request with lastSync=$lastSync")
|
||||
sendSynchronize(lastSync)
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSyncPacket(packet: PacketSync) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
when (packet.status) {
|
||||
SyncStatus.BATCH_START -> {
|
||||
addLog("🔄 SYNC BATCH_START — incoming message batch")
|
||||
markSyncInProgress(true)
|
||||
inboundProcessingFailures.set(0)
|
||||
inboundTasksInCurrentBatch.set(0)
|
||||
}
|
||||
|
||||
SyncStatus.BATCH_END -> {
|
||||
addLog("🔄 SYNC BATCH_END — waiting for tasks to finish (ts=${packet.timestamp})")
|
||||
scope.launch {
|
||||
syncBatchEndMutex.withLock {
|
||||
val tasksFinished = whenInboundTasksFinish()
|
||||
if (!tasksFinished) {
|
||||
val fallbackCursor = getRepository()?.getLastSyncTimestamp() ?: 0L
|
||||
sendSynchronize(fallbackCursor)
|
||||
return@launch
|
||||
}
|
||||
val failuresInBatch = inboundProcessingFailures.getAndSet(0)
|
||||
val tasksInBatch = inboundTasksInCurrentBatch.getAndSet(0)
|
||||
val fullBatchFailure = tasksInBatch > 0 && failuresInBatch >= tasksInBatch
|
||||
if (failuresInBatch > 0) {
|
||||
addLog(
|
||||
"⚠️ SYNC batch had $failuresInBatch processing error(s) out of $tasksInBatch task(s)"
|
||||
)
|
||||
if (fullBatchFailure) {
|
||||
val streak = fullFailureBatchStreak.incrementAndGet()
|
||||
val fallbackCursor = getRepository()?.getLastSyncTimestamp() ?: 0L
|
||||
if (streak <= 2) {
|
||||
addLog(
|
||||
"🛟 SYNC full-batch failure ($failuresInBatch/$tasksInBatch), keeping cursor=$fallbackCursor and retrying batch (streak=$streak)"
|
||||
)
|
||||
sendSynchronize(fallbackCursor)
|
||||
return@launch
|
||||
}
|
||||
addLog(
|
||||
"⚠️ SYNC full-batch failure streak=$streak, advancing cursor to avoid deadlock"
|
||||
)
|
||||
} else {
|
||||
fullFailureBatchStreak.set(0)
|
||||
}
|
||||
} else {
|
||||
fullFailureBatchStreak.set(0)
|
||||
}
|
||||
getRepository()?.updateLastSyncTimestamp(packet.timestamp)
|
||||
addLog("🔄 SYNC tasks done — cursor=${packet.timestamp}, requesting next batch")
|
||||
sendSynchronize(packet.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SyncStatus.NOT_NEEDED -> {
|
||||
onSyncCompleted("✅ SYNC COMPLETE — no more messages to sync")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncOnForeground() {
|
||||
if (!isAuthenticated()) return
|
||||
if (syncBatchInProgress) return
|
||||
if (syncRequestInFlight) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastForegroundSyncTime < 5_000L) return
|
||||
lastForegroundSyncTime = now
|
||||
addLog("🔄 SYNC on foreground resume")
|
||||
requestSynchronize()
|
||||
}
|
||||
|
||||
fun forceSynchronize(backtrackMs: Long = manualSyncBacktrackMs) {
|
||||
if (!isAuthenticated()) return
|
||||
if (syncBatchInProgress) return
|
||||
if (syncRequestInFlight) return
|
||||
|
||||
scope.launch {
|
||||
val repository = getRepository()
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
requireResyncAfterAccountInit("⏳ Manual sync postponed until account is initialized")
|
||||
return@launch
|
||||
}
|
||||
val currentSync = repository.getLastSyncTimestamp()
|
||||
val rewindTo = (currentSync - backtrackMs.coerceAtLeast(0L)).coerceAtLeast(0L)
|
||||
|
||||
syncRequestInFlight = true
|
||||
addLog("🔄 MANUAL SYNC requested: lastSync=$currentSync -> rewind=$rewindTo")
|
||||
sendSynchronize(rewindTo)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSyncCompletedStateApplied() {
|
||||
clearRequestState()
|
||||
inboundProcessingFailures.set(0)
|
||||
inboundTasksInCurrentBatch.set(0)
|
||||
fullFailureBatchStreak.set(0)
|
||||
markSyncInProgress(false)
|
||||
}
|
||||
|
||||
fun resetForDisconnect() {
|
||||
clearRequestState()
|
||||
markSyncInProgress(false)
|
||||
clearResyncRequired()
|
||||
inboundProcessingFailures.set(0)
|
||||
inboundTasksInCurrentBatch.set(0)
|
||||
fullFailureBatchStreak.set(0)
|
||||
}
|
||||
|
||||
private fun sendSynchronize(timestamp: Long) {
|
||||
syncRequestInFlight = true
|
||||
scheduleSyncRequestTimeout(timestamp)
|
||||
sendPacket(
|
||||
PacketSync().apply {
|
||||
status = SyncStatus.NOT_NEEDED
|
||||
this.timestamp = timestamp
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun scheduleSyncRequestTimeout(cursor: Long) {
|
||||
syncRequestTimeoutJob?.cancel()
|
||||
syncRequestTimeoutJob =
|
||||
scope.launch {
|
||||
delay(syncRequestTimeoutMs)
|
||||
if (!syncRequestInFlight || !isAuthenticated()) return@launch
|
||||
syncRequestInFlight = false
|
||||
addLog("⏱️ SYNC response timeout for cursor=$cursor, retrying request")
|
||||
requestSynchronize()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearSyncRequestTimeout() {
|
||||
syncRequestTimeoutJob?.cancel()
|
||||
syncRequestTimeoutJob = null
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import android.graphics.BitmapFactory
|
||||
import android.os.Build
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.BuildConfig
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
@@ -19,12 +20,14 @@ import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.PreferencesManager
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.CallForegroundService
|
||||
import com.rosetta.messenger.network.CallManager
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.network.CallUiState
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -40,8 +43,13 @@ import java.util.Locale
|
||||
* - Получение push-уведомлений о новых сообщениях
|
||||
* - Отображение уведомлений
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
@Inject lateinit var accountManager: AccountManager
|
||||
@Inject lateinit var preferencesManager: PreferencesManager
|
||||
@Inject lateinit var protocolGateway: ProtocolGateway
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
companion object {
|
||||
@@ -120,16 +128,16 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
saveFcmToken(token)
|
||||
|
||||
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
|
||||
// Используем единую точку отправки в ProtocolManager (с дедупликацией).
|
||||
if (ProtocolManager.isAuthenticated()) {
|
||||
runCatching { ProtocolManager.subscribePushTokenIfAvailable(forceToken = token) }
|
||||
// Используем единую runtime-точку отправки (с дедупликацией).
|
||||
if (protocolGateway.isAuthenticated()) {
|
||||
runCatching { protocolGateway.subscribePushTokenIfAvailable(forceToken = token) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Вызывается когда получено push-уведомление */
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
super.onMessageReceived(remoteMessage)
|
||||
Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "\u2709\ufe0f onMessageReceived: messageId=${remoteMessage.messageId} from=${remoteMessage.from} data=${remoteMessage.data} notif=${remoteMessage.notification?.body}")
|
||||
|
||||
val data = remoteMessage.data
|
||||
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
|
||||
@@ -146,9 +154,9 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
val hasNotificationContent = notificationTitle.isNotBlank() || notificationBody.isNotBlank()
|
||||
|
||||
if (!hasDataContent && !hasNotificationContent) {
|
||||
Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)")
|
||||
// Still trigger reconnect if WebSocket is disconnected
|
||||
com.rosetta.messenger.network.ProtocolManager.reconnectNowIfNeeded("silent_push")
|
||||
protocolGateway.reconnectNowIfNeeded("silent_push")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -219,14 +227,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
isReadEvent -> {
|
||||
val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey)
|
||||
if (keysToClear.isEmpty()) {
|
||||
Log.d(TAG, "READ push received but no dialog key in payload: $data")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "READ push received but no dialog key in payload: $data")
|
||||
} else {
|
||||
keysToClear.forEach { key ->
|
||||
cancelNotificationForChat(applicationContext, key)
|
||||
}
|
||||
val titleHints = collectReadTitleHints(data, keysToClear)
|
||||
cancelMatchingActiveNotifications(keysToClear, titleHints)
|
||||
Log.d(
|
||||
if (BuildConfig.DEBUG) Log.d(
|
||||
TAG,
|
||||
"READ push cleared notifications for keys=$keysToClear titles=$titleHints"
|
||||
)
|
||||
@@ -310,11 +318,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
val now = System.currentTimeMillis()
|
||||
val lastTs = lastNotifTimestamps[dedupKey]
|
||||
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
|
||||
Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "\ud83d\udeab Dedup BLOCKED notification for key=$dedupKey, delta=${now - lastTs}ms")
|
||||
return // duplicate push — skip
|
||||
}
|
||||
lastNotifTimestamps[dedupKey] = now
|
||||
Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
|
||||
val senderKey = senderPublicKey?.trim().orEmpty()
|
||||
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
|
||||
return
|
||||
@@ -501,7 +509,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
}
|
||||
|
||||
private fun pushCallLog(msg: String) {
|
||||
Log.d(TAG, msg)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, msg)
|
||||
try {
|
||||
val dir = java.io.File(applicationContext.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
@@ -514,20 +522,20 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
|
||||
private fun wakeProtocolFromPush(reason: String) {
|
||||
runCatching {
|
||||
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
|
||||
ProtocolManager.initialize(applicationContext)
|
||||
val account = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||
protocolGateway.initialize(applicationContext)
|
||||
CallManager.initialize(applicationContext)
|
||||
if (account.isNotBlank()) {
|
||||
CallManager.bindAccount(account)
|
||||
}
|
||||
val restored = ProtocolManager.restoreAuthFromStoredCredentials(
|
||||
val restored = protocolGateway.restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey = account,
|
||||
reason = "push_$reason"
|
||||
)
|
||||
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}…")
|
||||
ProtocolManager.reconnectNowIfNeeded("push_$reason")
|
||||
protocolGateway.reconnectNowIfNeeded("push_$reason")
|
||||
}.onFailure { error ->
|
||||
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
|
||||
if (BuildConfig.DEBUG) Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,7 +568,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
private fun areNotificationsEnabled(): Boolean {
|
||||
return runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
PreferencesManager(applicationContext).notificationsEnabled.first()
|
||||
preferencesManager.notificationsEnabled.first()
|
||||
}
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
@@ -583,7 +591,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
parsedDialogKey: String?,
|
||||
parsedSenderKey: String?
|
||||
): Set<String> {
|
||||
val currentAccount = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty().trim()
|
||||
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty().trim()
|
||||
val candidates = linkedSetOf<String>()
|
||||
|
||||
fun addCandidate(raw: String?) {
|
||||
@@ -710,7 +718,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
|
||||
if (matchesDeterministicId || matchesDialogKey || matchesHint) {
|
||||
manager.cancel(sbn.tag, sbn.id)
|
||||
Log.d(
|
||||
if (BuildConfig.DEBUG) Log.d(
|
||||
TAG,
|
||||
"READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " +
|
||||
"channel=${notification.channelId} title='$title' " +
|
||||
@@ -719,14 +727,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
|
||||
if (BuildConfig.DEBUG) Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAvatarInNotificationsEnabled(): Boolean {
|
||||
return runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
PreferencesManager(applicationContext).notificationAvatarEnabled.first()
|
||||
preferencesManager.notificationAvatarEnabled.first()
|
||||
}
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
@@ -735,25 +743,23 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
||||
private fun isDialogMuted(senderPublicKey: String): Boolean {
|
||||
if (senderPublicKey.isBlank()) return false
|
||||
return runCatching {
|
||||
val accountManager = AccountManager(applicationContext)
|
||||
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||
runBlocking(Dispatchers.IO) {
|
||||
val preferences = PreferencesManager(applicationContext)
|
||||
buildDialogKeyVariants(senderPublicKey).any { key ->
|
||||
preferences.isChatMuted(currentAccount, key)
|
||||
preferencesManager.isChatMuted(currentAccount, key)
|
||||
}
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
/** Получить имя пользователя по publicKey (кэш ProtocolManager → БД dialogs) */
|
||||
/** Получить имя пользователя по publicKey (runtime-кэш → БД dialogs) */
|
||||
private fun resolveNameForKey(publicKey: String?): String? {
|
||||
if (publicKey.isNullOrBlank()) return null
|
||||
// 1. In-memory cache
|
||||
ProtocolManager.getCachedUserName(publicKey)?.let { return it }
|
||||
protocolGateway.getCachedUserName(publicKey)?.let { return it }
|
||||
// 2. DB dialogs table
|
||||
return runCatching {
|
||||
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
|
||||
val account = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||
if (account.isBlank()) return null
|
||||
val db = RosettaDatabase.getDatabase(applicationContext)
|
||||
val dialog = runBlocking(Dispatchers.IO) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rosetta.messenger.session
|
||||
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
sealed interface SessionState {
|
||||
data object LoggedOut : SessionState
|
||||
data class AuthInProgress(
|
||||
val publicKey: String? = null,
|
||||
val reason: String = ""
|
||||
) : SessionState
|
||||
data class Ready(
|
||||
val account: DecryptedAccount,
|
||||
val reason: String = ""
|
||||
) : SessionState
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for app-level auth/session lifecycle.
|
||||
* UI should rely on this state instead of scattering account checks.
|
||||
*/
|
||||
object AppSessionCoordinator {
|
||||
val sessionState: StateFlow<SessionState> = SessionStore.state
|
||||
|
||||
fun dispatch(action: SessionAction) {
|
||||
SessionStore.dispatch(action)
|
||||
}
|
||||
|
||||
fun markLoggedOut(reason: String = "") {
|
||||
dispatch(SessionAction.LoggedOut(reason = reason))
|
||||
}
|
||||
|
||||
fun markAuthInProgress(publicKey: String? = null, reason: String = "") {
|
||||
dispatch(
|
||||
SessionAction.AuthInProgress(
|
||||
publicKey = publicKey,
|
||||
reason = reason
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun markReady(account: DecryptedAccount, reason: String = "") {
|
||||
dispatch(SessionAction.Ready(account = account, reason = reason))
|
||||
}
|
||||
|
||||
fun syncFromCachedAccount(account: DecryptedAccount?) {
|
||||
dispatch(SessionAction.SyncFromCachedAccount(account = account))
|
||||
}
|
||||
}
|
||||
125
app/src/main/java/com/rosetta/messenger/session/IdentityStore.kt
Normal file
125
app/src/main/java/com/rosetta/messenger/session/IdentityStore.kt
Normal file
@@ -0,0 +1,125 @@
|
||||
package com.rosetta.messenger.session
|
||||
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class IdentityProfile(
|
||||
val publicKey: String,
|
||||
val displayName: String = "",
|
||||
val username: String = "",
|
||||
val verified: Int = 0,
|
||||
val resolved: Boolean = false,
|
||||
val updatedAtMs: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
data class IdentityStateSnapshot(
|
||||
val account: DecryptedAccount? = null,
|
||||
val profile: IdentityProfile? = null,
|
||||
val authInProgress: Boolean = false,
|
||||
val pendingPublicKey: String? = null,
|
||||
val reason: String = ""
|
||||
) {
|
||||
val ownProfileResolved: Boolean
|
||||
get() {
|
||||
val activeAccount = account ?: return false
|
||||
val ownProfile = profile ?: return false
|
||||
return ownProfile.resolved && ownProfile.publicKey.equals(activeAccount.publicKey, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime identity source of truth for account/profile resolution.
|
||||
*/
|
||||
object IdentityStore {
|
||||
private val _state = MutableStateFlow(IdentityStateSnapshot())
|
||||
val state: StateFlow<IdentityStateSnapshot> = _state.asStateFlow()
|
||||
|
||||
fun markLoggedOut(reason: String = "") {
|
||||
_state.value =
|
||||
IdentityStateSnapshot(
|
||||
account = null,
|
||||
profile = null,
|
||||
authInProgress = false,
|
||||
pendingPublicKey = null,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
|
||||
fun markAuthInProgress(publicKey: String? = null, reason: String = "") {
|
||||
_state.value =
|
||||
_state.value.copy(
|
||||
authInProgress = true,
|
||||
pendingPublicKey = publicKey?.trim().orEmpty().ifBlank { null },
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
|
||||
fun setAccount(account: DecryptedAccount, reason: String = "") {
|
||||
val current = _state.value
|
||||
val existingProfile = current.profile
|
||||
val nextProfile =
|
||||
if (
|
||||
existingProfile != null &&
|
||||
existingProfile.publicKey.equals(account.publicKey, ignoreCase = true)
|
||||
) {
|
||||
existingProfile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
_state.value =
|
||||
current.copy(
|
||||
account = account,
|
||||
profile = nextProfile,
|
||||
authInProgress = false,
|
||||
pendingPublicKey = null,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
|
||||
fun updateOwnProfile(
|
||||
publicKey: String,
|
||||
displayName: String? = null,
|
||||
username: String? = null,
|
||||
verified: Int? = null,
|
||||
resolved: Boolean = true,
|
||||
reason: String = ""
|
||||
) {
|
||||
val normalizedPublicKey = publicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return
|
||||
|
||||
val current = _state.value
|
||||
val base =
|
||||
current.profile?.takeIf { it.publicKey.equals(normalizedPublicKey, ignoreCase = true) }
|
||||
?: IdentityProfile(publicKey = normalizedPublicKey)
|
||||
|
||||
val nextProfile =
|
||||
base.copy(
|
||||
displayName = displayName?.takeIf { it.isNotBlank() } ?: base.displayName,
|
||||
username = username?.takeIf { it.isNotBlank() } ?: base.username,
|
||||
verified = verified ?: base.verified,
|
||||
resolved = base.resolved || resolved,
|
||||
updatedAtMs = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
_state.value =
|
||||
current.copy(
|
||||
profile = nextProfile,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
|
||||
fun updateOwnProfile(user: SearchUser, reason: String = "") {
|
||||
updateOwnProfile(
|
||||
publicKey = user.publicKey,
|
||||
displayName = user.title,
|
||||
username = user.username,
|
||||
verified = user.verified,
|
||||
resolved = true,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.rosetta.messenger.session
|
||||
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
|
||||
sealed interface SessionAction {
|
||||
data class LoggedOut(val reason: String = "") : SessionAction
|
||||
|
||||
data class AuthInProgress(
|
||||
val publicKey: String? = null,
|
||||
val reason: String = ""
|
||||
) : SessionAction
|
||||
|
||||
data class Ready(
|
||||
val account: DecryptedAccount,
|
||||
val reason: String = ""
|
||||
) : SessionAction
|
||||
|
||||
data class SyncFromCachedAccount(val account: DecryptedAccount?) : SessionAction
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.rosetta.messenger.session
|
||||
|
||||
object SessionReducer {
|
||||
fun reduce(current: SessionState, action: SessionAction): SessionState {
|
||||
return when (action) {
|
||||
is SessionAction.LoggedOut -> SessionState.LoggedOut
|
||||
is SessionAction.AuthInProgress ->
|
||||
SessionState.AuthInProgress(
|
||||
publicKey = action.publicKey?.trim().orEmpty().ifBlank { null },
|
||||
reason = action.reason
|
||||
)
|
||||
is SessionAction.Ready ->
|
||||
SessionState.Ready(
|
||||
account = action.account,
|
||||
reason = action.reason
|
||||
)
|
||||
is SessionAction.SyncFromCachedAccount -> {
|
||||
val account = action.account
|
||||
if (account == null) {
|
||||
if (current is SessionState.Ready) SessionState.LoggedOut else current
|
||||
} else {
|
||||
SessionState.Ready(account = account, reason = "cached")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.rosetta.messenger.session
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Single runtime source of truth for session lifecycle state.
|
||||
* State transitions are produced only by SessionReducer.
|
||||
*/
|
||||
object SessionStore {
|
||||
private val _state = MutableStateFlow<SessionState>(SessionState.LoggedOut)
|
||||
val state: StateFlow<SessionState> = _state.asStateFlow()
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
fun dispatch(action: SessionAction) {
|
||||
synchronized(lock) {
|
||||
_state.value = SessionReducer.reduce(_state.value, action)
|
||||
}
|
||||
syncIdentity(action)
|
||||
}
|
||||
|
||||
private fun syncIdentity(action: SessionAction) {
|
||||
when (action) {
|
||||
is SessionAction.LoggedOut -> {
|
||||
IdentityStore.markLoggedOut(reason = action.reason)
|
||||
}
|
||||
is SessionAction.AuthInProgress -> {
|
||||
IdentityStore.markAuthInProgress(
|
||||
publicKey = action.publicKey,
|
||||
reason = action.reason
|
||||
)
|
||||
}
|
||||
is SessionAction.Ready -> {
|
||||
IdentityStore.setAccount(
|
||||
account = action.account,
|
||||
reason = action.reason
|
||||
)
|
||||
}
|
||||
is SessionAction.SyncFromCachedAccount -> {
|
||||
val account = action.account
|
||||
if (account == null) {
|
||||
IdentityStore.markLoggedOut(reason = "cached_account_cleared")
|
||||
} else {
|
||||
IdentityStore.setAccount(account = account, reason = "cached")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.di.SessionCoordinator
|
||||
|
||||
enum class AuthScreen {
|
||||
SELECT_ACCOUNT,
|
||||
@@ -27,6 +29,8 @@ fun AuthFlow(
|
||||
hasExistingAccount: Boolean,
|
||||
accounts: List<AccountInfo> = emptyList(),
|
||||
accountManager: AccountManager,
|
||||
protocolGateway: ProtocolGateway,
|
||||
sessionCoordinator: SessionCoordinator,
|
||||
startInCreateMode: Boolean = false,
|
||||
onAuthComplete: (DecryptedAccount?) -> Unit,
|
||||
onLogout: () -> Unit = {}
|
||||
@@ -62,6 +66,13 @@ fun AuthFlow(
|
||||
var showCreateModal by remember { mutableStateOf(false) }
|
||||
var isImportMode by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(currentScreen, selectedAccountId) {
|
||||
sessionCoordinator.markAuthInProgress(
|
||||
publicKey = selectedAccountId,
|
||||
reason = "auth_flow_${currentScreen.name.lowercase()}"
|
||||
)
|
||||
}
|
||||
|
||||
// If parent requests create mode while AuthFlow is alive, jump to Welcome/Create path.
|
||||
LaunchedEffect(startInCreateMode) {
|
||||
if (startInCreateMode && currentScreen == AuthScreen.UNLOCK) {
|
||||
@@ -169,6 +180,8 @@ fun AuthFlow(
|
||||
seedPhrase = seedPhrase,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isImportMode = isImportMode,
|
||||
accountManager = accountManager,
|
||||
sessionCoordinator = sessionCoordinator,
|
||||
onBack = {
|
||||
if (isImportMode) {
|
||||
currentScreen = AuthScreen.IMPORT_SEED
|
||||
@@ -201,6 +214,8 @@ fun AuthFlow(
|
||||
SetProfileScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
account = createdAccount,
|
||||
protocolGateway = protocolGateway,
|
||||
accountManager = accountManager,
|
||||
onComplete = { onAuthComplete(createdAccount) },
|
||||
onSkip = { onAuthComplete(createdAccount) }
|
||||
)
|
||||
@@ -228,6 +243,8 @@ fun AuthFlow(
|
||||
UnlockScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
selectedAccountId = selectedAccountId,
|
||||
accountManager = accountManager,
|
||||
sessionCoordinator = sessionCoordinator,
|
||||
onUnlocked = { account -> onAuthComplete(account) },
|
||||
onSwitchAccount = {
|
||||
// Navigate to create new account screen
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
package com.rosetta.messenger.ui.auth
|
||||
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
internal fun startAuthHandshakeFast(publicKey: String, privateKeyHash: String) {
|
||||
internal fun startAuthHandshakeFast(
|
||||
protocolGateway: ProtocolGateway,
|
||||
publicKey: String,
|
||||
privateKeyHash: String
|
||||
) {
|
||||
// Desktop parity: start connection+handshake immediately, without artificial waits.
|
||||
ProtocolManager.connect()
|
||||
ProtocolManager.authenticate(publicKey, privateKeyHash)
|
||||
ProtocolManager.reconnectNowIfNeeded("auth_fast_start")
|
||||
protocolGateway.connect()
|
||||
protocolGateway.authenticate(publicKey, privateKeyHash)
|
||||
protocolGateway.reconnectNowIfNeeded("auth_fast_start")
|
||||
}
|
||||
|
||||
internal suspend fun awaitAuthHandshakeState(
|
||||
protocolGateway: ProtocolGateway,
|
||||
publicKey: String,
|
||||
privateKeyHash: String,
|
||||
attempts: Int = 2,
|
||||
timeoutMs: Long = 25_000L
|
||||
): ProtocolState? {
|
||||
repeat(attempts) { attempt ->
|
||||
startAuthHandshakeFast(publicKey, privateKeyHash)
|
||||
startAuthHandshakeFast(protocolGateway, publicKey, privateKeyHash)
|
||||
|
||||
val state = withTimeoutOrNull(timeoutMs) {
|
||||
ProtocolManager.state.first {
|
||||
protocolGateway.state.first {
|
||||
it == ProtocolState.AUTHENTICATED ||
|
||||
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
|
||||
}
|
||||
@@ -30,7 +35,7 @@ internal suspend fun awaitAuthHandshakeState(
|
||||
if (state != null) {
|
||||
return state
|
||||
}
|
||||
ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
|
||||
protocolGateway.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -33,10 +33,12 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -52,10 +54,10 @@ import com.airbnb.lottie.compose.LottieConstants
|
||||
import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.DeviceResolveSolution
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketDeviceResolve
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.DeviceMobile
|
||||
@@ -64,6 +66,7 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun DeviceConfirmScreen(
|
||||
isDarkTheme: Boolean,
|
||||
protocolGateway: ProtocolGateway,
|
||||
onExit: () -> Unit
|
||||
) {
|
||||
val view = LocalView.current
|
||||
@@ -110,10 +113,31 @@ fun DeviceConfirmScreen(
|
||||
val onExitState by rememberUpdatedState(onExit)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
|
||||
var isResumed by remember(lifecycleOwner) {
|
||||
mutableStateOf(
|
||||
lifecycleOwner.lifecycle.currentState.isAtLeast(
|
||||
androidx.lifecycle.Lifecycle.State.RESUMED
|
||||
)
|
||||
)
|
||||
}
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = androidx.lifecycle.LifecycleEventObserver { _, _ ->
|
||||
isResumed = lifecycleOwner.lifecycle.currentState.isAtLeast(
|
||||
androidx.lifecycle.Lifecycle.State.RESUMED
|
||||
)
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.device_confirm))
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = LottieConstants.IterateForever
|
||||
iterations = LottieConstants.IterateForever,
|
||||
isPlaying = isResumed
|
||||
)
|
||||
|
||||
val localDeviceName = remember {
|
||||
@@ -131,9 +155,9 @@ fun DeviceConfirmScreen(
|
||||
scope.launch { onExitState() }
|
||||
}
|
||||
}
|
||||
ProtocolManager.waitPacket(0x18, callback)
|
||||
protocolGateway.waitPacket(0x18, callback)
|
||||
onDispose {
|
||||
ProtocolManager.unwaitPacket(0x18, callback)
|
||||
protocolGateway.unwaitPacket(0x18, callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,10 @@ fun SeedPhraseScreen(
|
||||
delay(2000)
|
||||
hasCopied = false
|
||||
}
|
||||
scope.launch {
|
||||
delay(30_000)
|
||||
clipboardManager.setText(AnnotatedString(""))
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -29,6 +28,7 @@ import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.di.SessionCoordinator
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -38,6 +38,8 @@ fun SetPasswordScreen(
|
||||
seedPhrase: List<String>,
|
||||
isDarkTheme: Boolean,
|
||||
isImportMode: Boolean = false,
|
||||
accountManager: AccountManager,
|
||||
sessionCoordinator: SessionCoordinator,
|
||||
onBack: () -> Unit,
|
||||
onAccountCreated: (DecryptedAccount) -> Unit
|
||||
) {
|
||||
@@ -46,8 +48,6 @@ fun SetPasswordScreen(
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||
val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||
|
||||
val context = LocalContext.current
|
||||
val accountManager = remember { AccountManager(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -308,8 +308,6 @@ fun SetPasswordScreen(
|
||||
)
|
||||
accountManager.saveAccount(account)
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
|
||||
startAuthHandshakeFast(keyPair.publicKey, privateKeyHash)
|
||||
accountManager.setCurrentAccount(keyPair.publicKey)
|
||||
val decryptedAccount = DecryptedAccount(
|
||||
publicKey = keyPair.publicKey,
|
||||
privateKey = keyPair.privateKey,
|
||||
@@ -317,6 +315,10 @@ fun SetPasswordScreen(
|
||||
privateKeyHash = privateKeyHash,
|
||||
name = truncatedKey
|
||||
)
|
||||
sessionCoordinator.bootstrapAuthenticatedSession(
|
||||
account = decryptedAccount,
|
||||
reason = "set_password"
|
||||
)
|
||||
onAccountCreated(decryptedAccount)
|
||||
} catch (e: Exception) {
|
||||
error = "Failed to create account: ${e.message}"
|
||||
|
||||
@@ -29,8 +29,8 @@ import coil.request.ImageRequest
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.PacketUserInfo
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
@@ -71,6 +71,8 @@ private fun validateUsername(username: String): String? {
|
||||
fun SetProfileScreen(
|
||||
isDarkTheme: Boolean,
|
||||
account: DecryptedAccount?,
|
||||
protocolGateway: ProtocolGateway,
|
||||
accountManager: AccountManager,
|
||||
onComplete: () -> Unit,
|
||||
onSkip: () -> Unit
|
||||
) {
|
||||
@@ -104,7 +106,7 @@ fun SetProfileScreen(
|
||||
isCheckingUsername = true
|
||||
delay(600) // debounce
|
||||
try {
|
||||
val results = ProtocolManager.searchUsers(trimmed, 3000)
|
||||
val results = protocolGateway.searchUsers(trimmed, 3000)
|
||||
val taken = results.any { it.username.equals(trimmed, ignoreCase = true) }
|
||||
usernameAvailable = !taken
|
||||
} catch (_: Exception) {
|
||||
@@ -402,14 +404,13 @@ fun SetProfileScreen(
|
||||
try {
|
||||
// Wait for server connection (up to 8s)
|
||||
val connected = withTimeoutOrNull(8000) {
|
||||
while (!ProtocolManager.isAuthenticated()) {
|
||||
while (!protocolGateway.isAuthenticated()) {
|
||||
delay(300)
|
||||
}
|
||||
true
|
||||
} ?: false
|
||||
|
||||
// Save name and username locally first
|
||||
val accountManager = AccountManager(context)
|
||||
if (name.trim().isNotEmpty()) {
|
||||
accountManager.updateAccountName(account.publicKey, name.trim())
|
||||
}
|
||||
@@ -417,7 +418,7 @@ fun SetProfileScreen(
|
||||
accountManager.updateAccountUsername(account.publicKey, username.trim())
|
||||
}
|
||||
// Trigger UI refresh in MainActivity
|
||||
ProtocolManager.notifyOwnProfileUpdated()
|
||||
protocolGateway.notifyOwnProfileUpdated()
|
||||
|
||||
// Send name and username to server
|
||||
if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) {
|
||||
@@ -425,16 +426,16 @@ fun SetProfileScreen(
|
||||
packet.title = name.trim()
|
||||
packet.username = username.trim()
|
||||
packet.privateKey = account.privateKeyHash
|
||||
ProtocolManager.send(packet)
|
||||
protocolGateway.send(packet)
|
||||
delay(1500)
|
||||
|
||||
// Повторяем для надёжности
|
||||
if (ProtocolManager.isAuthenticated()) {
|
||||
if (protocolGateway.isAuthenticated()) {
|
||||
val packet2 = PacketUserInfo()
|
||||
packet2.title = name.trim()
|
||||
packet2.username = username.trim()
|
||||
packet2.privateKey = account.privateKeyHash
|
||||
ProtocolManager.send(packet2)
|
||||
protocolGateway.send(packet2)
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.di.SessionCoordinator
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.chats.getAvatarColor
|
||||
@@ -68,6 +69,7 @@ private suspend fun performUnlock(
|
||||
selectedAccount: AccountItem?,
|
||||
password: String,
|
||||
accountManager: AccountManager,
|
||||
sessionCoordinator: SessionCoordinator,
|
||||
onUnlocking: (Boolean) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
onSuccess: (DecryptedAccount) -> Unit
|
||||
@@ -116,9 +118,10 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
||||
name = selectedAccount.name
|
||||
)
|
||||
|
||||
startAuthHandshakeFast(account.publicKey, privateKeyHash)
|
||||
|
||||
accountManager.setCurrentAccount(account.publicKey)
|
||||
sessionCoordinator.bootstrapAuthenticatedSession(
|
||||
account = decryptedAccount,
|
||||
reason = "unlock"
|
||||
)
|
||||
onSuccess(decryptedAccount)
|
||||
} catch (e: Exception) {
|
||||
onError("Failed to unlock: ${e.message}")
|
||||
@@ -131,6 +134,8 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
||||
fun UnlockScreen(
|
||||
isDarkTheme: Boolean,
|
||||
selectedAccountId: String? = null,
|
||||
accountManager: AccountManager,
|
||||
sessionCoordinator: SessionCoordinator,
|
||||
onUnlocked: (DecryptedAccount) -> Unit,
|
||||
onSwitchAccount: () -> Unit = {},
|
||||
onRecover: () -> Unit = {}
|
||||
@@ -160,7 +165,6 @@ fun UnlockScreen(
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? FragmentActivity
|
||||
val accountManager = remember { AccountManager(context) }
|
||||
val biometricManager = remember { BiometricAuthManager(context) }
|
||||
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -259,6 +263,7 @@ fun UnlockScreen(
|
||||
selectedAccount = selectedAccount,
|
||||
password = decryptedPassword,
|
||||
accountManager = accountManager,
|
||||
sessionCoordinator = sessionCoordinator,
|
||||
onUnlocking = { isUnlocking = it },
|
||||
onError = { error = it },
|
||||
onSuccess = { decryptedAccount ->
|
||||
@@ -604,6 +609,7 @@ fun UnlockScreen(
|
||||
selectedAccount = selectedAccount,
|
||||
password = password,
|
||||
accountManager = accountManager,
|
||||
sessionCoordinator = sessionCoordinator,
|
||||
onUnlocking = { isUnlocking = it },
|
||||
onError = { error = it },
|
||||
onSuccess = { decryptedAccount ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,713 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand
|
||||
import com.rosetta.messenger.domain.chats.usecase.SendTypingIndicatorCommand
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.chats.models.ChatMessage
|
||||
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import com.rosetta.messenger.utils.MediaUtils
|
||||
import com.rosetta.messenger.utils.RosettaDev1Log
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class MessagesViewModel internal constructor(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
val messages: StateFlow<List<ChatMessage>> = chatViewModel.messages
|
||||
val messagesWithDates: StateFlow<List<Pair<ChatMessage, Boolean>>> = chatViewModel.messagesWithDates
|
||||
val isLoading: StateFlow<Boolean> = chatViewModel.isLoading
|
||||
val isLoadingMore: StateFlow<Boolean> = chatViewModel.isLoadingMore
|
||||
val groupRequiresRejoin: StateFlow<Boolean> = chatViewModel.groupRequiresRejoin
|
||||
val inputText: StateFlow<String> = chatViewModel.inputText
|
||||
val replyMessages: StateFlow<List<ChatViewModel.ReplyMessage>> = chatViewModel.replyMessages
|
||||
val isForwardMode: StateFlow<Boolean> = chatViewModel.isForwardMode
|
||||
val pendingDeleteIds: StateFlow<Set<String>> = chatViewModel.pendingDeleteIds
|
||||
val pinnedMessages = chatViewModel.pinnedMessages
|
||||
val pinnedMessagePreviews = chatViewModel.pinnedMessagePreviews
|
||||
val currentPinnedIndex = chatViewModel.currentPinnedIndex
|
||||
val chatOpenMetrics = chatViewModel.chatOpenMetrics
|
||||
val myPublicKey: String? get() = chatViewModel.myPublicKey
|
||||
|
||||
fun setUserKeys(publicKey: String, privateKey: String) = chatViewModel.setUserKeys(publicKey, privateKey)
|
||||
|
||||
fun ensureSendContext(
|
||||
publicKey: String,
|
||||
title: String = "",
|
||||
username: String = "",
|
||||
verified: Int = 0
|
||||
) = chatViewModel.ensureSendContext(publicKey, title, username, verified)
|
||||
|
||||
fun openDialog(
|
||||
publicKey: String,
|
||||
title: String = "",
|
||||
username: String = "",
|
||||
verified: Int = 0
|
||||
) = chatViewModel.openDialog(publicKey, title, username, verified)
|
||||
|
||||
fun closeDialog() = chatViewModel.closeDialog()
|
||||
|
||||
fun setDialogActive(active: Boolean) = chatViewModel.setDialogActive(active)
|
||||
|
||||
fun loadMoreMessages() = chatViewModel.loadMoreMessages()
|
||||
|
||||
fun updateInputText(text: String) = chatViewModel.updateInputText(text)
|
||||
|
||||
fun setReplyMessages(messages: List<ChatMessage>) = chatViewModel.setReplyMessages(messages)
|
||||
|
||||
fun setForwardMessages(messages: List<ChatMessage>) = chatViewModel.setForwardMessages(messages)
|
||||
|
||||
fun clearReplyMessages() = chatViewModel.clearReplyMessages()
|
||||
|
||||
suspend fun ensureMessageLoaded(messageId: String): Boolean = chatViewModel.ensureMessageLoaded(messageId)
|
||||
|
||||
fun pinMessage(messageId: String) = chatViewModel.pinMessage(messageId)
|
||||
|
||||
fun unpinMessage(messageId: String) = chatViewModel.unpinMessage(messageId)
|
||||
|
||||
suspend fun isMessagePinned(messageId: String): Boolean = chatViewModel.isMessagePinned(messageId)
|
||||
|
||||
fun navigateToNextPinned(): String? = chatViewModel.navigateToNextPinned()
|
||||
|
||||
fun unpinAllMessages() = chatViewModel.unpinAllMessages()
|
||||
|
||||
fun deleteMessage(messageId: String) = chatViewModel.deleteMessage(messageId)
|
||||
|
||||
suspend fun resolveUserForProfile(publicKey: String): SearchUser? =
|
||||
chatViewModel.resolveUserForProfile(publicKey)
|
||||
|
||||
suspend fun resolveUserByUsername(username: String, timeoutMs: Long = 3000): SearchUser? =
|
||||
chatViewModel.resolveUserByUsername(username, timeoutMs)
|
||||
|
||||
fun retryMessage(message: ChatMessage) {
|
||||
deleteMessage(message.id)
|
||||
updateInputText(message.text)
|
||||
chatViewModel.launchInViewModel {
|
||||
delay(100)
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
val hasPayload = inputText.value.trim().isNotEmpty() || replyMessages.value.isNotEmpty()
|
||||
if (!hasPayload) return
|
||||
chatViewModel.trySendTextMessage(allowPendingRecovery = true)
|
||||
}
|
||||
|
||||
fun sendForwardDirectly(
|
||||
targetPublicKey: String,
|
||||
forwardedMessages: List<ForwardManager.ForwardMessage>
|
||||
) = chatViewModel.sendForwardDirectly(targetPublicKey, forwardedMessages)
|
||||
|
||||
fun markVisibleMessagesAsRead() = chatViewModel.markVisibleMessagesAsRead()
|
||||
|
||||
fun subscribeToOnlineStatus() = chatViewModel.subscribeToOnlineStatus()
|
||||
|
||||
fun markFirstListLayoutReady() = chatViewModel.markFirstListLayoutReady()
|
||||
|
||||
fun addChatOpenTraceEvent(event: String, details: String = "") =
|
||||
chatViewModel.addChatOpenTraceEvent(event, details)
|
||||
}
|
||||
|
||||
class VoiceRecordingViewModel internal constructor(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
RosettaDev1Log.w("voice-send slot busy: request dropped")
|
||||
return
|
||||
}
|
||||
|
||||
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
|
||||
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
|
||||
RosettaDev1Log.w("voice-send payload invalid: empty normalized voice")
|
||||
chatViewModel.releaseSendSlot()
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val attachmentId = "voice_$timestamp"
|
||||
val senderShort = sendContext.sender.take(12)
|
||||
val recipientShort = sendContext.recipient.take(12)
|
||||
RosettaDev1Log.i(
|
||||
"voice-send start msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
|
||||
"from=$senderShort to=$recipientShort dur=${voicePayload.durationSec}s " +
|
||||
"waves=${voicePayload.normalizedWaves.size} blobLen=${voicePayload.normalizedVoiceHex.length}"
|
||||
)
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = "",
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
type = AttachmentType.VOICE,
|
||||
preview = voicePayload.preview,
|
||||
blob = voicePayload.normalizedVoiceHex
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
|
||||
chatViewModel.launchOnIo {
|
||||
try {
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = "",
|
||||
recipient = sendContext.recipient,
|
||||
privateKey = sendContext.privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||
val isSavedMessages = (sendContext.sender == sendContext.recipient)
|
||||
RosettaDev1Log.d(
|
||||
"voice-send upload begin msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
|
||||
"saved=$isSavedMessages"
|
||||
)
|
||||
val uploadResult =
|
||||
chatViewModel.encryptAndUploadAttachment(
|
||||
EncryptAndUploadAttachmentCommand(
|
||||
payload = voicePayload.normalizedVoiceHex,
|
||||
attachmentPassword = encryptionContext.attachmentPassword,
|
||||
attachmentId = attachmentId,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
RosettaDev1Log.d(
|
||||
"voice-send upload done msg=${messageId.take(8)} att=${attachmentId.take(12)} " +
|
||||
"tag=${uploadResult.transportTag.take(16)} server=${uploadResult.transportServer}"
|
||||
)
|
||||
|
||||
val voiceAttachment =
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "",
|
||||
type = AttachmentType.VOICE,
|
||||
preview = voicePayload.preview,
|
||||
transportTag = uploadResult.transportTag,
|
||||
transportServer = uploadResult.transportServer
|
||||
)
|
||||
|
||||
chatViewModel.sendMediaMessage(
|
||||
SendMediaMessageCommand(
|
||||
fromPublicKey = sendContext.sender,
|
||||
toPublicKey = sendContext.recipient,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = encryptionContext.encryptedKey,
|
||||
aesChachaKey = encryptionContext.aesChachaKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
mediaAttachments = listOf(voiceAttachment),
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
RosettaDev1Log.i(
|
||||
"voice-send packet dispatched msg=${messageId.take(8)} " +
|
||||
"saved=$isSavedMessages attachments=1"
|
||||
)
|
||||
|
||||
runCatching {
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = chatViewModel.appContext(),
|
||||
blob = voicePayload.normalizedVoiceHex,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
}
|
||||
|
||||
val attachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.VOICE.value)
|
||||
put("preview", voicePayload.preview)
|
||||
put("blob", "")
|
||||
put("transportTag", uploadResult.transportTag)
|
||||
put("transportServer", uploadResult.transportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
val storedEncryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
sendContext.privateKey
|
||||
)
|
||||
} else {
|
||||
encryptionContext.encryptedKey
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = "",
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = storedEncryptedKey,
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isSavedMessages) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = "Voice message",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
RosettaDev1Log.i("voice-send done msg=${messageId.take(8)} status=queued")
|
||||
} catch (e: Exception) {
|
||||
RosettaDev1Log.e(
|
||||
"voice-send failed msg=${messageId.take(8)} " +
|
||||
"from=${sendContext.sender.take(12)} to=${sendContext.recipient.take(12)}",
|
||||
e
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = "Voice message",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} finally {
|
||||
RosettaDev1Log.d("voice-send slot released msg=${messageId.take(8)}")
|
||||
chatViewModel.releaseSendSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentsViewModel internal constructor(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) =
|
||||
chatViewModel.cancelOutgoingImageUpload(messageId, attachmentId)
|
||||
|
||||
fun sendImageFromUri(imageUri: Uri, caption: String = "") {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext()
|
||||
if (sendContext == null) {
|
||||
chatViewModel.addProtocolLog("❌ IMG send aborted: missing keys or dialog")
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val text = caption.trim()
|
||||
val attachmentId = "img_$timestamp"
|
||||
val context = chatViewModel.appContext()
|
||||
|
||||
chatViewModel.logPhotoEvent(
|
||||
messageId,
|
||||
"start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${chatViewModel.shortPhotoLogId(attachmentId)}"
|
||||
)
|
||||
|
||||
val (imageWidth, imageHeight) = MediaUtils.getImageDimensions(context, imageUri)
|
||||
chatViewModel.logPhotoEvent(messageId, "dimensions: ${imageWidth}x$imageHeight")
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "",
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = "",
|
||||
width = imageWidth,
|
||||
height = imageHeight,
|
||||
localUri = imageUri.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
chatViewModel.logPhotoEvent(messageId, "optimistic UI added")
|
||||
|
||||
val uploadJob =
|
||||
chatViewModel.launchBackgroundUpload imageUpload@{
|
||||
try {
|
||||
chatViewModel.logPhotoEvent(messageId, "persist optimistic message in DB")
|
||||
val optimisticAttachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", "")
|
||||
put("blob", "")
|
||||
put("width", imageWidth)
|
||||
put("height", imageHeight)
|
||||
put("localUri", imageUri.toString())
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = "",
|
||||
encryptedKey = "",
|
||||
timestamp = timestamp,
|
||||
delivered = 0,
|
||||
attachmentsJson = optimisticAttachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
chatViewModel.logPhotoEvent(messageId, "optimistic dialog updated")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
chatViewModel.logPhotoEvent(messageId, "optimistic DB save skipped (non-fatal)")
|
||||
}
|
||||
|
||||
try {
|
||||
val convertStartedAt = System.currentTimeMillis()
|
||||
val (width, height) = MediaUtils.getImageDimensions(context, imageUri)
|
||||
val imageBase64 = MediaUtils.uriToBase64Image(context, imageUri)
|
||||
if (imageBase64 == null) {
|
||||
chatViewModel.logPhotoEvent(messageId, "base64 conversion returned null")
|
||||
if (!chatViewModel.isViewModelCleared()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
}
|
||||
return@imageUpload
|
||||
}
|
||||
chatViewModel.logPhotoEvent(
|
||||
messageId,
|
||||
"base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms"
|
||||
)
|
||||
|
||||
val blurhash = MediaUtils.generateBlurhash(context, imageUri)
|
||||
chatViewModel.logPhotoEvent(messageId, "blurhash ready: len=${blurhash.length}")
|
||||
|
||||
if (!chatViewModel.isViewModelCleared()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateOptimisticImageMessage(
|
||||
messageId = messageId,
|
||||
base64 = imageBase64,
|
||||
blurhash = blurhash,
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
}
|
||||
chatViewModel.logPhotoEvent(messageId, "optimistic payload updated in UI")
|
||||
}
|
||||
|
||||
chatViewModel.sendImageMessageInternal(
|
||||
messageId = messageId,
|
||||
imageBase64 = imageBase64,
|
||||
blurhash = blurhash,
|
||||
caption = text,
|
||||
width = width,
|
||||
height = height,
|
||||
timestamp = timestamp,
|
||||
recipient = sendContext.recipient,
|
||||
sender = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
chatViewModel.logPhotoEvent(messageId, "pipeline completed")
|
||||
} catch (e: CancellationException) {
|
||||
chatViewModel.logPhotoEvent(messageId, "pipeline cancelled by user")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
chatViewModel.logPhotoErrorEvent(messageId, "prepare+convert", e)
|
||||
if (!chatViewModel.isViewModelCleared()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chatViewModel.registerOutgoingImageUploadJob(messageId, uploadJob)
|
||||
}
|
||||
|
||||
fun sendImageMessage(
|
||||
imageBase64: String,
|
||||
blurhash: String,
|
||||
caption: String = "",
|
||||
width: Int = 0,
|
||||
height: Int = 0
|
||||
) {
|
||||
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||
return
|
||||
}
|
||||
|
||||
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val text = caption.trim()
|
||||
val attachmentId = "img_$timestamp"
|
||||
|
||||
chatViewModel.addOutgoingMessageOptimistic(
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
text = text,
|
||||
isOutgoing = true,
|
||||
timestamp = Date(timestamp),
|
||||
status = MessageStatus.SENDING,
|
||||
attachments =
|
||||
listOf(
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = blurhash,
|
||||
blob = imageBase64,
|
||||
width = width,
|
||||
height = height
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
chatViewModel.clearInputText()
|
||||
|
||||
chatViewModel.launchBackgroundUpload prepareGroup@{
|
||||
try {
|
||||
val encryptionContext =
|
||||
chatViewModel.buildEncryptionContext(
|
||||
plaintext = text,
|
||||
recipient = sendContext.recipient,
|
||||
privateKey = sendContext.privateKey
|
||||
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||
val isSavedMessages = sendContext.sender == sendContext.recipient
|
||||
val uploadResult =
|
||||
chatViewModel.encryptAndUploadAttachment(
|
||||
EncryptAndUploadAttachmentCommand(
|
||||
payload = imageBase64,
|
||||
attachmentPassword = encryptionContext.attachmentPassword,
|
||||
attachmentId = attachmentId,
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
val imageAttachment =
|
||||
MessageAttachment(
|
||||
id = attachmentId,
|
||||
blob = "",
|
||||
type = AttachmentType.IMAGE,
|
||||
preview = blurhash,
|
||||
width = width,
|
||||
height = height,
|
||||
transportTag = uploadResult.transportTag,
|
||||
transportServer = uploadResult.transportServer
|
||||
)
|
||||
|
||||
chatViewModel.sendMediaMessage(
|
||||
SendMediaMessageCommand(
|
||||
fromPublicKey = sendContext.sender,
|
||||
toPublicKey = sendContext.recipient,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = encryptionContext.encryptedKey,
|
||||
aesChachaKey = encryptionContext.aesChachaKey,
|
||||
privateKeyHash = privateKeyHash,
|
||||
messageId = messageId,
|
||||
timestamp = timestamp,
|
||||
mediaAttachments = listOf(imageAttachment),
|
||||
isSavedMessages = isSavedMessages
|
||||
)
|
||||
)
|
||||
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = chatViewModel.appContext(),
|
||||
blob = imageBase64,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = sendContext.sender,
|
||||
privateKey = sendContext.privateKey
|
||||
)
|
||||
|
||||
val attachmentsJson =
|
||||
JSONArray()
|
||||
.apply {
|
||||
put(
|
||||
JSONObject().apply {
|
||||
put("id", attachmentId)
|
||||
put("type", AttachmentType.IMAGE.value)
|
||||
put("preview", blurhash)
|
||||
put("blob", "")
|
||||
put("width", width)
|
||||
put("height", height)
|
||||
put("transportTag", uploadResult.transportTag)
|
||||
put("transportServer", uploadResult.transportServer)
|
||||
}
|
||||
)
|
||||
}
|
||||
.toString()
|
||||
|
||||
val storedEncryptedKey =
|
||||
if (encryptionContext.isGroup) {
|
||||
chatViewModel.buildStoredGroupEncryptedKey(
|
||||
encryptionContext.attachmentPassword,
|
||||
sendContext.privateKey
|
||||
)
|
||||
} else {
|
||||
encryptionContext.encryptedKey
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingMessage(
|
||||
messageId = messageId,
|
||||
text = text,
|
||||
encryptedContent = encryptionContext.encryptedContent,
|
||||
encryptedKey = storedEncryptedKey,
|
||||
timestamp = timestamp,
|
||||
delivered = if (isSavedMessages) 1 else 0,
|
||||
attachmentsJson = attachmentsJson,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||
}
|
||||
|
||||
chatViewModel.saveOutgoingDialog(
|
||||
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||
timestamp = timestamp,
|
||||
accountPublicKey = sendContext.sender,
|
||||
accountPrivateKey = sendContext.privateKey,
|
||||
opponentPublicKey = sendContext.recipient
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||
}
|
||||
} finally {
|
||||
chatViewModel.releaseSendSlot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendImageGroupFromUris(imageUris: List<Uri>, caption: String = "") {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendImageGroupFromUris(
|
||||
imageUris = imageUris,
|
||||
caption = caption
|
||||
)
|
||||
}
|
||||
|
||||
fun sendImageGroup(images: List<ChatViewModel.ImageData>, caption: String = "") {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendImageGroup(
|
||||
images = images,
|
||||
caption = caption
|
||||
)
|
||||
}
|
||||
|
||||
fun sendFileMessage(
|
||||
fileBase64: String,
|
||||
fileName: String,
|
||||
fileSize: Long,
|
||||
caption: String = ""
|
||||
) {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendFileMessage(
|
||||
fileBase64 = fileBase64,
|
||||
fileName = fileName,
|
||||
fileSize = fileSize,
|
||||
caption = caption
|
||||
)
|
||||
}
|
||||
|
||||
fun sendVideoCircleFromUri(videoUri: Uri) {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendVideoCircleFromUri(videoUri)
|
||||
}
|
||||
|
||||
fun sendAvatarMessage() {
|
||||
chatViewModel.attachmentsFeatureCoordinator.sendAvatarMessage()
|
||||
}
|
||||
}
|
||||
|
||||
class TypingViewModel internal constructor(
|
||||
private val chatViewModel: ChatViewModel
|
||||
) {
|
||||
val opponentTyping: StateFlow<Boolean> = chatViewModel.opponentTyping
|
||||
val typingDisplayName: StateFlow<String> = chatViewModel.typingDisplayName
|
||||
val typingDisplayPublicKey: StateFlow<String> = chatViewModel.typingDisplayPublicKey
|
||||
val opponentOnline: StateFlow<Boolean> = chatViewModel.opponentOnline
|
||||
|
||||
fun sendTypingIndicator() {
|
||||
val now = System.currentTimeMillis()
|
||||
val context = chatViewModel.resolveTypingSendContext() ?: return
|
||||
val decision =
|
||||
chatViewModel.decideTypingSend(
|
||||
SendTypingIndicatorCommand(
|
||||
nowMs = now,
|
||||
lastSentMs = chatViewModel.lastTypingSentTimeMs(),
|
||||
throttleMs = chatViewModel.typingThrottleMs(),
|
||||
opponentPublicKey = context.opponent,
|
||||
senderPublicKey = context.sender,
|
||||
isGroupDialog = context.isGroupDialog,
|
||||
isOpponentOnline = context.isOpponentOnline
|
||||
)
|
||||
)
|
||||
if (!decision.shouldSend) return
|
||||
chatViewModel.setLastTypingSentTimeMs(decision.nextLastSentMs)
|
||||
chatViewModel.sendTypingPacket(
|
||||
privateKey = context.privateKey,
|
||||
sender = context.sender,
|
||||
opponent = context.opponent
|
||||
)
|
||||
}
|
||||
}
|
||||
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
@@ -2,28 +2,33 @@ package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.DraftManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.database.BlacklistEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.PacketOnlineSubscribe
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/** UI модель диалога с расшифрованным lastMessage */
|
||||
@Immutable
|
||||
@@ -67,11 +72,16 @@ data class ChatsUiState(
|
||||
}
|
||||
|
||||
/** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */
|
||||
class ChatsListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
@HiltViewModel
|
||||
class ChatsListViewModel @Inject constructor(
|
||||
private val app: Application,
|
||||
private val protocolGateway: ProtocolGateway,
|
||||
private val messageRepository: MessageRepository,
|
||||
private val groupRepository: GroupRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val database = RosettaDatabase.getDatabase(application)
|
||||
private val database = RosettaDatabase.getDatabase(app)
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val groupRepository = GroupRepository.getInstance(application)
|
||||
|
||||
private var currentAccount: String = ""
|
||||
private var currentPrivateKey: String? = null
|
||||
@@ -92,6 +102,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
// Job для отмены подписок при смене аккаунта
|
||||
private var accountSubscriptionsJob: Job? = null
|
||||
private var loadingFailSafeJob: Job? = null
|
||||
|
||||
// Список диалогов с расшифрованными сообщениями
|
||||
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
||||
@@ -104,6 +115,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
// Количество requests
|
||||
private val _requestsCount = MutableStateFlow(0)
|
||||
val requestsCount: StateFlow<Int> = _requestsCount.asStateFlow()
|
||||
val syncInProgress: StateFlow<Boolean> = protocolGateway.syncInProgress
|
||||
|
||||
// Заблокированные пользователи (реактивный Set из Room Flow)
|
||||
private val _blockedUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
@@ -132,20 +144,36 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
ChatsUiState()
|
||||
)
|
||||
|
||||
// Загрузка (🔥 true по умолчанию — skeleton на первом кадре, чтобы не мигало empty→skeleton→empty)
|
||||
private val _isLoading = MutableStateFlow(true)
|
||||
// Загрузка
|
||||
// Важно: false по умолчанию, чтобы исключить "вечный skeleton", если setAccount не был вызван.
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
private val loadingFailSafeTimeoutMs = 4500L
|
||||
|
||||
private val TAG = "ChatsListVM"
|
||||
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
|
||||
private val groupJoinedMarker = "\$a=Group joined"
|
||||
private val groupCreatedMarker = "\$a=Group created"
|
||||
private val attachmentTagUuidRegex =
|
||||
Regex(
|
||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
)
|
||||
|
||||
private fun isGroupKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase()
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun rosettaDev1Log(msg: String) {
|
||||
runCatching {
|
||||
val appContext = app
|
||||
val dir = java.io.File(appContext.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n")
|
||||
}
|
||||
}
|
||||
|
||||
private data class GroupLastSenderInfo(
|
||||
val senderPrefix: String,
|
||||
val senderKey: String
|
||||
@@ -195,7 +223,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
if (!dialogName.isNullOrBlank()) return dialogName
|
||||
|
||||
val cached = ProtocolManager.getCachedUserName(publicKey).orEmpty().trim()
|
||||
val cached = protocolGateway.getCachedUserName(publicKey).orEmpty().trim()
|
||||
if (cached.isNotBlank() && cached != publicKey) {
|
||||
return cached
|
||||
}
|
||||
@@ -269,6 +297,40 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
return deduped.values.sortedByDescending { it.lastMessageTimestamp }
|
||||
}
|
||||
|
||||
/**
|
||||
* During sync we keep list stable only when there are truly no visible dialog changes.
|
||||
* This lets local sends/new system dialogs appear immediately even if sync is active.
|
||||
*/
|
||||
private fun canFreezeDialogsDuringSync(
|
||||
dialogsList: List<com.rosetta.messenger.database.DialogEntity>
|
||||
): Boolean {
|
||||
val currentDialogs = _dialogs.value
|
||||
if (currentDialogs.isEmpty()) return false
|
||||
if (dialogsList.size != currentDialogs.size) return false
|
||||
|
||||
val currentByKey = currentDialogs.associateBy { it.opponentKey }
|
||||
return dialogsList.all { entity ->
|
||||
val current = currentByKey[entity.opponentKey] ?: return@all false
|
||||
current.lastMessageTimestamp == entity.lastMessageTimestamp &&
|
||||
current.unreadCount == entity.unreadCount &&
|
||||
current.isOnline == entity.isOnline &&
|
||||
current.lastSeen == entity.lastSeen &&
|
||||
current.verified == entity.verified &&
|
||||
current.opponentTitle == entity.opponentTitle &&
|
||||
current.opponentUsername == entity.opponentUsername &&
|
||||
current.lastMessageFromMe == entity.lastMessageFromMe &&
|
||||
current.lastMessageDelivered == entity.lastMessageDelivered &&
|
||||
current.lastMessageRead == entity.lastMessageRead &&
|
||||
current.lastMessage == entity.lastMessage &&
|
||||
current.lastMessageAttachmentType ==
|
||||
resolveAttachmentType(
|
||||
attachmentType = entity.lastMessageAttachmentType,
|
||||
decryptedLastMessage = current.lastMessage,
|
||||
lastMessageAttachments = entity.lastMessageAttachments
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mapDialogListIncremental(
|
||||
dialogsList: List<com.rosetta.messenger.database.DialogEntity>,
|
||||
privateKey: String,
|
||||
@@ -345,15 +407,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
/** Установить текущий аккаунт и загрузить диалоги */
|
||||
fun setAccount(publicKey: String, privateKey: String) {
|
||||
val setAccountStart = System.currentTimeMillis()
|
||||
if (currentAccount == publicKey) {
|
||||
val resolvedPrivateKey =
|
||||
when {
|
||||
privateKey.isNotBlank() -> privateKey
|
||||
currentAccount == publicKey -> currentPrivateKey.orEmpty()
|
||||
else -> ""
|
||||
}
|
||||
|
||||
if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
|
||||
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
|
||||
if (_isLoading.value) _isLoading.value = false
|
||||
if (_isLoading.value) {
|
||||
_isLoading.value = false
|
||||
}
|
||||
loadingFailSafeJob?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Показываем skeleton пока данные грузятся
|
||||
_isLoading.value = true
|
||||
loadingFailSafeJob?.cancel()
|
||||
loadingFailSafeJob =
|
||||
viewModelScope.launch {
|
||||
delay(loadingFailSafeTimeoutMs)
|
||||
if (_isLoading.value) {
|
||||
_isLoading.value = false
|
||||
android.util.Log.w(
|
||||
TAG,
|
||||
"Fail-safe: forced isLoading=false after ${loadingFailSafeTimeoutMs}ms for account=${publicKey.take(8)}..."
|
||||
)
|
||||
rosettaDev1Log(
|
||||
"Fail-safe isLoading=false account=${publicKey.take(8)} timeoutMs=$loadingFailSafeTimeoutMs"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||
requestedUserInfoKeys.clear()
|
||||
@@ -369,7 +455,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
accountSubscriptionsJob?.cancel()
|
||||
|
||||
currentAccount = publicKey
|
||||
currentPrivateKey = privateKey
|
||||
currentPrivateKey = resolvedPrivateKey
|
||||
|
||||
// <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
|
||||
DraftManager.setAccount(publicKey)
|
||||
@@ -380,7 +466,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
_requestsCount.value = 0
|
||||
|
||||
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
|
||||
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
|
||||
if (resolvedPrivateKey.isNotEmpty()) {
|
||||
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
|
||||
}
|
||||
|
||||
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
|
||||
accountSubscriptionsJob = viewModelScope.launch {
|
||||
@@ -398,19 +486,18 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
.getDialogsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
|
||||
.combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
|
||||
.combine(protocolGateway.syncInProgress) { dialogsList, syncing ->
|
||||
dialogsList to syncing
|
||||
}
|
||||
.mapLatest { (dialogsList, syncing) ->
|
||||
// Desktop behavior parity:
|
||||
// while sync is active we keep current chats list stable (no per-message UI churn),
|
||||
// then apply one consolidated update when sync finishes.
|
||||
if (syncing && _dialogs.value.isNotEmpty()) {
|
||||
// Keep list stable during sync only when the snapshot is effectively unchanged.
|
||||
// Otherwise (new message/dialog/status) update immediately.
|
||||
if (syncing && canFreezeDialogsDuringSync(dialogsList)) {
|
||||
null
|
||||
} else {
|
||||
mapDialogListIncremental(
|
||||
dialogsList = dialogsList,
|
||||
privateKey = privateKey,
|
||||
privateKey = resolvedPrivateKey,
|
||||
cache = dialogsUiCache,
|
||||
isRequestsFlow = false
|
||||
)
|
||||
@@ -418,10 +505,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
.filterNotNull()
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.catch { e ->
|
||||
android.util.Log.e(TAG, "Dialogs flow failed in setAccount()", e)
|
||||
rosettaDev1Log("Dialogs flow failed: ${e.message}")
|
||||
if (_isLoading.value) _isLoading.value = false
|
||||
emit(emptyList())
|
||||
}
|
||||
.collect { decryptedDialogs ->
|
||||
_dialogs.value = decryptedDialogs
|
||||
// 🚀 Убираем skeleton после первой загрузки
|
||||
if (_isLoading.value) _isLoading.value = false
|
||||
if (_isLoading.value) {
|
||||
_isLoading.value = false
|
||||
loadingFailSafeJob?.cancel()
|
||||
}
|
||||
|
||||
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
|
||||
@@ -430,7 +526,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
decryptedDialogs.filter { !it.isSavedMessages }.map {
|
||||
it.opponentKey
|
||||
}
|
||||
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
|
||||
subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +537,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
.getRequestsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.debounce(100) // 🚀 Батчим быстрые обновления
|
||||
.combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
|
||||
.combine(protocolGateway.syncInProgress) { requestsList, syncing ->
|
||||
requestsList to syncing
|
||||
}
|
||||
.mapLatest { (requestsList, syncing) ->
|
||||
@@ -450,7 +546,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
} else {
|
||||
mapDialogListIncremental(
|
||||
dialogsList = requestsList,
|
||||
privateKey = privateKey,
|
||||
privateKey = resolvedPrivateKey,
|
||||
cache = requestsUiCache,
|
||||
isRequestsFlow = true
|
||||
)
|
||||
@@ -465,7 +561,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
dialogDao
|
||||
.getRequestsCountFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.combine(ProtocolManager.syncInProgress) { count, syncing ->
|
||||
.combine(protocolGateway.syncInProgress) { count, syncing ->
|
||||
if (syncing) 0 else count
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
|
||||
@@ -489,7 +585,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
// dialogs that still have empty titles.
|
||||
launch {
|
||||
var wasSyncing = false
|
||||
ProtocolManager.syncInProgress.collect { syncing ->
|
||||
protocolGateway.syncInProgress.collect { syncing ->
|
||||
if (wasSyncing && !syncing) {
|
||||
requestedUserInfoKeys.clear()
|
||||
}
|
||||
@@ -498,6 +594,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
|
||||
} // end accountSubscriptionsJob
|
||||
|
||||
accountSubscriptionsJob?.invokeOnCompletion { cause ->
|
||||
if (cause != null && _isLoading.value) {
|
||||
_isLoading.value = false
|
||||
loadingFailSafeJob?.cancel()
|
||||
android.util.Log.e(TAG, "accountSubscriptionsJob completed with error", cause)
|
||||
rosettaDev1Log("accountSubscriptionsJob error: ${cause.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun forceStopLoading(reason: String) {
|
||||
if (_isLoading.value) {
|
||||
_isLoading.value = false
|
||||
loadingFailSafeJob?.cancel()
|
||||
android.util.Log.w(TAG, "forceStopLoading: $reason")
|
||||
rosettaDev1Log("forceStopLoading: $reason")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -506,6 +620,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
*/
|
||||
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
|
||||
if (opponentKeys.isEmpty()) return
|
||||
if (privateKey.isBlank()) return
|
||||
|
||||
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
|
||||
val newKeys =
|
||||
@@ -527,7 +642,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
newKeys.forEach { key -> addPublicKey(key) }
|
||||
}
|
||||
|
||||
ProtocolManager.send(packet)
|
||||
protocolGateway.send(packet)
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -595,24 +710,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
|
||||
if (attachments.length() <= 0) return -1
|
||||
val first = attachments.optJSONObject(0) ?: return -1
|
||||
val rawType = first.opt("type")
|
||||
when (rawType) {
|
||||
is Number -> rawType.toInt()
|
||||
is String -> {
|
||||
val normalized = rawType.trim()
|
||||
normalized.toIntOrNull()
|
||||
?: when (normalized.lowercase(Locale.ROOT)) {
|
||||
"image" -> 0
|
||||
"messages", "reply", "forward" -> 1
|
||||
"file" -> 2
|
||||
"avatar" -> 3
|
||||
"call" -> 4
|
||||
"voice" -> 5
|
||||
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video" -> 6
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
else -> -1
|
||||
val parsedType = parseAttachmentTypeValue(first.opt("type"))
|
||||
if (parsedType in 0..6) {
|
||||
parsedType
|
||||
} else {
|
||||
inferLegacyAttachmentTypeFromJson(first)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
-1
|
||||
@@ -626,34 +728,149 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
if (attachments.length() != 1) return false
|
||||
val first = attachments.optJSONObject(0) ?: return false
|
||||
|
||||
val rawType = first.opt("type")
|
||||
val typeValue =
|
||||
when (rawType) {
|
||||
is Number -> rawType.toInt()
|
||||
is String -> {
|
||||
val normalized = rawType.trim()
|
||||
normalized.toIntOrNull()
|
||||
?: when (normalized.lowercase(Locale.ROOT)) {
|
||||
"call" -> 4
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
else -> -1
|
||||
}
|
||||
val typeValue = parseAttachmentTypeValue(first.opt("type"))
|
||||
if (typeValue == 4) return true
|
||||
|
||||
val preview = first.optString("preview", "").trim()
|
||||
if (preview.isEmpty()) return false
|
||||
val tail = preview.substringAfterLast("::", preview).trim()
|
||||
if (tail.toIntOrNull() != null) return true
|
||||
|
||||
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(preview)
|
||||
isLikelyCallAttachmentPreview(preview)
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAttachmentTypeValue(rawType: Any?): Int {
|
||||
return when (rawType) {
|
||||
is Number -> rawType.toInt()
|
||||
is String -> {
|
||||
val normalized = rawType.trim()
|
||||
normalized.toIntOrNull()
|
||||
?: run {
|
||||
val token =
|
||||
normalized.lowercase(Locale.ROOT)
|
||||
.replace('-', '_')
|
||||
.replace(' ', '_')
|
||||
when (token) {
|
||||
"image", "photo", "picture" -> 0
|
||||
"messages", "message", "reply", "forward", "forwarded" -> 1
|
||||
"file", "document", "doc" -> 2
|
||||
"avatar", "profile_photo", "profile_avatar" -> 3
|
||||
"call", "phone_call" -> 4
|
||||
"voice", "voice_message", "voice_note", "audio", "audio_message", "audio_note", "audiomessage", "audionote" -> 5
|
||||
"video_circle", "videocircle", "circle_video", "circlevideo", "video_note", "videonote", "round_video", "videoround", "video_message", "video" -> 6
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferLegacyAttachmentTypeFromJson(attachment: JSONObject): Int {
|
||||
val preview = attachment.optString("preview", "")
|
||||
val blob = attachment.optString("blob", "")
|
||||
val width = attachment.optInt("width", 0)
|
||||
val height = attachment.optInt("height", 0)
|
||||
val attachmentId = attachment.optString("id", "")
|
||||
val transportObj = attachment.optJSONObject("transport")
|
||||
val transportTag =
|
||||
attachment.optString(
|
||||
"transportTag",
|
||||
attachment.optString(
|
||||
"transport_tag",
|
||||
transportObj?.optString("transport_tag", "") ?: ""
|
||||
)
|
||||
)
|
||||
|
||||
if (isLikelyMessagesAttachmentPayload(preview = preview, blob = blob)) return 1
|
||||
if (blob.isBlank() && width <= 0 && height <= 0 && isLikelyCallAttachmentPreview(preview)) return 4
|
||||
if (isLikelyVideoCircleAttachmentPreview(preview = preview, attachmentId = attachmentId)) return 6
|
||||
if (isLikelyVoiceAttachmentPreview(preview = preview, attachmentId = attachmentId)) return 5
|
||||
if (width > 0 || height > 0) return 0
|
||||
if (isLikelyFileAttachmentPreview(preview) || transportTag.isNotBlank()) return 2
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun isLikelyCallAttachmentPreview(preview: String): Boolean {
|
||||
if (preview.isBlank()) return false
|
||||
val normalized = preview.trim()
|
||||
val tail = normalized.substringAfterLast("::", normalized).trim()
|
||||
if (tail.toIntOrNull() != null) return true
|
||||
return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(normalized)
|
||||
}
|
||||
|
||||
private fun isLikelyMessagesAttachmentPayload(preview: String, blob: String): Boolean {
|
||||
val payload = blob.ifBlank { preview }.trim()
|
||||
if (payload.isEmpty()) return false
|
||||
if (!payload.startsWith("{") && !payload.startsWith("[")) return false
|
||||
|
||||
val objectCandidate =
|
||||
runCatching {
|
||||
if (payload.startsWith("[")) JSONArray(payload).optJSONObject(0)
|
||||
else JSONObject(payload)
|
||||
}
|
||||
.getOrNull()
|
||||
?: return false
|
||||
|
||||
return objectCandidate.has("message_id") ||
|
||||
objectCandidate.has("publicKey") ||
|
||||
objectCandidate.has("message") ||
|
||||
objectCandidate.has("attachments")
|
||||
}
|
||||
|
||||
private fun isLikelyVoiceAttachmentPreview(preview: String, attachmentId: String): Boolean {
|
||||
val id = attachmentId.trim().lowercase(Locale.ROOT)
|
||||
if (id.startsWith("voice_") || id.startsWith("voice-") || id.startsWith("audio_") || id.startsWith("audio-")) {
|
||||
return true
|
||||
}
|
||||
|
||||
val normalized = preview.trim()
|
||||
if (normalized.isEmpty()) return false
|
||||
|
||||
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
|
||||
if (duration < 0) return false
|
||||
|
||||
val tail = normalized.substringAfter("::", "").trim()
|
||||
if (tail.isEmpty()) return true
|
||||
if (tail.startsWith("video/", ignoreCase = true)) return false
|
||||
if (tail.startsWith("audio/", ignoreCase = true)) return true
|
||||
if (!tail.contains(",")) return false
|
||||
|
||||
val values = tail.split(",")
|
||||
return values.size >= 2 && values.all { it.trim().toFloatOrNull() != null }
|
||||
}
|
||||
|
||||
private fun isLikelyVideoCircleAttachmentPreview(preview: String, attachmentId: String): Boolean {
|
||||
val id = attachmentId.trim().lowercase(Locale.ROOT)
|
||||
if (id.startsWith("video_circle_") || id.startsWith("video-circle-")) {
|
||||
return true
|
||||
}
|
||||
|
||||
val normalized = preview.trim()
|
||||
if (normalized.isEmpty()) return false
|
||||
|
||||
val duration = normalized.substringBefore("::", "").trim().toIntOrNull() ?: return false
|
||||
if (duration < 0) return false
|
||||
|
||||
val mime = normalized.substringAfter("::", "").trim().lowercase(Locale.ROOT)
|
||||
return mime.startsWith("video/")
|
||||
}
|
||||
|
||||
private fun isLikelyFileAttachmentPreview(preview: String): Boolean {
|
||||
val normalized = preview.trim()
|
||||
if (normalized.isEmpty()) return false
|
||||
val parts = normalized.split("::")
|
||||
if (parts.size < 2) return false
|
||||
|
||||
val first = parts[0]
|
||||
return when {
|
||||
parts.size >= 3 && attachmentTagUuidRegex.matches(first) && parts[1].toLongOrNull() != null -> true
|
||||
parts.size >= 2 && first.toLongOrNull() != null -> true
|
||||
parts.size >= 2 && attachmentTagUuidRegex.matches(first) -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAttachmentsJsonArray(rawAttachments: String): JSONArray? {
|
||||
val normalized = rawAttachments.trim()
|
||||
if (normalized.isEmpty() || normalized == "[]") return null
|
||||
@@ -771,7 +988,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
|
||||
// 🗑️ 1. Очищаем ВСЕ кэши сообщений
|
||||
MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey)
|
||||
messageRepository.clearDialogCache(opponentKey)
|
||||
// 🗑️ Очищаем кэш ChatViewModel
|
||||
ChatViewModel.clearCacheForOpponent(opponentKey)
|
||||
|
||||
@@ -820,7 +1037,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
_requestsCount.value = _requests.value.size
|
||||
dialogsUiCache.remove(groupPublicKey)
|
||||
requestsUiCache.remove(groupPublicKey)
|
||||
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
|
||||
messageRepository.clearDialogCache(groupPublicKey)
|
||||
ChatViewModel.clearCacheForOpponent(groupPublicKey)
|
||||
}
|
||||
left
|
||||
@@ -880,7 +1097,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val sharedPrefs =
|
||||
getApplication<Application>()
|
||||
app
|
||||
.getSharedPreferences("rosetta", Application.MODE_PRIVATE)
|
||||
val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: ""
|
||||
|
||||
@@ -895,7 +1112,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
this.privateKey = privateKeyHash
|
||||
this.search = publicKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolGateway.send(packet)
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
@@ -26,11 +26,12 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun ConnectionLogsScreen(
|
||||
isDarkTheme: Boolean,
|
||||
protocolGateway: ProtocolGateway,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val logs by ProtocolManager.debugLogs.collectAsState()
|
||||
val protocolState by ProtocolManager.getProtocol().state.collectAsState()
|
||||
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||
val logs by protocolGateway.debugLogs.collectAsState()
|
||||
val protocolState by protocolGateway.state.collectAsState()
|
||||
val syncInProgress by protocolGateway.syncInProgress.collectAsState()
|
||||
|
||||
val bgColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFF5F5F5)
|
||||
val cardColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
|
||||
@@ -41,9 +42,9 @@ fun ConnectionLogsScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
ProtocolManager.enableUILogs(true)
|
||||
protocolGateway.enableUILogs(true)
|
||||
onDispose {
|
||||
ProtocolManager.enableUILogs(false)
|
||||
protocolGateway.enableUILogs(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ fun ConnectionLogsScreen(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(onClick = { ProtocolManager.clearLogs() }) {
|
||||
IconButton(onClick = { protocolGateway.clearLogs() }) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Trash,
|
||||
contentDescription = "Clear logs",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user