Compare commits
82 Commits
b81b38f40d
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ad08af7f0c | |||
| 9fe5f35923 | |||
| 78925dd61d | |||
| 1ac3d93f74 | |||
| 6ad24974e0 | |||
| e825a1ef30 | |||
| 7fcf1195e1 | |||
| a10482b794 | |||
| 419761e34d | |||
| 988896c080 | |||
| b57e48fe20 | |||
| 5c02ff6fd3 | |||
| 7630aa6874 | |||
| afebbf6acb | |||
| aa3cc76646 | |||
| 8dac52c2eb | |||
| 946ba7838c | |||
| 78fbe0b3c8 | |||
| b13cdb7ea1 | |||
| b6055c98a5 | |||
| 3e3f501b9b | |||
| 620200ca44 | |||
| 47a6e20834 | |||
| fad8bfb1d1 | |||
| 5e5c4c11ac | |||
| 8d8b02a3ec | |||
| 6124a52c84 | |||
| 3485cb458f | |||
| 0dd3255cfe | |||
| accf34f233 | |||
| 30327fade2 | |||
| e5ff42ce1d | |||
| 06f43b9d4e | |||
| 655cc10a3e | |||
| d02f03516c | |||
| d94b3ec37a | |||
| 73d3b2baf6 | |||
| 66cc21fc29 | |||
| 3bef589274 | |||
| 7f79e4e0be | |||
| ae78e4a162 | |||
| 325073fc09 | |||
| 8bfbba3159 | |||
| 0427e2ba17 | |||
| 1e259f52ee | |||
| 299c84cb89 | |||
| 14d7fc6eb1 | |||
| 9fafa52483 | |||
| ecac56773a | |||
| 43422bb131 |
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("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("kotlin-kapt")
|
id("kotlin-kapt")
|
||||||
|
id("com.google.dagger.hilt.android")
|
||||||
id("com.google.gms.google-services")
|
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
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.4.8"
|
val rosettaVersionName = "1.5.4"
|
||||||
val rosettaVersionCode = 50 // Increment on each release
|
val rosettaVersionCode = 56 // Increment on each release
|
||||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -119,6 +120,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kapt {
|
||||||
|
correctErrorTypes = true
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.core:core-ktx:1.12.0")
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
@@ -182,6 +187,11 @@ dependencies {
|
|||||||
implementation("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
kapt("androidx.room:room-compiler: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
|
// Biometric authentication
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|
||||||
@@ -207,6 +217,10 @@ dependencies {
|
|||||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||||
|
|
||||||
|
// QR Code generation (ZXing) + scanning (ML Kit)
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||||
|
|
||||||
// Testing dependencies
|
// Testing dependencies
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("io.mockk:mockk:1.13.8")
|
testImplementation("io.mockk:mockk:1.13.8")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<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.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
@@ -26,10 +27,8 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RosettaApplication"
|
android:name=".RosettaApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:enableOnBackInvokedCallback="true"
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
@@ -47,13 +46,93 @@
|
|||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
|
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:screenOrientation="portrait">
|
android:screenOrientation="portrait">
|
||||||
|
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="rosetta" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<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>
|
</intent-filter>
|
||||||
</activity>
|
</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
|
<activity
|
||||||
android:name=".IncomingCallActivity"
|
android:name=".IncomingCallActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition(
|
|||||||
wasEmojiShown = true
|
wasEmojiShown = true
|
||||||
}
|
}
|
||||||
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
|
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
|
||||||
// Emoji закрылся после того как был открыт = переход emoji→keyboard
|
// Keep reserved space only if keyboard is actually opening.
|
||||||
isTransitioningToKeyboard = true
|
// 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
|
// 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji
|
||||||
@@ -63,6 +72,19 @@ fun AnimatedKeyboardTransition(
|
|||||||
isTransitioningToKeyboard = false
|
isTransitioningToKeyboard = false
|
||||||
wasEmojiShown = 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
|
val targetAlpha = if (showEmojiPicker) 1f else 0f
|
||||||
@@ -109,4 +131,4 @@ fun AnimatedKeyboardTransition(
|
|||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,27 @@ import android.view.WindowManager
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import com.rosetta.messenger.data.AccountManager
|
||||||
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.CallForegroundService
|
import com.rosetta.messenger.network.CallForegroundService
|
||||||
import com.rosetta.messenger.network.CallManager
|
import com.rosetta.messenger.network.CallManager
|
||||||
import com.rosetta.messenger.network.CallPhase
|
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.chats.calls.CallOverlay
|
||||||
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Лёгкая Activity для показа входящего звонка на lock screen.
|
* Лёгкая Activity для показа входящего звонка на lock screen.
|
||||||
* Показывается поверх экрана блокировки, без auth/splash.
|
* Показывается поверх экрана блокировки, без auth/splash.
|
||||||
* При Accept → переходит в MainActivity. При Decline → закрывается.
|
* При Accept → переходит в MainActivity. При Decline → закрывается.
|
||||||
*/
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
class IncomingCallActivity : ComponentActivity() {
|
class IncomingCallActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var accountManager: AccountManager
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "IncomingCallActivity"
|
private const val TAG = "IncomingCallActivity"
|
||||||
}
|
}
|
||||||
@@ -115,10 +123,23 @@ class IncomingCallActivity : ComponentActivity() {
|
|||||||
callState
|
callState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val avatarRepository = remember {
|
||||||
|
val accountKey = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||||
|
if (accountKey.isNotBlank()) {
|
||||||
|
val db = RosettaDatabase.getDatabase(applicationContext)
|
||||||
|
AvatarRepository(
|
||||||
|
context = applicationContext,
|
||||||
|
avatarDao = db.avatarDao(),
|
||||||
|
currentPublicKey = accountKey
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
|
||||||
RosettaAndroidTheme(darkTheme = true) {
|
RosettaAndroidTheme(darkTheme = true) {
|
||||||
CallOverlay(
|
CallOverlay(
|
||||||
state = displayState,
|
state = displayState,
|
||||||
isDarkTheme = true,
|
isDarkTheme = true,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
isExpanded = true,
|
isExpanded = true,
|
||||||
onAccept = {
|
onAccept = {
|
||||||
callLog("onAccept tapped, phase=${callState.phase}")
|
callLog("onAccept tapped, phase=${callState.phase}")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,29 @@ package com.rosetta.messenger
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import com.airbnb.lottie.L
|
import com.airbnb.lottie.L
|
||||||
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.DraftManager
|
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.network.TransportManager
|
||||||
import com.rosetta.messenger.update.UpdateManager
|
import com.rosetta.messenger.update.UpdateManager
|
||||||
import com.rosetta.messenger.utils.CrashReportManager
|
import com.rosetta.messenger.utils.CrashReportManager
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application класс для инициализации глобальных компонентов приложения
|
* Application класс для инициализации глобальных компонентов приложения
|
||||||
*/
|
*/
|
||||||
|
@HiltAndroidApp
|
||||||
class RosettaApplication : Application() {
|
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 {
|
companion object {
|
||||||
private const val TAG = "RosettaApplication"
|
private const val TAG = "RosettaApplication"
|
||||||
@@ -24,6 +38,9 @@ class RosettaApplication : Application() {
|
|||||||
|
|
||||||
// Инициализируем crash reporter
|
// Инициализируем crash reporter
|
||||||
initCrashReporting()
|
initCrashReporting()
|
||||||
|
|
||||||
|
// Install instance-based protocol runtime for non-Hilt singleton objects.
|
||||||
|
ProtocolRuntimeAccess.install(protocolRuntime)
|
||||||
|
|
||||||
// Инициализируем менеджер черновиков
|
// Инициализируем менеджер черновиков
|
||||||
DraftManager.init(this)
|
DraftManager.init(this)
|
||||||
@@ -33,6 +50,11 @@ class RosettaApplication : Application() {
|
|||||||
|
|
||||||
// Инициализируем менеджер обновлений (SDU)
|
// Инициализируем менеджер обновлений (SDU)
|
||||||
UpdateManager.init(this)
|
UpdateManager.init(this)
|
||||||
|
|
||||||
|
CallManager.bindDependencies(
|
||||||
|
messageRepository = messageRepository,
|
||||||
|
accountManager = accountManager
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,50 +14,36 @@ import kotlinx.coroutines.withContext
|
|||||||
/**
|
/**
|
||||||
* Безопасное хранилище настроек биометрической аутентификации
|
* Безопасное хранилище настроек биометрической аутентификации
|
||||||
* Использует EncryptedSharedPreferences с MasterKey из Android Keystore
|
* Использует EncryptedSharedPreferences с MasterKey из Android Keystore
|
||||||
*
|
*
|
||||||
* Уровни защиты:
|
* Биометрия привязана к конкретному аккаунту (per-account), не глобальная.
|
||||||
* - AES256_GCM для шифрования значений
|
|
||||||
* - AES256_SIV для шифрования ключей
|
|
||||||
* - MasterKey хранится в Android Keystore (TEE/StrongBox)
|
|
||||||
*/
|
*/
|
||||||
class BiometricPreferences(private val context: Context) {
|
class BiometricPreferences(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BiometricPreferences"
|
private const val TAG = "BiometricPreferences"
|
||||||
private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs"
|
private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs"
|
||||||
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
|
private const val KEY_BIOMETRIC_ENABLED_PREFIX = "biometric_enabled_"
|
||||||
private const val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_"
|
private const val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_"
|
||||||
// Shared between all BiometricPreferences instances so UI in different screens
|
// Legacy key (global) — for migration
|
||||||
// receives updates immediately (ProfileScreen <-> BiometricEnableScreen).
|
private const val KEY_BIOMETRIC_ENABLED_LEGACY = "biometric_enabled"
|
||||||
|
// Shared state for reactive UI updates
|
||||||
private val biometricEnabledState = MutableStateFlow(false)
|
private val biometricEnabledState = MutableStateFlow(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val _isBiometricEnabled = biometricEnabledState
|
private val _isBiometricEnabled = biometricEnabledState
|
||||||
|
|
||||||
private val encryptedPrefs: SharedPreferences by lazy {
|
private val encryptedPrefs: SharedPreferences by lazy {
|
||||||
createEncryptedPreferences()
|
createEncryptedPreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
|
||||||
// Загружаем начальное значение
|
|
||||||
try {
|
|
||||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создает EncryptedSharedPreferences с максимальной защитой
|
|
||||||
*/
|
|
||||||
private fun createEncryptedPreferences(): SharedPreferences {
|
private fun createEncryptedPreferences(): SharedPreferences {
|
||||||
try {
|
try {
|
||||||
// Создаем MasterKey с максимальной защитой
|
|
||||||
val masterKey = MasterKey.Builder(appContext)
|
val masterKey = MasterKey.Builder(appContext)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.setUserAuthenticationRequired(false) // Биометрия проверяется отдельно в BiometricAuthManager
|
.setUserAuthenticationRequired(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return EncryptedSharedPreferences.create(
|
return EncryptedSharedPreferences.create(
|
||||||
appContext,
|
appContext,
|
||||||
PREFS_FILE_NAME,
|
PREFS_FILE_NAME,
|
||||||
@@ -66,77 +52,93 @@ class BiometricPreferences(private val context: Context) {
|
|||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Fallback на обычные SharedPreferences в случае ошибки (не должно произойти)
|
|
||||||
return appContext.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE)
|
return appContext.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Включена ли биометрическая аутентификация
|
|
||||||
*/
|
|
||||||
val isBiometricEnabled: Flow<Boolean> = _isBiometricEnabled.asStateFlow()
|
val isBiometricEnabled: Flow<Boolean> = _isBiometricEnabled.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Включить биометрическую аутентификацию
|
* Загрузить состояние биометрии для конкретного аккаунта
|
||||||
*/
|
*/
|
||||||
|
fun loadForAccount(publicKey: String) {
|
||||||
|
try {
|
||||||
|
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
|
||||||
|
val perAccount = encryptedPrefs.getBoolean(key, false)
|
||||||
|
// Migration: если per-account нет, проверяем legacy глобальный ключ
|
||||||
|
if (!perAccount && encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false)) {
|
||||||
|
// Мигрируем: копируем глобальное значение в per-account
|
||||||
|
encryptedPrefs.edit().putBoolean(key, true).apply()
|
||||||
|
_isBiometricEnabled.value = true
|
||||||
|
} else {
|
||||||
|
_isBiometricEnabled.value = perAccount
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_isBiometricEnabled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Включить биометрическую аутентификацию для аккаунта
|
||||||
|
*/
|
||||||
|
suspend fun enableBiometric(publicKey: String) = withContext(Dispatchers.IO) {
|
||||||
|
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
|
||||||
|
encryptedPrefs.edit().putBoolean(key, true).commit()
|
||||||
|
_isBiometricEnabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отключить биометрическую аутентификацию для аккаунта
|
||||||
|
*/
|
||||||
|
suspend fun disableBiometric(publicKey: String) = withContext(Dispatchers.IO) {
|
||||||
|
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
|
||||||
|
encryptedPrefs.edit().putBoolean(key, false).commit()
|
||||||
|
_isBiometricEnabled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить включена ли биометрия для аккаунта (синхронно)
|
||||||
|
*/
|
||||||
|
fun isBiometricEnabledForAccount(publicKey: String): Boolean {
|
||||||
|
return try {
|
||||||
|
encryptedPrefs.getBoolean("$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey", false)
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legacy compat: old callers without publicKey ---
|
||||||
|
|
||||||
|
@Deprecated("Use enableBiometric(publicKey) instead")
|
||||||
suspend fun enableBiometric() = withContext(Dispatchers.IO) {
|
suspend fun enableBiometric() = withContext(Dispatchers.IO) {
|
||||||
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).commit()
|
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, true).commit()
|
||||||
if (!success) {
|
_isBiometricEnabled.value = true
|
||||||
Log.w(TAG, "Failed to persist biometric enabled state")
|
|
||||||
}
|
|
||||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Deprecated("Use disableBiometric(publicKey) instead")
|
||||||
* Отключить биометрическую аутентификацию
|
|
||||||
*/
|
|
||||||
suspend fun disableBiometric() = withContext(Dispatchers.IO) {
|
suspend fun disableBiometric() = withContext(Dispatchers.IO) {
|
||||||
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).commit()
|
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false).commit()
|
||||||
if (!success) {
|
_isBiometricEnabled.value = false
|
||||||
Log.w(TAG, "Failed to persist biometric disabled state")
|
|
||||||
}
|
|
||||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Сохранить зашифрованный пароль для аккаунта
|
|
||||||
* Пароль уже зашифрован BiometricAuthManager, здесь добавляется второй слой шифрования
|
|
||||||
*/
|
|
||||||
suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) = withContext(Dispatchers.IO) {
|
suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) = withContext(Dispatchers.IO) {
|
||||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||||
encryptedPrefs.edit().putString(key, encryptedPassword).apply()
|
encryptedPrefs.edit().putString(key, encryptedPassword).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить зашифрованный пароль для аккаунта
|
|
||||||
*/
|
|
||||||
suspend fun getEncryptedPassword(publicKey: String): String? = withContext(Dispatchers.IO) {
|
suspend fun getEncryptedPassword(publicKey: String): String? = withContext(Dispatchers.IO) {
|
||||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||||
encryptedPrefs.getString(key, null)
|
encryptedPrefs.getString(key, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Удалить зашифрованный пароль для аккаунта
|
|
||||||
*/
|
|
||||||
suspend fun removeEncryptedPassword(publicKey: String) = withContext(Dispatchers.IO) {
|
suspend fun removeEncryptedPassword(publicKey: String) = withContext(Dispatchers.IO) {
|
||||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||||
encryptedPrefs.edit().remove(key).apply()
|
encryptedPrefs.edit().remove(key).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Удалить все биометрические данные
|
|
||||||
*/
|
|
||||||
suspend fun clearAll() = withContext(Dispatchers.IO) {
|
suspend fun clearAll() = withContext(Dispatchers.IO) {
|
||||||
val success = encryptedPrefs.edit().clear().commit()
|
encryptedPrefs.edit().clear().commit()
|
||||||
if (!success) {
|
_isBiometricEnabled.value = false
|
||||||
Log.w(TAG, "Failed to clear biometric preferences")
|
|
||||||
}
|
|
||||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверить, есть ли сохраненный зашифрованный пароль для аккаунта
|
|
||||||
*/
|
|
||||||
suspend fun hasEncryptedPassword(publicKey: String): Boolean {
|
suspend fun hasEncryptedPassword(publicKey: String): Boolean {
|
||||||
return getEncryptedPassword(publicKey) != null
|
return getEncryptedPassword(publicKey) != null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ object CryptoManager {
|
|||||||
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
||||||
// расшифровке
|
// расшифровке
|
||||||
private const val DECRYPTION_CACHE_SIZE = 2000
|
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)
|
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -298,17 +302,21 @@ object CryptoManager {
|
|||||||
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
||||||
*/
|
*/
|
||||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
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)
|
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||||
val cacheKey = "$password:$encryptedData"
|
if (cacheKey != null) {
|
||||||
decryptionCache[cacheKey]?.let {
|
decryptionCache[cacheKey]?.let {
|
||||||
return it
|
return it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||||
|
|
||||||
// 🚀 Сохраняем в кэш (lock-free)
|
// 🚀 Сохраняем в кэш (lock-free)
|
||||||
if (result != null) {
|
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
|
||||||
// Ограничиваем размер кэша
|
// Ограничиваем размер кэша
|
||||||
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||||
// Удаляем ~10% самых старых записей
|
// Удаляем ~10% самых старых записей
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ object ForwardManager {
|
|||||||
val senderPublicKey: String, // publicKey отправителя сообщения
|
val senderPublicKey: String, // publicKey отправителя сообщения
|
||||||
val originalChatPublicKey: String, // publicKey чата откуда пересылается
|
val originalChatPublicKey: String, // publicKey чата откуда пересылается
|
||||||
val senderName: String = "", // Имя отправителя для атрибуции
|
val senderName: String = "", // Имя отправителя для атрибуции
|
||||||
val attachments: List<MessageAttachment> = emptyList()
|
val attachments: List<MessageAttachment> = emptyList(),
|
||||||
|
val chachaKeyPlain: String = "" // Hex plainKeyAndNonce оригинального сообщения
|
||||||
)
|
)
|
||||||
|
|
||||||
// Сообщения для пересылки
|
// Сообщения для пересылки
|
||||||
|
|||||||
@@ -14,17 +14,24 @@ import com.rosetta.messenger.network.PacketGroupInfo
|
|||||||
import com.rosetta.messenger.network.PacketGroupInviteInfo
|
import com.rosetta.messenger.network.PacketGroupInviteInfo
|
||||||
import com.rosetta.messenger.network.PacketGroupJoin
|
import com.rosetta.messenger.network.PacketGroupJoin
|
||||||
import com.rosetta.messenger.network.PacketGroupLeave
|
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.security.SecureRandom
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlin.coroutines.resume
|
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 db = RosettaDatabase.getDatabase(context.applicationContext)
|
||||||
private val groupDao = db.groupDao()
|
private val groupDao = db.groupDao()
|
||||||
private val messageDao = db.messageDao()
|
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_INVITE_PASSWORD = "rosetta_group"
|
||||||
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
|
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
|
||||||
private const val GROUP_CREATED_MARKER = "\$a=Group created"
|
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(
|
data class ParsedGroupInvite(
|
||||||
@@ -155,7 +153,7 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
this.groupId = groupId
|
this.groupId = groupId
|
||||||
this.members = emptyList()
|
this.members = emptyList()
|
||||||
}
|
}
|
||||||
ProtocolManager.send(packet)
|
protocolClient.send(packet)
|
||||||
|
|
||||||
val response = awaitPacketOnce<PacketGroupInfo>(
|
val response = awaitPacketOnce<PacketGroupInfo>(
|
||||||
packetId = 0x12,
|
packetId = 0x12,
|
||||||
@@ -189,7 +187,7 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
this.membersCount = 0
|
this.membersCount = 0
|
||||||
this.groupStatus = GroupStatus.NOT_JOINED
|
this.groupStatus = GroupStatus.NOT_JOINED
|
||||||
}
|
}
|
||||||
ProtocolManager.send(packet)
|
protocolClient.send(packet)
|
||||||
|
|
||||||
val response = awaitPacketOnce<PacketGroupInviteInfo>(
|
val response = awaitPacketOnce<PacketGroupInviteInfo>(
|
||||||
packetId = 0x13,
|
packetId = 0x13,
|
||||||
@@ -217,7 +215,7 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val createPacket = PacketCreateGroup()
|
val createPacket = PacketCreateGroup()
|
||||||
ProtocolManager.send(createPacket)
|
protocolClient.send(createPacket)
|
||||||
|
|
||||||
val response = awaitPacketOnce<PacketCreateGroup>(
|
val response = awaitPacketOnce<PacketCreateGroup>(
|
||||||
packetId = 0x11,
|
packetId = 0x11,
|
||||||
@@ -268,7 +266,7 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
groupString = encodedGroupStringForServer
|
groupString = encodedGroupStringForServer
|
||||||
groupStatus = GroupStatus.NOT_JOINED
|
groupStatus = GroupStatus.NOT_JOINED
|
||||||
}
|
}
|
||||||
ProtocolManager.send(packet)
|
protocolClient.send(packet)
|
||||||
|
|
||||||
val response = awaitPacketOnce<PacketGroupJoin>(
|
val response = awaitPacketOnce<PacketGroupJoin>(
|
||||||
packetId = 0x14,
|
packetId = 0x14,
|
||||||
@@ -376,7 +374,7 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
val packet = PacketGroupLeave().apply {
|
val packet = PacketGroupLeave().apply {
|
||||||
this.groupId = groupId
|
this.groupId = groupId
|
||||||
}
|
}
|
||||||
ProtocolManager.send(packet)
|
protocolClient.send(packet)
|
||||||
|
|
||||||
val response = awaitPacketOnce<PacketGroupLeave>(
|
val response = awaitPacketOnce<PacketGroupLeave>(
|
||||||
packetId = 0x15,
|
packetId = 0x15,
|
||||||
@@ -402,7 +400,7 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
this.groupId = groupId
|
this.groupId = groupId
|
||||||
this.publicKey = targetPublicKey
|
this.publicKey = targetPublicKey
|
||||||
}
|
}
|
||||||
ProtocolManager.send(packet)
|
protocolClient.send(packet)
|
||||||
|
|
||||||
val response = awaitPacketOnce<PacketGroupBan>(
|
val response = awaitPacketOnce<PacketGroupBan>(
|
||||||
packetId = 0x16,
|
packetId = 0x16,
|
||||||
@@ -479,9 +477,8 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
dialogPublicKey: String
|
dialogPublicKey: String
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val messages = MessageRepository.getInstance(appContext)
|
messageRepository.initialize(accountPublicKey, accountPrivateKey)
|
||||||
messages.initialize(accountPublicKey, accountPrivateKey)
|
messageRepository.sendMessage(
|
||||||
messages.sendMessage(
|
|
||||||
toPublicKey = dialogPublicKey,
|
toPublicKey = dialogPublicKey,
|
||||||
text = GROUP_CREATED_MARKER
|
text = GROUP_CREATED_MARKER
|
||||||
)
|
)
|
||||||
@@ -512,13 +509,13 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
callback = { packet ->
|
callback = { packet ->
|
||||||
val typedPacket = packet as? T
|
val typedPacket = packet as? T
|
||||||
if (typedPacket != null && predicate(typedPacket)) {
|
if (typedPacket != null && predicate(typedPacket)) {
|
||||||
ProtocolManager.unwaitPacket(packetId, callback)
|
protocolClient.unwaitPacket(packetId, callback)
|
||||||
continuation.resume(typedPacket)
|
continuation.resume(typedPacket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProtocolManager.waitPacket(packetId, callback)
|
protocolClient.waitPacket(packetId, callback)
|
||||||
continuation.invokeOnCancellation {
|
continuation.invokeOnCancellation {
|
||||||
ProtocolManager.unwaitPacket(packetId, callback)
|
protocolClient.unwaitPacket(packetId, callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.rosetta.messenger.data
|
package com.rosetta.messenger.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.rosetta.messenger.BuildConfig
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.crypto.MessageCrypto
|
import com.rosetta.messenger.crypto.MessageCrypto
|
||||||
import com.rosetta.messenger.database.*
|
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.AttachmentFileManager
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
import com.rosetta.messenger.utils.MessageLogger
|
import com.rosetta.messenger.utils.MessageLogger
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
@@ -30,7 +34,6 @@ data class Message(
|
|||||||
val replyToMessageId: String? = null
|
val replyToMessageId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/** UI модель диалога */
|
|
||||||
data class Dialog(
|
data class Dialog(
|
||||||
val opponentKey: String,
|
val opponentKey: String,
|
||||||
val opponentTitle: String,
|
val opponentTitle: String,
|
||||||
@@ -44,7 +47,11 @@ data class Dialog(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */
|
/** 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 database = RosettaDatabase.getDatabase(context)
|
||||||
private val messageDao = database.messageDao()
|
private val messageDao = database.messageDao()
|
||||||
@@ -97,8 +104,6 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
private var currentPrivateKey: String? = null
|
private var currentPrivateKey: String? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var INSTANCE: MessageRepository? = null
|
|
||||||
|
|
||||||
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
|
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
|
||||||
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
|
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
|
||||||
|
|
||||||
@@ -136,16 +141,6 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
/** Очистка кэша (вызывается при logout) */
|
/** Очистка кэша (вызывается при logout) */
|
||||||
fun clearProcessedCache() = processedMessageIds.clear()
|
fun clearProcessedCache() = processedMessageIds.clear()
|
||||||
|
|
||||||
fun getInstance(context: Context): MessageRepository {
|
|
||||||
return INSTANCE
|
|
||||||
?: synchronized(this) {
|
|
||||||
INSTANCE
|
|
||||||
?: MessageRepository(context.applicationContext).also {
|
|
||||||
INSTANCE = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
|
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
|
||||||
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
|
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
|
||||||
@@ -245,6 +240,13 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
opponentUsername =
|
opponentUsername =
|
||||||
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
|
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
|
||||||
?: 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,
|
isOnline = existing?.isOnline ?: 0,
|
||||||
lastSeen = existing?.lastSeen ?: 0,
|
lastSeen = existing?.lastSeen ?: 0,
|
||||||
verified = maxOf(existing?.verified ?: 0, 1),
|
verified = maxOf(existing?.verified ?: 0, 1),
|
||||||
@@ -265,7 +267,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
try {
|
try {
|
||||||
CryptoManager.encryptWithPassword(messageText, privateKey)
|
CryptoManager.encryptWithPassword(messageText, privateKey)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
|
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +326,13 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
opponentUsername =
|
opponentUsername =
|
||||||
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
|
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
|
||||||
?: 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,
|
isOnline = existing?.isOnline ?: 0,
|
||||||
lastSeen = existing?.lastSeen ?: 0,
|
lastSeen = existing?.lastSeen ?: 0,
|
||||||
verified = maxOf(existing?.verified ?: 0, 1),
|
verified = maxOf(existing?.verified ?: 0, 1),
|
||||||
@@ -343,12 +352,12 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
suspend fun checkAndSendVersionUpdateMessage() {
|
suspend fun checkAndSendVersionUpdateMessage() {
|
||||||
val account = currentAccount
|
val account = currentAccount
|
||||||
if (account == null) {
|
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
|
return
|
||||||
}
|
}
|
||||||
val privateKey = currentPrivateKey
|
val privateKey = currentPrivateKey
|
||||||
if (privateKey == null) {
|
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
|
return
|
||||||
}
|
}
|
||||||
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
|
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 currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
|
||||||
val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}"
|
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) {
|
if (lastNoticeKey != currentKey) {
|
||||||
// Delete the previous message for this version (if any)
|
// 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))
|
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) {
|
if (messageId != null) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("lastNoticeKey", currentKey)
|
.putString("lastNoticeKey", currentKey)
|
||||||
.putString("lastNoticeMessageId_$currentVersion", messageId)
|
.putString("lastNoticeMessageId_$currentVersion", messageId)
|
||||||
.apply()
|
.apply()
|
||||||
android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
|
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
|
||||||
} else {
|
} 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
|
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||||
dialogDao.updateDialogFromMessages(account, toPublicKey)
|
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
|
// 📁 Для saved messages - гарантируем создание/обновление dialog
|
||||||
if (isSavedMessages) {
|
if (isSavedMessages) {
|
||||||
val existing = dialogDao.getDialog(account, account)
|
val existing = dialogDao.getDialog(account, account)
|
||||||
@@ -674,7 +689,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
|
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
|
||||||
|
|
||||||
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
|
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
|
||||||
ProtocolManager.sendMessageWithRetry(packet)
|
protocolClient.sendMessageWithRetry(packet)
|
||||||
|
|
||||||
// 📝 LOG: Успешная отправка
|
// 📝 LOG: Успешная отправка
|
||||||
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
|
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||||
@@ -814,11 +829,19 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isGroupMessage && groupKey.isNullOrBlank()) {
|
if (isGroupMessage && groupKey.isNullOrBlank()) {
|
||||||
MessageLogger.debug(
|
val requiresGroupKey =
|
||||||
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
|
(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 =
|
val plainKeyAndNonce =
|
||||||
@@ -830,7 +853,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||||
ProtocolManager.addLog(
|
protocolClient.addLog(
|
||||||
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
|
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -849,8 +872,9 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
if (isAttachmentOnly) {
|
if (isAttachmentOnly) {
|
||||||
""
|
""
|
||||||
} else if (isGroupMessage) {
|
} else if (isGroupMessage) {
|
||||||
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
val decryptedGroupPayload =
|
||||||
?: throw IllegalStateException("Failed to decrypt group payload")
|
groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
|
||||||
|
decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
|
||||||
} else if (plainKeyAndNonce != null) {
|
} else if (plainKeyAndNonce != null) {
|
||||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||||
} else {
|
} else {
|
||||||
@@ -858,7 +882,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
|
// 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) {
|
} catch (e: Exception) {
|
||||||
// 📝 LOG: Ошибка обработки
|
// 📝 LOG: Ошибка обработки
|
||||||
MessageLogger.logDecryptionError(messageId, e)
|
MessageLogger.logDecryptionError(messageId, e)
|
||||||
ProtocolManager.addLog(
|
protocolClient.addLog(
|
||||||
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
|
"❌ 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, если пакет не удалось сохранить.
|
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
|
||||||
processedMessageIds.remove(messageId)
|
processedMessageIds.remove(messageId)
|
||||||
@@ -1012,15 +1038,12 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
suspend fun handleDelivery(packet: PacketDelivery) {
|
suspend fun handleDelivery(packet: PacketDelivery) {
|
||||||
val account = currentAccount ?: return
|
val account = currentAccount ?: return
|
||||||
|
|
||||||
// 📝 LOG: Получено подтверждение доставки
|
|
||||||
MessageLogger.logDeliveryStatus(
|
MessageLogger.logDeliveryStatus(
|
||||||
messageId = packet.messageId,
|
messageId = packet.messageId,
|
||||||
toPublicKey = packet.toPublicKey,
|
toPublicKey = packet.toPublicKey,
|
||||||
status = "DELIVERED"
|
status = "DELIVERED"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Desktop parity: update both delivery status AND timestamp on delivery confirmation.
|
|
||||||
// Desktop sets timestamp = Date.now() when PacketDelivery arrives (useSynchronize.ts).
|
|
||||||
val deliveryTimestamp = System.currentTimeMillis()
|
val deliveryTimestamp = System.currentTimeMillis()
|
||||||
messageDao.updateDeliveryStatusAndTimestamp(
|
messageDao.updateDeliveryStatusAndTimestamp(
|
||||||
account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp
|
account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp
|
||||||
@@ -1045,6 +1068,50 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
|
dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an incoming call event locally (for CALLEE side).
|
||||||
|
* Creates a message as if received from the peer, with CALL attachment.
|
||||||
|
*/
|
||||||
|
suspend fun saveIncomingCallEvent(fromPublicKey: String, durationSec: Int) {
|
||||||
|
val account = currentAccount ?: return
|
||||||
|
val privateKey = currentPrivateKey ?: return
|
||||||
|
val messageId = java.util.UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val dialogKey = getDialogKey(fromPublicKey)
|
||||||
|
|
||||||
|
val attId = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
|
||||||
|
val attachmentsJson = org.json.JSONArray().apply {
|
||||||
|
put(org.json.JSONObject().apply {
|
||||||
|
put("id", attId)
|
||||||
|
put("type", com.rosetta.messenger.network.AttachmentType.CALL.value)
|
||||||
|
put("preview", durationSec.toString())
|
||||||
|
put("blob", "")
|
||||||
|
})
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
val encryptedPlainMessage = com.rosetta.messenger.crypto.CryptoManager.encryptWithPassword("", privateKey)
|
||||||
|
|
||||||
|
val entity = com.rosetta.messenger.database.MessageEntity(
|
||||||
|
account = account,
|
||||||
|
fromPublicKey = fromPublicKey,
|
||||||
|
toPublicKey = account,
|
||||||
|
content = "",
|
||||||
|
timestamp = timestamp,
|
||||||
|
chachaKey = "",
|
||||||
|
read = 1,
|
||||||
|
fromMe = 0,
|
||||||
|
delivered = com.rosetta.messenger.network.DeliveryStatus.DELIVERED.value,
|
||||||
|
messageId = messageId,
|
||||||
|
plainMessage = encryptedPlainMessage,
|
||||||
|
attachments = attachmentsJson,
|
||||||
|
primaryAttachmentType = com.rosetta.messenger.network.AttachmentType.CALL.value,
|
||||||
|
dialogKey = dialogKey
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(entity)
|
||||||
|
dialogDao.updateDialogFromMessages(account, fromPublicKey)
|
||||||
|
_newMessageEvents.tryEmit(dialogKey)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
|
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
|
||||||
* fromPublicKey - кто прочитал (собеседник)
|
* fromPublicKey - кто прочитал (собеседник)
|
||||||
@@ -1195,7 +1262,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
this.toPublicKey = toPublicKey
|
this.toPublicKey = toPublicKey
|
||||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||||
}
|
}
|
||||||
ProtocolManager.send(packet)
|
protocolClient.send(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,7 +1327,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
syncedOpponentsWithWrongStatus.forEach { opponentKey ->
|
syncedOpponentsWithWrongStatus.forEach { opponentKey ->
|
||||||
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
|
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
|
||||||
}
|
}
|
||||||
android.util.Log.i(
|
if (BuildConfig.DEBUG) android.util.Log.i(
|
||||||
"MessageRepository",
|
"MessageRepository",
|
||||||
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
|
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
|
||||||
)
|
)
|
||||||
@@ -1269,14 +1336,14 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// Mark expired messages as ERROR (older than 80 seconds)
|
// Mark expired messages as ERROR (older than 80 seconds)
|
||||||
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
||||||
if (expiredCount > 0) {
|
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)
|
// Get remaining WAITING messages (younger than 80s)
|
||||||
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
||||||
if (waitingMessages.isEmpty()) return
|
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) {
|
for (entity in waitingMessages) {
|
||||||
// Skip saved messages (should not happen, but guard)
|
// Skip saved messages (should not happen, but guard)
|
||||||
@@ -1300,7 +1367,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} 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")
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1326,10 +1393,10 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// iOS parity: use retry mechanism for reconnect-resent messages too
|
// iOS parity: use retry mechanism for reconnect-resent messages too
|
||||||
ProtocolManager.sendMessageWithRetry(packet)
|
protocolClient.sendMessageWithRetry(packet)
|
||||||
android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
|
if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
|
||||||
} catch (e: Exception) {
|
} 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
|
// Mark as ERROR if retry fails
|
||||||
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
|
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
|
||||||
val dialogKey = getDialogKey(entity.toPublicKey)
|
val dialogKey = getDialogKey(entity.toPublicKey)
|
||||||
@@ -1430,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) {
|
suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
|
||||||
val account = currentAccount ?: return
|
val account = currentAccount ?: return
|
||||||
@@ -1591,7 +1658,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
this.privateKey = privateKeyHash
|
this.privateKey = privateKeyHash
|
||||||
this.search = dialog.opponentKey
|
this.search = dialog.opponentKey
|
||||||
}
|
}
|
||||||
ProtocolManager.send(packet)
|
protocolClient.send(packet)
|
||||||
// Small delay to avoid flooding the server with search requests
|
// Small delay to avoid flooding the server with search requests
|
||||||
kotlinx.coroutines.delay(50)
|
kotlinx.coroutines.delay(50)
|
||||||
}
|
}
|
||||||
@@ -1628,7 +1695,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
this.privateKey = privateKeyHash
|
this.privateKey = privateKeyHash
|
||||||
this.search = publicKey
|
this.search = publicKey
|
||||||
}
|
}
|
||||||
ProtocolManager.send(packet)
|
protocolClient.send(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1714,6 +1781,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
put("preview", attachment.preview)
|
put("preview", attachment.preview)
|
||||||
put("width", attachment.width)
|
put("width", attachment.width)
|
||||||
put("height", attachment.height)
|
put("height", attachment.height)
|
||||||
|
put("localUri", attachment.localUri)
|
||||||
put("transportTag", attachment.transportTag)
|
put("transportTag", attachment.transportTag)
|
||||||
put("transportServer", attachment.transportServer)
|
put("transportServer", attachment.transportServer)
|
||||||
}
|
}
|
||||||
@@ -1812,7 +1880,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||||
val decryptedBlob =
|
val decryptedBlob =
|
||||||
if (groupKey != null) {
|
if (groupKey != null) {
|
||||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||||
} else {
|
} else {
|
||||||
plainKeyAndNonce?.let {
|
plainKeyAndNonce?.let {
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||||
@@ -1869,7 +1937,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||||
val decryptedBlob =
|
val decryptedBlob =
|
||||||
if (groupKey != null) {
|
if (groupKey != null) {
|
||||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||||
} else {
|
} else {
|
||||||
plainKeyAndNonce?.let {
|
plainKeyAndNonce?.let {
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||||
@@ -1933,7 +2001,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 1. Расшифровываем с ChaCha ключом сообщения
|
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||||
val decryptedBlob =
|
val decryptedBlob =
|
||||||
if (groupKey != null) {
|
if (groupKey != null) {
|
||||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||||
} else {
|
} else {
|
||||||
plainKeyAndNonce?.let {
|
plainKeyAndNonce?.let {
|
||||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||||
@@ -1958,6 +2026,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
jsonObj.put("preview", attachment.preview)
|
jsonObj.put("preview", attachment.preview)
|
||||||
jsonObj.put("width", attachment.width)
|
jsonObj.put("width", attachment.width)
|
||||||
jsonObj.put("height", attachment.height)
|
jsonObj.put("height", attachment.height)
|
||||||
|
jsonObj.put("localUri", attachment.localUri)
|
||||||
jsonObj.put("transportTag", attachment.transportTag)
|
jsonObj.put("transportTag", attachment.transportTag)
|
||||||
jsonObj.put("transportServer", attachment.transportServer)
|
jsonObj.put("transportServer", attachment.transportServer)
|
||||||
} else {
|
} else {
|
||||||
@@ -1968,6 +2037,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
jsonObj.put("preview", attachment.preview)
|
jsonObj.put("preview", attachment.preview)
|
||||||
jsonObj.put("width", attachment.width)
|
jsonObj.put("width", attachment.width)
|
||||||
jsonObj.put("height", attachment.height)
|
jsonObj.put("height", attachment.height)
|
||||||
|
jsonObj.put("localUri", attachment.localUri)
|
||||||
jsonObj.put("transportTag", attachment.transportTag)
|
jsonObj.put("transportTag", attachment.transportTag)
|
||||||
jsonObj.put("transportServer", attachment.transportServer)
|
jsonObj.put("transportServer", attachment.transportServer)
|
||||||
}
|
}
|
||||||
@@ -1979,6 +2049,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
jsonObj.put("preview", attachment.preview)
|
jsonObj.put("preview", attachment.preview)
|
||||||
jsonObj.put("width", attachment.width)
|
jsonObj.put("width", attachment.width)
|
||||||
jsonObj.put("height", attachment.height)
|
jsonObj.put("height", attachment.height)
|
||||||
|
jsonObj.put("localUri", attachment.localUri)
|
||||||
jsonObj.put("transportTag", attachment.transportTag)
|
jsonObj.put("transportTag", attachment.transportTag)
|
||||||
jsonObj.put("transportServer", attachment.transportServer)
|
jsonObj.put("transportServer", attachment.transportServer)
|
||||||
}
|
}
|
||||||
@@ -1990,6 +2061,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
jsonObj.put("preview", attachment.preview)
|
jsonObj.put("preview", attachment.preview)
|
||||||
jsonObj.put("width", attachment.width)
|
jsonObj.put("width", attachment.width)
|
||||||
jsonObj.put("height", attachment.height)
|
jsonObj.put("height", attachment.height)
|
||||||
|
jsonObj.put("localUri", attachment.localUri)
|
||||||
jsonObj.put("transportTag", attachment.transportTag)
|
jsonObj.put("transportTag", attachment.transportTag)
|
||||||
jsonObj.put("transportServer", attachment.transportServer)
|
jsonObj.put("transportServer", attachment.transportServer)
|
||||||
}
|
}
|
||||||
@@ -1998,4 +2070,26 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
return jsonArray.toString()
|
return jsonArray.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop parity for group attachment blobs:
|
||||||
|
* old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes).
|
||||||
|
*/
|
||||||
|
private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? {
|
||||||
|
if (encryptedBlob.isBlank() || groupKey.isBlank()) return null
|
||||||
|
|
||||||
|
val rawAttempt = runCatching {
|
||||||
|
CryptoManager.decryptWithPassword(encryptedBlob, groupKey)
|
||||||
|
}.getOrNull()
|
||||||
|
if (rawAttempt != null) return rawAttempt
|
||||||
|
|
||||||
|
val hexKey =
|
||||||
|
groupKey.toByteArray(Charsets.ISO_8859_1)
|
||||||
|
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||||
|
if (hexKey == groupKey) return null
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
CryptoManager.decryptWithPassword(encryptedBlob, hexKey)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class PreferencesManager(private val context: Context) {
|
|||||||
val BACKGROUND_BLUR_COLOR_ID =
|
val BACKGROUND_BLUR_COLOR_ID =
|
||||||
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
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)
|
// Pinned Chats (max 3)
|
||||||
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
||||||
|
|
||||||
@@ -333,6 +336,19 @@ class PreferencesManager(private val context: Context) {
|
|||||||
return wasPinned
|
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
|
// 🔕 MUTED CHATS
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -17,19 +17,11 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Синхронизация (как на Desktop)
|
- Исправлена перемотка голосовых: waveform продолжается с текущей позиции после seek, без перерисовки с нуля
|
||||||
- Во время sync экран чатов показывает "Updating..." и скрывает шумящие промежуточные индикаторы
|
- Стабилизирован вход и переподключение после подтверждения или отклонения верификации на другом устройстве
|
||||||
- На период синхронизации скрываются badge'ы непрочитанного и requests, чтобы список не "прыгал"
|
- Исправлена отправка сообщений и синхронизация после повторного запроса входа
|
||||||
|
- Восстановлена совместимость старых вложений и голосовых между версиями приложения
|
||||||
Медиа и вложения
|
- Улучшен запрос Full Screen Intent для звонков на Android 14+
|
||||||
- Исправлен кейс, когда фото уже отправлено, но локально оставалось в ERROR с красным индикатором
|
|
||||||
- Для исходящих медиа стабилизирован переход статусов: после успешной отправки фиксируется SENT без ложного timeout->ERROR
|
|
||||||
- Таймаут/ретрай WAITING из БД больше не портит медиа-вложения (применяется только к обычным текстовым ожиданиям)
|
|
||||||
- Для legacy/неподдерживаемых attachment добавлен desktop-style fallback:
|
|
||||||
"This attachment is no longer available because it was sent for a previous version of the app."
|
|
||||||
|
|
||||||
Группы и UI
|
|
||||||
- Исправлена геометрия входящих фото в группах: пузырь больше не прилипает к аватарке
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -440,6 +440,10 @@ interface MessageDao {
|
|||||||
)
|
)
|
||||||
suspend fun messageExists(account: String, messageId: String): Boolean
|
suspend fun messageExists(account: String, messageId: String): Boolean
|
||||||
|
|
||||||
|
/** Найти сообщение по ID */
|
||||||
|
@Query("SELECT * FROM messages WHERE account = :account AND message_id = :messageId LIMIT 1")
|
||||||
|
suspend fun findMessageById(account: String, messageId: String): MessageEntity?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отметить все исходящие сообщения к собеседнику как прочитанные Используется когда приходит
|
* Отметить все исходящие сообщения к собеседнику как прочитанные Используется когда приходит
|
||||||
* PacketRead от собеседника.
|
* PacketRead от собеседника.
|
||||||
|
|||||||
199
app/src/main/java/com/rosetta/messenger/di/AppContainer.kt
Normal file
199
app/src/main/java/com/rosetta/messenger/di/AppContainer.kt
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ enum class AttachmentType(val value: Int) {
|
|||||||
FILE(2), // Файл
|
FILE(2), // Файл
|
||||||
AVATAR(3), // Аватар пользователя
|
AVATAR(3), // Аватар пользователя
|
||||||
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||||
|
VOICE(5), // Голосовое сообщение
|
||||||
|
VIDEO_CIRCLE(6), // Видео-кружок (video note)
|
||||||
UNKNOWN(-1); // Неизвестный тип
|
UNKNOWN(-1); // Неизвестный тип
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import androidx.core.content.ContextCompat
|
|||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import com.rosetta.messenger.MainActivity
|
import com.rosetta.messenger.MainActivity
|
||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
|
import com.rosetta.messenger.data.PreferencesManager
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -34,13 +37,17 @@ import kotlinx.coroutines.runBlocking
|
|||||||
* Keeps call alive while app goes to background.
|
* Keeps call alive while app goes to background.
|
||||||
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
|
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
|
||||||
*/
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
class CallForegroundService : Service() {
|
class CallForegroundService : Service() {
|
||||||
|
|
||||||
|
@Inject lateinit var preferencesManager: PreferencesManager
|
||||||
|
|
||||||
private data class Snapshot(
|
private data class Snapshot(
|
||||||
val phase: CallPhase,
|
val phase: CallPhase,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val statusText: String,
|
val statusText: String,
|
||||||
val durationSec: Int
|
val durationSec: Int,
|
||||||
|
val peerPublicKey: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
@@ -145,7 +152,8 @@ class CallForegroundService : Service() {
|
|||||||
phase = state.phase,
|
phase = state.phase,
|
||||||
displayName = state.displayName,
|
displayName = state.displayName,
|
||||||
statusText = state.statusText,
|
statusText = state.statusText,
|
||||||
durationSec = state.durationSec
|
durationSec = state.durationSec,
|
||||||
|
peerPublicKey = state.peerPublicKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,11 +166,15 @@ class CallForegroundService : Service() {
|
|||||||
.ifBlank { "Unknown" }
|
.ifBlank { "Unknown" }
|
||||||
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
|
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
|
||||||
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
|
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
|
||||||
|
val peerPublicKey = payloadIntent.getStringExtra(EXTRA_PEER_PUBLIC_KEY)
|
||||||
|
.orEmpty()
|
||||||
|
.ifBlank { state.peerPublicKey }
|
||||||
return Snapshot(
|
return Snapshot(
|
||||||
phase = phase,
|
phase = phase,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
statusText = statusText,
|
statusText = statusText,
|
||||||
durationSec = durationSec.coerceAtLeast(0)
|
durationSec = durationSec.coerceAtLeast(0),
|
||||||
|
peerPublicKey = peerPublicKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +258,7 @@ class CallForegroundService : Service() {
|
|||||||
CallPhase.IDLE -> "Call ended"
|
CallPhase.IDLE -> "Call ended"
|
||||||
}
|
}
|
||||||
val contentText = snapshot.statusText.ifBlank { defaultStatus }
|
val contentText = snapshot.statusText.ifBlank { defaultStatus }
|
||||||
val avatarBitmap = loadAvatarBitmap(CallManager.state.value.peerPublicKey)
|
val avatarBitmap = loadAvatarBitmap(snapshot.peerPublicKey)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true)
|
val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true)
|
||||||
@@ -420,6 +432,7 @@ class CallForegroundService : Service() {
|
|||||||
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
|
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
|
||||||
private const val EXTRA_STATUS_TEXT = "extra_status_text"
|
private const val EXTRA_STATUS_TEXT = "extra_status_text"
|
||||||
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
|
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
|
||||||
|
private const val EXTRA_PEER_PUBLIC_KEY = "extra_peer_public_key"
|
||||||
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
|
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
|
||||||
|
|
||||||
fun syncWithCallState(context: Context, state: CallUiState) {
|
fun syncWithCallState(context: Context, state: CallUiState) {
|
||||||
@@ -439,6 +452,7 @@ class CallForegroundService : Service() {
|
|||||||
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
|
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
|
||||||
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
|
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
|
||||||
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
|
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
|
||||||
|
.putExtra(EXTRA_PEER_PUBLIC_KEY, state.peerPublicKey)
|
||||||
|
|
||||||
runCatching { ContextCompat.startForegroundService(appContext, intent) }
|
runCatching { ContextCompat.startForegroundService(appContext, intent) }
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
@@ -461,8 +475,7 @@ class CallForegroundService : Service() {
|
|||||||
// Проверяем настройку
|
// Проверяем настройку
|
||||||
val avatarEnabled = runCatching {
|
val avatarEnabled = runCatching {
|
||||||
runBlocking(Dispatchers.IO) {
|
runBlocking(Dispatchers.IO) {
|
||||||
com.rosetta.messenger.data.PreferencesManager(applicationContext)
|
preferencesManager.notificationAvatarEnabled.first()
|
||||||
.notificationAvatarEnabled.first()
|
|
||||||
}
|
}
|
||||||
}.getOrDefault(true)
|
}.getOrDefault(true)
|
||||||
if (!avatarEnabled) return null
|
if (!avatarEnabled) return null
|
||||||
@@ -471,8 +484,9 @@ class CallForegroundService : Service() {
|
|||||||
val entity = runBlocking(Dispatchers.IO) {
|
val entity = runBlocking(Dispatchers.IO) {
|
||||||
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
|
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
|
||||||
} ?: return null
|
} ?: return null
|
||||||
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
|
val rawBase64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
|
||||||
?: return null
|
?: return null
|
||||||
|
val base64 = if (rawBase64.contains(",")) rawBase64.substringAfter(",") else rawBase64
|
||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
||||||
toCircleBitmap(original)
|
toCircleBitmap(original)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.rosetta.messenger.BuildConfig
|
import com.rosetta.messenger.BuildConfig
|
||||||
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
@@ -95,7 +96,11 @@ object CallManager {
|
|||||||
private const val TAIL_LINES = 300
|
private const val TAIL_LINES = 300
|
||||||
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
private const val PROTOCOL_LOG_TAIL_LINES = 180
|
||||||
private const val MAX_LOG_PREFIX = 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 const val CONNECTING_TIMEOUT_MS = 30_000L
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
@@ -107,6 +112,8 @@ object CallManager {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
|
private var messageRepository: MessageRepository? = null
|
||||||
|
private var accountManager: AccountManager? = null
|
||||||
private var ownPublicKey: String = ""
|
private var ownPublicKey: String = ""
|
||||||
|
|
||||||
private var role: CallRole? = null
|
private var role: CallRole? = null
|
||||||
@@ -127,6 +134,7 @@ object CallManager {
|
|||||||
private var protocolStateJob: Job? = null
|
private var protocolStateJob: Job? = null
|
||||||
private var disconnectResetJob: Job? = null
|
private var disconnectResetJob: Job? = null
|
||||||
private var incomingRingTimeoutJob: Job? = null
|
private var incomingRingTimeoutJob: Job? = null
|
||||||
|
private var outgoingRingTimeoutJob: Job? = null
|
||||||
private var connectingTimeoutJob: Job? = null
|
private var connectingTimeoutJob: Job? = null
|
||||||
|
|
||||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||||
@@ -157,24 +165,25 @@ object CallManager {
|
|||||||
initialized = true
|
initialized = true
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
CallSoundManager.initialize(context)
|
CallSoundManager.initialize(context)
|
||||||
|
CallProximityManager.initialize(context)
|
||||||
XChaCha20E2EE.initWithContext(context)
|
XChaCha20E2EE.initWithContext(context)
|
||||||
|
|
||||||
signalWaiter = ProtocolManager.waitCallSignal { packet ->
|
signalWaiter = ProtocolRuntimeAccess.get().waitCallSignal { packet ->
|
||||||
scope.launch { handleSignalPacket(packet) }
|
scope.launch { handleSignalPacket(packet) }
|
||||||
}
|
}
|
||||||
webRtcWaiter = ProtocolManager.waitWebRtcSignal { packet ->
|
webRtcWaiter = ProtocolRuntimeAccess.get().waitWebRtcSignal { packet ->
|
||||||
scope.launch { handleWebRtcPacket(packet) }
|
scope.launch { handleWebRtcPacket(packet) }
|
||||||
}
|
}
|
||||||
iceWaiter = ProtocolManager.waitIceServers { packet ->
|
iceWaiter = ProtocolRuntimeAccess.get().waitIceServers { packet ->
|
||||||
handleIceServersPacket(packet)
|
handleIceServersPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocolStateJob =
|
protocolStateJob =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
ProtocolManager.state.collect { protocolState ->
|
ProtocolRuntimeAccess.get().state.collect { protocolState ->
|
||||||
when (protocolState) {
|
when (protocolState) {
|
||||||
ProtocolState.AUTHENTICATED -> {
|
ProtocolState.AUTHENTICATED -> {
|
||||||
ProtocolManager.requestIceServers()
|
ProtocolRuntimeAccess.get().requestIceServers()
|
||||||
}
|
}
|
||||||
ProtocolState.DISCONNECTED -> {
|
ProtocolState.DISCONNECTED -> {
|
||||||
// Не сбрасываем звонок при переподключении WebSocket —
|
// Не сбрасываем звонок при переподключении 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) {
|
fun bindAccount(publicKey: String) {
|
||||||
@@ -238,7 +255,7 @@ object CallManager {
|
|||||||
beginCallSession("incoming-push:${peer.take(8)}")
|
beginCallSession("incoming-push:${peer.take(8)}")
|
||||||
role = CallRole.CALLEE
|
role = CallRole.CALLEE
|
||||||
resetRtcObjects()
|
resetRtcObjects()
|
||||||
val cachedInfo = ProtocolManager.getCachedUserInfo(peer)
|
val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(peer)
|
||||||
val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() }
|
val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() }
|
||||||
val username = cachedInfo?.username.orEmpty()
|
val username = cachedInfo?.username.orEmpty()
|
||||||
setPeer(peer, title, username)
|
setPeer(peer, title, username)
|
||||||
@@ -269,7 +286,7 @@ object CallManager {
|
|||||||
if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
|
if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
|
||||||
if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL
|
if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL
|
||||||
if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND
|
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)
|
resetSession(reason = null, notifyPeer = false)
|
||||||
beginCallSession("outgoing:${targetKey.take(8)}")
|
beginCallSession("outgoing:${targetKey.take(8)}")
|
||||||
@@ -283,13 +300,25 @@ object CallManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||||
signalType = SignalType.CALL,
|
signalType = SignalType.CALL,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = targetKey
|
dst = targetKey
|
||||||
)
|
)
|
||||||
breadcrumbState("startOutgoingCall")
|
breadcrumbState("startOutgoingCall")
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
|
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
|
return CallActionResult.STARTED
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +329,7 @@ object CallManager {
|
|||||||
|
|
||||||
// Если ownPublicKey пустой (push разбудил но аккаунт не привязан) — попробуем привязать
|
// Если ownPublicKey пустой (push разбудил но аккаунт не привязан) — попробуем привязать
|
||||||
if (ownPublicKey.isBlank()) {
|
if (ownPublicKey.isBlank()) {
|
||||||
val lastPk = appContext?.let { com.rosetta.messenger.data.AccountManager(it).getLastLoggedPublicKey() }.orEmpty()
|
val lastPk = accountManager?.getLastLoggedPublicKey().orEmpty()
|
||||||
if (lastPk.isNotBlank()) {
|
if (lastPk.isNotBlank()) {
|
||||||
bindAccount(lastPk)
|
bindAccount(lastPk)
|
||||||
breadcrumb("acceptIncomingCall: auto-bind account pk=${lastPk.take(8)}…")
|
breadcrumb("acceptIncomingCall: auto-bind account pk=${lastPk.take(8)}…")
|
||||||
@@ -308,12 +337,12 @@ object CallManager {
|
|||||||
return CallActionResult.ACCOUNT_NOT_BOUND
|
return CallActionResult.ACCOUNT_NOT_BOUND
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val restoredAuth = ProtocolManager.restoreAuthFromStoredCredentials(
|
val restoredAuth = ProtocolRuntimeAccess.get().restoreAuthFromStoredCredentials(
|
||||||
preferredPublicKey = ownPublicKey,
|
preferredPublicKey = ownPublicKey,
|
||||||
reason = "accept_incoming_call"
|
reason = "accept_incoming_call"
|
||||||
)
|
)
|
||||||
if (restoredAuth) {
|
if (restoredAuth) {
|
||||||
ProtocolManager.reconnectNowIfNeeded("accept_incoming_call")
|
ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_incoming_call")
|
||||||
breadcrumb("acceptIncomingCall: auth restore requested")
|
breadcrumb("acceptIncomingCall: auth restore requested")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +372,7 @@ object CallManager {
|
|||||||
kotlinx.coroutines.delay(200)
|
kotlinx.coroutines.delay(200)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||||
signalType = SignalType.ACCEPT,
|
signalType = SignalType.ACCEPT,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = snapshot.peerPublicKey,
|
dst = snapshot.peerPublicKey,
|
||||||
@@ -352,7 +381,7 @@ object CallManager {
|
|||||||
)
|
)
|
||||||
// ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен
|
// ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен
|
||||||
// сразу при открытии сокета (или останется в очереди до onOpen).
|
// сразу при открытии сокета (или останется в очереди до onOpen).
|
||||||
ProtocolManager.reconnectNowIfNeeded("accept_send_$attempt")
|
ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_send_$attempt")
|
||||||
breadcrumb(
|
breadcrumb(
|
||||||
"acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " +
|
"acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " +
|
||||||
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
|
"callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}"
|
||||||
@@ -378,7 +407,7 @@ object CallManager {
|
|||||||
val callIdNow = serverCallId.trim()
|
val callIdNow = serverCallId.trim()
|
||||||
val joinTokenNow = serverJoinToken.trim()
|
val joinTokenNow = serverJoinToken.trim()
|
||||||
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank() && callIdNow.isNotBlank() && joinTokenNow.isNotBlank()) {
|
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank() && callIdNow.isNotBlank() && joinTokenNow.isNotBlank()) {
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||||
signalType = SignalType.END_CALL,
|
signalType = SignalType.END_CALL,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = snapshot.peerPublicKey,
|
dst = snapshot.peerPublicKey,
|
||||||
@@ -478,7 +507,7 @@ object CallManager {
|
|||||||
if (_state.value.phase != CallPhase.IDLE) {
|
if (_state.value.phase != CallPhase.IDLE) {
|
||||||
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
|
breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY")
|
||||||
if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) {
|
if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) {
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||||
signalType = SignalType.END_CALL_BECAUSE_BUSY,
|
signalType = SignalType.END_CALL_BECAUSE_BUSY,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = incomingPeer
|
dst = incomingPeer
|
||||||
@@ -494,7 +523,7 @@ object CallManager {
|
|||||||
role = CallRole.CALLEE
|
role = CallRole.CALLEE
|
||||||
resetRtcObjects()
|
resetRtcObjects()
|
||||||
// Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
|
// Пробуем сразу взять имя из кэша чтобы ForegroundService показал его
|
||||||
val cachedInfo = ProtocolManager.getCachedUserInfo(incomingPeer)
|
val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(incomingPeer)
|
||||||
val cachedTitle = cachedInfo?.title.orEmpty()
|
val cachedTitle = cachedInfo?.title.orEmpty()
|
||||||
val cachedUsername = cachedInfo?.username.orEmpty()
|
val cachedUsername = cachedInfo?.username.orEmpty()
|
||||||
setPeer(incomingPeer, cachedTitle, cachedUsername)
|
setPeer(incomingPeer, cachedTitle, cachedUsername)
|
||||||
@@ -551,12 +580,15 @@ object CallManager {
|
|||||||
breadcrumb("SIG: ACCEPT ignored — role=$role")
|
breadcrumb("SIG: ACCEPT ignored — role=$role")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Callee answered before timeout — cancel outgoing ring timer
|
||||||
|
outgoingRingTimeoutJob?.cancel()
|
||||||
|
outgoingRingTimeoutJob = null
|
||||||
if (localPrivateKey == null || localPublicKey == null) {
|
if (localPrivateKey == null || localPublicKey == null) {
|
||||||
breadcrumb("SIG: ACCEPT — generating local session keys")
|
breadcrumb("SIG: ACCEPT — generating local session keys")
|
||||||
generateSessionKeys()
|
generateSessionKeys()
|
||||||
}
|
}
|
||||||
val localPublic = localPublicKey ?: return
|
val localPublic = localPublicKey ?: return
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||||
signalType = SignalType.KEY_EXCHANGE,
|
signalType = SignalType.KEY_EXCHANGE,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = _state.value.peerPublicKey,
|
dst = _state.value.peerPublicKey,
|
||||||
@@ -628,7 +660,7 @@ object CallManager {
|
|||||||
breadcrumb("KE: CALLER — E2EE ready, notifying ACTIVE")
|
breadcrumb("KE: CALLER — E2EE ready, notifying ACTIVE")
|
||||||
updateState { it.copy(keyCast = sharedKey, statusText = "Connecting...") }
|
updateState { it.copy(keyCast = sharedKey, statusText = "Connecting...") }
|
||||||
if (!activeSignalSent) {
|
if (!activeSignalSent) {
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||||
signalType = SignalType.ACTIVE,
|
signalType = SignalType.ACTIVE,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = peerKey
|
dst = peerKey
|
||||||
@@ -653,7 +685,7 @@ object CallManager {
|
|||||||
setupE2EE(sharedKey)
|
setupE2EE(sharedKey)
|
||||||
if (!keyExchangeSent) {
|
if (!keyExchangeSent) {
|
||||||
val localPublic = localPublicKey ?: return
|
val localPublic = localPublicKey ?: return
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||||
signalType = SignalType.KEY_EXCHANGE,
|
signalType = SignalType.KEY_EXCHANGE,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = peerKey,
|
dst = peerKey,
|
||||||
@@ -754,7 +786,7 @@ object CallManager {
|
|||||||
|
|
||||||
val answer = pc.createAnswerAwait()
|
val answer = pc.createAnswerAwait()
|
||||||
pc.setLocalDescriptionAwait(answer)
|
pc.setLocalDescriptionAwait(answer)
|
||||||
ProtocolManager.sendWebRtcSignal(
|
ProtocolRuntimeAccess.get().sendWebRtcSignal(
|
||||||
signalType = WebRTCSignalType.ANSWER,
|
signalType = WebRTCSignalType.ANSWER,
|
||||||
sdpOrCandidate = serializeSessionDescription(answer)
|
sdpOrCandidate = serializeSessionDescription(answer)
|
||||||
)
|
)
|
||||||
@@ -842,7 +874,7 @@ object CallManager {
|
|||||||
pc.setLocalDescriptionAwait(offer)
|
pc.setLocalDescriptionAwait(offer)
|
||||||
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
|
lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10)
|
||||||
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
|
breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint")
|
||||||
ProtocolManager.sendWebRtcSignal(
|
ProtocolRuntimeAccess.get().sendWebRtcSignal(
|
||||||
signalType = WebRTCSignalType.OFFER,
|
signalType = WebRTCSignalType.OFFER,
|
||||||
sdpOrCandidate = serializeSessionDescription(offer)
|
sdpOrCandidate = serializeSessionDescription(offer)
|
||||||
)
|
)
|
||||||
@@ -883,7 +915,7 @@ object CallManager {
|
|||||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||||
if (candidate == null) return
|
if (candidate == null) return
|
||||||
breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}…")
|
breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}…")
|
||||||
ProtocolManager.sendWebRtcSignal(
|
ProtocolRuntimeAccess.get().sendWebRtcSignal(
|
||||||
signalType = WebRTCSignalType.ICE_CANDIDATE,
|
signalType = WebRTCSignalType.ICE_CANDIDATE,
|
||||||
sdpOrCandidate = serializeIceCandidate(candidate)
|
sdpOrCandidate = serializeIceCandidate(candidate)
|
||||||
)
|
)
|
||||||
@@ -1002,7 +1034,7 @@ object CallManager {
|
|||||||
|
|
||||||
private fun resolvePeerIdentity(publicKey: String) {
|
private fun resolvePeerIdentity(publicKey: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val resolved = ProtocolManager.resolveUserInfo(publicKey)
|
val resolved = ProtocolRuntimeAccess.get().resolveUserInfo(publicKey)
|
||||||
if (resolved != null && _state.value.peerPublicKey == publicKey) {
|
if (resolved != null && _state.value.peerPublicKey == publicKey) {
|
||||||
setPeer(publicKey, resolved.title, resolved.username)
|
setPeer(publicKey, resolved.title, resolved.username)
|
||||||
}
|
}
|
||||||
@@ -1020,9 +1052,7 @@ object CallManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
|
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
|
||||||
if (role != CallRole.CALLER) return
|
|
||||||
val peerPublicKey = snapshot.peerPublicKey.trim()
|
val peerPublicKey = snapshot.peerPublicKey.trim()
|
||||||
val context = appContext ?: return
|
|
||||||
if (peerPublicKey.isBlank()) return
|
if (peerPublicKey.isBlank()) return
|
||||||
|
|
||||||
val durationSec = snapshot.durationSec.coerceAtLeast(0)
|
val durationSec = snapshot.durationSec.coerceAtLeast(0)
|
||||||
@@ -1034,15 +1064,33 @@ object CallManager {
|
|||||||
preview = durationSec.toString()
|
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 {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
MessageRepository.getInstance(context).sendMessage(
|
val repository = messageRepository
|
||||||
toPublicKey = peerPublicKey,
|
if (repository == null) {
|
||||||
text = "",
|
breadcrumb("CALL ATTACHMENT: MessageRepository not bound")
|
||||||
attachments = listOf(callAttachment)
|
return@runCatching
|
||||||
)
|
}
|
||||||
|
if (capturedRole == CallRole.CALLER) {
|
||||||
|
// CALLER: send call attachment as a message (peer will receive it)
|
||||||
|
repository.sendMessage(
|
||||||
|
toPublicKey = peerPublicKey,
|
||||||
|
text = "",
|
||||||
|
attachments = listOf(callAttachment)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 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 ->
|
}.onFailure { error ->
|
||||||
Log.w(TAG, "Failed to send call attachment", error)
|
Log.w(TAG, "Failed to emit call attachment", error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1052,11 +1100,12 @@ object CallManager {
|
|||||||
disarmConnectingTimeout("resetSession")
|
disarmConnectingTimeout("resetSession")
|
||||||
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
breadcrumb("RESET: reason=$reason notifyPeer=$notifyPeer phase=${_state.value.phase}")
|
||||||
breadcrumbState("resetSession")
|
breadcrumbState("resetSession")
|
||||||
|
appContext?.let { CallProximityManager.setEnabled(it, false) }
|
||||||
val snapshot = _state.value
|
val snapshot = _state.value
|
||||||
val wasActive = snapshot.phase != CallPhase.IDLE
|
val wasActive = snapshot.phase != CallPhase.IDLE
|
||||||
val peerToNotify = snapshot.peerPublicKey
|
val peerToNotify = snapshot.peerPublicKey
|
||||||
if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
|
if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
|
||||||
ProtocolManager.sendCallSignal(
|
ProtocolRuntimeAccess.get().sendCallSignal(
|
||||||
signalType = SignalType.END_CALL,
|
signalType = SignalType.END_CALL,
|
||||||
src = ownPublicKey,
|
src = ownPublicKey,
|
||||||
dst = peerToNotify,
|
dst = peerToNotify,
|
||||||
@@ -1073,6 +1122,8 @@ object CallManager {
|
|||||||
disconnectResetJob = null
|
disconnectResetJob = null
|
||||||
incomingRingTimeoutJob?.cancel()
|
incomingRingTimeoutJob?.cancel()
|
||||||
incomingRingTimeoutJob = null
|
incomingRingTimeoutJob = null
|
||||||
|
outgoingRingTimeoutJob?.cancel()
|
||||||
|
outgoingRingTimeoutJob = null
|
||||||
// Play end call sound, then stop all
|
// Play end call sound, then stop all
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
||||||
@@ -1277,7 +1328,7 @@ object CallManager {
|
|||||||
val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES)
|
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 nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES)
|
||||||
val protocolTail =
|
val protocolTail =
|
||||||
ProtocolManager.debugLogs.value
|
ProtocolRuntimeAccess.get().debugLogs.value
|
||||||
.takeLast(PROTOCOL_LOG_TAIL_LINES)
|
.takeLast(PROTOCOL_LOG_TAIL_LINES)
|
||||||
.joinToString("\n")
|
.joinToString("\n")
|
||||||
f.writeText(
|
f.writeText(
|
||||||
@@ -1580,6 +1631,13 @@ object CallManager {
|
|||||||
val old = _state.value
|
val old = _state.value
|
||||||
_state.update(reducer)
|
_state.update(reducer)
|
||||||
val newState = _state.value
|
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 при смене фазы или имени
|
// Синхронизируем ForegroundService при смене фазы или имени
|
||||||
if (newState.phase != CallPhase.IDLE &&
|
if (newState.phase != CallPhase.IDLE &&
|
||||||
(newState.phase != old.phase || newState.displayName != old.displayName)) {
|
(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()
|
stop()
|
||||||
currentSound = sound
|
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) {
|
val resId = when (sound) {
|
||||||
CallSound.RINGTONE -> R.raw.call_ringtone
|
CallSound.RINGTONE -> R.raw.call_ringtone
|
||||||
CallSound.CALLING -> R.raw.call_calling
|
CallSound.CALLING -> R.raw.call_calling
|
||||||
@@ -86,7 +102,7 @@ object CallSoundManager {
|
|||||||
mediaPlayer = player
|
mediaPlayer = player
|
||||||
|
|
||||||
// Vibrate for incoming calls
|
// Vibrate for incoming calls
|
||||||
if (sound == CallSound.RINGTONE) {
|
if (allowVibration) {
|
||||||
startVibration()
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okio.ByteString
|
import okio.ByteString
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
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
|
* Protocol connection states
|
||||||
@@ -35,12 +39,14 @@ class Protocol(
|
|||||||
private const val TAG = "RosettaProtocol"
|
private const val TAG = "RosettaProtocol"
|
||||||
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
||||||
private const val HANDSHAKE_TIMEOUT = 10000L // 10 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 MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
||||||
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||||
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||||
private const val HEX_PREVIEW_BYTES = 64
|
private const val HEX_PREVIEW_BYTES = 64
|
||||||
private const val TEXT_PREVIEW_CHARS = 80
|
private const val TEXT_PREVIEW_CHARS = 80
|
||||||
|
private val INSTANCE_COUNTER = AtomicInteger(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun log(message: String) {
|
private fun log(message: String) {
|
||||||
@@ -181,9 +187,103 @@ class Protocol(
|
|||||||
private var lastStateChangeTime = System.currentTimeMillis()
|
private var lastStateChangeTime = System.currentTimeMillis()
|
||||||
private var lastSuccessfulConnection = 0L
|
private var lastSuccessfulConnection = 0L
|
||||||
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
|
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
|
||||||
|
private var connectingTimeoutJob: Job? = null
|
||||||
private var isConnecting = false // Флаг для защиты от одновременных подключений
|
private var isConnecting = false // Флаг для защиты от одновременных подключений
|
||||||
|
private var connectingSinceMs = 0L
|
||||||
|
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
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)
|
private val _state = MutableStateFlow(ProtocolState.DISCONNECTED)
|
||||||
val state: StateFlow<ProtocolState> = _state.asStateFlow()
|
val state: StateFlow<ProtocolState> = _state.asStateFlow()
|
||||||
@@ -215,12 +315,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)
|
private val _lastError = MutableStateFlow<String?>(null)
|
||||||
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||||
|
|
||||||
// Packet waiters - callbacks for specific packet types (thread-safe)
|
// 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)
|
// Packet queue for packets sent before handshake complete (thread-safe)
|
||||||
private val packetQueue = java.util.Collections.synchronizedList(mutableListOf<Packet>())
|
private val packetQueue = java.util.Collections.synchronizedList(mutableListOf<Packet>())
|
||||||
@@ -230,7 +527,7 @@ class Protocol(
|
|||||||
private var lastPrivateHash: String? = null
|
private var lastPrivateHash: String? = null
|
||||||
private var lastDevice: HandshakeDevice = HandshakeDevice()
|
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 getPublicKey(): String? = lastPublicKey
|
||||||
fun getPrivateHash(): String? = lastPrivateHash
|
fun getPrivateHash(): String? = lastPrivateHash
|
||||||
|
|
||||||
@@ -271,32 +568,48 @@ class Protocol(
|
|||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
log("🧩 Protocol init: instance=$instanceId")
|
||||||
|
|
||||||
// Register handshake response handler
|
// Register handshake response handler
|
||||||
waitPacket(0x00) { packet ->
|
waitPacket(0x00) { packet ->
|
||||||
if (packet is PacketHandshake) {
|
if (packet is PacketHandshake) {
|
||||||
handshakeJob?.cancel()
|
enqueueSessionEvent(SessionEvent.HandshakeResponse(packet))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
when (packet.handshakeState) {
|
// Device verification resolution from primary device.
|
||||||
HandshakeState.COMPLETED -> {
|
// Desktop typically continues after next handshake response; here we also
|
||||||
log("✅ HANDSHAKE COMPLETE: protocol=${packet.protocolVersion}, heartbeat=${packet.heartbeatInterval}s")
|
// add a safety re-handshake trigger on ACCEPT to avoid being stuck in
|
||||||
handshakeComplete = true
|
// DEVICE_VERIFICATION_REQUIRED if server doesn't immediately push 0x00.
|
||||||
setState(ProtocolState.AUTHENTICATED, "Handshake completed")
|
waitPacket(0x18) { packet ->
|
||||||
flushPacketQueue()
|
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 -> {
|
// Critical recovery: after DECLINE user may retry login without app restart.
|
||||||
log("🔐 HANDSHAKE NEEDS DEVICE VERIFICATION")
|
// Keep socket session alive when possible, but leave DEVICE_VERIFICATION_REQUIRED
|
||||||
handshakeComplete = false
|
// state so next authenticate() is not ignored by startHandshake guards.
|
||||||
setState(
|
if (
|
||||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED,
|
stateAtDecline == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||||
"Handshake requires device verification"
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +679,7 @@ class Protocol(
|
|||||||
// Триггерим reconnect если heartbeat не прошёл
|
// Триггерим reconnect если heartbeat не прошёл
|
||||||
if (!isManuallyClosed) {
|
if (!isManuallyClosed) {
|
||||||
log("🔄 TRIGGERING RECONNECT due to failed heartbeat")
|
log("🔄 TRIGGERING RECONNECT due to failed heartbeat")
|
||||||
handleDisconnect()
|
handleDisconnect("heartbeat_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -384,8 +697,13 @@ class Protocol(
|
|||||||
* Initialize connection to server
|
* Initialize connection to server
|
||||||
*/
|
*/
|
||||||
fun connect() {
|
fun connect() {
|
||||||
|
enqueueSessionEvent(SessionEvent.Connect())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectLocked() {
|
||||||
val currentState = _state.value
|
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 (
|
if (
|
||||||
@@ -403,10 +721,21 @@ class Protocol(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
|
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
|
||||||
|
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
|
||||||
if (isConnecting || currentState == ProtocolState.CONNECTING) {
|
if (isConnecting || currentState == ProtocolState.CONNECTING) {
|
||||||
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
|
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
|
||||||
return
|
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
|
val networkReady = isNetworkAvailable?.invoke() ?: true
|
||||||
@@ -424,9 +753,11 @@ class Protocol(
|
|||||||
|
|
||||||
// Устанавливаем флаг ПЕРЕД любыми операциями
|
// Устанавливаем флаг ПЕРЕД любыми операциями
|
||||||
isConnecting = true
|
isConnecting = true
|
||||||
|
connectingSinceMs = now
|
||||||
|
|
||||||
reconnectAttempts++
|
reconnectAttempts++
|
||||||
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
|
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
|
||||||
|
val generation = rotateConnectionGeneration("connect_attempt_$reconnectAttempts")
|
||||||
|
|
||||||
// Закрываем старый сокет если есть (как в Архиве)
|
// Закрываем старый сокет если есть (как в Архиве)
|
||||||
webSocket?.let { oldSocket ->
|
webSocket?.let { oldSocket ->
|
||||||
@@ -442,6 +773,7 @@ class Protocol(
|
|||||||
isManuallyClosed = false
|
isManuallyClosed = false
|
||||||
setState(ProtocolState.CONNECTING, "Starting new connection attempt #$reconnectAttempts")
|
setState(ProtocolState.CONNECTING, "Starting new connection attempt #$reconnectAttempts")
|
||||||
_lastError.value = null
|
_lastError.value = null
|
||||||
|
armConnectingTimeout(generation)
|
||||||
|
|
||||||
log("🔌 Connecting to: $serverAddress (attempt #$reconnectAttempts)")
|
log("🔌 Connecting to: $serverAddress (attempt #$reconnectAttempts)")
|
||||||
|
|
||||||
@@ -451,40 +783,28 @@ class Protocol(
|
|||||||
|
|
||||||
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
log("✅ WebSocket OPEN: response=${response.code}, hasCredentials=${lastPublicKey != null}")
|
enqueueSessionEvent(
|
||||||
|
SessionEvent.SocketOpened(
|
||||||
// Сбрасываем флаг подключения
|
generation = generation,
|
||||||
isConnecting = false
|
socket = webSocket,
|
||||||
|
responseCode = response.code
|
||||||
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}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||||
|
if (isStaleSocketEvent("onMessage(bytes)", generation, webSocket)) return
|
||||||
log("📥 onMessage called - ${bytes.size} bytes")
|
log("📥 onMessage called - ${bytes.size} bytes")
|
||||||
handleMessage(bytes.toByteArray())
|
handleMessage(bytes.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
if (isStaleSocketEvent("onMessage(text)", generation, webSocket)) return
|
||||||
log("Received text message (unexpected): $text")
|
log("Received text message (unexpected): $text")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
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}")
|
log("⚠️ WebSocket CLOSING: code=$code reason='$reason' state=${_state.value}")
|
||||||
// Must respond with close() so OkHttp transitions to onClosed.
|
// Must respond with close() so OkHttp transitions to onClosed.
|
||||||
// Without this, the socket stays in a half-closed "zombie" state —
|
// Without this, the socket stays in a half-closed "zombie" state —
|
||||||
@@ -498,21 +818,26 @@ class Protocol(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
|
enqueueSessionEvent(
|
||||||
isConnecting = false // Сбрасываем флаг
|
SessionEvent.SocketClosed(
|
||||||
handleDisconnect()
|
generation = generation,
|
||||||
|
socket = webSocket,
|
||||||
|
code = code,
|
||||||
|
reason = reason
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
log("❌ WebSocket FAILURE: ${t.message}")
|
enqueueSessionEvent(
|
||||||
log(" Response: ${response?.code} ${response?.message}")
|
SessionEvent.SocketFailure(
|
||||||
log(" State: ${_state.value}")
|
generation = generation,
|
||||||
log(" Manually closed: $isManuallyClosed")
|
socket = webSocket,
|
||||||
log(" Reconnect attempts: $reconnectAttempts")
|
throwable = t,
|
||||||
t.printStackTrace()
|
responseCode = response?.code,
|
||||||
isConnecting = false // Сбрасываем флаг
|
responseMessage = response?.message
|
||||||
_lastError.value = t.message
|
)
|
||||||
handleDisconnect()
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -542,8 +867,9 @@ class Protocol(
|
|||||||
// If switching accounts, force disconnect and reconnect with new credentials
|
// If switching accounts, force disconnect and reconnect with new credentials
|
||||||
if (switchingAccount) {
|
if (switchingAccount) {
|
||||||
log("🔄 Account switch detected, forcing reconnect with new credentials")
|
log("🔄 Account switch detected, forcing reconnect with new credentials")
|
||||||
disconnect()
|
enqueueSessionEvent(
|
||||||
connect() // Will auto-handshake with saved credentials (publicKey, privateHash) on connect
|
SessionEvent.AccountSwitchReconnect(reason = "Account switch reconnect")
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,7 +927,14 @@ class Protocol(
|
|||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
val socket = webSocket
|
val socket = webSocket
|
||||||
val socketReady = socket != null
|
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 =
|
val preAuthAllowedPacket =
|
||||||
packet is PacketSignalPeer || packet is PacketWebRTC || packet is PacketIceServers
|
packet is PacketSignalPeer || packet is PacketWebRTC || packet is PacketIceServers
|
||||||
val preAuthReady =
|
val preAuthReady =
|
||||||
@@ -726,15 +1059,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
|
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) {
|
if (isConnecting) {
|
||||||
log("⚠️ DISCONNECT IGNORED: connection already in progress")
|
log("⚠️ DISCONNECT IGNORED: connection already in progress")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rotateConnectionGeneration("disconnect:$source")
|
||||||
|
|
||||||
setState(ProtocolState.DISCONNECTED, "Disconnect handler called from $previousState")
|
setState(ProtocolState.DISCONNECTED, "Disconnect handler called from $previousState")
|
||||||
handshakeComplete = false
|
handshakeComplete = false
|
||||||
@@ -756,12 +1106,16 @@ class Protocol(
|
|||||||
// КРИТИЧНО: отменяем предыдущий reconnect job если есть
|
// КРИТИЧНО: отменяем предыдущий reconnect job если есть
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
|
|
||||||
// Экспоненциальная задержка: 1s, 2s, 4s, 8s, максимум 30s
|
// Экспоненциальная задержка: 1s, 2s, 4s, 8s, 16s, максимум 30s.
|
||||||
val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempts - 1, 4)), 30000L)
|
// IMPORTANT: reconnectAttempts may be 0 right after AUTHENTICATED reset.
|
||||||
log("🔄 SCHEDULING RECONNECT: attempt #$reconnectAttempts, delay=${delayMs}ms")
|
// Using (1 shl -1) causes overflow (seen in logs as -2147483648000ms).
|
||||||
|
val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1)
|
||||||
|
val exponent = (nextAttemptNumber - 1).coerceIn(0, 4)
|
||||||
|
val delayMs = minOf(1000L * (1L shl exponent), 30000L)
|
||||||
|
log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber, delay=${delayMs}ms")
|
||||||
|
|
||||||
if (reconnectAttempts > 20) {
|
if (nextAttemptNumber > 20) {
|
||||||
log("⚠️ WARNING: Too many reconnect attempts ($reconnectAttempts), may be stuck in loop")
|
log("⚠️ WARNING: Too many reconnect attempts ($nextAttemptNumber), may be stuck in loop")
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnectJob = scope.launch {
|
reconnectJob = scope.launch {
|
||||||
@@ -782,33 +1136,58 @@ class Protocol(
|
|||||||
* Register callback for specific packet type
|
* Register callback for specific packet type
|
||||||
*/
|
*/
|
||||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||||
packetWaiters.getOrPut(packetId) { mutableListOf() }.add(callback)
|
val waiters = packetWaiters.computeIfAbsent(packetId) { CopyOnWriteArrayList() }
|
||||||
val count = packetWaiters[packetId]?.size ?: 0
|
if (waiters.contains(callback)) {
|
||||||
log("📝 waitPacket(0x${Integer.toHexString(packetId)}) registered. Total handlers for 0x${Integer.toHexString(packetId)}: $count")
|
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
|
* Unregister callback for specific packet type
|
||||||
*/
|
*/
|
||||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
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
|
* Disconnect from server
|
||||||
*/
|
*/
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
log("🔌 Manual disconnect requested")
|
enqueueSessionEvent(
|
||||||
isManuallyClosed = true
|
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 // Сбрасываем флаг
|
isConnecting = false // Сбрасываем флаг
|
||||||
|
connectingSinceMs = 0L
|
||||||
reconnectJob?.cancel() // Отменяем запланированные переподключения
|
reconnectJob?.cancel() // Отменяем запланированные переподключения
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
handshakeJob?.cancel()
|
handshakeJob?.cancel()
|
||||||
heartbeatJob?.cancel()
|
heartbeatJob?.cancel()
|
||||||
heartbeatPeriodMs = 0L
|
heartbeatPeriodMs = 0L
|
||||||
webSocket?.close(1000, "User disconnected")
|
rotateConnectionGeneration("disconnect_locked:${if (manual) "manual" else "internal"}")
|
||||||
|
|
||||||
|
val socket = webSocket
|
||||||
webSocket = null
|
webSocket = null
|
||||||
_state.value = ProtocolState.DISCONNECTED
|
runCatching { socket?.close(1000, reason) }
|
||||||
|
setState(ProtocolState.DISCONNECTED, "disconnectLocked(manual=$manual, reason=$reason)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -821,21 +1200,43 @@ class Protocol(
|
|||||||
* on app resume we should not wait scheduled exponential backoff.
|
* on app resume we should not wait scheduled exponential backoff.
|
||||||
*/
|
*/
|
||||||
fun reconnectNowIfNeeded(reason: String = "foreground") {
|
fun reconnectNowIfNeeded(reason: String = "foreground") {
|
||||||
|
enqueueSessionEvent(SessionEvent.FastReconnect(reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnectNowIfNeededLocked(reason: String) {
|
||||||
val currentState = _state.value
|
val currentState = _state.value
|
||||||
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
|
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
log(
|
log(
|
||||||
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
|
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isManuallyClosed) {
|
||||||
|
log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasCredentials) 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.AUTHENTICATED ||
|
||||||
currentState == ProtocolState.HANDSHAKING ||
|
currentState == ProtocolState.HANDSHAKING ||
|
||||||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||||
currentState == ProtocolState.CONNECTED ||
|
currentState == ProtocolState.CONNECTED
|
||||||
(currentState == ProtocolState.CONNECTING && isConnecting)
|
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -844,7 +1245,7 @@ class Protocol(
|
|||||||
reconnectAttempts = 0
|
reconnectAttempts = 0
|
||||||
reconnectJob?.cancel()
|
reconnectJob?.cancel()
|
||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
connect()
|
connectLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -867,7 +1268,20 @@ class Protocol(
|
|||||||
* Release resources
|
* Release resources
|
||||||
*/
|
*/
|
||||||
fun destroy() {
|
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()
|
heartbeatJob?.cancel()
|
||||||
scope.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,156 @@
|
|||||||
|
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 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,86 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rosetta.messenger.network.connection.AuthRestoreService
|
||||||
|
import com.rosetta.messenger.network.connection.ProtocolInstanceManager
|
||||||
|
import com.rosetta.messenger.network.connection.RuntimeInitializationCoordinator
|
||||||
|
import com.rosetta.messenger.network.connection.RuntimeShutdownCoordinator
|
||||||
|
import com.rosetta.messenger.network.connection.SyncCoordinator
|
||||||
|
|
||||||
|
class RuntimeConnectionControlFacade(
|
||||||
|
private val postConnectionEvent: (ConnectionEvent) -> Unit,
|
||||||
|
private val initializationCoordinator: RuntimeInitializationCoordinator,
|
||||||
|
private val syncCoordinator: SyncCoordinator,
|
||||||
|
private val authRestoreService: AuthRestoreService,
|
||||||
|
private val runtimeShutdownCoordinator: RuntimeShutdownCoordinator,
|
||||||
|
private val protocolInstanceManager: ProtocolInstanceManager,
|
||||||
|
private val subscribePushTokenIfAvailable: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
initializationCoordinator.initialize(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initializeAccount(publicKey: String, privateKey: String) {
|
||||||
|
postConnectionEvent(
|
||||||
|
ConnectionEvent.InitializeAccount(publicKey = publicKey, privateKey = privateKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect() {
|
||||||
|
postConnectionEvent(ConnectionEvent.Connect(reason = "api_connect"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reconnectNowIfNeeded(reason: String = "foreground_resume") {
|
||||||
|
postConnectionEvent(ConnectionEvent.FastReconnect(reason = reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncOnForeground() {
|
||||||
|
syncCoordinator.syncOnForeground()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceSynchronize(backtrackMs: Long) {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
reconnectNowIfNeeded("manual_sync_button")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncCoordinator.forceSynchronize(backtrackMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authenticate(publicKey: String, privateHash: String) {
|
||||||
|
postConnectionEvent(
|
||||||
|
ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreAuthFromStoredCredentials(
|
||||||
|
preferredPublicKey: String? = null,
|
||||||
|
reason: String = "background_restore"
|
||||||
|
): Boolean {
|
||||||
|
return authRestoreService.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribePushToken(forceToken: String? = null) {
|
||||||
|
subscribePushTokenIfAvailable(forceToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
postConnectionEvent(
|
||||||
|
ConnectionEvent.Disconnect(
|
||||||
|
reason = "manual_disconnect",
|
||||||
|
clearCredentials = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
runtimeShutdownCoordinator.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAuthenticated(): Boolean = protocolInstanceManager.isAuthenticated()
|
||||||
|
|
||||||
|
fun isConnected(): Boolean = protocolInstanceManager.isConnected()
|
||||||
|
|
||||||
|
fun getPrivateHashOrNull(): String? {
|
||||||
|
return runCatching { protocolInstanceManager.getOrCreateProtocol().getPrivateHash() }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.connection.DeviceRuntimeService
|
||||||
|
import com.rosetta.messenger.network.connection.OwnProfileSyncService
|
||||||
|
import com.rosetta.messenger.network.connection.PacketRouter
|
||||||
|
import com.rosetta.messenger.network.connection.PresenceTypingService
|
||||||
|
|
||||||
|
class RuntimeDirectoryFacade(
|
||||||
|
private val packetRouter: PacketRouter,
|
||||||
|
private val ownProfileSyncService: OwnProfileSyncService,
|
||||||
|
private val deviceRuntimeService: DeviceRuntimeService,
|
||||||
|
private val presenceTypingService: PresenceTypingService
|
||||||
|
) {
|
||||||
|
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
|
||||||
|
return presenceTypingService.getTypingUsersForDialog(dialogKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
|
||||||
|
return packetRouter.resolveUserName(publicKey = publicKey, timeoutMs = timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCachedUserName(publicKey: String): String? {
|
||||||
|
return packetRouter.getCachedUserName(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyOwnProfileUpdated() {
|
||||||
|
ownProfileSyncService.notifyOwnProfileUpdated()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCachedUserInfo(publicKey: String): SearchUser? {
|
||||||
|
return packetRouter.getCachedUserInfo(publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCachedUserByUsername(username: String): SearchUser? {
|
||||||
|
return packetRouter.getCachedUserByUsername(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
|
||||||
|
return packetRouter.resolveUserInfo(publicKey = publicKey, timeoutMs = timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
|
||||||
|
return packetRouter.searchUsers(query = query, timeoutMs = timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptDevice(deviceId: String) {
|
||||||
|
deviceRuntimeService.acceptDevice(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun declineDevice(deviceId: String) {
|
||||||
|
deviceRuntimeService.declineDevice(deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import com.rosetta.messenger.data.GroupRepository
|
||||||
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
|
import com.rosetta.messenger.network.connection.CallSignalBridge
|
||||||
|
import com.rosetta.messenger.network.connection.DeviceRuntimeService
|
||||||
|
import com.rosetta.messenger.network.connection.InboundPacketHandlerRegistrar
|
||||||
|
import com.rosetta.messenger.network.connection.InboundTaskQueueService
|
||||||
|
import com.rosetta.messenger.network.connection.OutgoingMessagePipelineService
|
||||||
|
import com.rosetta.messenger.network.connection.OwnProfileSyncService
|
||||||
|
import com.rosetta.messenger.network.connection.PacketRouter
|
||||||
|
import com.rosetta.messenger.network.connection.PresenceTypingService
|
||||||
|
import com.rosetta.messenger.network.connection.SyncCoordinator
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
|
internal class RuntimeMessagingAssembly(
|
||||||
|
tag: String,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
typingIndicatorTimeoutMs: Long,
|
||||||
|
syncRequestTimeoutMs: Long,
|
||||||
|
manualSyncBacktrackMs: Long,
|
||||||
|
deviceRuntimeService: DeviceRuntimeService,
|
||||||
|
ownProfileSyncService: OwnProfileSyncService,
|
||||||
|
isAuthenticated: () -> Boolean,
|
||||||
|
getProtocolPublicKey: () -> String,
|
||||||
|
getProtocolPrivateHash: () -> String?,
|
||||||
|
getMessageRepository: () -> MessageRepository?,
|
||||||
|
getGroupRepository: () -> GroupRepository?,
|
||||||
|
sendSearchPacket: (PacketSearch) -> Unit,
|
||||||
|
sendMessagePacket: (PacketMessage) -> Unit,
|
||||||
|
sendSyncPacket: (PacketSync) -> Unit,
|
||||||
|
sendPacket: (Packet) -> Unit,
|
||||||
|
waitPacket: (Int, (Packet) -> Unit) -> Unit,
|
||||||
|
unwaitPacket: (Int, (Packet) -> Unit) -> Unit,
|
||||||
|
addLog: (String) -> Unit,
|
||||||
|
shortKeyForLog: (String, Int) -> String,
|
||||||
|
shortTextForLog: (String, Int) -> String,
|
||||||
|
onSyncCompleted: (String) -> Unit,
|
||||||
|
onInboundTaskQueued: () -> Unit,
|
||||||
|
onInboundTaskFailure: (String, Throwable?) -> Unit,
|
||||||
|
resolveOutgoingRetry: (String) -> Unit,
|
||||||
|
isGroupDialogKey: (String) -> Boolean,
|
||||||
|
setTransportServer: (String) -> Unit,
|
||||||
|
onOwnProfileResolved: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val packetRouter =
|
||||||
|
PacketRouter(
|
||||||
|
sendSearchPacket = sendSearchPacket,
|
||||||
|
privateHashProvider = getProtocolPrivateHash
|
||||||
|
)
|
||||||
|
|
||||||
|
val outgoingMessagePipelineService =
|
||||||
|
OutgoingMessagePipelineService(
|
||||||
|
scope = scope,
|
||||||
|
getRepository = getMessageRepository,
|
||||||
|
sendPacket = sendMessagePacket,
|
||||||
|
isAuthenticated = isAuthenticated,
|
||||||
|
addLog = addLog
|
||||||
|
)
|
||||||
|
|
||||||
|
val presenceTypingService =
|
||||||
|
PresenceTypingService(
|
||||||
|
scope = scope,
|
||||||
|
typingIndicatorTimeoutMs = typingIndicatorTimeoutMs
|
||||||
|
)
|
||||||
|
|
||||||
|
val inboundTaskQueueService =
|
||||||
|
InboundTaskQueueService(
|
||||||
|
scope = scope,
|
||||||
|
onTaskQueued = onInboundTaskQueued,
|
||||||
|
onTaskFailure = onInboundTaskFailure
|
||||||
|
)
|
||||||
|
|
||||||
|
val syncCoordinator =
|
||||||
|
SyncCoordinator(
|
||||||
|
scope = scope,
|
||||||
|
syncRequestTimeoutMs = syncRequestTimeoutMs,
|
||||||
|
manualSyncBacktrackMs = manualSyncBacktrackMs,
|
||||||
|
addLog = addLog,
|
||||||
|
isAuthenticated = isAuthenticated,
|
||||||
|
getRepository = getMessageRepository,
|
||||||
|
getProtocolPublicKey = getProtocolPublicKey,
|
||||||
|
sendPacket = sendSyncPacket,
|
||||||
|
onSyncCompleted = onSyncCompleted,
|
||||||
|
whenInboundTasksFinish = inboundTaskQueueService::whenTasksFinish
|
||||||
|
)
|
||||||
|
|
||||||
|
val callSignalBridge =
|
||||||
|
CallSignalBridge(
|
||||||
|
sendPacket = sendPacket,
|
||||||
|
waitPacket = waitPacket,
|
||||||
|
unwaitPacket = unwaitPacket,
|
||||||
|
addLog = addLog,
|
||||||
|
shortKeyForLog = shortKeyForLog,
|
||||||
|
shortTextForLog = shortTextForLog
|
||||||
|
)
|
||||||
|
|
||||||
|
val inboundPacketHandlerRegistrar =
|
||||||
|
InboundPacketHandlerRegistrar(
|
||||||
|
tag = tag,
|
||||||
|
scope = scope,
|
||||||
|
syncCoordinator = syncCoordinator,
|
||||||
|
presenceTypingService = presenceTypingService,
|
||||||
|
deviceRuntimeService = deviceRuntimeService,
|
||||||
|
packetRouter = packetRouter,
|
||||||
|
ownProfileSyncService = ownProfileSyncService,
|
||||||
|
waitPacket = waitPacket,
|
||||||
|
launchInboundPacketTask = inboundTaskQueueService::enqueue,
|
||||||
|
getMessageRepository = getMessageRepository,
|
||||||
|
getGroupRepository = getGroupRepository,
|
||||||
|
getProtocolPublicKey = { getProtocolPublicKey().trim().orEmpty() },
|
||||||
|
addLog = addLog,
|
||||||
|
markInboundProcessingFailure = onInboundTaskFailure,
|
||||||
|
resolveOutgoingRetry = resolveOutgoingRetry,
|
||||||
|
isGroupDialogKey = isGroupDialogKey,
|
||||||
|
onOwnProfileResolved = onOwnProfileResolved,
|
||||||
|
setTransportServer = setTransportServer
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.connection.CallSignalBridge
|
||||||
|
import com.rosetta.messenger.network.connection.OutgoingMessagePipelineService
|
||||||
|
import com.rosetta.messenger.network.connection.PacketSubscriptionFacade
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
||||||
|
class RuntimePacketIoFacade(
|
||||||
|
private val postConnectionEvent: (ConnectionEvent) -> Unit,
|
||||||
|
private val outgoingMessagePipelineServiceProvider: () -> OutgoingMessagePipelineService,
|
||||||
|
private val callSignalBridge: CallSignalBridge,
|
||||||
|
private val packetSubscriptionFacade: PacketSubscriptionFacade
|
||||||
|
) {
|
||||||
|
fun send(packet: Packet) {
|
||||||
|
postConnectionEvent(ConnectionEvent.SendPacket(packet))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessageWithRetry(packet: PacketMessage) {
|
||||||
|
outgoingMessagePipelineServiceProvider().sendWithRetry(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveOutgoingRetry(messageId: String) {
|
||||||
|
outgoingMessagePipelineServiceProvider().resolveOutgoingRetry(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearOutgoingRetries() {
|
||||||
|
outgoingMessagePipelineServiceProvider().clearRetryQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendPacket(packet: Packet) {
|
||||||
|
send(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendCallSignal(
|
||||||
|
signalType: SignalType,
|
||||||
|
src: String = "",
|
||||||
|
dst: String = "",
|
||||||
|
sharedPublic: String = "",
|
||||||
|
callId: String = "",
|
||||||
|
joinToken: String = ""
|
||||||
|
) {
|
||||||
|
callSignalBridge.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||||
|
callSignalBridge.sendWebRtcSignal(signalType, sdpOrCandidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestIceServers() {
|
||||||
|
callSignalBridge.requestIceServers()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||||
|
return callSignalBridge.waitCallSignal(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unwaitCallSignal(callback: (Packet) -> Unit) {
|
||||||
|
callSignalBridge.unwaitCallSignal(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||||
|
return callSignalBridge.waitWebRtcSignal(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
|
||||||
|
callSignalBridge.unwaitWebRtcSignal(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||||
|
return callSignalBridge.waitIceServers(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unwaitIceServers(callback: (Packet) -> Unit) {
|
||||||
|
callSignalBridge.unwaitIceServers(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||||
|
packetSubscriptionFacade.waitPacket(packetId, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||||
|
packetSubscriptionFacade.unwaitPacket(packetId, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun packetFlow(packetId: Int): SharedFlow<Packet> {
|
||||||
|
return packetSubscriptionFacade.packetFlow(packetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.rosetta.messenger.network.connection.ConnectionEventRouter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
|
internal class RuntimeRoutingAssembly(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
tag: String,
|
||||||
|
addLog: (String) -> Unit,
|
||||||
|
handleInitializeAccount: (publicKey: String, privateKey: String) -> Unit,
|
||||||
|
handleConnect: (reason: String) -> Unit,
|
||||||
|
handleFastReconnect: (reason: String) -> Unit,
|
||||||
|
handleDisconnect: (reason: String, clearCredentials: Boolean) -> Unit,
|
||||||
|
handleAuthenticate: (publicKey: String, privateHash: String) -> Unit,
|
||||||
|
handleProtocolStateChanged: (state: ProtocolState) -> Unit,
|
||||||
|
handleSendPacket: (packet: Packet) -> Unit,
|
||||||
|
handleSyncCompleted: (reason: String) -> Unit,
|
||||||
|
handleOwnProfileResolved: (publicKey: String) -> Unit,
|
||||||
|
handleOwnProfileFallbackTimeout: (sessionGeneration: Long) -> Unit
|
||||||
|
) {
|
||||||
|
private val connectionEventRouter =
|
||||||
|
ConnectionEventRouter(
|
||||||
|
handleInitializeAccount = handleInitializeAccount,
|
||||||
|
handleConnect = handleConnect,
|
||||||
|
handleFastReconnect = handleFastReconnect,
|
||||||
|
handleDisconnect = handleDisconnect,
|
||||||
|
handleAuthenticate = handleAuthenticate,
|
||||||
|
handleProtocolStateChanged = handleProtocolStateChanged,
|
||||||
|
handleSendPacket = handleSendPacket,
|
||||||
|
handleSyncCompleted = handleSyncCompleted,
|
||||||
|
handleOwnProfileResolved = handleOwnProfileResolved,
|
||||||
|
handleOwnProfileFallbackTimeout = handleOwnProfileFallbackTimeout
|
||||||
|
)
|
||||||
|
|
||||||
|
private val connectionSupervisor =
|
||||||
|
ProtocolConnectionSupervisor(
|
||||||
|
scope = scope,
|
||||||
|
onEvent = { event -> connectionEventRouter.route(event) },
|
||||||
|
onError = { error -> Log.e(tag, "ConnectionSupervisor event failed", error) },
|
||||||
|
addLog = addLog
|
||||||
|
)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
connectionSupervisor.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun post(event: ConnectionEvent) {
|
||||||
|
connectionSupervisor.post(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.connection.BootstrapCoordinator
|
||||||
|
import com.rosetta.messenger.network.connection.OwnProfileFallbackTimerService
|
||||||
|
import com.rosetta.messenger.network.connection.ProtocolLifecycleStateStoreImpl
|
||||||
|
import com.rosetta.messenger.network.connection.RuntimeLifecycleStateMachine
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
internal class RuntimeStateAssembly(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
readyPacketQueueMax: Int,
|
||||||
|
readyPacketQueueTtlMs: Long,
|
||||||
|
ownProfileFallbackTimeoutMs: Long,
|
||||||
|
addLog: (String) -> Unit,
|
||||||
|
shortKeyForLog: (String) -> String,
|
||||||
|
sendPacketDirect: (Packet) -> Unit,
|
||||||
|
onOwnProfileFallbackTimeout: (Long) -> Unit,
|
||||||
|
clearLastSubscribedTokenValue: () -> Unit
|
||||||
|
) {
|
||||||
|
private val readyPacketGate =
|
||||||
|
ReadyPacketGate(
|
||||||
|
maxSize = readyPacketQueueMax,
|
||||||
|
ttlMs = readyPacketQueueTtlMs
|
||||||
|
)
|
||||||
|
|
||||||
|
val bootstrapCoordinator =
|
||||||
|
BootstrapCoordinator(
|
||||||
|
readyPacketGate = readyPacketGate,
|
||||||
|
addLog = addLog,
|
||||||
|
shortKeyForLog = shortKeyForLog,
|
||||||
|
sendPacketDirect = sendPacketDirect
|
||||||
|
)
|
||||||
|
|
||||||
|
val lifecycleStateMachine =
|
||||||
|
RuntimeLifecycleStateMachine(
|
||||||
|
bootstrapCoordinator = bootstrapCoordinator,
|
||||||
|
addLog = addLog
|
||||||
|
)
|
||||||
|
|
||||||
|
private val ownProfileFallbackTimerService =
|
||||||
|
OwnProfileFallbackTimerService(
|
||||||
|
scope = scope,
|
||||||
|
fallbackTimeoutMs = ownProfileFallbackTimeoutMs,
|
||||||
|
onTimeout = onOwnProfileFallbackTimeout
|
||||||
|
)
|
||||||
|
|
||||||
|
val lifecycleStateStore =
|
||||||
|
ProtocolLifecycleStateStoreImpl(
|
||||||
|
lifecycleStateMachine = lifecycleStateMachine,
|
||||||
|
ownProfileFallbackTimerService = ownProfileFallbackTimerService,
|
||||||
|
clearLastSubscribedTokenValue = clearLastSubscribedTokenValue
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
|
||||||
|
lifecycleStateMachine.connectionLifecycleState
|
||||||
|
|
||||||
|
fun recomputeConnectionLifecycleState(reason: String) {
|
||||||
|
lifecycleStateMachine.recompute(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rosetta.messenger.network.connection.NetworkConnectivityFacade
|
||||||
|
import com.rosetta.messenger.network.connection.NetworkReconnectWatcher
|
||||||
|
import com.rosetta.messenger.network.connection.PacketSubscriptionFacade
|
||||||
|
import com.rosetta.messenger.network.connection.ProtocolInstanceManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
|
internal class RuntimeTransportAssembly(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
networkWaitTimeoutMs: Long,
|
||||||
|
serverAddress: String,
|
||||||
|
getAppContext: () -> Context?,
|
||||||
|
addLog: (String) -> Unit,
|
||||||
|
onFastReconnectRequested: (String) -> Unit
|
||||||
|
) {
|
||||||
|
private val networkReconnectWatcher =
|
||||||
|
NetworkReconnectWatcher(
|
||||||
|
scope = scope,
|
||||||
|
networkWaitTimeoutMs = networkWaitTimeoutMs,
|
||||||
|
addLog = addLog,
|
||||||
|
onReconnectRequested = onFastReconnectRequested
|
||||||
|
)
|
||||||
|
|
||||||
|
val networkConnectivityFacade =
|
||||||
|
NetworkConnectivityFacade(
|
||||||
|
networkReconnectWatcher = networkReconnectWatcher,
|
||||||
|
getAppContext = getAppContext
|
||||||
|
)
|
||||||
|
|
||||||
|
val protocolInstanceManager =
|
||||||
|
ProtocolInstanceManager(
|
||||||
|
serverAddress = serverAddress,
|
||||||
|
addLog = addLog,
|
||||||
|
isNetworkAvailable = networkConnectivityFacade::hasActiveInternet,
|
||||||
|
onNetworkUnavailable = {
|
||||||
|
networkConnectivityFacade.waitForNetworkAndReconnect("protocol_connect")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
private val packetSubscriptionRegistry =
|
||||||
|
PacketSubscriptionRegistry(
|
||||||
|
protocolProvider = protocolInstanceManager::getOrCreateProtocol,
|
||||||
|
scope = scope,
|
||||||
|
addLog = addLog
|
||||||
|
)
|
||||||
|
|
||||||
|
val packetSubscriptionFacade = PacketSubscriptionFacade(packetSubscriptionRegistry)
|
||||||
|
|
||||||
|
fun destroyPacketSubscriptions() {
|
||||||
|
packetSubscriptionRegistry.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ object TransportManager {
|
|||||||
*/
|
*/
|
||||||
fun requestTransportServer() {
|
fun requestTransportServer() {
|
||||||
val packet = PacketRequestTransport()
|
val packet = PacketRequestTransport()
|
||||||
ProtocolManager.sendPacket(packet)
|
ProtocolRuntimeAccess.get().sendPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,7 +188,7 @@ object TransportManager {
|
|||||||
*/
|
*/
|
||||||
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
||||||
val server = getActiveServer()
|
val server = getActiveServer()
|
||||||
ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
||||||
|
|
||||||
// Добавляем в список загрузок
|
// Добавляем в список загрузок
|
||||||
_uploading.value = _uploading.value + TransportState(id, 0)
|
_uploading.value = _uploading.value + TransportState(id, 0)
|
||||||
@@ -275,15 +275,15 @@ object TransportManager {
|
|||||||
if (it.id == id) it.copy(progress = 100) else it
|
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)}")
|
||||||
|
|
||||||
tag
|
tag
|
||||||
}
|
}
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ProtocolManager.addLog(
|
ProtocolRuntimeAccess.get().addLog(
|
||||||
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
)
|
)
|
||||||
throw e
|
throw e
|
||||||
@@ -309,7 +309,7 @@ object TransportManager {
|
|||||||
transportServer: String? = null
|
transportServer: String? = null
|
||||||
): String = withContext(Dispatchers.IO) {
|
): String = withContext(Dispatchers.IO) {
|
||||||
val server = getActiveServer(transportServer)
|
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)
|
_downloading.value = _downloading.value + TransportState(id, 0)
|
||||||
@@ -336,7 +336,7 @@ object TransportManager {
|
|||||||
_downloading.value = _downloading.value.map {
|
_downloading.value = _downloading.value.map {
|
||||||
if (it.id == id) it.copy(progress = 100) else it
|
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
|
return@withRetry content
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,14 +383,14 @@ object TransportManager {
|
|||||||
if (it.id == id) it.copy(progress = 100) else it
|
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
|
content
|
||||||
} finally {
|
} finally {
|
||||||
tempFile.delete()
|
tempFile.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ProtocolManager.addLog(
|
ProtocolRuntimeAccess.get().addLog(
|
||||||
"❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
"❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
)
|
)
|
||||||
throw e
|
throw e
|
||||||
@@ -457,7 +457,7 @@ object TransportManager {
|
|||||||
transportServer: String? = null
|
transportServer: String? = null
|
||||||
): File = withContext(Dispatchers.IO) {
|
): File = withContext(Dispatchers.IO) {
|
||||||
val server = getActiveServer(transportServer)
|
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"
|
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -541,13 +541,13 @@ object TransportManager {
|
|||||||
_downloading.value = _downloading.value.map {
|
_downloading.value = _downloading.value.map {
|
||||||
if (it.id == id) it.copy(progress = 100) else it
|
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"
|
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
|
||||||
)
|
)
|
||||||
targetFile
|
targetFile
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ProtocolManager.addLog(
|
ProtocolRuntimeAccess.get().addLog(
|
||||||
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
)
|
)
|
||||||
throw e
|
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,48 @@
|
|||||||
|
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 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) {
|
||||||
|
sendPacket(packet)
|
||||||
|
retryQueueService.register(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveOutgoingRetry(messageId: String) {
|
||||||
|
retryQueueService.resolve(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun 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)
|
||||||
|
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,132 @@
|
|||||||
|
package com.rosetta.messenger.network.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
internal class ProtocolDebugLogService(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val maxDebugLogs: Int,
|
||||||
|
private val debugLogFlushDelayMs: Long,
|
||||||
|
private val heartbeatOkLogMinIntervalMs: Long,
|
||||||
|
private val protocolTraceFileName: String,
|
||||||
|
private val protocolTraceMaxBytes: Long,
|
||||||
|
private val protocolTraceKeepBytes: Int,
|
||||||
|
private val appContextProvider: () -> Context?
|
||||||
|
) {
|
||||||
|
private var uiLogsEnabled = false
|
||||||
|
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||||
|
private val debugLogsBuffer = ArrayDeque<String>(maxDebugLogs)
|
||||||
|
private val debugLogsLock = Any()
|
||||||
|
private val protocolTraceLock = Any()
|
||||||
|
@Volatile private var debugFlushJob: Job? = null
|
||||||
|
private val debugFlushPending = AtomicBoolean(false)
|
||||||
|
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||||
|
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
|
||||||
|
|
||||||
|
fun addLog(message: String) {
|
||||||
|
var normalizedMessage = message
|
||||||
|
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
||||||
|
if (isHeartbeatOk) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastHeartbeatOkLogAtMs < heartbeatOkLogMinIntervalMs) {
|
||||||
|
suppressedHeartbeatOkLogs++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (suppressedHeartbeatOkLogs > 0) {
|
||||||
|
normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)"
|
||||||
|
suppressedHeartbeatOkLogs = 0
|
||||||
|
}
|
||||||
|
lastHeartbeatOkLogAtMs = now
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp =
|
||||||
|
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||||
|
val line = "[$timestamp] $normalizedMessage"
|
||||||
|
if (shouldPersistProtocolTrace(normalizedMessage)) {
|
||||||
|
persistProtocolTraceLine(line)
|
||||||
|
}
|
||||||
|
if (!uiLogsEnabled) return
|
||||||
|
|
||||||
|
synchronized(debugLogsLock) {
|
||||||
|
if (debugLogsBuffer.size >= maxDebugLogs) {
|
||||||
|
debugLogsBuffer.removeFirst()
|
||||||
|
}
|
||||||
|
debugLogsBuffer.addLast(line)
|
||||||
|
}
|
||||||
|
flushDebugLogsThrottled()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableUILogs(enabled: Boolean) {
|
||||||
|
uiLogsEnabled = enabled
|
||||||
|
if (enabled) {
|
||||||
|
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||||
|
_debugLogs.value = snapshot
|
||||||
|
} else {
|
||||||
|
_debugLogs.value = emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLogs() {
|
||||||
|
synchronized(debugLogsLock) {
|
||||||
|
debugLogsBuffer.clear()
|
||||||
|
}
|
||||||
|
suppressedHeartbeatOkLogs = 0
|
||||||
|
lastHeartbeatOkLogAtMs = 0L
|
||||||
|
_debugLogs.value = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldPersistProtocolTrace(message: String): Boolean {
|
||||||
|
if (uiLogsEnabled) return true
|
||||||
|
if (message.startsWith("❌") || message.startsWith("⚠️")) return true
|
||||||
|
if (message.contains("STATE CHANGE")) return true
|
||||||
|
if (message.contains("CONNECTION FULLY ESTABLISHED")) return true
|
||||||
|
if (message.contains("HANDSHAKE COMPLETE")) return true
|
||||||
|
if (message.contains("SYNC COMPLETE")) return true
|
||||||
|
if (message.startsWith("🔌 CONNECT CALLED") || message.startsWith("🔌 Connecting to")) return true
|
||||||
|
if (message.startsWith("✅ WebSocket OPEN")) return true
|
||||||
|
if (message.startsWith("📡 NETWORK")) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistProtocolTraceLine(line: String) {
|
||||||
|
val context = appContextProvider() ?: return
|
||||||
|
runCatching {
|
||||||
|
val dir = File(context.filesDir, "crash_reports")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val traceFile = File(dir, protocolTraceFileName)
|
||||||
|
synchronized(protocolTraceLock) {
|
||||||
|
if (traceFile.exists() && traceFile.length() > protocolTraceMaxBytes) {
|
||||||
|
val tail = runCatching {
|
||||||
|
traceFile.readText(Charsets.UTF_8).takeLast(protocolTraceKeepBytes)
|
||||||
|
}.getOrDefault("")
|
||||||
|
traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
traceFile.appendText("$line\n", Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flushDebugLogsThrottled() {
|
||||||
|
debugFlushPending.set(true)
|
||||||
|
if (debugFlushJob?.isActive == true) return
|
||||||
|
debugFlushJob =
|
||||||
|
scope.launch {
|
||||||
|
while (debugFlushPending.getAndSet(false)) {
|
||||||
|
delay(debugLogFlushDelayMs)
|
||||||
|
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||||
|
_debugLogs.value = snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.rosetta.messenger.network.connection
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.Protocol
|
||||||
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
class ProtocolInstanceManager(
|
||||||
|
private val serverAddress: String,
|
||||||
|
private val addLog: (String) -> Unit,
|
||||||
|
private val isNetworkAvailable: () -> Boolean,
|
||||||
|
private val onNetworkUnavailable: () -> Unit
|
||||||
|
) {
|
||||||
|
@Volatile private var protocol: Protocol? = null
|
||||||
|
private val protocolInstanceLock = Any()
|
||||||
|
|
||||||
|
fun getOrCreateProtocol(): Protocol {
|
||||||
|
protocol?.let { return it }
|
||||||
|
|
||||||
|
synchronized(protocolInstanceLock) {
|
||||||
|
protocol?.let { return it }
|
||||||
|
|
||||||
|
val created =
|
||||||
|
Protocol(
|
||||||
|
serverAddress = serverAddress,
|
||||||
|
logger = { msg -> addLog(msg) },
|
||||||
|
isNetworkAvailable = isNetworkAvailable,
|
||||||
|
onNetworkUnavailable = onNetworkUnavailable
|
||||||
|
)
|
||||||
|
protocol = created
|
||||||
|
addLog("🧩 Protocol singleton created: id=${System.identityHashCode(created)}")
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val state: StateFlow<ProtocolState>
|
||||||
|
get() = getOrCreateProtocol().state
|
||||||
|
|
||||||
|
val lastError: StateFlow<String?>
|
||||||
|
get() = getOrCreateProtocol().lastError
|
||||||
|
|
||||||
|
fun disconnect(clearCredentials: Boolean) {
|
||||||
|
protocol?.disconnect()
|
||||||
|
if (clearCredentials) {
|
||||||
|
protocol?.clearCredentials()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
synchronized(protocolInstanceLock) {
|
||||||
|
protocol?.destroy()
|
||||||
|
protocol = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAuthenticated(): Boolean = protocol?.isAuthenticated() ?: false
|
||||||
|
|
||||||
|
fun isConnected(): Boolean = protocol?.isConnected() ?: false
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package com.rosetta.messenger.network.connection
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.ConnectionBootstrapContext
|
||||||
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
interface ProtocolLifecycleStateStore {
|
||||||
|
var bootstrapContext: ConnectionBootstrapContext
|
||||||
|
|
||||||
|
fun clearLastSubscribedToken()
|
||||||
|
fun cancelOwnProfileFallbackTimeout()
|
||||||
|
fun scheduleOwnProfileFallbackTimeout(sessionGeneration: Long)
|
||||||
|
fun nextSessionGeneration(): Long
|
||||||
|
fun currentSessionGeneration(): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProtocolLifecycleCoordinator(
|
||||||
|
private val stateStore: ProtocolLifecycleStateStore,
|
||||||
|
private val syncCoordinator: SyncCoordinator,
|
||||||
|
private val authBootstrapCoordinator: AuthBootstrapCoordinator,
|
||||||
|
private val addLog: (String) -> Unit,
|
||||||
|
private val shortKeyForLog: (String) -> String,
|
||||||
|
private val stopWaitingForNetwork: (String) -> Unit,
|
||||||
|
private val cancelAllOutgoingRetries: () -> Unit,
|
||||||
|
private val recomputeConnectionLifecycleState: (String) -> Unit,
|
||||||
|
private val onAuthenticated: () -> Unit,
|
||||||
|
private val onSyncCompletedSideEffects: () -> Unit,
|
||||||
|
private val updateOwnProfileResolved: (publicKey: String, reason: String) -> Unit,
|
||||||
|
) {
|
||||||
|
fun handleProtocolStateChanged(newProtocolState: ProtocolState) {
|
||||||
|
val context = stateStore.bootstrapContext
|
||||||
|
val previousProtocolState = context.protocolState
|
||||||
|
|
||||||
|
if (
|
||||||
|
newProtocolState == ProtocolState.AUTHENTICATED &&
|
||||||
|
previousProtocolState != ProtocolState.AUTHENTICATED
|
||||||
|
) {
|
||||||
|
stateStore.clearLastSubscribedToken()
|
||||||
|
stopWaitingForNetwork("authenticated")
|
||||||
|
stateStore.cancelOwnProfileFallbackTimeout()
|
||||||
|
val generation = stateStore.nextSessionGeneration()
|
||||||
|
authBootstrapCoordinator.onAuthenticatedSessionStarted()
|
||||||
|
stateStore.bootstrapContext =
|
||||||
|
context.copy(
|
||||||
|
protocolState = newProtocolState,
|
||||||
|
authenticated = true,
|
||||||
|
syncCompleted = false,
|
||||||
|
ownProfileResolved = false
|
||||||
|
)
|
||||||
|
recomputeConnectionLifecycleState("protocol_authenticated")
|
||||||
|
stateStore.scheduleOwnProfileFallbackTimeout(generation)
|
||||||
|
onAuthenticated()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
newProtocolState != ProtocolState.AUTHENTICATED &&
|
||||||
|
newProtocolState != ProtocolState.HANDSHAKING
|
||||||
|
) {
|
||||||
|
syncCoordinator.clearRequestState()
|
||||||
|
syncCoordinator.markSyncInProgress(false)
|
||||||
|
stateStore.clearLastSubscribedToken()
|
||||||
|
cancelAllOutgoingRetries()
|
||||||
|
stateStore.cancelOwnProfileFallbackTimeout()
|
||||||
|
authBootstrapCoordinator.reset()
|
||||||
|
stateStore.bootstrapContext =
|
||||||
|
context.copy(
|
||||||
|
protocolState = newProtocolState,
|
||||||
|
authenticated = false,
|
||||||
|
syncCompleted = false,
|
||||||
|
ownProfileResolved = false
|
||||||
|
)
|
||||||
|
recomputeConnectionLifecycleState(
|
||||||
|
"protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newProtocolState == ProtocolState.HANDSHAKING && context.authenticated) {
|
||||||
|
stateStore.cancelOwnProfileFallbackTimeout()
|
||||||
|
authBootstrapCoordinator.reset()
|
||||||
|
stateStore.bootstrapContext =
|
||||||
|
context.copy(
|
||||||
|
protocolState = newProtocolState,
|
||||||
|
authenticated = false,
|
||||||
|
syncCompleted = false,
|
||||||
|
ownProfileResolved = false
|
||||||
|
)
|
||||||
|
recomputeConnectionLifecycleState("protocol_re_handshaking")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stateStore.bootstrapContext = context.copy(protocolState = newProtocolState)
|
||||||
|
recomputeConnectionLifecycleState(
|
||||||
|
"protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleSyncCompleted(reason: String) {
|
||||||
|
syncCoordinator.onSyncCompletedStateApplied()
|
||||||
|
addLog(reason)
|
||||||
|
onSyncCompletedSideEffects()
|
||||||
|
|
||||||
|
stateStore.bootstrapContext = stateStore.bootstrapContext.copy(syncCompleted = true)
|
||||||
|
recomputeConnectionLifecycleState("sync_completed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleOwnProfileResolved(publicKey: String) {
|
||||||
|
val accountPublicKey = stateStore.bootstrapContext.accountPublicKey
|
||||||
|
val matchesAccount =
|
||||||
|
accountPublicKey.isBlank() || publicKey.equals(accountPublicKey, ignoreCase = true)
|
||||||
|
if (!matchesAccount) return
|
||||||
|
|
||||||
|
stateStore.cancelOwnProfileFallbackTimeout()
|
||||||
|
stateStore.bootstrapContext = stateStore.bootstrapContext.copy(ownProfileResolved = true)
|
||||||
|
updateOwnProfileResolved(publicKey, "protocol_own_profile_resolved")
|
||||||
|
recomputeConnectionLifecycleState("own_profile_resolved")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleOwnProfileFallbackTimeout(sessionGeneration: Long) {
|
||||||
|
if (stateStore.currentSessionGeneration() != sessionGeneration) return
|
||||||
|
|
||||||
|
val context = stateStore.bootstrapContext
|
||||||
|
if (!context.authenticated || context.ownProfileResolved) return
|
||||||
|
|
||||||
|
addLog(
|
||||||
|
"⏱️ Own profile fetch timeout — continuing bootstrap for ${shortKeyForLog(context.accountPublicKey)}"
|
||||||
|
)
|
||||||
|
val updatedContext = context.copy(ownProfileResolved = true)
|
||||||
|
stateStore.bootstrapContext = updatedContext
|
||||||
|
|
||||||
|
val accountPublicKey = updatedContext.accountPublicKey
|
||||||
|
if (accountPublicKey.isNotBlank()) {
|
||||||
|
updateOwnProfileResolved(accountPublicKey, "protocol_own_profile_fallback_timeout")
|
||||||
|
}
|
||||||
|
recomputeConnectionLifecycleState("own_profile_fallback_timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.rosetta.messenger.network.connection
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.ConnectionBootstrapContext
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
class ProtocolLifecycleStateStoreImpl(
|
||||||
|
private val lifecycleStateMachine: RuntimeLifecycleStateMachine,
|
||||||
|
private val ownProfileFallbackTimerService: OwnProfileFallbackTimerService,
|
||||||
|
private val clearLastSubscribedTokenValue: () -> Unit
|
||||||
|
) : ProtocolLifecycleStateStore {
|
||||||
|
private val sessionGeneration = AtomicLong(0L)
|
||||||
|
|
||||||
|
override var bootstrapContext: ConnectionBootstrapContext
|
||||||
|
get() = lifecycleStateMachine.bootstrapContext
|
||||||
|
set(value) {
|
||||||
|
lifecycleStateMachine.bootstrapContext = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearLastSubscribedToken() {
|
||||||
|
clearLastSubscribedTokenValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelOwnProfileFallbackTimeout() {
|
||||||
|
ownProfileFallbackTimerService.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun scheduleOwnProfileFallbackTimeout(sessionGeneration: Long) {
|
||||||
|
ownProfileFallbackTimerService.schedule(sessionGeneration)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nextSessionGeneration(): Long = sessionGeneration.incrementAndGet()
|
||||||
|
|
||||||
|
override fun currentSessionGeneration(): Long = sessionGeneration.get()
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.rosetta.messenger.network.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
|
import com.rosetta.messenger.network.Packet
|
||||||
|
import com.rosetta.messenger.network.PacketPushNotification
|
||||||
|
import com.rosetta.messenger.network.PushNotificationAction
|
||||||
|
import com.rosetta.messenger.network.PushTokenType
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ProtocolPostAuthBootstrapCoordinator(
|
||||||
|
private val tag: String,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val authBootstrapCoordinator: AuthBootstrapCoordinator,
|
||||||
|
private val syncCoordinator: SyncCoordinator,
|
||||||
|
private val ownProfileSyncService: OwnProfileSyncService,
|
||||||
|
private val deviceRuntimeService: DeviceRuntimeService,
|
||||||
|
private val getMessageRepository: () -> MessageRepository?,
|
||||||
|
private val getAppContext: () -> Context?,
|
||||||
|
private val getProtocolPublicKey: () -> String?,
|
||||||
|
private val getProtocolPrivateHash: () -> String?,
|
||||||
|
private val sendPacket: (Packet) -> Unit,
|
||||||
|
private val requestTransportServer: () -> Unit,
|
||||||
|
private val requestUpdateServer: () -> Unit,
|
||||||
|
private val addLog: (String) -> Unit,
|
||||||
|
private val shortKeyForLog: (String) -> String,
|
||||||
|
private val getLastSubscribedToken: () -> String?,
|
||||||
|
private val setLastSubscribedToken: (String?) -> Unit
|
||||||
|
) {
|
||||||
|
fun runPostAuthBootstrap(trigger: String) {
|
||||||
|
authBootstrapCoordinator.tryRun(
|
||||||
|
trigger = trigger,
|
||||||
|
canRun = ::canRunPostAuthBootstrap,
|
||||||
|
onDeferred = {
|
||||||
|
val repositoryAccount =
|
||||||
|
getMessageRepository()?.getCurrentAccountKey()?.let { shortKeyForLog(it) }
|
||||||
|
?: "<none>"
|
||||||
|
val protocolAccount = getProtocolPublicKey()?.let { shortKeyForLog(it) } ?: "<none>"
|
||||||
|
addLog(
|
||||||
|
"⏳ AUTH bootstrap deferred trigger=$trigger repo=$repositoryAccount proto=$protocolAccount"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
syncCoordinator.markSyncInProgress(false)
|
||||||
|
requestTransportServer()
|
||||||
|
requestUpdateServer()
|
||||||
|
fetchOwnProfile()
|
||||||
|
syncCoordinator.requestSynchronize()
|
||||||
|
subscribePushTokenIfAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleSyncCompletedSideEffects() {
|
||||||
|
retryWaitingMessages()
|
||||||
|
requestMissingUserInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribePushTokenIfAvailable(forceToken: String? = null) {
|
||||||
|
val context = getAppContext() ?: return
|
||||||
|
val token =
|
||||||
|
(forceToken
|
||||||
|
?: context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
|
||||||
|
.getString("fcm_token", null))
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
if (token.isEmpty()) return
|
||||||
|
|
||||||
|
if (token == getLastSubscribedToken()) {
|
||||||
|
addLog("🔔 Push token already subscribed this session — skipped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val deviceId = deviceRuntimeService.resolvePushDeviceId()
|
||||||
|
val subPacket =
|
||||||
|
PacketPushNotification().apply {
|
||||||
|
notificationsToken = token
|
||||||
|
action = PushNotificationAction.SUBSCRIBE
|
||||||
|
tokenType = PushTokenType.FCM
|
||||||
|
this.deviceId = deviceId
|
||||||
|
}
|
||||||
|
sendPacket(subPacket)
|
||||||
|
setLastSubscribedToken(token)
|
||||||
|
addLog("🔔 Push token SUBSCRIBE sent")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val dir = File(context.filesDir, "crash_reports")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val file = File(dir, "fcm_token.txt")
|
||||||
|
val ts =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
|
||||||
|
file.writeText(
|
||||||
|
"=== FCM TOKEN ===\n\nTimestamp: $ts\nDeviceId: $deviceId\n\nToken:\n$token\n"
|
||||||
|
)
|
||||||
|
} catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canRunPostAuthBootstrap(): Boolean {
|
||||||
|
val repository = getMessageRepository() ?: return false
|
||||||
|
if (!repository.isInitialized()) return false
|
||||||
|
val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty()
|
||||||
|
if (repositoryAccount.isBlank()) return false
|
||||||
|
val protocolAccount = getProtocolPublicKey()?.trim().orEmpty()
|
||||||
|
if (protocolAccount.isBlank()) return true
|
||||||
|
return repositoryAccount.equals(protocolAccount, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchOwnProfile() {
|
||||||
|
val packet =
|
||||||
|
ownProfileSyncService.buildOwnProfilePacket(
|
||||||
|
publicKey = getProtocolPublicKey(),
|
||||||
|
privateHash = getProtocolPrivateHash()
|
||||||
|
) ?: return
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun retryWaitingMessages() {
|
||||||
|
scope.launch {
|
||||||
|
val repository = getMessageRepository()
|
||||||
|
if (repository == null || !repository.isInitialized()) return@launch
|
||||||
|
try {
|
||||||
|
repository.retryWaitingMessages()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "retryWaitingMessages failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestMissingUserInfo() {
|
||||||
|
scope.launch {
|
||||||
|
val repository = getMessageRepository()
|
||||||
|
if (repository == null || !repository.isInitialized()) return@launch
|
||||||
|
try {
|
||||||
|
repository.clearUserInfoRequestCache()
|
||||||
|
repository.requestMissingUserInfo()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "requestMissingUserInfo failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.rosetta.messenger.network.connection
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.ConnectionLifecycleState
|
||||||
|
import com.rosetta.messenger.network.Packet
|
||||||
|
|
||||||
|
class ReadyPacketDispatchCoordinator(
|
||||||
|
private val bootstrapCoordinator: BootstrapCoordinator,
|
||||||
|
private val getConnectionLifecycleState: () -> ConnectionLifecycleState,
|
||||||
|
private val resolveAccountPublicKey: () -> String,
|
||||||
|
private val sendPacketDirect: (Packet) -> Unit,
|
||||||
|
private val isAuthenticated: () -> Boolean,
|
||||||
|
private val hasActiveInternet: () -> Boolean,
|
||||||
|
private val waitForNetworkAndReconnect: (String) -> Unit,
|
||||||
|
private val reconnectNowIfNeeded: (String) -> Unit
|
||||||
|
) {
|
||||||
|
fun clearReadyPacketQueue(reason: String) {
|
||||||
|
bootstrapCoordinator.clearReadyPacketQueue(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleSendPacket(packet: Packet) {
|
||||||
|
val lifecycle = getConnectionLifecycleState()
|
||||||
|
if (
|
||||||
|
bootstrapCoordinator.packetCanBypassReadyGate(packet) ||
|
||||||
|
lifecycle == ConnectionLifecycleState.READY
|
||||||
|
) {
|
||||||
|
sendPacketDirect(packet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrapCoordinator.enqueueReadyPacket(
|
||||||
|
packet = packet,
|
||||||
|
accountPublicKey = resolveAccountPublicKey(),
|
||||||
|
state = lifecycle
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
if (!hasActiveInternet()) {
|
||||||
|
waitForNetworkAndReconnect("ready_gate_send")
|
||||||
|
} else {
|
||||||
|
reconnectNowIfNeeded("ready_gate_send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.rosetta.messenger.network.connection
|
||||||
|
|
||||||
|
import com.rosetta.messenger.network.PacketMessage
|
||||||
|
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
|
||||||
|
pendingOutgoingRetryJobs[messageId]?.cancel()
|
||||||
|
pendingOutgoingPackets[messageId] = packet
|
||||||
|
pendingOutgoingAttempts[messageId] = 0
|
||||||
|
schedule(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolve(messageId: String) {
|
||||||
|
pendingOutgoingRetryJobs[messageId]?.cancel()
|
||||||
|
pendingOutgoingRetryJobs.remove(messageId)
|
||||||
|
pendingOutgoingPackets.remove(messageId)
|
||||||
|
pendingOutgoingAttempts.remove(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
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) {
|
||||||
|
addLog(
|
||||||
|
"⚠️ Message ${messageId.take(8)} expired after ${ageMs}ms — marking as error"
|
||||||
|
)
|
||||||
|
scope.launch { markOutgoingAsError(messageId, packet) }
|
||||||
|
resolve(messageId)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts >= maxRetryAttempts) {
|
||||||
|
addLog(
|
||||||
|
"⚠️ Message ${messageId.take(8)} exhausted $attempts retries — marking as error"
|
||||||
|
)
|
||||||
|
scope.launch { markOutgoingAsError(messageId, packet) }
|
||||||
|
resolve(messageId)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
addLog("⏳ Message ${messageId.take(8)} retry deferred — not authenticated")
|
||||||
|
resolve(messageId)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextAttempt = attempts + 1
|
||||||
|
pendingOutgoingAttempts[messageId] = nextAttempt
|
||||||
|
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.os.Build
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.rosetta.messenger.BuildConfig
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import com.google.firebase.messaging.FirebaseMessagingService
|
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.AccountManager
|
||||||
import com.rosetta.messenger.data.PreferencesManager
|
import com.rosetta.messenger.data.PreferencesManager
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
|
import com.rosetta.messenger.di.ProtocolGateway
|
||||||
import com.rosetta.messenger.network.CallForegroundService
|
import com.rosetta.messenger.network.CallForegroundService
|
||||||
import com.rosetta.messenger.network.CallManager
|
import com.rosetta.messenger.network.CallManager
|
||||||
import com.rosetta.messenger.network.CallPhase
|
import com.rosetta.messenger.network.CallPhase
|
||||||
import com.rosetta.messenger.network.CallUiState
|
import com.rosetta.messenger.network.CallUiState
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
|
||||||
import com.rosetta.messenger.utils.AvatarFileManager
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -40,8 +43,13 @@ import java.util.Locale
|
|||||||
* - Получение push-уведомлений о новых сообщениях
|
* - Получение push-уведомлений о новых сообщениях
|
||||||
* - Отображение уведомлений
|
* - Отображение уведомлений
|
||||||
*/
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
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)
|
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -120,22 +128,40 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
saveFcmToken(token)
|
saveFcmToken(token)
|
||||||
|
|
||||||
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
|
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
|
||||||
// Используем единую точку отправки в ProtocolManager (с дедупликацией).
|
// Используем единую runtime-точку отправки (с дедупликацией).
|
||||||
if (ProtocolManager.isAuthenticated()) {
|
if (protocolGateway.isAuthenticated()) {
|
||||||
runCatching { ProtocolManager.subscribePushTokenIfAvailable(forceToken = token) }
|
runCatching { protocolGateway.subscribePushTokenIfAvailable(forceToken = token) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Вызывается когда получено push-уведомление */
|
/** Вызывается когда получено push-уведомление */
|
||||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||||
super.onMessageReceived(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}")
|
||||||
|
|
||||||
var handledByData = false
|
|
||||||
val data = remoteMessage.data
|
val data = remoteMessage.data
|
||||||
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
|
val notificationTitle = remoteMessage.notification?.title?.trim().orEmpty()
|
||||||
val notificationBody = remoteMessage.notification?.body?.trim().orEmpty()
|
val notificationBody = remoteMessage.notification?.body?.trim().orEmpty()
|
||||||
|
|
||||||
|
// Filter out empty/silent pushes (iOS wake-up pushes with mutable-content, empty alerts, etc.)
|
||||||
|
val hasDataContent = data.isNotEmpty() && data.any { (key, value) ->
|
||||||
|
key !in setOf("google.delivered_priority", "google.sent_time", "google.ttl",
|
||||||
|
"google.original_priority", "gcm.notification.e", "gcm.notification.tag",
|
||||||
|
"google.c.a.e", "google.c.sender.id", "google.c.fid",
|
||||||
|
"mutable-content", "mutable_content", "content-available", "content_available") &&
|
||||||
|
value.isNotBlank()
|
||||||
|
}
|
||||||
|
val hasNotificationContent = notificationTitle.isNotBlank() || notificationBody.isNotBlank()
|
||||||
|
|
||||||
|
if (!hasDataContent && !hasNotificationContent) {
|
||||||
|
if (BuildConfig.DEBUG) Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)")
|
||||||
|
// Still trigger reconnect if WebSocket is disconnected
|
||||||
|
protocolGateway.reconnectNowIfNeeded("silent_push")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var handledByData = false
|
||||||
|
|
||||||
// Обрабатываем data payload (новый server формат + legacy fallback)
|
// Обрабатываем data payload (новый server формат + legacy fallback)
|
||||||
if (data.isNotEmpty()) {
|
if (data.isNotEmpty()) {
|
||||||
val type =
|
val type =
|
||||||
@@ -201,14 +227,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
isReadEvent -> {
|
isReadEvent -> {
|
||||||
val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey)
|
val keysToClear = collectReadDialogKeys(data, dialogKey, senderPublicKey)
|
||||||
if (keysToClear.isEmpty()) {
|
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 {
|
} else {
|
||||||
keysToClear.forEach { key ->
|
keysToClear.forEach { key ->
|
||||||
cancelNotificationForChat(applicationContext, key)
|
cancelNotificationForChat(applicationContext, key)
|
||||||
}
|
}
|
||||||
val titleHints = collectReadTitleHints(data, keysToClear)
|
val titleHints = collectReadTitleHints(data, keysToClear)
|
||||||
cancelMatchingActiveNotifications(keysToClear, titleHints)
|
cancelMatchingActiveNotifications(keysToClear, titleHints)
|
||||||
Log.d(
|
if (BuildConfig.DEBUG) Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"READ push cleared notifications for keys=$keysToClear titles=$titleHints"
|
"READ push cleared notifications for keys=$keysToClear titles=$titleHints"
|
||||||
)
|
)
|
||||||
@@ -292,11 +318,11 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val lastTs = lastNotifTimestamps[dedupKey]
|
val lastTs = lastNotifTimestamps[dedupKey]
|
||||||
if (lastTs != null && now - lastTs < DEDUP_WINDOW_MS) {
|
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
|
return // duplicate push — skip
|
||||||
}
|
}
|
||||||
lastNotifTimestamps[dedupKey] = now
|
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()
|
val senderKey = senderPublicKey?.trim().orEmpty()
|
||||||
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
|
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
|
||||||
return
|
return
|
||||||
@@ -483,7 +509,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun pushCallLog(msg: String) {
|
private fun pushCallLog(msg: String) {
|
||||||
Log.d(TAG, msg)
|
if (BuildConfig.DEBUG) Log.d(TAG, msg)
|
||||||
try {
|
try {
|
||||||
val dir = java.io.File(applicationContext.filesDir, "crash_reports")
|
val dir = java.io.File(applicationContext.filesDir, "crash_reports")
|
||||||
if (!dir.exists()) dir.mkdirs()
|
if (!dir.exists()) dir.mkdirs()
|
||||||
@@ -496,20 +522,20 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
|
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
|
||||||
private fun wakeProtocolFromPush(reason: String) {
|
private fun wakeProtocolFromPush(reason: String) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
|
val account = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||||
ProtocolManager.initialize(applicationContext)
|
protocolGateway.initialize(applicationContext)
|
||||||
CallManager.initialize(applicationContext)
|
CallManager.initialize(applicationContext)
|
||||||
if (account.isNotBlank()) {
|
if (account.isNotBlank()) {
|
||||||
CallManager.bindAccount(account)
|
CallManager.bindAccount(account)
|
||||||
}
|
}
|
||||||
val restored = ProtocolManager.restoreAuthFromStoredCredentials(
|
val restored = protocolGateway.restoreAuthFromStoredCredentials(
|
||||||
preferredPublicKey = account,
|
preferredPublicKey = account,
|
||||||
reason = "push_$reason"
|
reason = "push_$reason"
|
||||||
)
|
)
|
||||||
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}…")
|
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}…")
|
||||||
ProtocolManager.reconnectNowIfNeeded("push_$reason")
|
protocolGateway.reconnectNowIfNeeded("push_$reason")
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
|
if (BuildConfig.DEBUG) Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,7 +568,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
private fun areNotificationsEnabled(): Boolean {
|
private fun areNotificationsEnabled(): Boolean {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
runBlocking(Dispatchers.IO) {
|
runBlocking(Dispatchers.IO) {
|
||||||
PreferencesManager(applicationContext).notificationsEnabled.first()
|
preferencesManager.notificationsEnabled.first()
|
||||||
}
|
}
|
||||||
}.getOrDefault(true)
|
}.getOrDefault(true)
|
||||||
}
|
}
|
||||||
@@ -565,7 +591,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
parsedDialogKey: String?,
|
parsedDialogKey: String?,
|
||||||
parsedSenderKey: String?
|
parsedSenderKey: String?
|
||||||
): Set<String> {
|
): Set<String> {
|
||||||
val currentAccount = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty().trim()
|
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty().trim()
|
||||||
val candidates = linkedSetOf<String>()
|
val candidates = linkedSetOf<String>()
|
||||||
|
|
||||||
fun addCandidate(raw: String?) {
|
fun addCandidate(raw: String?) {
|
||||||
@@ -692,7 +718,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
|
|
||||||
if (matchesDeterministicId || matchesDialogKey || matchesHint) {
|
if (matchesDeterministicId || matchesDialogKey || matchesHint) {
|
||||||
manager.cancel(sbn.tag, sbn.id)
|
manager.cancel(sbn.tag, sbn.id)
|
||||||
Log.d(
|
if (BuildConfig.DEBUG) Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " +
|
"READ push fallback cancel id=${sbn.id} tag=${sbn.tag} " +
|
||||||
"channel=${notification.channelId} title='$title' " +
|
"channel=${notification.channelId} title='$title' " +
|
||||||
@@ -701,14 +727,14 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
|
if (BuildConfig.DEBUG) Log.w(TAG, "cancelMatchingActiveNotifications failed: ${error.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isAvatarInNotificationsEnabled(): Boolean {
|
private fun isAvatarInNotificationsEnabled(): Boolean {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
runBlocking(Dispatchers.IO) {
|
runBlocking(Dispatchers.IO) {
|
||||||
PreferencesManager(applicationContext).notificationAvatarEnabled.first()
|
preferencesManager.notificationAvatarEnabled.first()
|
||||||
}
|
}
|
||||||
}.getOrDefault(true)
|
}.getOrDefault(true)
|
||||||
}
|
}
|
||||||
@@ -717,25 +743,23 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
private fun isDialogMuted(senderPublicKey: String): Boolean {
|
private fun isDialogMuted(senderPublicKey: String): Boolean {
|
||||||
if (senderPublicKey.isBlank()) return false
|
if (senderPublicKey.isBlank()) return false
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val accountManager = AccountManager(applicationContext)
|
|
||||||
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
|
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||||
runBlocking(Dispatchers.IO) {
|
runBlocking(Dispatchers.IO) {
|
||||||
val preferences = PreferencesManager(applicationContext)
|
|
||||||
buildDialogKeyVariants(senderPublicKey).any { key ->
|
buildDialogKeyVariants(senderPublicKey).any { key ->
|
||||||
preferences.isChatMuted(currentAccount, key)
|
preferencesManager.isChatMuted(currentAccount, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Получить имя пользователя по publicKey (кэш ProtocolManager → БД dialogs) */
|
/** Получить имя пользователя по publicKey (runtime-кэш → БД dialogs) */
|
||||||
private fun resolveNameForKey(publicKey: String?): String? {
|
private fun resolveNameForKey(publicKey: String?): String? {
|
||||||
if (publicKey.isNullOrBlank()) return null
|
if (publicKey.isNullOrBlank()) return null
|
||||||
// 1. In-memory cache
|
// 1. In-memory cache
|
||||||
ProtocolManager.getCachedUserName(publicKey)?.let { return it }
|
protocolGateway.getCachedUserName(publicKey)?.let { return it }
|
||||||
// 2. DB dialogs table
|
// 2. DB dialogs table
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
|
val account = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||||
if (account.isBlank()) return null
|
if (account.isBlank()) return null
|
||||||
val db = RosettaDatabase.getDatabase(applicationContext)
|
val db = RosettaDatabase.getDatabase(applicationContext)
|
||||||
val dialog = runBlocking(Dispatchers.IO) {
|
val dialog = runBlocking(Dispatchers.IO) {
|
||||||
@@ -756,8 +780,9 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val entity = runBlocking(Dispatchers.IO) {
|
val entity = runBlocking(Dispatchers.IO) {
|
||||||
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
|
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
|
||||||
} ?: return null
|
} ?: return null
|
||||||
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
|
val rawBase64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
|
||||||
?: return null
|
?: return null
|
||||||
|
val base64 = if (rawBase64.contains(",")) rawBase64.substringAfter(",") else rawBase64
|
||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
||||||
// Делаем круглый bitmap для notification
|
// Делаем круглый bitmap для notification
|
||||||
|
|||||||
@@ -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 androidx.core.view.WindowCompat
|
||||||
import com.rosetta.messenger.data.DecryptedAccount
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
|
import com.rosetta.messenger.di.ProtocolGateway
|
||||||
|
import com.rosetta.messenger.di.SessionCoordinator
|
||||||
|
|
||||||
enum class AuthScreen {
|
enum class AuthScreen {
|
||||||
SELECT_ACCOUNT,
|
SELECT_ACCOUNT,
|
||||||
@@ -15,6 +17,8 @@ enum class AuthScreen {
|
|||||||
SEED_PHRASE,
|
SEED_PHRASE,
|
||||||
CONFIRM_SEED,
|
CONFIRM_SEED,
|
||||||
SET_PASSWORD,
|
SET_PASSWORD,
|
||||||
|
SET_BIOMETRIC,
|
||||||
|
SET_PROFILE,
|
||||||
IMPORT_SEED,
|
IMPORT_SEED,
|
||||||
UNLOCK
|
UNLOCK
|
||||||
}
|
}
|
||||||
@@ -25,6 +29,8 @@ fun AuthFlow(
|
|||||||
hasExistingAccount: Boolean,
|
hasExistingAccount: Boolean,
|
||||||
accounts: List<AccountInfo> = emptyList(),
|
accounts: List<AccountInfo> = emptyList(),
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
|
protocolGateway: ProtocolGateway,
|
||||||
|
sessionCoordinator: SessionCoordinator,
|
||||||
startInCreateMode: Boolean = false,
|
startInCreateMode: Boolean = false,
|
||||||
onAuthComplete: (DecryptedAccount?) -> Unit,
|
onAuthComplete: (DecryptedAccount?) -> Unit,
|
||||||
onLogout: () -> Unit = {}
|
onLogout: () -> Unit = {}
|
||||||
@@ -50,6 +56,7 @@ fun AuthFlow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
|
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
var createdAccount by remember { mutableStateOf<DecryptedAccount?>(null) }
|
||||||
// Use last logged account or fallback to first account
|
// Use last logged account or fallback to first account
|
||||||
var selectedAccountId by remember {
|
var selectedAccountId by remember {
|
||||||
mutableStateOf<String?>(
|
mutableStateOf<String?>(
|
||||||
@@ -59,6 +66,13 @@ fun AuthFlow(
|
|||||||
var showCreateModal by remember { mutableStateOf(false) }
|
var showCreateModal by remember { mutableStateOf(false) }
|
||||||
var isImportMode 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.
|
// If parent requests create mode while AuthFlow is alive, jump to Welcome/Create path.
|
||||||
LaunchedEffect(startInCreateMode) {
|
LaunchedEffect(startInCreateMode) {
|
||||||
if (startInCreateMode && currentScreen == AuthScreen.UNLOCK) {
|
if (startInCreateMode && currentScreen == AuthScreen.UNLOCK) {
|
||||||
@@ -82,12 +96,17 @@ fun AuthFlow(
|
|||||||
} else if (hasExistingAccount) {
|
} else if (hasExistingAccount) {
|
||||||
currentScreen = AuthScreen.UNLOCK
|
currentScreen = AuthScreen.UNLOCK
|
||||||
} else {
|
} else {
|
||||||
currentScreen = AuthScreen.CONFIRM_SEED
|
currentScreen = AuthScreen.SEED_PHRASE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AuthScreen.SET_BIOMETRIC -> {
|
||||||
|
currentScreen = AuthScreen.SET_PROFILE
|
||||||
|
}
|
||||||
|
AuthScreen.SET_PROFILE -> {
|
||||||
|
onAuthComplete(createdAccount)
|
||||||
|
}
|
||||||
AuthScreen.IMPORT_SEED -> {
|
AuthScreen.IMPORT_SEED -> {
|
||||||
if (isImportMode && hasExistingAccount) {
|
if (isImportMode && hasExistingAccount) {
|
||||||
// Came from UnlockScreen recover — go back to unlock
|
|
||||||
currentScreen = AuthScreen.UNLOCK
|
currentScreen = AuthScreen.UNLOCK
|
||||||
isImportMode = false
|
isImportMode = false
|
||||||
} else {
|
} else {
|
||||||
@@ -146,18 +165,14 @@ fun AuthFlow(
|
|||||||
onBack = { currentScreen = AuthScreen.WELCOME },
|
onBack = { currentScreen = AuthScreen.WELCOME },
|
||||||
onConfirm = { words ->
|
onConfirm = { words ->
|
||||||
seedPhrase = words
|
seedPhrase = words
|
||||||
currentScreen = AuthScreen.CONFIRM_SEED
|
currentScreen = AuthScreen.SET_PASSWORD
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthScreen.CONFIRM_SEED -> {
|
AuthScreen.CONFIRM_SEED -> {
|
||||||
ConfirmSeedPhraseScreen(
|
// Skipped — go directly from SEED_PHRASE to SET_PASSWORD
|
||||||
seedPhrase = seedPhrase,
|
LaunchedEffect(Unit) { currentScreen = AuthScreen.SET_PASSWORD }
|
||||||
isDarkTheme = isDarkTheme,
|
|
||||||
onBack = { currentScreen = AuthScreen.SEED_PHRASE },
|
|
||||||
onConfirmed = { currentScreen = AuthScreen.SET_PASSWORD }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthScreen.SET_PASSWORD -> {
|
AuthScreen.SET_PASSWORD -> {
|
||||||
@@ -165,19 +180,47 @@ fun AuthFlow(
|
|||||||
seedPhrase = seedPhrase,
|
seedPhrase = seedPhrase,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
isImportMode = isImportMode,
|
isImportMode = isImportMode,
|
||||||
onBack = {
|
accountManager = accountManager,
|
||||||
|
sessionCoordinator = sessionCoordinator,
|
||||||
|
onBack = {
|
||||||
if (isImportMode) {
|
if (isImportMode) {
|
||||||
currentScreen = AuthScreen.IMPORT_SEED
|
currentScreen = AuthScreen.IMPORT_SEED
|
||||||
} else if (hasExistingAccount) {
|
} else if (hasExistingAccount) {
|
||||||
currentScreen = AuthScreen.UNLOCK
|
currentScreen = AuthScreen.UNLOCK
|
||||||
} else {
|
} else {
|
||||||
currentScreen = AuthScreen.CONFIRM_SEED
|
currentScreen = AuthScreen.SEED_PHRASE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAccountCreated = { account -> onAuthComplete(account) }
|
onAccountCreated = { account ->
|
||||||
|
if (isImportMode) {
|
||||||
|
onAuthComplete(account)
|
||||||
|
} else {
|
||||||
|
createdAccount = account
|
||||||
|
currentScreen = AuthScreen.SET_BIOMETRIC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthScreen.SET_BIOMETRIC -> {
|
||||||
|
SetBiometricScreen(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
account = createdAccount,
|
||||||
|
onContinue = { currentScreen = AuthScreen.SET_PROFILE }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AuthScreen.SET_PROFILE -> {
|
||||||
|
SetProfileScreen(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
account = createdAccount,
|
||||||
|
protocolGateway = protocolGateway,
|
||||||
|
accountManager = accountManager,
|
||||||
|
onComplete = { onAuthComplete(createdAccount) },
|
||||||
|
onSkip = { onAuthComplete(createdAccount) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
AuthScreen.IMPORT_SEED -> {
|
AuthScreen.IMPORT_SEED -> {
|
||||||
ImportSeedPhraseScreen(
|
ImportSeedPhraseScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
@@ -200,6 +243,8 @@ fun AuthFlow(
|
|||||||
UnlockScreen(
|
UnlockScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
selectedAccountId = selectedAccountId,
|
selectedAccountId = selectedAccountId,
|
||||||
|
accountManager = accountManager,
|
||||||
|
sessionCoordinator = sessionCoordinator,
|
||||||
onUnlocked = { account -> onAuthComplete(account) },
|
onUnlocked = { account -> onAuthComplete(account) },
|
||||||
onSwitchAccount = {
|
onSwitchAccount = {
|
||||||
// Navigate to create new account screen
|
// Navigate to create new account screen
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
package com.rosetta.messenger.ui.auth
|
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 com.rosetta.messenger.network.ProtocolState
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
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.
|
// Desktop parity: start connection+handshake immediately, without artificial waits.
|
||||||
ProtocolManager.connect()
|
protocolGateway.connect()
|
||||||
ProtocolManager.authenticate(publicKey, privateKeyHash)
|
protocolGateway.authenticate(publicKey, privateKeyHash)
|
||||||
ProtocolManager.reconnectNowIfNeeded("auth_fast_start")
|
protocolGateway.reconnectNowIfNeeded("auth_fast_start")
|
||||||
}
|
}
|
||||||
|
|
||||||
internal suspend fun awaitAuthHandshakeState(
|
internal suspend fun awaitAuthHandshakeState(
|
||||||
|
protocolGateway: ProtocolGateway,
|
||||||
publicKey: String,
|
publicKey: String,
|
||||||
privateKeyHash: String,
|
privateKeyHash: String,
|
||||||
attempts: Int = 2,
|
attempts: Int = 2,
|
||||||
timeoutMs: Long = 25_000L
|
timeoutMs: Long = 25_000L
|
||||||
): ProtocolState? {
|
): ProtocolState? {
|
||||||
repeat(attempts) { attempt ->
|
repeat(attempts) { attempt ->
|
||||||
startAuthHandshakeFast(publicKey, privateKeyHash)
|
startAuthHandshakeFast(protocolGateway, publicKey, privateKeyHash)
|
||||||
|
|
||||||
val state = withTimeoutOrNull(timeoutMs) {
|
val state = withTimeoutOrNull(timeoutMs) {
|
||||||
ProtocolManager.state.first {
|
protocolGateway.state.first {
|
||||||
it == ProtocolState.AUTHENTICATED ||
|
it == ProtocolState.AUTHENTICATED ||
|
||||||
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
|
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
|
||||||
}
|
}
|
||||||
@@ -30,7 +35,7 @@ internal suspend fun awaitAuthHandshakeState(
|
|||||||
if (state != null) {
|
if (state != null) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
|
protocolGateway.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -144,7 +146,7 @@ fun ConfirmSeedPhraseScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Default.ArrowBack, "Back", tint = textColor)
|
Icon(TablerIcons.ChevronLeft, "Back", tint = textColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ import com.airbnb.lottie.compose.LottieConstants
|
|||||||
import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
import com.airbnb.lottie.compose.animateLottieCompositionAsState
|
||||||
import com.airbnb.lottie.compose.rememberLottieComposition
|
import com.airbnb.lottie.compose.rememberLottieComposition
|
||||||
import com.rosetta.messenger.R
|
import com.rosetta.messenger.R
|
||||||
|
import com.rosetta.messenger.di.ProtocolGateway
|
||||||
import com.rosetta.messenger.network.DeviceResolveSolution
|
import com.rosetta.messenger.network.DeviceResolveSolution
|
||||||
import com.rosetta.messenger.network.Packet
|
import com.rosetta.messenger.network.Packet
|
||||||
import com.rosetta.messenger.network.PacketDeviceResolve
|
import com.rosetta.messenger.network.PacketDeviceResolve
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
import compose.icons.tablericons.DeviceMobile
|
import compose.icons.tablericons.DeviceMobile
|
||||||
@@ -64,6 +64,7 @@ import kotlinx.coroutines.launch
|
|||||||
@Composable
|
@Composable
|
||||||
fun DeviceConfirmScreen(
|
fun DeviceConfirmScreen(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
|
protocolGateway: ProtocolGateway,
|
||||||
onExit: () -> Unit
|
onExit: () -> Unit
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
@@ -131,9 +132,9 @@ fun DeviceConfirmScreen(
|
|||||||
scope.launch { onExitState() }
|
scope.launch { onExitState() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProtocolManager.waitPacket(0x18, callback)
|
protocolGateway.waitPacket(0x18, callback)
|
||||||
onDispose {
|
onDispose {
|
||||||
ProtocolManager.unwaitPacket(0x18, callback)
|
protocolGateway.unwaitPacket(0x18, callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -64,7 +66,7 @@ fun ImportSeedPhraseScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Default.ArrowBack, "Back", tint = textColor)
|
Icon(TablerIcons.ChevronLeft, "Back", tint = textColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -65,7 +67,7 @@ fun SeedPhraseScreen(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Default.ArrowBack, "Back", tint = textColor)
|
Icon(TablerIcons.ChevronLeft, "Back", tint = textColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +205,7 @@ fun SeedPhraseScreen(
|
|||||||
shape = RoundedCornerShape(14.dp)
|
shape = RoundedCornerShape(14.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Continue",
|
text = "I Saved It",
|
||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package com.rosetta.messenger.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rosetta.messenger.biometric.BiometricAuthManager
|
||||||
|
import com.rosetta.messenger.biometric.BiometricAvailability
|
||||||
|
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||||
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private val PrimaryBlue = Color(0xFF228BE6)
|
||||||
|
private val PrimaryBlueDark = Color(0xFF5AA5FF)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SetBiometricScreen(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
account: DecryptedAccount?,
|
||||||
|
onContinue: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val biometricManager = remember { BiometricAuthManager(context) }
|
||||||
|
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||||
|
val biometricAvailable = remember { biometricManager.isBiometricAvailable() is BiometricAvailability.Available }
|
||||||
|
var biometricEnabled by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF8F8FF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||||
|
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
|
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
if (!view.isInEditMode) {
|
||||||
|
SideEffect {
|
||||||
|
val window = (view.context as android.app.Activity).window
|
||||||
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Skip button
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onContinue) {
|
||||||
|
Text(
|
||||||
|
text = "Skip",
|
||||||
|
color = accentColor,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Lock illustration
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// Background circle
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(100.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(accentColor.copy(alpha = 0.15f))
|
||||||
|
)
|
||||||
|
// Lock icon
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.ShieldLock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = accentColor,
|
||||||
|
modifier = Modifier.size(56.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Protect Your Account",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Adding biometric protection ensures\nthat only you can access your account.",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
lineHeight = 22.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(36.dp))
|
||||||
|
|
||||||
|
// Biometric toggle card
|
||||||
|
if (biometricAvailable) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(cardColor)
|
||||||
|
.clickable { biometricEnabled = !biometricEnabled }
|
||||||
|
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = accentColor,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Biometrics",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Use biometric authentication to unlock",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Switch(
|
||||||
|
checked = biometricEnabled,
|
||||||
|
onCheckedChange = { biometricEnabled = it },
|
||||||
|
colors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = Color.White,
|
||||||
|
checkedTrackColor = accentColor,
|
||||||
|
uncheckedThumbColor = Color.White,
|
||||||
|
uncheckedTrackColor = secondaryTextColor.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Device doesn't support biometrics
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(cardColor)
|
||||||
|
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Biometrics",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = secondaryTextColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Not available on this device",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = secondaryTextColor.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Continue button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (biometricEnabled && account != null) {
|
||||||
|
try {
|
||||||
|
biometricPrefs.enableBiometric(account.publicKey)
|
||||||
|
// Save encrypted password for biometric unlock
|
||||||
|
biometricPrefs.saveEncryptedPassword(
|
||||||
|
account.publicKey,
|
||||||
|
CryptoManager.encryptWithPassword(
|
||||||
|
account.privateKey.take(16),
|
||||||
|
account.publicKey
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
onContinue()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(52.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = accentColor,
|
||||||
|
contentColor = Color.White
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(14.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Continue",
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
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.AccountManager
|
||||||
import com.rosetta.messenger.data.DecryptedAccount
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
import com.rosetta.messenger.data.EncryptedAccount
|
import com.rosetta.messenger.data.EncryptedAccount
|
||||||
|
import com.rosetta.messenger.di.SessionCoordinator
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -38,34 +38,16 @@ fun SetPasswordScreen(
|
|||||||
seedPhrase: List<String>,
|
seedPhrase: List<String>,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
isImportMode: Boolean = false,
|
isImportMode: Boolean = false,
|
||||||
|
accountManager: AccountManager,
|
||||||
|
sessionCoordinator: SessionCoordinator,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAccountCreated: (DecryptedAccount) -> Unit
|
onAccountCreated: (DecryptedAccount) -> Unit
|
||||||
) {
|
) {
|
||||||
val themeAnimSpec =
|
val backgroundColor = if (isDarkTheme) AuthBackground else AuthBackgroundLight
|
||||||
tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor by
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||||
animateColorAsState(
|
val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
if (isDarkTheme) AuthBackground else AuthBackgroundLight,
|
|
||||||
animationSpec = themeAnimSpec
|
|
||||||
)
|
|
||||||
val textColor by
|
|
||||||
animateColorAsState(
|
|
||||||
if (isDarkTheme) Color.White else Color.Black,
|
|
||||||
animationSpec = themeAnimSpec
|
|
||||||
)
|
|
||||||
val secondaryTextColor by
|
|
||||||
animateColorAsState(
|
|
||||||
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666),
|
|
||||||
animationSpec = themeAnimSpec
|
|
||||||
)
|
|
||||||
val cardColor by
|
|
||||||
animateColorAsState(
|
|
||||||
if (isDarkTheme) AuthSurface else AuthSurfaceLight,
|
|
||||||
animationSpec = themeAnimSpec
|
|
||||||
)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val accountManager = remember { AccountManager(context) }
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var password by remember { mutableStateOf("") }
|
var password by remember { mutableStateOf("") }
|
||||||
@@ -74,511 +56,302 @@ fun SetPasswordScreen(
|
|||||||
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||||
var isCreating by remember { mutableStateOf(false) }
|
var isCreating by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
var visible by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Track keyboard visibility
|
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
// Auth screens should always keep white status bar icons.
|
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var isKeyboardVisible by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
DisposableEffect(view) {
|
|
||||||
val listener =
|
|
||||||
android.view.ViewTreeObserver.OnGlobalLayoutListener {
|
|
||||||
val rect = android.graphics.Rect()
|
|
||||||
view.getWindowVisibleDisplayFrame(rect)
|
|
||||||
val screenHeight = view.rootView.height
|
|
||||||
val keypadHeight = screenHeight - rect.bottom
|
|
||||||
isKeyboardVisible = keypadHeight > screenHeight * 0.15
|
|
||||||
}
|
|
||||||
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
|
|
||||||
onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) }
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) { visible = true }
|
|
||||||
|
|
||||||
val passwordsMatch = password == confirmPassword && password.isNotEmpty()
|
val passwordsMatch = password == confirmPassword && password.isNotEmpty()
|
||||||
val isPasswordWeak = password.isNotEmpty() && password.length < 6
|
val isPasswordWeak = password.isNotEmpty() && password.length < 6
|
||||||
val canContinue = passwordsMatch && !isCreating
|
val canContinue = passwordsMatch && !isCreating
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) {
|
Scaffold(
|
||||||
Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
|
containerColor = backgroundColor,
|
||||||
// Top Bar
|
topBar = {
|
||||||
Row(
|
TopAppBar(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
title = {},
|
||||||
verticalAlignment = Alignment.CenterVertically
|
navigationIcon = {
|
||||||
) {
|
IconButton(onClick = onBack, enabled = !isCreating) {
|
||||||
IconButton(onClick = onBack, enabled = !isCreating) {
|
Icon(
|
||||||
Icon(
|
|
||||||
imageVector = TablerIcons.ChevronLeft,
|
imageVector = TablerIcons.ChevronLeft,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
tint = textColor.copy(alpha = 0.6f)
|
tint = textColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
},
|
||||||
Text(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
text = "Set Password",
|
containerColor = Color.Transparent
|
||||||
fontSize = 18.sp,
|
)
|
||||||
fontWeight = FontWeight.SemiBold,
|
)
|
||||||
color = textColor
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.imePadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Lock icon
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(PrimaryBlue.copy(alpha = 0.1f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
TablerIcons.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Spacer(modifier = Modifier.width(48.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxSize()
|
|
||||||
.imePadding()
|
|
||||||
.padding(horizontal = 24.dp)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp))
|
|
||||||
|
|
||||||
// Lock Icon - smaller when keyboard is visible
|
Text(
|
||||||
val iconSize by
|
text = if (isImportMode) "Recover Account" else "Protect Your Account",
|
||||||
animateDpAsState(
|
fontSize = 20.sp,
|
||||||
targetValue = if (isKeyboardVisible) 48.dp else 80.dp,
|
fontWeight = FontWeight.Bold,
|
||||||
animationSpec = tween(300, easing = FastOutSlowInEasing)
|
color = textColor
|
||||||
)
|
)
|
||||||
val iconInnerSize by
|
|
||||||
animateDpAsState(
|
|
||||||
targetValue = if (isKeyboardVisible) 24.dp else 40.dp,
|
|
||||||
animationSpec = tween(300, easing = FastOutSlowInEasing)
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
visible = visible,
|
|
||||||
enter =
|
Text(
|
||||||
fadeIn(tween(250)) +
|
text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
|
||||||
scaleIn(
|
fontSize = 14.sp,
|
||||||
initialScale = 0.5f,
|
color = secondaryTextColor,
|
||||||
animationSpec =
|
textAlign = TextAlign.Center,
|
||||||
tween(250, easing = FastOutSlowInEasing)
|
lineHeight = 20.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
|
||||||
|
// Password field — clean Telegram style
|
||||||
|
TextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it; error = null },
|
||||||
|
placeholder = { Text("Password", color = secondaryTextColor) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedTextColor = textColor,
|
||||||
|
unfocusedTextColor = textColor,
|
||||||
|
focusedContainerColor = fieldBackground,
|
||||||
|
unfocusedContainerColor = fieldBackground,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = PrimaryBlue
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Next
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Strength bar
|
||||||
|
if (password.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
val strength = when {
|
||||||
|
password.length < 6 -> 0.25f
|
||||||
|
password.length < 10 -> 0.6f
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
|
val strengthColor = when {
|
||||||
|
password.length < 6 -> Color(0xFFE53935)
|
||||||
|
password.length < 10 -> Color(0xFFFFA726)
|
||||||
|
else -> Color(0xFF4CAF50)
|
||||||
|
}
|
||||||
|
val strengthLabel = when {
|
||||||
|
password.length < 6 -> "Weak"
|
||||||
|
password.length < 10 -> "Medium"
|
||||||
|
else -> "Strong"
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.size(iconSize)
|
.weight(1f)
|
||||||
.clip(
|
.height(3.dp)
|
||||||
RoundedCornerShape(
|
.clip(RoundedCornerShape(2.dp))
|
||||||
if (isKeyboardVisible) 12.dp else 20.dp
|
.background(fieldBackground)
|
||||||
)
|
|
||||||
)
|
|
||||||
.background(PrimaryBlue.copy(alpha = 0.1f)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
TablerIcons.Lock,
|
modifier = Modifier
|
||||||
contentDescription = null,
|
.fillMaxHeight()
|
||||||
tint = PrimaryBlue,
|
.fillMaxWidth(strength)
|
||||||
modifier = Modifier.size(iconInnerSize)
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(strengthColor)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp))
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 100))
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = if (isImportMode) "Recover Account" else "Protect Your Account",
|
text = strengthLabel,
|
||||||
fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
color = strengthColor
|
||||||
color = textColor
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(500, delayMillis = 200))
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (isImportMode)
|
|
||||||
"Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
|
|
||||||
else
|
|
||||||
"This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
|
|
||||||
fontSize = if (isKeyboardVisible) 12.sp else 14.sp,
|
|
||||||
color = secondaryTextColor,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp))
|
|
||||||
|
|
||||||
// Password Field
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 300))
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = password,
|
|
||||||
onValueChange = {
|
|
||||||
password = it
|
|
||||||
error = null
|
|
||||||
},
|
|
||||||
label = { Text("Password") },
|
|
||||||
placeholder = { Text("Enter password") },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation =
|
|
||||||
if (passwordVisible) VisualTransformation.None
|
|
||||||
else PasswordVisualTransformation(),
|
|
||||||
trailingIcon = {
|
|
||||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (passwordVisible) TablerIcons.EyeOff
|
|
||||||
else TablerIcons.Eye,
|
|
||||||
contentDescription =
|
|
||||||
if (passwordVisible) "Hide" else "Show"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors =
|
|
||||||
OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = PrimaryBlue,
|
|
||||||
unfocusedBorderColor =
|
|
||||||
if (isDarkTheme) Color(0xFF4A4A4A)
|
|
||||||
else Color(0xFFD0D0D0),
|
|
||||||
focusedLabelColor = PrimaryBlue,
|
|
||||||
cursorColor = PrimaryBlue,
|
|
||||||
focusedTextColor = textColor,
|
|
||||||
unfocusedTextColor = textColor
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
keyboardOptions =
|
|
||||||
KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
imeAction = ImeAction.Next
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password strength indicator
|
|
||||||
if (password.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 350))
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
val strength =
|
|
||||||
when {
|
|
||||||
password.length < 6 -> "Weak"
|
|
||||||
password.length < 10 -> "Medium"
|
|
||||||
else -> "Strong"
|
|
||||||
}
|
|
||||||
val strengthColor =
|
|
||||||
when {
|
|
||||||
password.length < 6 -> Color(0xFFE53935)
|
|
||||||
password.length < 10 -> Color(0xFFFFA726)
|
|
||||||
else -> Color(0xFF4CAF50)
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
painter = TelegramIcons.Secret,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = strengthColor,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "Password strength: $strength",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = strengthColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Warning for weak passwords
|
|
||||||
if (isPasswordWeak) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(
|
|
||||||
Color(0xFFE53935).copy(alpha = 0.1f)
|
|
||||||
)
|
|
||||||
.padding(8.dp),
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = TelegramIcons.Warning,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFFE53935),
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
"Your password is too weak. Consider using at least 6 characters for better security.",
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = Color(0xFFE53935),
|
|
||||||
lineHeight = 14.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Confirm Password Field
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 400))
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = confirmPassword,
|
|
||||||
onValueChange = {
|
|
||||||
confirmPassword = it
|
|
||||||
error = null
|
|
||||||
},
|
|
||||||
label = { Text("Confirm Password") },
|
|
||||||
placeholder = { Text("Re-enter password") },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation =
|
|
||||||
if (confirmPasswordVisible) VisualTransformation.None
|
|
||||||
else PasswordVisualTransformation(),
|
|
||||||
trailingIcon = {
|
|
||||||
IconButton(
|
|
||||||
onClick = {
|
|
||||||
confirmPasswordVisible = !confirmPasswordVisible
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector =
|
|
||||||
if (confirmPasswordVisible)
|
|
||||||
TablerIcons.EyeOff
|
|
||||||
else TablerIcons.Eye,
|
|
||||||
contentDescription =
|
|
||||||
if (confirmPasswordVisible) "Hide" else "Show"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
|
|
||||||
colors =
|
|
||||||
OutlinedTextFieldDefaults.colors(
|
|
||||||
focusedBorderColor = PrimaryBlue,
|
|
||||||
unfocusedBorderColor =
|
|
||||||
if (isDarkTheme) Color(0xFF4A4A4A)
|
|
||||||
else Color(0xFFD0D0D0),
|
|
||||||
focusedLabelColor = PrimaryBlue,
|
|
||||||
cursorColor = PrimaryBlue,
|
|
||||||
focusedTextColor = textColor,
|
|
||||||
unfocusedTextColor = textColor
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
keyboardOptions =
|
|
||||||
KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
|
||||||
imeAction = ImeAction.Done
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match indicator
|
|
||||||
if (confirmPassword.isNotEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 450))
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
val matchIcon =
|
|
||||||
if (passwordsMatch) TablerIcons.Check else TablerIcons.X
|
|
||||||
val matchColor =
|
|
||||||
if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
|
|
||||||
val matchText =
|
|
||||||
if (passwordsMatch) "Passwords match"
|
|
||||||
else "Passwords don't match"
|
|
||||||
|
|
||||||
Icon(
|
|
||||||
imageVector = matchIcon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = matchColor,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(text = matchText, fontSize = 12.sp, color = matchColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error message
|
|
||||||
error?.let { errorMsg ->
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = true,
|
|
||||||
enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = errorMsg,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = Color(0xFFE53935),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
// Info - hide when keyboard is visible
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible && !isKeyboardVisible,
|
|
||||||
enter = fadeIn(tween(300)),
|
|
||||||
exit = fadeOut(tween(200))
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier =
|
|
||||||
Modifier.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(cardColor)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = TelegramIcons.Info,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = PrimaryBlue,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Text(
|
|
||||||
text =
|
|
||||||
"Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
color = secondaryTextColor,
|
|
||||||
lineHeight = 18.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Create Account Button
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 600))
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (!passwordsMatch) {
|
|
||||||
error = "Passwords don't match"
|
|
||||||
return@Button
|
|
||||||
}
|
|
||||||
|
|
||||||
isCreating = true
|
|
||||||
scope.launch {
|
|
||||||
try {
|
|
||||||
// Generate keys from seed phrase
|
|
||||||
val keyPair =
|
|
||||||
CryptoManager.generateKeyPairFromSeed(seedPhrase)
|
|
||||||
|
|
||||||
// Encrypt private key and seed phrase
|
|
||||||
val encryptedPrivateKey =
|
|
||||||
CryptoManager.encryptWithPassword(
|
|
||||||
keyPair.privateKey,
|
|
||||||
password
|
|
||||||
)
|
|
||||||
val encryptedSeedPhrase =
|
|
||||||
CryptoManager.encryptWithPassword(
|
|
||||||
seedPhrase.joinToString(" "),
|
|
||||||
password
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save account with truncated public key as name
|
|
||||||
val truncatedKey =
|
|
||||||
"${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}"
|
|
||||||
val account =
|
|
||||||
EncryptedAccount(
|
|
||||||
publicKey = keyPair.publicKey,
|
|
||||||
encryptedPrivateKey = encryptedPrivateKey,
|
|
||||||
encryptedSeedPhrase = encryptedSeedPhrase,
|
|
||||||
name = truncatedKey
|
|
||||||
)
|
|
||||||
|
|
||||||
accountManager.saveAccount(account)
|
|
||||||
|
|
||||||
// 🔌 Connect to server and authenticate
|
|
||||||
val privateKeyHash =
|
|
||||||
CryptoManager.generatePrivateKeyHash(
|
|
||||||
keyPair.privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
startAuthHandshakeFast(
|
|
||||||
keyPair.publicKey,
|
|
||||||
privateKeyHash
|
|
||||||
)
|
|
||||||
|
|
||||||
accountManager.setCurrentAccount(keyPair.publicKey)
|
|
||||||
|
|
||||||
// Create DecryptedAccount to pass to callback
|
|
||||||
val decryptedAccount =
|
|
||||||
DecryptedAccount(
|
|
||||||
publicKey = keyPair.publicKey,
|
|
||||||
privateKey = keyPair.privateKey,
|
|
||||||
seedPhrase = seedPhrase,
|
|
||||||
privateKeyHash = privateKeyHash,
|
|
||||||
name = truncatedKey
|
|
||||||
)
|
|
||||||
|
|
||||||
onAccountCreated(decryptedAccount)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
error = "Failed to create account: ${e.message}"
|
|
||||||
isCreating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = canContinue,
|
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
|
||||||
colors =
|
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = PrimaryBlue,
|
|
||||||
contentColor = Color.White,
|
|
||||||
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
|
|
||||||
disabledContentColor = Color.White.copy(alpha = 0.5f)
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
if (isCreating) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
color = Color.White,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = if (isImportMode) "Recover Account" else "Create Account",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Confirm password field
|
||||||
|
TextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = { confirmPassword = it; error = null },
|
||||||
|
placeholder = { Text("Confirm password", color = secondaryTextColor) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (confirmPasswordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedTextColor = textColor,
|
||||||
|
unfocusedTextColor = textColor,
|
||||||
|
focusedContainerColor = fieldBackground,
|
||||||
|
unfocusedContainerColor = fieldBackground,
|
||||||
|
errorContainerColor = fieldBackground,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
errorIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = PrimaryBlue
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Match status
|
||||||
|
if (confirmPassword.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
val matchColor = if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordsMatch) TablerIcons.Check else TablerIcons.X,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = matchColor,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = if (passwordsMatch) "Passwords match" else "Passwords don't match",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = matchColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error
|
||||||
|
error?.let {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(text = it, fontSize = 13.sp, color = Color(0xFFE53935), textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Create button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (!passwordsMatch) {
|
||||||
|
error = "Passwords don't match"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
isCreating = true
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
|
||||||
|
val encryptedPrivateKey = CryptoManager.encryptWithPassword(keyPair.privateKey, password)
|
||||||
|
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(seedPhrase.joinToString(" "), password)
|
||||||
|
val truncatedKey = "${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}"
|
||||||
|
val account = EncryptedAccount(
|
||||||
|
publicKey = keyPair.publicKey,
|
||||||
|
encryptedPrivateKey = encryptedPrivateKey,
|
||||||
|
encryptedSeedPhrase = encryptedSeedPhrase,
|
||||||
|
name = truncatedKey
|
||||||
|
)
|
||||||
|
accountManager.saveAccount(account)
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
|
||||||
|
val decryptedAccount = DecryptedAccount(
|
||||||
|
publicKey = keyPair.publicKey,
|
||||||
|
privateKey = keyPair.privateKey,
|
||||||
|
seedPhrase = seedPhrase,
|
||||||
|
privateKeyHash = privateKeyHash,
|
||||||
|
name = truncatedKey
|
||||||
|
)
|
||||||
|
sessionCoordinator.bootstrapAuthenticatedSession(
|
||||||
|
account = decryptedAccount,
|
||||||
|
reason = "set_password"
|
||||||
|
)
|
||||||
|
onAccountCreated(decryptedAccount)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
error = "Failed to create account: ${e.message}"
|
||||||
|
isCreating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = canContinue,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PrimaryBlue,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = PrimaryBlue.copy(alpha = 0.4f),
|
||||||
|
disabledContentColor = Color.White.copy(alpha = 0.5f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
if (isCreating) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = if (isImportMode) "Recover Account" else "Create Account",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,542 @@
|
|||||||
|
package com.rosetta.messenger.ui.auth
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
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 coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
|
import com.rosetta.messenger.data.AccountManager
|
||||||
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
|
import com.rosetta.messenger.di.ProtocolGateway
|
||||||
|
import com.rosetta.messenger.network.PacketUserInfo
|
||||||
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
|
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
|
||||||
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
|
import com.rosetta.messenger.utils.ImageCropHelper
|
||||||
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
private val PrimaryBlue = Color(0xFF248AE6)
|
||||||
|
private val ErrorRed = Color(0xFFFF3B30)
|
||||||
|
|
||||||
|
private const val NAME_MAX_LENGTH = 40
|
||||||
|
private const val USERNAME_MIN_LENGTH = 5
|
||||||
|
private const val USERNAME_MAX_LENGTH = 32
|
||||||
|
private val NAME_ALLOWED_REGEX = Regex("^[\\p{L}\\p{N} ._'-]+$")
|
||||||
|
private val USERNAME_ALLOWED_REGEX = Regex("^[A-Za-z0-9_]+$")
|
||||||
|
|
||||||
|
private fun validateName(name: String): String? {
|
||||||
|
if (name.isBlank()) return "Name can't be empty"
|
||||||
|
if (name.length > NAME_MAX_LENGTH) return "Name is too long (max $NAME_MAX_LENGTH)"
|
||||||
|
if (!NAME_ALLOWED_REGEX.matches(name)) return "Only letters, numbers, spaces, . _ - ' are allowed"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateUsername(username: String): String? {
|
||||||
|
if (username.isBlank()) return null // optional
|
||||||
|
if (username.length < USERNAME_MIN_LENGTH) return "Username must be at least $USERNAME_MIN_LENGTH characters"
|
||||||
|
if (username.length > USERNAME_MAX_LENGTH) return "Username is too long (max $USERNAME_MAX_LENGTH)"
|
||||||
|
if (!USERNAME_ALLOWED_REGEX.matches(username)) return "Use only letters, numbers, and underscore"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SetProfileScreen(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
account: DecryptedAccount?,
|
||||||
|
protocolGateway: ProtocolGateway,
|
||||||
|
accountManager: AccountManager,
|
||||||
|
onComplete: () -> Unit,
|
||||||
|
onSkip: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var username by remember { mutableStateOf("") }
|
||||||
|
var nameTouched by remember { mutableStateOf(false) }
|
||||||
|
var usernameTouched by remember { mutableStateOf(false) }
|
||||||
|
var avatarUri by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showPhotoPicker by remember { mutableStateOf(false) }
|
||||||
|
var isSaving by remember { mutableStateOf(false) }
|
||||||
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Username availability check
|
||||||
|
var usernameAvailable by remember { mutableStateOf<Boolean?>(null) }
|
||||||
|
var isCheckingUsername by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(username) {
|
||||||
|
val trimmed = username.trim()
|
||||||
|
if (trimmed.length < USERNAME_MIN_LENGTH) {
|
||||||
|
usernameAvailable = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (validateUsername(trimmed) != null) {
|
||||||
|
usernameAvailable = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
usernameAvailable = null
|
||||||
|
isCheckingUsername = true
|
||||||
|
delay(600) // debounce
|
||||||
|
try {
|
||||||
|
val results = protocolGateway.searchUsers(trimmed, 3000)
|
||||||
|
val taken = results.any { it.username.equals(trimmed, ignoreCase = true) }
|
||||||
|
usernameAvailable = !taken
|
||||||
|
} catch (_: Exception) {
|
||||||
|
usernameAvailable = null
|
||||||
|
}
|
||||||
|
isCheckingUsername = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val nameError = if (nameTouched) validateName(name.trim()) else null
|
||||||
|
val localUsernameError = if (usernameTouched) validateUsername(username.trim()) else null
|
||||||
|
val usernameError = localUsernameError
|
||||||
|
?: if (usernameTouched && usernameAvailable == false) "Username is already taken" else null
|
||||||
|
val isFormValid = validateName(name.trim()) == null
|
||||||
|
&& validateUsername(username.trim()) == null
|
||||||
|
&& usernameAvailable != false
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { visible = true }
|
||||||
|
|
||||||
|
val cropLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
|
||||||
|
if (croppedUri != null) {
|
||||||
|
avatarUri = croppedUri.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF8F8FF)
|
||||||
|
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color(0xFF1A1A1A)
|
||||||
|
val secondaryText = Color(0xFF8E8E93)
|
||||||
|
val avatarBg = if (isDarkTheme) Color(0xFF333336) else PrimaryBlue
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Skip button
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onSkip) {
|
||||||
|
Text(
|
||||||
|
text = "Skip",
|
||||||
|
color = PrimaryBlue,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(400)) + scaleIn(tween(400), initialScale = 0.8f)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(100.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(avatarBg)
|
||||||
|
.clickable { showPhotoPicker = true }
|
||||||
|
) {
|
||||||
|
if (avatarUri != null) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(context)
|
||||||
|
.data(Uri.parse(avatarUri))
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = "Avatar",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Camera,
|
||||||
|
contentDescription = "Set photo",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
TextButton(onClick = { showPhotoPicker = true }) {
|
||||||
|
Text(
|
||||||
|
text = "Set Photo",
|
||||||
|
color = PrimaryBlue,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Title
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(400, delayMillis = 150))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "What's your name?",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = textColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(400, delayMillis = 250))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Your friends can find you by this name. It will be displayed in chats, groups, and your profile.",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
color = secondaryText,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
lineHeight = 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
|
||||||
|
// Name & Username inputs
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(400, delayMillis = 350))
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
// Name field
|
||||||
|
Column {
|
||||||
|
TextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = {
|
||||||
|
name = it.take(NAME_MAX_LENGTH)
|
||||||
|
nameTouched = true
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text("Your name", color = secondaryText)
|
||||||
|
},
|
||||||
|
isError = nameError != null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp)),
|
||||||
|
singleLine = true,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedTextColor = textColor,
|
||||||
|
unfocusedTextColor = textColor,
|
||||||
|
focusedContainerColor = cardColor,
|
||||||
|
unfocusedContainerColor = cardColor,
|
||||||
|
errorContainerColor = cardColor,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
errorIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = PrimaryBlue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (nameError != null) {
|
||||||
|
Text(
|
||||||
|
text = nameError,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = ErrorRed,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Username field
|
||||||
|
Column {
|
||||||
|
TextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { raw ->
|
||||||
|
username = raw.take(USERNAME_MAX_LENGTH)
|
||||||
|
.lowercase()
|
||||||
|
.filter { it.isLetterOrDigit() || it == '_' }
|
||||||
|
usernameTouched = true
|
||||||
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text("Username", color = secondaryText)
|
||||||
|
},
|
||||||
|
prefix = {
|
||||||
|
Text("@", color = secondaryText, fontSize = 16.sp)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
when {
|
||||||
|
isCheckingUsername && username.trim().length >= USERNAME_MIN_LENGTH -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = secondaryText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
usernameAvailable == true && usernameError == null -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.Check,
|
||||||
|
contentDescription = "Available",
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
usernameAvailable == false -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.X,
|
||||||
|
contentDescription = "Taken",
|
||||||
|
tint = ErrorRed,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isError = usernameError != null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(14.dp)),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
capitalization = androidx.compose.ui.text.input.KeyboardCapitalization.None,
|
||||||
|
autoCorrect = false
|
||||||
|
),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedTextColor = textColor,
|
||||||
|
unfocusedTextColor = textColor,
|
||||||
|
focusedContainerColor = cardColor,
|
||||||
|
unfocusedContainerColor = cardColor,
|
||||||
|
errorContainerColor = cardColor,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
errorIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = PrimaryBlue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (usernameError != null) {
|
||||||
|
Text(
|
||||||
|
text = usernameError,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = ErrorRed,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
} else if (usernameAvailable == true) {
|
||||||
|
Text(
|
||||||
|
text = "Username is available!",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Username is optional. People can use it to find you without sharing your key.",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = secondaryText.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
|
||||||
|
lineHeight = 16.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
// Continue button
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(400, delayMillis = 450))
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (account == null) {
|
||||||
|
onComplete()
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
isSaving = true
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
// Wait for server connection (up to 8s)
|
||||||
|
val connected = withTimeoutOrNull(8000) {
|
||||||
|
while (!protocolGateway.isAuthenticated()) {
|
||||||
|
delay(300)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
// Save name and username locally first
|
||||||
|
if (name.trim().isNotEmpty()) {
|
||||||
|
accountManager.updateAccountName(account.publicKey, name.trim())
|
||||||
|
}
|
||||||
|
if (username.trim().isNotEmpty()) {
|
||||||
|
accountManager.updateAccountUsername(account.publicKey, username.trim())
|
||||||
|
}
|
||||||
|
// Trigger UI refresh in MainActivity
|
||||||
|
protocolGateway.notifyOwnProfileUpdated()
|
||||||
|
|
||||||
|
// Send name and username to server
|
||||||
|
if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) {
|
||||||
|
val packet = PacketUserInfo()
|
||||||
|
packet.title = name.trim()
|
||||||
|
packet.username = username.trim()
|
||||||
|
packet.privateKey = account.privateKeyHash
|
||||||
|
protocolGateway.send(packet)
|
||||||
|
delay(1500)
|
||||||
|
|
||||||
|
// Повторяем для надёжности
|
||||||
|
if (protocolGateway.isAuthenticated()) {
|
||||||
|
val packet2 = PacketUserInfo()
|
||||||
|
packet2.title = name.trim()
|
||||||
|
packet2.username = username.trim()
|
||||||
|
packet2.privateKey = account.privateKeyHash
|
||||||
|
protocolGateway.send(packet2)
|
||||||
|
delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save avatar
|
||||||
|
if (avatarUri != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val uri = Uri.parse(avatarUri)
|
||||||
|
val rawBytes = context.contentResolver
|
||||||
|
.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
if (rawBytes != null && rawBytes.isNotEmpty()) {
|
||||||
|
val preparedBase64 = AvatarFileManager
|
||||||
|
.imagePrepareForNetworkTransfer(context, rawBytes)
|
||||||
|
if (preparedBase64.isNotBlank()) {
|
||||||
|
val db = RosettaDatabase.getDatabase(context)
|
||||||
|
val avatarRepo = AvatarRepository(
|
||||||
|
context = context,
|
||||||
|
avatarDao = db.avatarDao(),
|
||||||
|
currentPublicKey = account.publicKey
|
||||||
|
)
|
||||||
|
avatarRepo.saveAvatar(
|
||||||
|
fromPublicKey = account.publicKey,
|
||||||
|
base64Image = "data:image/png;base64,$preparedBase64"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = isFormValid && !isSaving,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(52.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PrimaryBlue,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
|
||||||
|
disabledContentColor = Color.White.copy(alpha = 0.5f)
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(14.dp)
|
||||||
|
) {
|
||||||
|
if (isSaving) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Continue",
|
||||||
|
fontSize = 17.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Examples of where name appears
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(400, delayMillis = 550))
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Your name will appear in:",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = secondaryText,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Chat list • Messages • Groups • Profile",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = secondaryText.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfilePhotoPicker(
|
||||||
|
isVisible = showPhotoPicker,
|
||||||
|
onDismiss = { showPhotoPicker = false },
|
||||||
|
onPhotoSelected = { uri ->
|
||||||
|
showPhotoPicker = false
|
||||||
|
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
|
||||||
|
cropLauncher.launch(cropIntent)
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import com.rosetta.messenger.data.DecryptedAccount
|
|||||||
import com.rosetta.messenger.data.EncryptedAccount
|
import com.rosetta.messenger.data.EncryptedAccount
|
||||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
|
import com.rosetta.messenger.di.SessionCoordinator
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.chats.getAvatarColor
|
import com.rosetta.messenger.ui.chats.getAvatarColor
|
||||||
@@ -68,6 +69,7 @@ private suspend fun performUnlock(
|
|||||||
selectedAccount: AccountItem?,
|
selectedAccount: AccountItem?,
|
||||||
password: String,
|
password: String,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
|
sessionCoordinator: SessionCoordinator,
|
||||||
onUnlocking: (Boolean) -> Unit,
|
onUnlocking: (Boolean) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
onSuccess: (DecryptedAccount) -> Unit
|
onSuccess: (DecryptedAccount) -> Unit
|
||||||
@@ -116,9 +118,10 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
|||||||
name = selectedAccount.name
|
name = selectedAccount.name
|
||||||
)
|
)
|
||||||
|
|
||||||
startAuthHandshakeFast(account.publicKey, privateKeyHash)
|
sessionCoordinator.bootstrapAuthenticatedSession(
|
||||||
|
account = decryptedAccount,
|
||||||
accountManager.setCurrentAccount(account.publicKey)
|
reason = "unlock"
|
||||||
|
)
|
||||||
onSuccess(decryptedAccount)
|
onSuccess(decryptedAccount)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
onError("Failed to unlock: ${e.message}")
|
onError("Failed to unlock: ${e.message}")
|
||||||
@@ -131,6 +134,8 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
|||||||
fun UnlockScreen(
|
fun UnlockScreen(
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
selectedAccountId: String? = null,
|
selectedAccountId: String? = null,
|
||||||
|
accountManager: AccountManager,
|
||||||
|
sessionCoordinator: SessionCoordinator,
|
||||||
onUnlocked: (DecryptedAccount) -> Unit,
|
onUnlocked: (DecryptedAccount) -> Unit,
|
||||||
onSwitchAccount: () -> Unit = {},
|
onSwitchAccount: () -> Unit = {},
|
||||||
onRecover: () -> Unit = {}
|
onRecover: () -> Unit = {}
|
||||||
@@ -160,7 +165,6 @@ fun UnlockScreen(
|
|||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? FragmentActivity
|
val activity = context as? FragmentActivity
|
||||||
val accountManager = remember { AccountManager(context) }
|
|
||||||
val biometricManager = remember { BiometricAuthManager(context) }
|
val biometricManager = remember { BiometricAuthManager(context) }
|
||||||
val biometricPrefs = remember { BiometricPreferences(context) }
|
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -217,6 +221,10 @@ fun UnlockScreen(
|
|||||||
|
|
||||||
// Проверяем доступность биометрии
|
// Проверяем доступность биометрии
|
||||||
biometricAvailable = biometricManager.isBiometricAvailable()
|
biometricAvailable = biometricManager.isBiometricAvailable()
|
||||||
|
val accountKey = targetAccount?.publicKey ?: accounts.firstOrNull()?.publicKey ?: ""
|
||||||
|
if (accountKey.isNotEmpty()) {
|
||||||
|
biometricPrefs.loadForAccount(accountKey)
|
||||||
|
}
|
||||||
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
|
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
|
||||||
|
|
||||||
// Загружаем сохранённые пароли для всех аккаунтов
|
// Загружаем сохранённые пароли для всех аккаунтов
|
||||||
@@ -255,6 +263,7 @@ fun UnlockScreen(
|
|||||||
selectedAccount = selectedAccount,
|
selectedAccount = selectedAccount,
|
||||||
password = decryptedPassword,
|
password = decryptedPassword,
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
|
sessionCoordinator = sessionCoordinator,
|
||||||
onUnlocking = { isUnlocking = it },
|
onUnlocking = { isUnlocking = it },
|
||||||
onError = { error = it },
|
onError = { error = it },
|
||||||
onSuccess = { decryptedAccount ->
|
onSuccess = { decryptedAccount ->
|
||||||
@@ -441,6 +450,8 @@ fun UnlockScreen(
|
|||||||
isDropdownExpanded = false
|
isDropdownExpanded = false
|
||||||
password = ""
|
password = ""
|
||||||
error = null
|
error = null
|
||||||
|
biometricPrefs.loadForAccount(account.publicKey)
|
||||||
|
isBiometricEnabled = biometricPrefs.isBiometricEnabledForAccount(account.publicKey)
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
if (isSelected) PrimaryBlue.copy(alpha = 0.1f)
|
if (isSelected) PrimaryBlue.copy(alpha = 0.1f)
|
||||||
@@ -598,6 +609,7 @@ fun UnlockScreen(
|
|||||||
selectedAccount = selectedAccount,
|
selectedAccount = selectedAccount,
|
||||||
password = password,
|
password = password,
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
|
sessionCoordinator = sessionCoordinator,
|
||||||
onUnlocking = { isUnlocking = it },
|
onUnlocking = { isUnlocking = it },
|
||||||
onError = { error = it },
|
onError = { error = it },
|
||||||
onSuccess = { decryptedAccount ->
|
onSuccess = { decryptedAccount ->
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -81,7 +83,7 @@ fun WelcomeScreen(
|
|||||||
if (hasExistingAccount) {
|
if (hasExistingAccount) {
|
||||||
IconButton(onClick = onBack, modifier = Modifier.statusBarsPadding().padding(4.dp)) {
|
IconButton(onClick = onBack, modifier = Modifier.statusBarsPadding().padding(4.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.ArrowBack,
|
TablerIcons.ChevronLeft,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
tint = textColor.copy(alpha = 0.6f)
|
tint = textColor.copy(alpha = 0.6f)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,684 @@
|
|||||||
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
|
import com.rosetta.messenger.data.ForwardManager
|
||||||
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
|
import com.rosetta.messenger.domain.chats.usecase.CreateAvatarAttachmentCommand
|
||||||
|
import com.rosetta.messenger.domain.chats.usecase.CreateFileAttachmentCommand
|
||||||
|
import com.rosetta.messenger.domain.chats.usecase.CreateVideoCircleAttachmentCommand
|
||||||
|
import com.rosetta.messenger.domain.chats.usecase.EncryptAndUploadAttachmentCommand
|
||||||
|
import com.rosetta.messenger.domain.chats.usecase.SendMediaMessageCommand
|
||||||
|
import com.rosetta.messenger.domain.chats.usecase.SendTypingIndicatorCommand
|
||||||
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
|
import com.rosetta.messenger.network.DeliveryStatus
|
||||||
|
import com.rosetta.messenger.network.MessageAttachment
|
||||||
|
import com.rosetta.messenger.network.SearchUser
|
||||||
|
import com.rosetta.messenger.ui.chats.models.ChatMessage
|
||||||
|
import com.rosetta.messenger.ui.chats.models.MessageStatus
|
||||||
|
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||||
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
|
import com.rosetta.messenger.utils.MediaUtils
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class MessagesViewModel internal constructor(
|
||||||
|
private val chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
|
val messages: StateFlow<List<ChatMessage>> = chatViewModel.messages
|
||||||
|
val messagesWithDates: StateFlow<List<Pair<ChatMessage, Boolean>>> = chatViewModel.messagesWithDates
|
||||||
|
val isLoading: StateFlow<Boolean> = chatViewModel.isLoading
|
||||||
|
val isLoadingMore: StateFlow<Boolean> = chatViewModel.isLoadingMore
|
||||||
|
val groupRequiresRejoin: StateFlow<Boolean> = chatViewModel.groupRequiresRejoin
|
||||||
|
val inputText: StateFlow<String> = chatViewModel.inputText
|
||||||
|
val replyMessages: StateFlow<List<ChatViewModel.ReplyMessage>> = chatViewModel.replyMessages
|
||||||
|
val isForwardMode: StateFlow<Boolean> = chatViewModel.isForwardMode
|
||||||
|
val pendingDeleteIds: StateFlow<Set<String>> = chatViewModel.pendingDeleteIds
|
||||||
|
val pinnedMessages = chatViewModel.pinnedMessages
|
||||||
|
val pinnedMessagePreviews = chatViewModel.pinnedMessagePreviews
|
||||||
|
val currentPinnedIndex = chatViewModel.currentPinnedIndex
|
||||||
|
val chatOpenMetrics = chatViewModel.chatOpenMetrics
|
||||||
|
val myPublicKey: String? get() = chatViewModel.myPublicKey
|
||||||
|
|
||||||
|
fun setUserKeys(publicKey: String, privateKey: String) = chatViewModel.setUserKeys(publicKey, privateKey)
|
||||||
|
|
||||||
|
fun ensureSendContext(
|
||||||
|
publicKey: String,
|
||||||
|
title: String = "",
|
||||||
|
username: String = "",
|
||||||
|
verified: Int = 0
|
||||||
|
) = chatViewModel.ensureSendContext(publicKey, title, username, verified)
|
||||||
|
|
||||||
|
fun openDialog(
|
||||||
|
publicKey: String,
|
||||||
|
title: String = "",
|
||||||
|
username: String = "",
|
||||||
|
verified: Int = 0
|
||||||
|
) = chatViewModel.openDialog(publicKey, title, username, verified)
|
||||||
|
|
||||||
|
fun closeDialog() = chatViewModel.closeDialog()
|
||||||
|
|
||||||
|
fun setDialogActive(active: Boolean) = chatViewModel.setDialogActive(active)
|
||||||
|
|
||||||
|
fun loadMoreMessages() = chatViewModel.loadMoreMessages()
|
||||||
|
|
||||||
|
fun updateInputText(text: String) = chatViewModel.updateInputText(text)
|
||||||
|
|
||||||
|
fun setReplyMessages(messages: List<ChatMessage>) = chatViewModel.setReplyMessages(messages)
|
||||||
|
|
||||||
|
fun setForwardMessages(messages: List<ChatMessage>) = chatViewModel.setForwardMessages(messages)
|
||||||
|
|
||||||
|
fun clearReplyMessages() = chatViewModel.clearReplyMessages()
|
||||||
|
|
||||||
|
suspend fun ensureMessageLoaded(messageId: String): Boolean = chatViewModel.ensureMessageLoaded(messageId)
|
||||||
|
|
||||||
|
fun pinMessage(messageId: String) = chatViewModel.pinMessage(messageId)
|
||||||
|
|
||||||
|
fun unpinMessage(messageId: String) = chatViewModel.unpinMessage(messageId)
|
||||||
|
|
||||||
|
suspend fun isMessagePinned(messageId: String): Boolean = chatViewModel.isMessagePinned(messageId)
|
||||||
|
|
||||||
|
fun navigateToNextPinned(): String? = chatViewModel.navigateToNextPinned()
|
||||||
|
|
||||||
|
fun unpinAllMessages() = chatViewModel.unpinAllMessages()
|
||||||
|
|
||||||
|
fun deleteMessage(messageId: String) = chatViewModel.deleteMessage(messageId)
|
||||||
|
|
||||||
|
suspend fun resolveUserForProfile(publicKey: String): SearchUser? =
|
||||||
|
chatViewModel.resolveUserForProfile(publicKey)
|
||||||
|
|
||||||
|
suspend fun resolveUserByUsername(username: String, timeoutMs: Long = 3000): SearchUser? =
|
||||||
|
chatViewModel.resolveUserByUsername(username, timeoutMs)
|
||||||
|
|
||||||
|
fun retryMessage(message: ChatMessage) {
|
||||||
|
deleteMessage(message.id)
|
||||||
|
updateInputText(message.text)
|
||||||
|
chatViewModel.launchInViewModel {
|
||||||
|
delay(100)
|
||||||
|
sendMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage() {
|
||||||
|
val hasPayload = inputText.value.trim().isNotEmpty() || replyMessages.value.isNotEmpty()
|
||||||
|
if (!hasPayload) return
|
||||||
|
chatViewModel.trySendTextMessage(allowPendingRecovery = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendForwardDirectly(
|
||||||
|
targetPublicKey: String,
|
||||||
|
forwardedMessages: List<ForwardManager.ForwardMessage>
|
||||||
|
) = chatViewModel.sendForwardDirectly(targetPublicKey, forwardedMessages)
|
||||||
|
|
||||||
|
fun markVisibleMessagesAsRead() = chatViewModel.markVisibleMessagesAsRead()
|
||||||
|
|
||||||
|
fun subscribeToOnlineStatus() = chatViewModel.subscribeToOnlineStatus()
|
||||||
|
|
||||||
|
fun markFirstListLayoutReady() = chatViewModel.markFirstListLayoutReady()
|
||||||
|
|
||||||
|
fun addChatOpenTraceEvent(event: String, details: String = "") =
|
||||||
|
chatViewModel.addChatOpenTraceEvent(event, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoiceRecordingViewModel internal constructor(
|
||||||
|
private val chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
|
fun sendVoiceMessage(voiceHex: String, durationSec: Int, waves: List<Float>) {
|
||||||
|
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||||
|
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val voicePayload = chatViewModel.buildVoicePayload(voiceHex, durationSec, waves)
|
||||||
|
if (voicePayload == null || voicePayload.normalizedVoiceHex.isEmpty()) {
|
||||||
|
chatViewModel.releaseSendSlot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val attachmentId = "voice_$timestamp"
|
||||||
|
|
||||||
|
chatViewModel.addOutgoingMessageOptimistic(
|
||||||
|
ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = "",
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = MessageStatus.SENDING,
|
||||||
|
attachments =
|
||||||
|
listOf(
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
type = AttachmentType.VOICE,
|
||||||
|
preview = voicePayload.preview,
|
||||||
|
blob = voicePayload.normalizedVoiceHex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chatViewModel.clearInputText()
|
||||||
|
|
||||||
|
chatViewModel.launchOnIo {
|
||||||
|
try {
|
||||||
|
val encryptionContext =
|
||||||
|
chatViewModel.buildEncryptionContext(
|
||||||
|
plaintext = "",
|
||||||
|
recipient = sendContext.recipient,
|
||||||
|
privateKey = sendContext.privateKey
|
||||||
|
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||||
|
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||||
|
val isSavedMessages = (sendContext.sender == sendContext.recipient)
|
||||||
|
val uploadResult =
|
||||||
|
chatViewModel.encryptAndUploadAttachment(
|
||||||
|
EncryptAndUploadAttachmentCommand(
|
||||||
|
payload = voicePayload.normalizedVoiceHex,
|
||||||
|
attachmentPassword = encryptionContext.attachmentPassword,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
isSavedMessages = isSavedMessages
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val voiceAttachment =
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
blob = "",
|
||||||
|
type = AttachmentType.VOICE,
|
||||||
|
preview = voicePayload.preview,
|
||||||
|
transportTag = uploadResult.transportTag,
|
||||||
|
transportServer = uploadResult.transportServer
|
||||||
|
)
|
||||||
|
|
||||||
|
chatViewModel.sendMediaMessage(
|
||||||
|
SendMediaMessageCommand(
|
||||||
|
fromPublicKey = sendContext.sender,
|
||||||
|
toPublicKey = sendContext.recipient,
|
||||||
|
encryptedContent = encryptionContext.encryptedContent,
|
||||||
|
encryptedKey = encryptionContext.encryptedKey,
|
||||||
|
aesChachaKey = encryptionContext.aesChachaKey,
|
||||||
|
privateKeyHash = privateKeyHash,
|
||||||
|
messageId = messageId,
|
||||||
|
timestamp = timestamp,
|
||||||
|
mediaAttachments = listOf(voiceAttachment),
|
||||||
|
isSavedMessages = isSavedMessages
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = chatViewModel.appContext(),
|
||||||
|
blob = voicePayload.normalizedVoiceHex,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
publicKey = sendContext.sender,
|
||||||
|
privateKey = sendContext.privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val attachmentsJson =
|
||||||
|
JSONArray()
|
||||||
|
.apply {
|
||||||
|
put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", attachmentId)
|
||||||
|
put("type", AttachmentType.VOICE.value)
|
||||||
|
put("preview", voicePayload.preview)
|
||||||
|
put("blob", "")
|
||||||
|
put("transportTag", uploadResult.transportTag)
|
||||||
|
put("transportServer", uploadResult.transportServer)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
val storedEncryptedKey =
|
||||||
|
if (encryptionContext.isGroup) {
|
||||||
|
chatViewModel.buildStoredGroupEncryptedKey(
|
||||||
|
encryptionContext.attachmentPassword,
|
||||||
|
sendContext.privateKey
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
encryptionContext.encryptedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.saveOutgoingMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
text = "",
|
||||||
|
encryptedContent = encryptionContext.encryptedContent,
|
||||||
|
encryptedKey = storedEncryptedKey,
|
||||||
|
timestamp = timestamp,
|
||||||
|
delivered = if (isSavedMessages) 1 else 0,
|
||||||
|
attachmentsJson = attachmentsJson,
|
||||||
|
accountPublicKey = sendContext.sender,
|
||||||
|
accountPrivateKey = sendContext.privateKey,
|
||||||
|
opponentPublicKey = sendContext.recipient
|
||||||
|
)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (isSavedMessages) {
|
||||||
|
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.saveOutgoingDialog(
|
||||||
|
lastMessage = "Voice message",
|
||||||
|
timestamp = timestamp,
|
||||||
|
accountPublicKey = sendContext.sender,
|
||||||
|
accountPrivateKey = sendContext.privateKey,
|
||||||
|
opponentPublicKey = sendContext.recipient
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
chatViewModel.updateMessageStatusDb(messageId, DeliveryStatus.ERROR.value)
|
||||||
|
chatViewModel.saveOutgoingDialog(
|
||||||
|
lastMessage = "Voice message",
|
||||||
|
timestamp = timestamp,
|
||||||
|
accountPublicKey = sendContext.sender,
|
||||||
|
accountPrivateKey = sendContext.privateKey,
|
||||||
|
opponentPublicKey = sendContext.recipient
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
chatViewModel.releaseSendSlot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttachmentsViewModel internal constructor(
|
||||||
|
private val chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
|
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) =
|
||||||
|
chatViewModel.cancelOutgoingImageUpload(messageId, attachmentId)
|
||||||
|
|
||||||
|
fun sendImageFromUri(imageUri: Uri, caption: String = "") {
|
||||||
|
val sendContext = chatViewModel.resolveOutgoingSendContext()
|
||||||
|
if (sendContext == null) {
|
||||||
|
chatViewModel.addProtocolLog("❌ IMG send aborted: missing keys or dialog")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val text = caption.trim()
|
||||||
|
val attachmentId = "img_$timestamp"
|
||||||
|
val context = chatViewModel.appContext()
|
||||||
|
|
||||||
|
chatViewModel.logPhotoEvent(
|
||||||
|
messageId,
|
||||||
|
"start: uri=${imageUri.lastPathSegment ?: "unknown"}, captionLen=${text.length}, attachment=${chatViewModel.shortPhotoLogId(attachmentId)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val (imageWidth, imageHeight) = MediaUtils.getImageDimensions(context, imageUri)
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "dimensions: ${imageWidth}x$imageHeight")
|
||||||
|
|
||||||
|
chatViewModel.addOutgoingMessageOptimistic(
|
||||||
|
ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = text,
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = MessageStatus.SENDING,
|
||||||
|
attachments =
|
||||||
|
listOf(
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
blob = "",
|
||||||
|
type = AttachmentType.IMAGE,
|
||||||
|
preview = "",
|
||||||
|
width = imageWidth,
|
||||||
|
height = imageHeight,
|
||||||
|
localUri = imageUri.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chatViewModel.clearInputText()
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "optimistic UI added")
|
||||||
|
|
||||||
|
val uploadJob =
|
||||||
|
chatViewModel.launchBackgroundUpload imageUpload@{
|
||||||
|
try {
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "persist optimistic message in DB")
|
||||||
|
val optimisticAttachmentsJson =
|
||||||
|
JSONArray()
|
||||||
|
.apply {
|
||||||
|
put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", attachmentId)
|
||||||
|
put("type", AttachmentType.IMAGE.value)
|
||||||
|
put("preview", "")
|
||||||
|
put("blob", "")
|
||||||
|
put("width", imageWidth)
|
||||||
|
put("height", imageHeight)
|
||||||
|
put("localUri", imageUri.toString())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
chatViewModel.saveOutgoingMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
text = text,
|
||||||
|
encryptedContent = "",
|
||||||
|
encryptedKey = "",
|
||||||
|
timestamp = timestamp,
|
||||||
|
delivered = 0,
|
||||||
|
attachmentsJson = optimisticAttachmentsJson,
|
||||||
|
accountPublicKey = sendContext.sender,
|
||||||
|
accountPrivateKey = sendContext.privateKey,
|
||||||
|
opponentPublicKey = sendContext.recipient
|
||||||
|
)
|
||||||
|
|
||||||
|
chatViewModel.saveOutgoingDialog(
|
||||||
|
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||||
|
timestamp = timestamp,
|
||||||
|
accountPublicKey = sendContext.sender,
|
||||||
|
accountPrivateKey = sendContext.privateKey,
|
||||||
|
opponentPublicKey = sendContext.recipient
|
||||||
|
)
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "optimistic dialog updated")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "optimistic DB save skipped (non-fatal)")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val convertStartedAt = System.currentTimeMillis()
|
||||||
|
val (width, height) = MediaUtils.getImageDimensions(context, imageUri)
|
||||||
|
val imageBase64 = MediaUtils.uriToBase64Image(context, imageUri)
|
||||||
|
if (imageBase64 == null) {
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "base64 conversion returned null")
|
||||||
|
if (!chatViewModel.isViewModelCleared()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return@imageUpload
|
||||||
|
}
|
||||||
|
chatViewModel.logPhotoEvent(
|
||||||
|
messageId,
|
||||||
|
"base64 ready: len=${imageBase64.length}, elapsed=${System.currentTimeMillis() - convertStartedAt}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
val blurhash = MediaUtils.generateBlurhash(context, imageUri)
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "blurhash ready: len=${blurhash.length}")
|
||||||
|
|
||||||
|
if (!chatViewModel.isViewModelCleared()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chatViewModel.updateOptimisticImageMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
base64 = imageBase64,
|
||||||
|
blurhash = blurhash,
|
||||||
|
width = width,
|
||||||
|
height = height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "optimistic payload updated in UI")
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.sendImageMessageInternal(
|
||||||
|
messageId = messageId,
|
||||||
|
imageBase64 = imageBase64,
|
||||||
|
blurhash = blurhash,
|
||||||
|
caption = text,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
timestamp = timestamp,
|
||||||
|
recipient = sendContext.recipient,
|
||||||
|
sender = sendContext.sender,
|
||||||
|
privateKey = sendContext.privateKey
|
||||||
|
)
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "pipeline completed")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
chatViewModel.logPhotoEvent(messageId, "pipeline cancelled by user")
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
chatViewModel.logPhotoErrorEvent(messageId, "prepare+convert", e)
|
||||||
|
if (!chatViewModel.isViewModelCleared()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.registerOutgoingImageUploadJob(messageId, uploadJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendImageMessage(
|
||||||
|
imageBase64: String,
|
||||||
|
blurhash: String,
|
||||||
|
caption: String = "",
|
||||||
|
width: Int = 0,
|
||||||
|
height: Int = 0
|
||||||
|
) {
|
||||||
|
val sendContext = chatViewModel.resolveOutgoingSendContext() ?: return
|
||||||
|
if (!chatViewModel.tryAcquireSendSlot()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
val text = caption.trim()
|
||||||
|
val attachmentId = "img_$timestamp"
|
||||||
|
|
||||||
|
chatViewModel.addOutgoingMessageOptimistic(
|
||||||
|
ChatMessage(
|
||||||
|
id = messageId,
|
||||||
|
text = text,
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = Date(timestamp),
|
||||||
|
status = MessageStatus.SENDING,
|
||||||
|
attachments =
|
||||||
|
listOf(
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
type = AttachmentType.IMAGE,
|
||||||
|
preview = blurhash,
|
||||||
|
blob = imageBase64,
|
||||||
|
width = width,
|
||||||
|
height = height
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chatViewModel.clearInputText()
|
||||||
|
|
||||||
|
chatViewModel.launchBackgroundUpload prepareGroup@{
|
||||||
|
try {
|
||||||
|
val encryptionContext =
|
||||||
|
chatViewModel.buildEncryptionContext(
|
||||||
|
plaintext = text,
|
||||||
|
recipient = sendContext.recipient,
|
||||||
|
privateKey = sendContext.privateKey
|
||||||
|
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
|
||||||
|
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(sendContext.privateKey)
|
||||||
|
val isSavedMessages = sendContext.sender == sendContext.recipient
|
||||||
|
val uploadResult =
|
||||||
|
chatViewModel.encryptAndUploadAttachment(
|
||||||
|
EncryptAndUploadAttachmentCommand(
|
||||||
|
payload = imageBase64,
|
||||||
|
attachmentPassword = encryptionContext.attachmentPassword,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
isSavedMessages = isSavedMessages
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val imageAttachment =
|
||||||
|
MessageAttachment(
|
||||||
|
id = attachmentId,
|
||||||
|
blob = "",
|
||||||
|
type = AttachmentType.IMAGE,
|
||||||
|
preview = blurhash,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
transportTag = uploadResult.transportTag,
|
||||||
|
transportServer = uploadResult.transportServer
|
||||||
|
)
|
||||||
|
|
||||||
|
chatViewModel.sendMediaMessage(
|
||||||
|
SendMediaMessageCommand(
|
||||||
|
fromPublicKey = sendContext.sender,
|
||||||
|
toPublicKey = sendContext.recipient,
|
||||||
|
encryptedContent = encryptionContext.encryptedContent,
|
||||||
|
encryptedKey = encryptionContext.encryptedKey,
|
||||||
|
aesChachaKey = encryptionContext.aesChachaKey,
|
||||||
|
privateKeyHash = privateKeyHash,
|
||||||
|
messageId = messageId,
|
||||||
|
timestamp = timestamp,
|
||||||
|
mediaAttachments = listOf(imageAttachment),
|
||||||
|
isSavedMessages = isSavedMessages
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
AttachmentFileManager.saveAttachment(
|
||||||
|
context = chatViewModel.appContext(),
|
||||||
|
blob = imageBase64,
|
||||||
|
attachmentId = attachmentId,
|
||||||
|
publicKey = sendContext.sender,
|
||||||
|
privateKey = sendContext.privateKey
|
||||||
|
)
|
||||||
|
|
||||||
|
val attachmentsJson =
|
||||||
|
JSONArray()
|
||||||
|
.apply {
|
||||||
|
put(
|
||||||
|
JSONObject().apply {
|
||||||
|
put("id", attachmentId)
|
||||||
|
put("type", AttachmentType.IMAGE.value)
|
||||||
|
put("preview", blurhash)
|
||||||
|
put("blob", "")
|
||||||
|
put("width", width)
|
||||||
|
put("height", height)
|
||||||
|
put("transportTag", uploadResult.transportTag)
|
||||||
|
put("transportServer", uploadResult.transportServer)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
val storedEncryptedKey =
|
||||||
|
if (encryptionContext.isGroup) {
|
||||||
|
chatViewModel.buildStoredGroupEncryptedKey(
|
||||||
|
encryptionContext.attachmentPassword,
|
||||||
|
sendContext.privateKey
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
encryptionContext.encryptedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.saveOutgoingMessage(
|
||||||
|
messageId = messageId,
|
||||||
|
text = text,
|
||||||
|
encryptedContent = encryptionContext.encryptedContent,
|
||||||
|
encryptedKey = storedEncryptedKey,
|
||||||
|
timestamp = timestamp,
|
||||||
|
delivered = if (isSavedMessages) 1 else 0,
|
||||||
|
attachmentsJson = attachmentsJson,
|
||||||
|
accountPublicKey = sendContext.sender,
|
||||||
|
accountPrivateKey = sendContext.privateKey,
|
||||||
|
opponentPublicKey = sendContext.recipient
|
||||||
|
)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.saveOutgoingDialog(
|
||||||
|
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||||
|
timestamp = timestamp,
|
||||||
|
accountPublicKey = sendContext.sender,
|
||||||
|
accountPrivateKey = sendContext.privateKey,
|
||||||
|
opponentPublicKey = sendContext.recipient
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
chatViewModel.updateMessageStatusUi(messageId, MessageStatus.ERROR)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
chatViewModel.releaseSendSlot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendImageGroupFromUris(imageUris: List<Uri>, caption: String = "") {
|
||||||
|
chatViewModel.attachmentsFeatureCoordinator.sendImageGroupFromUris(
|
||||||
|
imageUris = imageUris,
|
||||||
|
caption = caption
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendImageGroup(images: List<ChatViewModel.ImageData>, caption: String = "") {
|
||||||
|
chatViewModel.attachmentsFeatureCoordinator.sendImageGroup(
|
||||||
|
images = images,
|
||||||
|
caption = caption
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendFileMessage(
|
||||||
|
fileBase64: String,
|
||||||
|
fileName: String,
|
||||||
|
fileSize: Long,
|
||||||
|
caption: String = ""
|
||||||
|
) {
|
||||||
|
chatViewModel.attachmentsFeatureCoordinator.sendFileMessage(
|
||||||
|
fileBase64 = fileBase64,
|
||||||
|
fileName = fileName,
|
||||||
|
fileSize = fileSize,
|
||||||
|
caption = caption
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendVideoCircleFromUri(videoUri: Uri) {
|
||||||
|
chatViewModel.attachmentsFeatureCoordinator.sendVideoCircleFromUri(videoUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendAvatarMessage() {
|
||||||
|
chatViewModel.attachmentsFeatureCoordinator.sendAvatarMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TypingViewModel internal constructor(
|
||||||
|
private val chatViewModel: ChatViewModel
|
||||||
|
) {
|
||||||
|
val opponentTyping: StateFlow<Boolean> = chatViewModel.opponentTyping
|
||||||
|
val typingDisplayName: StateFlow<String> = chatViewModel.typingDisplayName
|
||||||
|
val typingDisplayPublicKey: StateFlow<String> = chatViewModel.typingDisplayPublicKey
|
||||||
|
val opponentOnline: StateFlow<Boolean> = chatViewModel.opponentOnline
|
||||||
|
|
||||||
|
fun sendTypingIndicator() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val context = chatViewModel.resolveTypingSendContext() ?: return
|
||||||
|
val decision =
|
||||||
|
chatViewModel.decideTypingSend(
|
||||||
|
SendTypingIndicatorCommand(
|
||||||
|
nowMs = now,
|
||||||
|
lastSentMs = chatViewModel.lastTypingSentTimeMs(),
|
||||||
|
throttleMs = chatViewModel.typingThrottleMs(),
|
||||||
|
opponentPublicKey = context.opponent,
|
||||||
|
senderPublicKey = context.sender,
|
||||||
|
isGroupDialog = context.isGroupDialog,
|
||||||
|
isOpponentOnline = context.isOpponentOnline
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (!decision.shouldSend) return
|
||||||
|
chatViewModel.setLastTypingSentTimeMs(decision.nextLastSentMs)
|
||||||
|
chatViewModel.sendTypingPacket(
|
||||||
|
privateKey = context.privateKey,
|
||||||
|
sender = context.sender,
|
||||||
|
opponent = context.opponent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user