diff --git a/Architecture.md b/Architecture.md index d41b9c7..946534f 100644 --- a/Architecture.md +++ b/Architecture.md @@ -7,12 +7,14 @@ Приложение сейчас устроено как layered + service-oriented архитектура: - UI: `MainActivity` + Compose-экраны + ViewModel. - DI: Hilt (`@HiltAndroidApp`, `@AndroidEntryPoint`, модули в `di/AppContainer.kt`). -- Runtime orchestration: `ProtocolManager`, `CallManager`, `TransportManager`, `UpdateManager`. +- Runtime orchestration: `ProtocolRuntime` -> `ProtocolRuntimeCore` (+ compatibility facade `ProtocolManager`), `CallManager`, `TransportManager`, `UpdateManager`. - Session/Identity runtime state: `SessionStore`, `SessionReducer`, `IdentityStore`. - Data: `MessageRepository`, `GroupRepository`, `AccountManager`, `PreferencesManager`. - Persistence: Room (`RosettaDatabase`) + DataStore/SharedPreferences. -`ProtocolManager` остается крупным runtime orchestrator-объектом, но критичные зоны уже вынесены в отдельные сервисы. +Основная runtime-логика сети вынесена в instance-класс `ProtocolRuntimeCore`. +`ProtocolManager` сохранен как тонкий compatibility facade. +DI-вход в network core идет через `ProtocolRuntime` (Hilt singleton). --- @@ -29,11 +31,12 @@ flowchart TB subgraph DI["Hilt Singleton Graph"] D1["ProtocolGateway"] + D1A["ProtocolRuntime"] D2["SessionCoordinator"] D3["IdentityGateway"] D4["AccountManager / PreferencesManager"] D5["MessageRepository / GroupRepository"] - D6["UiEntryPoint + UiDependencyAccess"] + D6["UiEntryPoint + EntryPointAccessors"] end subgraph SESSION["Session / Identity Runtime"] @@ -44,7 +47,9 @@ flowchart TB end subgraph NET["Network Runtime"] - N1["ProtocolManager"] + N0["ProtocolRuntime"] + N1["ProtocolRuntimeCore"] + N1A["ProtocolManager (compat facade)"] N2["Protocol"] N3["PacketSubscriptionRegistry"] N4["ReadyPacketGate"] @@ -60,6 +65,9 @@ flowchart TB DI --> SESSION DI --> NET DI --> DATA + D1 --> D1A + D1A --> N1 + N1A --> N1 SESSION --> NET DATA --> NET DATA --> R3 @@ -75,11 +83,26 @@ flowchart TB - Entry points уровня Android-компонентов: `MainActivity`, `IncomingCallActivity`, `CallForegroundService`, `RosettaFirebaseMessagingService`. - Основные модули: - `AppDataModule`: `AccountManager`, `PreferencesManager`. -- `AppGatewayModule`: биндинги `ProtocolGateway`, `SessionCoordinator`, `IdentityGateway`. +- `AppGatewayModule`: биндинги `ProtocolGateway`, `SessionCoordinator`, `IdentityGateway`, `ProtocolClient`. +- `ProtocolGatewayImpl` и `ProtocolClientImpl` делегируют в `ProtocolRuntime`, а не напрямую в UI-слой. -### 3.2 UI bridge для не-Hilt классов -Часть UI/VM пока получает зависимости через `UiDependencyAccess` -> `UiEntryPoint`. -Это transitional-слой, чтобы не тащить singleton `getInstance(...)` в UI и постепенно перейти на чистый constructor injection. +### 3.2 UI bridge для composable-слоя +UI-композаблы получают зависимости через `UiEntryPoint` + `EntryPointAccessors.fromApplication(...)`. +`UiDependencyAccess.get(...)` из `ui/*` удален (DoD: 0 вхождений). + +Для non-Hilt `object`-ов (`CallManager`, `TransportManager`, `UpdateManager`, utils) +используется `ProtocolRuntimeAccess` + `ProtocolRuntimePort`: +- runtime ставится в `RosettaApplication` через `ProtocolRuntimeAccess.install(protocolRuntime)`; +- доступ до install запрещен (fail-fast), чтобы не было тихого отката в `ProtocolManager`. + +### 3.3 Разрыв DI-cycle (Hilt) +После перехода на `ProtocolRuntime` был закрыт цикл зависимостей: +`MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository`. + +Текущее решение: +- `ProtocolClientImpl` получает `Provider` (ленивая резолюция). +- `ProtocolRuntime` остается singleton-композицией для `MessageRepository/GroupRepository/AccountManager`. +- На `assembleDebug/assembleRelease` больше нет `Dagger/DependencyCycle`. --- @@ -129,21 +152,36 @@ stateDiagram-v2 ## 5. Network orchestration после декомпозиции -`ProtocolManager` теперь делегирует отдельные зоны ответственности: +`ProtocolRuntime` — DI-фасад runtime слоя. +`ProtocolRuntimeCore` содержит runtime state machine и делегирует отдельные зоны ответственности: - `ConnectionOrchestrator`: connect/reconnect/authenticate + network-aware поведение. - `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. - `PacketSubscriptionRegistry`: централизованные подписки на пакеты и fan-out. +- `OutgoingMessagePipelineService`: отправка `PacketMessage` с retry/error policy. ```mermaid flowchart TB - PM["ProtocolManager"] --> CO["ConnectionOrchestrator"] + PM["ProtocolRuntimeCore"] --> CO["ConnectionOrchestrator"] PM --> BC["BootstrapCoordinator"] + PM --> SC["SyncCoordinator"] + PM --> PT["PresenceTypingService"] PM --> PR["PacketRouter"] PM --> OPS["OwnProfileSyncService"] PM --> RQ["RetryQueueService"] + PM --> ABC["AuthBootstrapCoordinator"] + PM --> NRW["NetworkReconnectWatcher"] + PM --> DVS["DeviceVerificationService"] + PM --> CSB["CallSignalBridge"] PM --> PSR["PacketSubscriptionRegistry"] PM --> SUP["ProtocolConnectionSupervisor"] PM --> RPG["ReadyPacketGate"] @@ -163,7 +201,7 @@ flowchart TB ```mermaid sequenceDiagram participant Feature as Feature/Service - participant PM as ProtocolManager + participant PM as ProtocolRuntimeCore participant REG as PacketSubscriptionRegistry participant P as Protocol @@ -189,14 +227,14 @@ sequenceDiagram 1. ViewModel готовит command и шифрованный payload. 2. UseCase собирает `PacketMessage`. 3. UseCase вызывает `protocolGateway.sendMessageWithRetry(packet)`. -4. `ProtocolManager` регистрирует пакет в `RetryQueueService` и отправляет в сеть. +4. `ProtocolRuntimeCore` регистрирует пакет в `RetryQueueService` и отправляет в сеть. 5. Если lifecycle еще не `READY`, пакет попадает в `ReadyPacketGate` и flush после `READY`. ```mermaid flowchart LR VM["ChatViewModel"] --> UC["Send*UseCase"] UC --> GW["ProtocolGateway.sendMessageWithRetry"] - GW --> PM["ProtocolManager"] + GW --> PM["ProtocolRuntimeCore"] PM --> RQ["RetryQueueService"] PM --> RG["ReadyPacketGate"] PM --> P["Protocol.sendPacket"] @@ -212,7 +250,7 @@ sequenceDiagram participant SC as SessionCoordinatorImpl participant SS as SessionStore participant PG as ProtocolGateway - participant PM as ProtocolManager + participant PM as ProtocolRuntimeCore participant AM as AccountManager UI->>SC: bootstrapAuthenticatedSession(account, reason) @@ -235,7 +273,7 @@ sequenceDiagram ## 9. Состояния соединения (network lifecycle) -`ProtocolManager.connectionLifecycleState`: +`ProtocolRuntimeCore.connectionLifecycleState`: - `DISCONNECTED` - `CONNECTING` - `HANDSHAKING` @@ -263,6 +301,10 @@ stateDiagram-v2 - `app/src/main/java/com/rosetta/messenger/di/AppContainer.kt` - `app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt` +- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt` +- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt` +- `app/src/main/java/com/rosetta/messenger/network/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` @@ -275,6 +317,14 @@ stateDiagram-v2 - `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/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/OutgoingMessagePipelineService.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` @@ -286,6 +336,7 @@ stateDiagram-v2 ## 11. Что осталось как технический долг -- `ProtocolManager` все еще содержит много cross-cutting логики и требует дальнейшей декомпозиции. -- Не весь UI перешел на constructor injection (`UiDependencyAccess` пока нужен для части ViewModel). -- Часть data-слоя напрямую знает о network singleton (`ProtocolManager`) и требует окончательного разрыва через интерфейсы. +- `ProtocolRuntimeCore` все еще содержит много cross-cutting логики и требует дальнейшей декомпозиции. +- UI больше не использует `UiDependencyAccess.get(...)`, но часть экранов все еще берет зависимости через `UiEntryPoint` (следующий шаг: передача зависимостей параметрами/через VM). +- DI-адаптеры (`ProtocolGatewayImpl`, `ProtocolClientImpl`) переведены на `ProtocolRuntime`, dependency-cycle закрыт через `Provider`. +- Следующий шаг по network core: продолжить декомпозицию `ProtocolRuntimeCore` (например: `ProtocolLifecycleLogger`, `AuthRestoreService`, `ProtocolTraceService`) и сократить фасад `ProtocolManager` до полного legacy-режима. diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 6e2fc9d..f1ca101 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -1630,10 +1630,10 @@ fun MainScreen( // ProfileViewModel для логов val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = - androidx.lifecycle.viewmodel.compose.viewModel() + androidx.hilt.navigation.compose.hiltViewModel() val profileState by profileViewModel.state.collectAsState() val chatsListViewModel: com.rosetta.messenger.ui.chats.ChatsListViewModel = - androidx.lifecycle.viewmodel.compose.viewModel() + androidx.hilt.navigation.compose.hiltViewModel() // Appearance: background blur color preference val prefsManager = preferencesManager diff --git a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt index 9f21509..ffe0751 100644 --- a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt +++ b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt @@ -7,7 +7,8 @@ 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.ProtocolManager +import com.rosetta.messenger.network.ProtocolRuntime +import com.rosetta.messenger.network.ProtocolRuntimeAccess import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.update.UpdateManager import com.rosetta.messenger.utils.CrashReportManager @@ -23,6 +24,7 @@ class RosettaApplication : Application() { @Inject lateinit var messageRepository: MessageRepository @Inject lateinit var groupRepository: GroupRepository @Inject lateinit var accountManager: AccountManager + @Inject lateinit var protocolRuntime: ProtocolRuntime companion object { private const val TAG = "RosettaApplication" @@ -36,6 +38,9 @@ class RosettaApplication : Application() { // Инициализируем crash reporter initCrashReporting() + + // Install instance-based protocol runtime for non-Hilt singleton objects. + ProtocolRuntimeAccess.install(protocolRuntime) // Инициализируем менеджер черновиков DraftManager.init(this) @@ -46,12 +51,6 @@ class RosettaApplication : Application() { // Инициализируем менеджер обновлений (SDU) UpdateManager.init(this) - // DI bootstrap for protocol internals (removes singleton-factory lookups in ProtocolManager). - ProtocolManager.bindDependencies( - messageRepository = messageRepository, - groupRepository = groupRepository, - accountManager = accountManager - ) CallManager.bindDependencies( messageRepository = messageRepository, accountManager = accountManager diff --git a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt index 62c8137..49321fd 100644 --- a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -14,7 +14,7 @@ import com.rosetta.messenger.network.PacketGroupInfo import com.rosetta.messenger.network.PacketGroupInviteInfo import com.rosetta.messenger.network.PacketGroupJoin import com.rosetta.messenger.network.PacketGroupLeave -import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.ProtocolClient import dagger.hilt.android.qualifiers.ApplicationContext import java.security.SecureRandom import java.util.UUID @@ -28,7 +28,8 @@ import kotlin.coroutines.resume @Singleton class GroupRepository @Inject constructor( @ApplicationContext context: Context, - private val messageRepository: MessageRepository + private val messageRepository: MessageRepository, + private val protocolClient: ProtocolClient ) { private val db = RosettaDatabase.getDatabase(context.applicationContext) @@ -152,7 +153,7 @@ class GroupRepository @Inject constructor( this.groupId = groupId this.members = emptyList() } - ProtocolManager.send(packet) + protocolClient.send(packet) val response = awaitPacketOnce( packetId = 0x12, @@ -186,7 +187,7 @@ class GroupRepository @Inject constructor( this.membersCount = 0 this.groupStatus = GroupStatus.NOT_JOINED } - ProtocolManager.send(packet) + protocolClient.send(packet) val response = awaitPacketOnce( packetId = 0x13, @@ -214,7 +215,7 @@ class GroupRepository @Inject constructor( } val createPacket = PacketCreateGroup() - ProtocolManager.send(createPacket) + protocolClient.send(createPacket) val response = awaitPacketOnce( packetId = 0x11, @@ -265,7 +266,7 @@ class GroupRepository @Inject constructor( groupString = encodedGroupStringForServer groupStatus = GroupStatus.NOT_JOINED } - ProtocolManager.send(packet) + protocolClient.send(packet) val response = awaitPacketOnce( packetId = 0x14, @@ -373,7 +374,7 @@ class GroupRepository @Inject constructor( val packet = PacketGroupLeave().apply { this.groupId = groupId } - ProtocolManager.send(packet) + protocolClient.send(packet) val response = awaitPacketOnce( packetId = 0x15, @@ -399,7 +400,7 @@ class GroupRepository @Inject constructor( this.groupId = groupId this.publicKey = targetPublicKey } - ProtocolManager.send(packet) + protocolClient.send(packet) val response = awaitPacketOnce( packetId = 0x16, @@ -508,13 +509,13 @@ class GroupRepository @Inject constructor( callback = { packet -> val typedPacket = packet as? T if (typedPacket != null && predicate(typedPacket)) { - ProtocolManager.unwaitPacket(packetId, callback) + protocolClient.unwaitPacket(packetId, callback) continuation.resume(typedPacket) } } - ProtocolManager.waitPacket(packetId, callback) + protocolClient.waitPacket(packetId, callback) continuation.invokeOnCancellation { - ProtocolManager.unwaitPacket(packetId, callback) + protocolClient.unwaitPacket(packetId, callback) } } } diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 9d5cb01..2fba807 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -48,7 +48,8 @@ data class Dialog( /** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */ @Singleton class MessageRepository @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val protocolClient: ProtocolClient ) { private val database = RosettaDatabase.getDatabase(context) @@ -687,7 +688,7 @@ class MessageRepository @Inject constructor( MessageLogger.logPacketSend(messageId, toPublicKey, timestamp) // iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout) - ProtocolManager.sendMessageWithRetry(packet) + protocolClient.sendMessageWithRetry(packet) // 📝 LOG: Успешная отправка MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime) @@ -837,7 +838,7 @@ class MessageRepository @Inject constructor( processedMessageIds.remove(messageId) return false } - ProtocolManager.addLog( + protocolClient.addLog( "⚠️ GROUP fallback without key: ${messageId.take(8)}..., contentLikelyPlain=true" ) } @@ -851,7 +852,7 @@ class MessageRepository @Inject constructor( } if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) { - ProtocolManager.addLog( + protocolClient.addLog( "⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..." ) } @@ -1020,7 +1021,7 @@ class MessageRepository @Inject constructor( } catch (e: Exception) { // 📝 LOG: Ошибка обработки MessageLogger.logDecryptionError(messageId, e) - ProtocolManager.addLog( + protocolClient.addLog( "❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " + "group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " + "aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: ""}" @@ -1260,7 +1261,7 @@ class MessageRepository @Inject constructor( this.toPublicKey = toPublicKey this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) } - ProtocolManager.send(packet) + protocolClient.send(packet) } } @@ -1391,7 +1392,7 @@ class MessageRepository @Inject constructor( } // 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)}") } catch (e: Exception) { android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}") @@ -1656,7 +1657,7 @@ class MessageRepository @Inject constructor( this.privateKey = privateKeyHash this.search = dialog.opponentKey } - ProtocolManager.send(packet) + protocolClient.send(packet) // Small delay to avoid flooding the server with search requests kotlinx.coroutines.delay(50) } @@ -1693,7 +1694,7 @@ class MessageRepository @Inject constructor( this.privateKey = privateKeyHash this.search = publicKey } - ProtocolManager.send(packet) + protocolClient.send(packet) } } diff --git a/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt b/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt index 6b648e5..7d4c4c3 100644 --- a/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt @@ -3,13 +3,14 @@ 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.GroupRepository -import com.rosetta.messenger.data.MessageRepository 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.ProtocolManager +import com.rosetta.messenger.network.ProtocolRuntime +import com.rosetta.messenger.network.ProtocolRuntimeCore import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.session.SessionAction @@ -25,6 +26,7 @@ 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 @@ -106,106 +108,106 @@ interface IdentityGateway { @Singleton class ProtocolGatewayImpl @Inject constructor( - private val messageRepository: MessageRepository, - private val groupRepository: GroupRepository, - private val accountManager: AccountManager + private val runtime: ProtocolRuntime ) : ProtocolGateway { - init { - ProtocolManager.bindDependencies( - messageRepository = messageRepository, - groupRepository = groupRepository, - accountManager = accountManager - ) - } - - override val state: StateFlow = ProtocolManager.state - override val syncInProgress: StateFlow = ProtocolManager.syncInProgress - override val pendingDeviceVerification: StateFlow = ProtocolManager.pendingDeviceVerification - override val typingUsers: StateFlow> = ProtocolManager.typingUsers + override val state: StateFlow = runtime.state + override val syncInProgress: StateFlow = runtime.syncInProgress + override val pendingDeviceVerification: StateFlow = runtime.pendingDeviceVerification + override val typingUsers: StateFlow> = runtime.typingUsers override val typingUsersByDialogSnapshot: StateFlow>> = - ProtocolManager.typingUsersByDialogSnapshot - override val debugLogs: StateFlow> = ProtocolManager.debugLogs - override val ownProfileUpdated: StateFlow = ProtocolManager.ownProfileUpdated + runtime.typingUsersByDialogSnapshot + override val debugLogs: StateFlow> = runtime.debugLogs + override val ownProfileUpdated: StateFlow = runtime.ownProfileUpdated - override fun initialize(context: Context) { - ProtocolManager.bindDependencies( - messageRepository = messageRepository, - groupRepository = groupRepository, - accountManager = accountManager - ) - ProtocolManager.initialize(context) - } + override fun initialize(context: Context) = runtime.initialize(context) override fun initializeAccount(publicKey: String, privateKey: String) = - ProtocolManager.initializeAccount(publicKey, privateKey) + runtime.initializeAccount(publicKey, privateKey) - override fun connect() = ProtocolManager.connect() + override fun connect() = runtime.connect() override fun authenticate(publicKey: String, privateHash: String) = - ProtocolManager.authenticate(publicKey, privateHash) + runtime.authenticate(publicKey, privateHash) - override fun reconnectNowIfNeeded(reason: String) = ProtocolManager.reconnectNowIfNeeded(reason) + override fun reconnectNowIfNeeded(reason: String) = runtime.reconnectNowIfNeeded(reason) - override fun disconnect() = ProtocolManager.disconnect() + override fun disconnect() = runtime.disconnect() - override fun isAuthenticated(): Boolean = ProtocolManager.isAuthenticated() + override fun isAuthenticated(): Boolean = runtime.isAuthenticated() - override fun getPrivateHash(): String? = - runCatching { ProtocolManager.getProtocol().getPrivateHash() }.getOrNull() + override fun getPrivateHash(): String? = runtime.getPrivateHash() override fun subscribePushTokenIfAvailable(forceToken: String?) = - ProtocolManager.subscribePushTokenIfAvailable(forceToken) + runtime.subscribePushTokenIfAvailable(forceToken) - override fun addLog(message: String) = ProtocolManager.addLog(message) + override fun addLog(message: String) = runtime.addLog(message) - override fun enableUILogs(enabled: Boolean) = ProtocolManager.enableUILogs(enabled) + override fun enableUILogs(enabled: Boolean) = runtime.enableUILogs(enabled) - override fun clearLogs() = ProtocolManager.clearLogs() + override fun clearLogs() = runtime.clearLogs() - override fun resolveOutgoingRetry(messageId: String) = ProtocolManager.resolveOutgoingRetry(messageId) + override fun resolveOutgoingRetry(messageId: String) = runtime.resolveOutgoingRetry(messageId) override fun getCachedUserByUsername(username: String): SearchUser? = - ProtocolManager.getCachedUserByUsername(username) + runtime.getCachedUserByUsername(username) override fun getCachedUserName(publicKey: String): String? = - ProtocolManager.getCachedUserName(publicKey) + runtime.getCachedUserName(publicKey) override fun getCachedUserInfo(publicKey: String): SearchUser? = - ProtocolManager.getCachedUserInfo(publicKey) + runtime.getCachedUserInfo(publicKey) - override fun acceptDevice(deviceId: String) = ProtocolManager.acceptDevice(deviceId) + override fun acceptDevice(deviceId: String) = runtime.acceptDevice(deviceId) - override fun declineDevice(deviceId: String) = ProtocolManager.declineDevice(deviceId) + override fun declineDevice(deviceId: String) = runtime.declineDevice(deviceId) - override fun send(packet: Packet) = ProtocolManager.send(packet) + override fun send(packet: Packet) = runtime.send(packet) - override fun sendPacket(packet: Packet) = ProtocolManager.sendPacket(packet) + override fun sendPacket(packet: Packet) = runtime.sendPacket(packet) - override fun sendMessageWithRetry(packet: PacketMessage) = ProtocolManager.sendMessageWithRetry(packet) + override fun sendMessageWithRetry(packet: PacketMessage) = runtime.sendMessageWithRetry(packet) override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) = - ProtocolManager.waitPacket(packetId, callback) + runtime.waitPacket(packetId, callback) override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) = - ProtocolManager.unwaitPacket(packetId, callback) + runtime.unwaitPacket(packetId, callback) - override fun packetFlow(packetId: Int): SharedFlow = ProtocolManager.packetFlow(packetId) + override fun packetFlow(packetId: Int): SharedFlow = runtime.packetFlow(packetId) - override fun notifyOwnProfileUpdated() = ProtocolManager.notifyOwnProfileUpdated() + override fun notifyOwnProfileUpdated() = runtime.notifyOwnProfileUpdated() override fun restoreAuthFromStoredCredentials( preferredPublicKey: String?, reason: String - ): Boolean = ProtocolManager.restoreAuthFromStoredCredentials(preferredPublicKey, reason) + ): Boolean = runtime.restoreAuthFromStoredCredentials(preferredPublicKey, reason) override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? = - ProtocolManager.resolveUserName(publicKey, timeoutMs) + runtime.resolveUserName(publicKey, timeoutMs) override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? = - ProtocolManager.resolveUserInfo(publicKey, timeoutMs) + runtime.resolveUserInfo(publicKey, timeoutMs) override suspend fun searchUsers(query: String, timeoutMs: Long): List = - ProtocolManager.searchUsers(query, timeoutMs) + runtime.searchUsers(query, timeoutMs) +} + +@Singleton +class ProtocolClientImpl @Inject constructor( + private val runtimeProvider: Provider +) : 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 @@ -266,6 +268,10 @@ object AppDataModule { fun providePreferencesManager(@ApplicationContext context: Context): PreferencesManager = PreferencesManager(context) + @Provides + @Singleton + fun provideProtocolRuntimeCore(): ProtocolRuntimeCore = ProtocolManager + } @Module @@ -282,4 +288,8 @@ abstract class AppGatewayModule { @Binds @Singleton abstract fun bindIdentityGateway(impl: IdentityGatewayImpl): IdentityGateway + + @Binds + @Singleton + abstract fun bindProtocolClient(impl: ProtocolClientImpl): ProtocolClient } diff --git a/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt b/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt index 9410ff6..c1d05a0 100644 --- a/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt +++ b/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt @@ -1,13 +1,11 @@ package com.rosetta.messenger.di -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.PreferencesManager import dagger.hilt.EntryPoint import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors import dagger.hilt.components.SingletonComponent @EntryPoint @@ -21,8 +19,3 @@ interface UiEntryPoint { fun messageRepository(): MessageRepository fun groupRepository(): GroupRepository } - -object UiDependencyAccess { - fun get(context: Context): UiEntryPoint = - EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) -} diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index abdbd05..48385f7 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -168,22 +168,22 @@ object CallManager { CallProximityManager.initialize(context) XChaCha20E2EE.initWithContext(context) - signalWaiter = ProtocolManager.waitCallSignal { packet -> + signalWaiter = ProtocolRuntimeAccess.get().waitCallSignal { packet -> scope.launch { handleSignalPacket(packet) } } - webRtcWaiter = ProtocolManager.waitWebRtcSignal { packet -> + webRtcWaiter = ProtocolRuntimeAccess.get().waitWebRtcSignal { packet -> scope.launch { handleWebRtcPacket(packet) } } - iceWaiter = ProtocolManager.waitIceServers { packet -> + iceWaiter = ProtocolRuntimeAccess.get().waitIceServers { packet -> handleIceServersPacket(packet) } protocolStateJob = scope.launch { - ProtocolManager.state.collect { protocolState -> + ProtocolRuntimeAccess.get().state.collect { protocolState -> when (protocolState) { ProtocolState.AUTHENTICATED -> { - ProtocolManager.requestIceServers() + ProtocolRuntimeAccess.get().requestIceServers() } ProtocolState.DISCONNECTED -> { // Не сбрасываем звонок при переподключении WebSocket — @@ -213,7 +213,7 @@ object CallManager { } } - ProtocolManager.requestIceServers() + ProtocolRuntimeAccess.get().requestIceServers() } fun bindDependencies( @@ -255,7 +255,7 @@ object CallManager { beginCallSession("incoming-push:${peer.take(8)}") role = CallRole.CALLEE resetRtcObjects() - val cachedInfo = ProtocolManager.getCachedUserInfo(peer) + val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(peer) val title = peerTitle.ifBlank { cachedInfo?.title.orEmpty() } val username = cachedInfo?.username.orEmpty() setPeer(peer, title, username) @@ -286,7 +286,7 @@ object CallManager { if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND - if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED + if (!ProtocolRuntimeAccess.get().isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED resetSession(reason = null, notifyPeer = false) beginCallSession("outgoing:${targetKey.take(8)}") @@ -300,7 +300,7 @@ object CallManager { ) } - ProtocolManager.sendCallSignal( + ProtocolRuntimeAccess.get().sendCallSignal( signalType = SignalType.CALL, src = ownPublicKey, dst = targetKey @@ -337,12 +337,12 @@ object CallManager { return CallActionResult.ACCOUNT_NOT_BOUND } } - val restoredAuth = ProtocolManager.restoreAuthFromStoredCredentials( + val restoredAuth = ProtocolRuntimeAccess.get().restoreAuthFromStoredCredentials( preferredPublicKey = ownPublicKey, reason = "accept_incoming_call" ) if (restoredAuth) { - ProtocolManager.reconnectNowIfNeeded("accept_incoming_call") + ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_incoming_call") breadcrumb("acceptIncomingCall: auth restore requested") } @@ -372,7 +372,7 @@ object CallManager { kotlinx.coroutines.delay(200) continue } - ProtocolManager.sendCallSignal( + ProtocolRuntimeAccess.get().sendCallSignal( signalType = SignalType.ACCEPT, src = ownPublicKey, dst = snapshot.peerPublicKey, @@ -381,7 +381,7 @@ object CallManager { ) // ACCEPT может быть отправлен до AUTHENTICATED — пакет будет отправлен // сразу при открытии сокета (или останется в очереди до onOpen). - ProtocolManager.reconnectNowIfNeeded("accept_send_$attempt") + ProtocolRuntimeAccess.get().reconnectNowIfNeeded("accept_send_$attempt") breadcrumb( "acceptIncomingCall: ACCEPT queued/sent (attempt #$attempt) " + "callId=${callIdNow.take(12)} join=${joinTokenNow.take(12)}" @@ -407,7 +407,7 @@ object CallManager { val callIdNow = serverCallId.trim() val joinTokenNow = serverJoinToken.trim() if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank() && callIdNow.isNotBlank() && joinTokenNow.isNotBlank()) { - ProtocolManager.sendCallSignal( + ProtocolRuntimeAccess.get().sendCallSignal( signalType = SignalType.END_CALL, src = ownPublicKey, dst = snapshot.peerPublicKey, @@ -507,7 +507,7 @@ object CallManager { if (_state.value.phase != CallPhase.IDLE) { breadcrumb("SIG: CALL but busy → sending END_CALL_BECAUSE_BUSY") if (incomingPeer.isNotBlank() && ownPublicKey.isNotBlank()) { - ProtocolManager.sendCallSignal( + ProtocolRuntimeAccess.get().sendCallSignal( signalType = SignalType.END_CALL_BECAUSE_BUSY, src = ownPublicKey, dst = incomingPeer @@ -523,7 +523,7 @@ object CallManager { role = CallRole.CALLEE resetRtcObjects() // Пробуем сразу взять имя из кэша чтобы ForegroundService показал его - val cachedInfo = ProtocolManager.getCachedUserInfo(incomingPeer) + val cachedInfo = ProtocolRuntimeAccess.get().getCachedUserInfo(incomingPeer) val cachedTitle = cachedInfo?.title.orEmpty() val cachedUsername = cachedInfo?.username.orEmpty() setPeer(incomingPeer, cachedTitle, cachedUsername) @@ -588,7 +588,7 @@ object CallManager { generateSessionKeys() } val localPublic = localPublicKey ?: return - ProtocolManager.sendCallSignal( + ProtocolRuntimeAccess.get().sendCallSignal( signalType = SignalType.KEY_EXCHANGE, src = ownPublicKey, dst = _state.value.peerPublicKey, @@ -660,7 +660,7 @@ object CallManager { breadcrumb("KE: CALLER — E2EE ready, notifying ACTIVE") updateState { it.copy(keyCast = sharedKey, statusText = "Connecting...") } if (!activeSignalSent) { - ProtocolManager.sendCallSignal( + ProtocolRuntimeAccess.get().sendCallSignal( signalType = SignalType.ACTIVE, src = ownPublicKey, dst = peerKey @@ -685,7 +685,7 @@ object CallManager { setupE2EE(sharedKey) if (!keyExchangeSent) { val localPublic = localPublicKey ?: return - ProtocolManager.sendCallSignal( + ProtocolRuntimeAccess.get().sendCallSignal( signalType = SignalType.KEY_EXCHANGE, src = ownPublicKey, dst = peerKey, @@ -786,7 +786,7 @@ object CallManager { val answer = pc.createAnswerAwait() pc.setLocalDescriptionAwait(answer) - ProtocolManager.sendWebRtcSignal( + ProtocolRuntimeAccess.get().sendWebRtcSignal( signalType = WebRTCSignalType.ANSWER, sdpOrCandidate = serializeSessionDescription(answer) ) @@ -874,7 +874,7 @@ object CallManager { pc.setLocalDescriptionAwait(offer) lastLocalOfferFingerprint = offer.description.shortFingerprintHex(10) breadcrumb("RTC: local OFFER fp=$lastLocalOfferFingerprint") - ProtocolManager.sendWebRtcSignal( + ProtocolRuntimeAccess.get().sendWebRtcSignal( signalType = WebRTCSignalType.OFFER, sdpOrCandidate = serializeSessionDescription(offer) ) @@ -915,7 +915,7 @@ object CallManager { override fun onIceCandidate(candidate: IceCandidate?) { if (candidate == null) return breadcrumb("PC: local ICE: ${candidate.sdp.take(30)}…") - ProtocolManager.sendWebRtcSignal( + ProtocolRuntimeAccess.get().sendWebRtcSignal( signalType = WebRTCSignalType.ICE_CANDIDATE, sdpOrCandidate = serializeIceCandidate(candidate) ) @@ -1034,7 +1034,7 @@ object CallManager { private fun resolvePeerIdentity(publicKey: String) { scope.launch { - val resolved = ProtocolManager.resolveUserInfo(publicKey) + val resolved = ProtocolRuntimeAccess.get().resolveUserInfo(publicKey) if (resolved != null && _state.value.peerPublicKey == publicKey) { setPeer(publicKey, resolved.title, resolved.username) } @@ -1105,7 +1105,7 @@ object CallManager { val wasActive = snapshot.phase != CallPhase.IDLE val peerToNotify = snapshot.peerPublicKey if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) { - ProtocolManager.sendCallSignal( + ProtocolRuntimeAccess.get().sendCallSignal( signalType = SignalType.END_CALL, src = ownPublicKey, dst = peerToNotify, @@ -1328,7 +1328,7 @@ object CallManager { val diagTail = readFileTail(java.io.File(dir, DIAG_FILE_NAME), TAIL_LINES) val nativeCrash = readFileTail(java.io.File(dir, NATIVE_CRASH_FILE_NAME), TAIL_LINES) val protocolTail = - ProtocolManager.debugLogs.value + ProtocolRuntimeAccess.get().debugLogs.value .takeLast(PROTOCOL_LOG_TAIL_LINES) .joinToString("\n") f.writeText( diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt new file mode 100644 index 0000000..592b970 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt @@ -0,0 +1,10 @@ +package com.rosetta.messenger.network + +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) +} + diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 127a8bf..4f69e52 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -1,1975 +1,7 @@ package com.rosetta.messenger.network -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 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.BootstrapCoordinator -import com.rosetta.messenger.network.connection.ConnectionOrchestrator -import com.rosetta.messenger.network.connection.OwnProfileSyncService -import com.rosetta.messenger.network.connection.PacketRouter -import com.rosetta.messenger.network.connection.RetryQueueService -import com.rosetta.messenger.session.IdentityStore -import com.rosetta.messenger.utils.MessageLogger -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.io.File -import java.security.SecureRandom -import java.util.* -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong - /** - * Singleton manager for Protocol instance - * Ensures single connection across the app + * Compatibility facade for legacy static call-sites. + * Runtime logic lives in [ProtocolRuntimeCore]. */ -object ProtocolManager { - private const val TAG = "ProtocolManager" - private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L - private const val SYNC_REQUEST_TIMEOUT_MS = 12_000L - private const val MAX_DEBUG_LOGS = 600 - private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L - private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L - private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L - private const val PROTOCOL_TRACE_FILE_NAME = "protocol_wire_log.txt" - private const val PROTOCOL_TRACE_MAX_BYTES = 2_000_000L - private const val PROTOCOL_TRACE_KEEP_BYTES = 1_200_000 - private const val PACKET_SIGNAL_PEER = 0x1A - private const val PACKET_WEB_RTC = 0x1B - private const val PACKET_ICE_SERVERS = 0x1C - private const val NETWORK_WAIT_TIMEOUT_MS = 20_000L - private const val BOOTSTRAP_OWN_PROFILE_FALLBACK_MS = 2_500L - private const val READY_PACKET_QUEUE_MAX = 500 - private const val READY_PACKET_QUEUE_TTL_MS = 120_000L - - // Desktop parity: use the same primary WebSocket endpoint as desktop client. - private const val SERVER_ADDRESS = "wss://wss.rosetta.im" - private const val DEVICE_PREFS = "rosetta_protocol" - private const val DEVICE_ID_KEY = "device_id" - private const val DEVICE_ID_LENGTH = 128 - - @Volatile private var protocol: Protocol? = null - private var messageRepository: MessageRepository? = null - private var groupRepository: GroupRepository? = null - private var accountManager: AccountManager? = null - private var appContext: Context? = null - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val protocolInstanceLock = Any() - private val packetSubscriptionRegistry = - PacketSubscriptionRegistry( - protocolProvider = ::getProtocol, - scope = scope, - addLog = ::addLog - ) - private val connectionSupervisor = - ProtocolConnectionSupervisor( - scope = scope, - onEvent = ::handleConnectionEvent, - onError = { error -> android.util.Log.e(TAG, "ConnectionSupervisor event failed", error) }, - addLog = ::addLog - ) - private val sessionGeneration = AtomicLong(0L) - private val readyPacketGate = - ReadyPacketGate( - maxSize = READY_PACKET_QUEUE_MAX, - ttlMs = READY_PACKET_QUEUE_TTL_MS - ) - private val bootstrapCoordinator = - BootstrapCoordinator( - readyPacketGate = readyPacketGate, - addLog = ::addLog, - shortKeyForLog = ::shortKeyForLog, - sendPacketDirect = { packet -> getProtocol().sendPacket(packet) } - ) - private val connectionOrchestrator = - ConnectionOrchestrator( - hasActiveInternet = ::hasActiveInternet, - waitForNetworkAndReconnect = ::waitForNetworkAndReconnect, - stopWaitingForNetwork = { reason -> stopWaitingForNetwork(reason) }, - getProtocol = ::getProtocol, - persistHandshakeCredentials = { publicKey, privateHash -> - accountManager?.setLastLoggedPublicKey(publicKey) - accountManager?.setLastLoggedPrivateKeyHash(privateHash) - }, - buildHandshakeDevice = ::buildHandshakeDevice - ) - private val ownProfileSyncService = - OwnProfileSyncService( - isPlaceholderAccountName = ::isPlaceholderAccountName, - updateAccountName = { publicKey, name -> - accountManager?.updateAccountName(publicKey, name) - }, - updateAccountUsername = { publicKey, username -> - accountManager?.updateAccountUsername(publicKey, username) - } - ) - private val packetRouter by lazy { - PacketRouter( - sendSearchPacket = { packet -> send(packet) }, - privateHashProvider = { - try { - getProtocol().getPrivateHash() - } catch (_: Exception) { - null - } - } - ) - } - private val retryQueueService = - RetryQueueService( - scope = scope, - sendPacket = { packet -> send(packet) }, - isAuthenticated = ::isAuthenticated, - addLog = ::addLog, - markOutgoingAsError = ::markOutgoingAsError - ) - - @Volatile private var packetHandlersRegistered = false - @Volatile private var stateMonitoringStarted = false - @Volatile private var syncRequestInFlight = false - @Volatile private var syncRequestTimeoutJob: Job? = null - private val networkReconnectLock = Any() - @Volatile private var networkReconnectRegistered = false - @Volatile private var networkReconnectCallback: ConnectivityManager.NetworkCallback? = null - @Volatile private var networkReconnectTimeoutJob: Job? = null - @Volatile private var ownProfileFallbackJob: Job? = null - - // Guard: prevent duplicate FCM token subscribe within a single session - @Volatile - private var lastSubscribedToken: String? = null - - private val _debugLogs = MutableStateFlow>(emptyList()) - val debugLogs: StateFlow> = _debugLogs.asStateFlow() - private val debugLogsBuffer = ArrayDeque(MAX_DEBUG_LOGS) - private val debugLogsLock = Any() - private val protocolTraceLock = Any() - @Volatile private var debugFlushJob: Job? = null - private val debugFlushPending = AtomicBoolean(false) - @Volatile private var lastHeartbeatOkLogAtMs: Long = 0L - @Volatile private var suppressedHeartbeatOkLogs: Int = 0 - - // Typing status - private val _typingUsers = MutableStateFlow>(emptySet()) - val typingUsers: StateFlow> = _typingUsers.asStateFlow() - private val _typingUsersByDialogSnapshot = - MutableStateFlow>>(emptyMap()) - val typingUsersByDialogSnapshot: StateFlow>> = - _typingUsersByDialogSnapshot.asStateFlow() - private val typingStateLock = Any() - private val typingUsersByDialog = mutableMapOf>() - private val typingTimeoutJobs = ConcurrentHashMap() - - // Connected devices and pending verification requests - private val _devices = MutableStateFlow>(emptyList()) - val devices: StateFlow> = _devices.asStateFlow() - - private val _pendingDeviceVerification = MutableStateFlow(null) - val pendingDeviceVerification: StateFlow = _pendingDeviceVerification.asStateFlow() - - // Сигнал обновления own profile (username/name загружены с сервера) - val ownProfileUpdated: StateFlow = ownProfileSyncService.ownProfileUpdated - - private fun ensureConnectionSupervisor() { - connectionSupervisor.start() - } - - private fun postConnectionEvent(event: ConnectionEvent) { - connectionSupervisor.post(event) - } - - private fun protocolToLifecycleState(state: ProtocolState): ConnectionLifecycleState = - bootstrapCoordinator.protocolToLifecycleState(state) - - private fun setConnectionLifecycleState(next: ConnectionLifecycleState, reason: String) { - if (_connectionLifecycleState.value == next) return - addLog("🧭 CONNECTION STATE: ${_connectionLifecycleState.value} -> $next ($reason)") - _connectionLifecycleState.value = next - } - - private fun recomputeConnectionLifecycleState(reason: String) { - val context = bootstrapContext - val nextState = - bootstrapCoordinator.recomputeLifecycleState( - context = context, - currentState = _connectionLifecycleState.value, - reason = reason - ) { state, updateReason -> - setConnectionLifecycleState(state, updateReason) - } - _connectionLifecycleState.value = nextState - } - - private fun clearReadyPacketQueue(reason: String) { - bootstrapCoordinator.clearReadyPacketQueue(reason) - } - - private fun enqueueReadyPacket(packet: Packet) { - val accountKey = - messageRepository?.getCurrentAccountKey()?.trim().orEmpty().ifBlank { - bootstrapContext.accountPublicKey - } - bootstrapCoordinator.enqueueReadyPacket( - packet = packet, - accountPublicKey = accountKey, - state = _connectionLifecycleState.value - ) - } - - private fun flushReadyPacketQueue(activeAccountKey: String, reason: String) { - bootstrapCoordinator.flushReadyPacketQueue(activeAccountKey, reason) - } - - private fun packetCanBypassReadyGate(packet: Packet): Boolean = - bootstrapCoordinator.packetCanBypassReadyGate(packet) - - private suspend fun handleConnectionEvent(event: ConnectionEvent) { - when (event) { - is ConnectionEvent.InitializeAccount -> { - val normalizedPublicKey = event.publicKey.trim() - val normalizedPrivateKey = event.privateKey.trim() - if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) { - addLog("⚠️ initializeAccount skipped: missing account credentials") - return - } - - val protocolState = getProtocol().state.value - addLog( - "🔐 initializeAccount pk=${shortKeyForLog(normalizedPublicKey)} keyLen=${normalizedPrivateKey.length} state=$protocolState" - ) - setSyncInProgress(false) - clearTypingState() - val repository = messageRepository - if (repository == null) { - addLog("❌ initializeAccount aborted: MessageRepository is not bound") - return - } - repository.initialize(normalizedPublicKey, normalizedPrivateKey) - - val sameAccount = - bootstrapContext.accountPublicKey.equals(normalizedPublicKey, ignoreCase = true) - if (!sameAccount) { - clearReadyPacketQueue("account_switch") - } - - bootstrapContext = - bootstrapContext.copy( - accountPublicKey = normalizedPublicKey, - accountInitialized = true, - syncCompleted = if (sameAccount) bootstrapContext.syncCompleted else false, - ownProfileResolved = if (sameAccount) bootstrapContext.ownProfileResolved else false - ) - recomputeConnectionLifecycleState("account_initialized") - - val shouldResync = - resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true - if (shouldResync) { - resyncRequiredAfterAccountInit = false - syncRequestInFlight = false - clearSyncRequestTimeout() - addLog( - "🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync" - ) - requestSynchronize() - } - if ( - protocol?.isAuthenticated() == true && - activeAuthenticatedSessionId > 0L && - lastBootstrappedSessionId != activeAuthenticatedSessionId - ) { - tryRunPostAuthBootstrap("account_initialized") - } - - scope.launch { - messageRepository?.checkAndSendVersionUpdateMessage() - } - } - is ConnectionEvent.Connect -> { - connectionOrchestrator.handleConnect(event.reason) - } - is ConnectionEvent.FastReconnect -> { - connectionOrchestrator.handleFastReconnect(event.reason) - } - is ConnectionEvent.Disconnect -> { - stopWaitingForNetwork(event.reason) - protocol?.disconnect() - if (event.clearCredentials) { - protocol?.clearCredentials() - } - messageRepository?.clearInitialization() - clearTypingState() - _devices.value = emptyList() - _pendingDeviceVerification.value = null - syncRequestInFlight = false - clearSyncRequestTimeout() - setSyncInProgress(false) - resyncRequiredAfterAccountInit = false - lastSubscribedToken = null - ownProfileFallbackJob?.cancel() - ownProfileFallbackJob = null - deferredAuthBootstrap = false - activeAuthenticatedSessionId = 0L - lastBootstrappedSessionId = 0L - bootstrapContext = ConnectionBootstrapContext() - clearReadyPacketQueue("disconnect:${event.reason}") - recomputeConnectionLifecycleState("disconnect:${event.reason}") - } - is ConnectionEvent.Authenticate -> { - connectionOrchestrator.handleAuthenticate(event.publicKey, event.privateHash) - } - is ConnectionEvent.ProtocolStateChanged -> { - val previousProtocolState = bootstrapContext.protocolState - val newProtocolState = event.state - - if ( - newProtocolState == ProtocolState.AUTHENTICATED && - previousProtocolState != ProtocolState.AUTHENTICATED - ) { - lastSubscribedToken = null - stopWaitingForNetwork("authenticated") - ownProfileFallbackJob?.cancel() - val generation = sessionGeneration.incrementAndGet() - activeAuthenticatedSessionId = authenticatedSessionCounter.incrementAndGet() - deferredAuthBootstrap = false - bootstrapContext = - bootstrapContext.copy( - protocolState = newProtocolState, - authenticated = true, - syncCompleted = false, - ownProfileResolved = false - ) - recomputeConnectionLifecycleState("protocol_authenticated") - ownProfileFallbackJob = - scope.launch { - delay(BOOTSTRAP_OWN_PROFILE_FALLBACK_MS) - postConnectionEvent( - ConnectionEvent.OwnProfileFallbackTimeout(generation) - ) - } - onAuthenticated() - return - } - - if ( - newProtocolState != ProtocolState.AUTHENTICATED && - newProtocolState != ProtocolState.HANDSHAKING - ) { - syncRequestInFlight = false - clearSyncRequestTimeout() - setSyncInProgress(false) - lastSubscribedToken = null - cancelAllOutgoingRetries() - ownProfileFallbackJob?.cancel() - ownProfileFallbackJob = null - deferredAuthBootstrap = false - activeAuthenticatedSessionId = 0L - bootstrapContext = - bootstrapContext.copy( - protocolState = newProtocolState, - authenticated = false, - syncCompleted = false, - ownProfileResolved = false - ) - recomputeConnectionLifecycleState("protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}") - return - } - - if (newProtocolState == ProtocolState.HANDSHAKING && bootstrapContext.authenticated) { - ownProfileFallbackJob?.cancel() - ownProfileFallbackJob = null - deferredAuthBootstrap = false - activeAuthenticatedSessionId = 0L - bootstrapContext = - bootstrapContext.copy( - protocolState = newProtocolState, - authenticated = false, - syncCompleted = false, - ownProfileResolved = false - ) - recomputeConnectionLifecycleState("protocol_re_handshaking") - return - } - - bootstrapContext = bootstrapContext.copy(protocolState = newProtocolState) - recomputeConnectionLifecycleState("protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}") - } - is ConnectionEvent.SendPacket -> { - val packet = event.packet - val lifecycle = _connectionLifecycleState.value - if (packetCanBypassReadyGate(packet) || lifecycle == ConnectionLifecycleState.READY) { - getProtocol().sendPacket(packet) - } else { - enqueueReadyPacket(packet) - if (!isAuthenticated()) { - if (!hasActiveInternet()) { - waitForNetworkAndReconnect("ready_gate_send") - } else { - getProtocol().reconnectNowIfNeeded("ready_gate_send") - } - } - } - } - is ConnectionEvent.SyncCompleted -> { - syncRequestInFlight = false - clearSyncRequestTimeout() - inboundProcessingFailures.set(0) - inboundTasksInCurrentBatch.set(0) - fullFailureBatchStreak.set(0) - addLog(event.reason) - setSyncInProgress(false) - retryWaitingMessages() - requestMissingUserInfo() - - bootstrapContext = bootstrapContext.copy(syncCompleted = true) - recomputeConnectionLifecycleState("sync_completed") - } - is ConnectionEvent.OwnProfileResolved -> { - val accountPublicKey = bootstrapContext.accountPublicKey - val matchesAccount = - accountPublicKey.isBlank() || - event.publicKey.equals(accountPublicKey, ignoreCase = true) - if (!matchesAccount) return - ownProfileFallbackJob?.cancel() - ownProfileFallbackJob = null - bootstrapContext = bootstrapContext.copy(ownProfileResolved = true) - IdentityStore.updateOwnProfile( - publicKey = event.publicKey, - resolved = true, - reason = "protocol_own_profile_resolved" - ) - recomputeConnectionLifecycleState("own_profile_resolved") - } - is ConnectionEvent.OwnProfileFallbackTimeout -> { - if (sessionGeneration.get() != event.sessionGeneration) return - if (!bootstrapContext.authenticated || bootstrapContext.ownProfileResolved) return - addLog( - "⏱️ Own profile fetch timeout — continuing bootstrap for ${shortKeyForLog(bootstrapContext.accountPublicKey)}" - ) - bootstrapContext = bootstrapContext.copy(ownProfileResolved = true) - val accountPublicKey = bootstrapContext.accountPublicKey - if (accountPublicKey.isNotBlank()) { - IdentityStore.updateOwnProfile( - publicKey = accountPublicKey, - resolved = true, - reason = "protocol_own_profile_fallback_timeout" - ) - } - recomputeConnectionLifecycleState("own_profile_fallback_timeout") - } - } - } - - // Keep heavy protocol/message UI logs disabled by default. - private var uiLogsEnabled = false - private val _connectionLifecycleState = MutableStateFlow(ConnectionLifecycleState.DISCONNECTED) - val connectionLifecycleState: StateFlow = _connectionLifecycleState.asStateFlow() - private var bootstrapContext = ConnectionBootstrapContext() - @Volatile private var syncBatchInProgress = false - private val _syncInProgress = MutableStateFlow(false) - val syncInProgress: StateFlow = _syncInProgress.asStateFlow() - @Volatile private var resyncRequiredAfterAccountInit = false - @Volatile private var lastForegroundSyncTime = 0L - private val authenticatedSessionCounter = AtomicLong(0L) - @Volatile private var activeAuthenticatedSessionId = 0L - @Volatile private var lastBootstrappedSessionId = 0L - @Volatile private var deferredAuthBootstrap = false - private val authBootstrapMutex = Mutex() - // Desktop parity: sequential task queue matching dialogQueue.ts (promise chain). - // Uses Channel to guarantee strict FIFO ordering (Mutex+lastInboundJob had a race - // condition: Dispatchers.IO doesn't guarantee FIFO, so the last-launched job could - // finish before earlier ones, causing whenInboundTasksFinish to return prematurely - // and BATCH_END to advance the sync timestamp while messages were still processing). - private val inboundTaskChannel = Channel Unit>(Channel.UNLIMITED) - // Tracks the tail of the sequential processing chain (like desktop's `tail` promise) - @Volatile private var inboundQueueDrainJob: Job? = null - private val inboundProcessingFailures = AtomicInteger(0) - private val inboundTasksInCurrentBatch = AtomicInteger(0) - private val fullFailureBatchStreak = AtomicInteger(0) - private val syncBatchEndMutex = Mutex() - - private fun setSyncInProgress(value: Boolean) { - syncBatchInProgress = value - if (_syncInProgress.value != value) { - _syncInProgress.value = value - } - } - - fun addLog(message: String) { - var normalizedMessage = message - val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK") - if (isHeartbeatOk) { - val now = System.currentTimeMillis() - if (now - lastHeartbeatOkLogAtMs < HEARTBEAT_OK_LOG_MIN_INTERVAL_MS) { - suppressedHeartbeatOkLogs++ - return - } - if (suppressedHeartbeatOkLogs > 0) { - normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)" - suppressedHeartbeatOkLogs = 0 - } - lastHeartbeatOkLogAtMs = now - } - val timestamp = - java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) - val line = "[$timestamp] $normalizedMessage" - if (shouldPersistProtocolTrace(normalizedMessage)) { - persistProtocolTraceLine(line) - } - if (!uiLogsEnabled) return - synchronized(debugLogsLock) { - if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) { - debugLogsBuffer.removeFirst() - } - debugLogsBuffer.addLast(line) - } - flushDebugLogsThrottled() - } - - /** - * Keep crash_reports trace lightweight when UI logs are disabled. - * This avoids excessive disk writes during long sync sessions. - */ - private fun shouldPersistProtocolTrace(message: String): Boolean { - if (uiLogsEnabled) return true - if (message.startsWith("❌") || message.startsWith("⚠️")) return true - if (message.contains("STATE CHANGE")) return true - if (message.contains("CONNECTION FULLY ESTABLISHED")) return true - if (message.contains("HANDSHAKE COMPLETE")) return true - if (message.contains("SYNC COMPLETE")) return true - if (message.startsWith("🔌 CONNECT CALLED") || message.startsWith("🔌 Connecting to")) return true - if (message.startsWith("✅ WebSocket OPEN")) return true - if (message.startsWith("📡 NETWORK")) return true - return false - } - - private fun persistProtocolTraceLine(line: String) { - val context = appContext ?: return - runCatching { - val dir = File(context.filesDir, "crash_reports") - if (!dir.exists()) dir.mkdirs() - val traceFile = File(dir, PROTOCOL_TRACE_FILE_NAME) - synchronized(protocolTraceLock) { - if (traceFile.exists() && traceFile.length() > PROTOCOL_TRACE_MAX_BYTES) { - val tail = runCatching { - traceFile.readText(Charsets.UTF_8).takeLast(PROTOCOL_TRACE_KEEP_BYTES) - }.getOrDefault("") - traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8) - } - traceFile.appendText("$line\n", Charsets.UTF_8) - } - } - } - - fun enableUILogs(enabled: Boolean) { - uiLogsEnabled = enabled - MessageLogger.setEnabled(enabled) - if (enabled) { - val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() } - _debugLogs.value = snapshot - } else { - _debugLogs.value = emptyList() - } - } - - fun clearLogs() { - synchronized(debugLogsLock) { - debugLogsBuffer.clear() - } - suppressedHeartbeatOkLogs = 0 - lastHeartbeatOkLogAtMs = 0L - _debugLogs.value = emptyList() - } - - private fun flushDebugLogsThrottled() { - debugFlushPending.set(true) - if (debugFlushJob?.isActive == true) return - debugFlushJob = - scope.launch { - while (debugFlushPending.getAndSet(false)) { - delay(DEBUG_LOG_FLUSH_DELAY_MS) - val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() } - _debugLogs.value = snapshot - } - } - } - - private fun markInboundProcessingFailure(reason: String, error: Throwable? = null) { - inboundProcessingFailures.incrementAndGet() - if (error != null) { - android.util.Log.e(TAG, reason, error) - addLog("❌ $reason: ${error.message ?: error.javaClass.simpleName}") - } else { - android.util.Log.w(TAG, reason) - addLog("⚠️ $reason") - } - } - - /** - * Inject process-wide dependencies from DI container. - */ - fun bindDependencies( - messageRepository: MessageRepository, - groupRepository: GroupRepository, - accountManager: AccountManager - ) { - this.messageRepository = messageRepository - this.groupRepository = groupRepository - this.accountManager = accountManager - } - - /** - * Backward-compatible alias kept while migrating call sites. - */ - fun bindRepositories( - messageRepository: MessageRepository, - groupRepository: GroupRepository - ) { - this.messageRepository = messageRepository - this.groupRepository = groupRepository - } - - /** - * Инициализация с контекстом для доступа к MessageRepository - */ - fun initialize(context: Context) { - appContext = context.applicationContext - if (messageRepository == null || groupRepository == null || accountManager == null) { - addLog("⚠️ initialize called before dependencies were bound via DI") - } - ensureConnectionSupervisor() - if (!packetHandlersRegistered) { - setupPacketHandlers() - packetHandlersRegistered = true - } - if (!stateMonitoringStarted) { - setupStateMonitoring() - stateMonitoringStarted = true - } - } - - /** - * 🔍 Мониторинг состояния соединения - */ - private fun setupStateMonitoring() { - scope.launch { - getProtocol().state.collect { newState -> - postConnectionEvent(ConnectionEvent.ProtocolStateChanged(newState)) - } - } - } - - /** - * 🔥 Инициализация аккаунта - КРИТИЧНО для получения сообщений! - * Должен вызываться после авторизации пользователя - */ - fun initializeAccount(publicKey: String, privateKey: String) { - postConnectionEvent( - ConnectionEvent.InitializeAccount(publicKey = publicKey, privateKey = privateKey) - ) - } - - /** - * Настройка обработчиков пакетов - */ - private fun setupPacketHandlers() { - // Обработчик входящих сообщений (0x06) - // Desktop parity: desktop client does not send PacketDelivery manually. - waitPacket(0x06) { packet -> - val messagePacket = packet as PacketMessage - - launchInboundPacketTask { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) { - requireResyncAfterAccountInit("⏳ Incoming message before account init, scheduling re-sync") - markInboundProcessingFailure("Incoming packet skipped before account init") - return@launchInboundPacketTask - } - val processed = repository.handleIncomingMessage(messagePacket) - if (!processed) { - markInboundProcessingFailure( - "Message processing failed for ${messagePacket.messageId.take(8)}" - ) - return@launchInboundPacketTask - } - if (!syncBatchInProgress) { - repository.updateLastSyncTimestamp(messagePacket.timestamp) - } - } - } - - // Обработчик доставки (0x08) - waitPacket(0x08) { packet -> - val deliveryPacket = packet as PacketDelivery - - launchInboundPacketTask { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) { - requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync") - markInboundProcessingFailure("Delivery packet skipped before account init") - return@launchInboundPacketTask - } - try { - repository.handleDelivery(deliveryPacket) - resolveOutgoingRetry(deliveryPacket.messageId) - } catch (e: Exception) { - markInboundProcessingFailure("Delivery processing failed", e) - return@launchInboundPacketTask - } - if (!syncBatchInProgress) { - repository.updateLastSyncTimestamp(System.currentTimeMillis()) - } - } - } - - // Обработчик прочтения (0x07) - // В Desktop PacketRead не содержит messageId - сообщает что собеседник прочитал сообщения - waitPacket(0x07) { packet -> - val readPacket = packet as PacketRead - - launchInboundPacketTask { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) { - requireResyncAfterAccountInit("⏳ Read status before account init, scheduling re-sync") - markInboundProcessingFailure("Read packet skipped before account init") - return@launchInboundPacketTask - } - val ownKey = getProtocol().getPublicKey().orEmpty() - if (ownKey.isBlank()) { - requireResyncAfterAccountInit("⏳ Read status before protocol account init, scheduling re-sync") - markInboundProcessingFailure("Read packet skipped before protocol account init") - return@launchInboundPacketTask - } - try { - repository.handleRead(readPacket) - } catch (e: Exception) { - markInboundProcessingFailure("Read processing failed", e) - return@launchInboundPacketTask - } - if (!syncBatchInProgress) { - // Desktop parity: - // own direct read sync (from=me,to=peer) does not advance sync cursor. - val isOwnDirectReadSync = - readPacket.fromPublicKey.trim() == ownKey && - !isGroupDialogKey(readPacket.toPublicKey) - if (!isOwnDirectReadSync) { - repository.updateLastSyncTimestamp(System.currentTimeMillis()) - } - } - } - } - - // 🔐 New device login attempt (0x09) — desktop parity (system Safe message) - waitPacket(0x09) { packet -> - val devicePacket = packet as PacketDeviceNew - - addLog( - "🔐 DEVICE LOGIN ATTEMPT: ip=${devicePacket.ipAddress}, device=${devicePacket.device.deviceName}, os=${devicePacket.device.deviceOs}" - ) - - launchInboundPacketTask { - messageRepository?.addDeviceLoginSystemMessage( - ipAddress = devicePacket.ipAddress, - deviceId = devicePacket.device.deviceId, - deviceName = devicePacket.device.deviceName, - deviceOs = devicePacket.device.deviceOs - ) - } - } - - // 🔄 Обработчик батчевой синхронизации (0x19) - waitPacket(0x19) { packet -> - handleSyncPacket(packet as PacketSync) - } - - // 👥 Обработчик синхронизации групп (0x14) - // Desktop parity: во время sync сервер отправляет PacketGroupJoin с groupString. - waitPacket(0x14) { packet -> - val joinPacket = packet as PacketGroupJoin - - launchInboundPacketTask { - val repository = messageRepository - val groups = groupRepository - val account = repository?.getCurrentAccountKey() - val privateKey = repository?.getCurrentPrivateKey() - if (groups == null || account.isNullOrBlank() || privateKey.isNullOrBlank()) { - return@launchInboundPacketTask - } - try { - val result = groups.synchronizeJoinedGroup( - accountPublicKey = account, - accountPrivateKey = privateKey, - packet = joinPacket - ) - if (result?.success == true) { - addLog("👥 GROUP synced: ${result.dialogPublicKey}") - } - } catch (e: Exception) { - android.util.Log.w(TAG, "Failed to sync group packet", e) - } - } - } - - // 🟢 Обработчик онлайн-статуса (0x05) - waitPacket(0x05) { packet -> - val onlinePacket = packet as PacketOnlineState - - scope.launch { - if (messageRepository != null) { - onlinePacket.publicKeysState.forEach { item -> - val isOnline = item.state == OnlineState.ONLINE - messageRepository?.updateOnlineStatus(item.publicKey, isOnline) - } - } - } - } - - // Обработчик typing (0x0B) - waitPacket(0x0B) { packet -> - val typingPacket = packet as PacketTyping - val fromPublicKey = typingPacket.fromPublicKey.trim() - val toPublicKey = typingPacket.toPublicKey.trim() - if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return@waitPacket - - val ownPublicKey = - getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { - messageRepository?.getCurrentAccountKey()?.trim().orEmpty() - } - if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) { - return@waitPacket - } - - val dialogKey = - resolveTypingDialogKey( - fromPublicKey = fromPublicKey, - toPublicKey = toPublicKey, - ownPublicKey = ownPublicKey - ) ?: return@waitPacket - - rememberTypingEvent(dialogKey, fromPublicKey) - } - - // 📱 Обработчик списка устройств (0x17) - waitPacket(0x17) { packet -> - val devicesPacket = packet as PacketDeviceList - val parsedDevices = devicesPacket.devices - _devices.value = parsedDevices - _pendingDeviceVerification.value = parsedDevices.firstOrNull { device -> - device.deviceVerify == DeviceVerifyState.NOT_VERIFIED - } - } - - // 🔥 Обработчик поиска/user info (0x03) - // Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера - // + обновляет own profile (username/name) аналогично Desktop useUserInformation() - waitPacket(0x03) { packet -> - val searchPacket = packet as PacketSearch - - scope.launch(Dispatchers.IO) { - val ownPublicKey = - getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { - messageRepository?.getCurrentAccountKey()?.trim().orEmpty() - } - - packetRouter.onSearchPacket(searchPacket) { user -> - val normalizedUserPublicKey = user.publicKey.trim() - messageRepository?.updateDialogUserInfo( - normalizedUserPublicKey, - user.title, - user.username, - user.verified - ) - - val ownProfileResolved = - ownProfileSyncService.applyOwnProfileFromSearch( - ownPublicKey = ownPublicKey, - user = user - ) - if (ownProfileResolved) { - postConnectionEvent(ConnectionEvent.OwnProfileResolved(user.publicKey)) - } - } - } - } - - // 🚀 Обработчик транспортного сервера (0x0F) - waitPacket(0x0F) { packet -> - val transportPacket = packet as PacketRequestTransport - TransportManager.setTransportServer(transportPacket.transportServer) - } - } - - /** - * Desktop parity: sequential task queue (like dialogQueue.ts runTaskInQueue / whenFinish). - * - * Desktop uses a promise chain: `tail = tail.then(fn).catch(...)` which guarantees - * strict FIFO ordering and `whenFinish = () => tail` returns a promise that resolves - * only after ALL queued tasks complete. - * - * We reproduce this with a Channel Unit> (UNLIMITED buffer) consumed - * by a single coroutine. Tasks are executed strictly in the order they were submitted, - * and `whenInboundTasksFinish()` waits for the queue to drain completely. - */ - private fun ensureInboundQueueDrainRunning() { - if (inboundQueueDrainJob?.isActive == true) return - inboundQueueDrainJob = scope.launch { - for (task in inboundTaskChannel) { - try { - task() - } catch (t: Throwable) { - markInboundProcessingFailure("Dialog queue error", t) - } - } - } - } - - private fun launchInboundPacketTask(block: suspend () -> Unit): Boolean { - ensureInboundQueueDrainRunning() - val countAsBatchTask = syncBatchInProgress - if (countAsBatchTask) { - inboundTasksInCurrentBatch.incrementAndGet() - } - val result = inboundTaskChannel.trySend(block) - if (result.isFailure) { - if (countAsBatchTask) { - inboundTasksInCurrentBatch.decrementAndGet() - } - markInboundProcessingFailure( - "Failed to enqueue inbound task", - result.exceptionOrNull() - ) - return false - } - return true - } - - private fun requireResyncAfterAccountInit(reason: String) { - if (!resyncRequiredAfterAccountInit) { - addLog(reason) - } - resyncRequiredAfterAccountInit = true - } - - /** - * Desktop parity: equivalent of `await whenFinish()` in useSynchronize.ts. - * Sends a sentinel task into the sequential queue and suspends until it executes. - * Since the queue is strictly FIFO, when the sentinel runs, all previously - * submitted tasks are guaranteed to have completed. - */ - private suspend fun whenInboundTasksFinish(): Boolean { - val done = CompletableDeferred() - if (!launchInboundPacketTask { done.complete(Unit) }) { - return false - } - done.await() - return true - } - - private fun isGroupDialogKey(value: String): Boolean { - val normalized = value.trim().lowercase(Locale.ROOT) - return normalized.startsWith("#group:") || normalized.startsWith("group:") - } - - 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)}" - } - - fun getTypingUsersForDialog(dialogKey: String): Set { - val normalizedDialogKey = - if (isGroupDialogKey(dialogKey)) { - normalizeGroupDialogKey(dialogKey) - } else { - dialogKey.trim() - } - if (normalizedDialogKey.isBlank()) return emptySet() - - synchronized(typingStateLock) { - return typingUsersByDialog[normalizedDialogKey]?.toSet() ?: emptySet() - } - } - - 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(TYPING_INDICATOR_TIMEOUT_MS) - 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) - } - } - - private fun clearTypingState() { - typingTimeoutJobs.values.forEach { it.cancel() } - typingTimeoutJobs.clear() - synchronized(typingStateLock) { - typingUsersByDialog.clear() - _typingUsers.value = emptySet() - _typingUsersByDialogSnapshot.value = emptyMap() - } - } - - private fun canRunPostAuthBootstrap(): Boolean { - val repository = messageRepository ?: return false - if (!repository.isInitialized()) return false - val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty() - if (repositoryAccount.isBlank()) return false - val protocolAccount = getProtocol().getPublicKey()?.trim().orEmpty() - if (protocolAccount.isBlank()) return true - return repositoryAccount.equals(protocolAccount, ignoreCase = true) - } - - private fun tryRunPostAuthBootstrap(trigger: String) { - val sessionId = activeAuthenticatedSessionId - if (sessionId <= 0L) return - scope.launch { - authBootstrapMutex.withLock { - if (sessionId != activeAuthenticatedSessionId) return@withLock - if (sessionId == lastBootstrappedSessionId) return@withLock - if (!canRunPostAuthBootstrap()) { - deferredAuthBootstrap = true - val repositoryAccount = - messageRepository?.getCurrentAccountKey()?.let { shortKeyForLog(it) } - ?: "" - val protocolAccount = - getProtocol().getPublicKey()?.let { shortKeyForLog(it) } - ?: "" - addLog( - "⏳ AUTH bootstrap deferred trigger=$trigger repo=$repositoryAccount proto=$protocolAccount" - ) - return@withLock - } - - deferredAuthBootstrap = false - setSyncInProgress(false) - addLog("🚀 AUTH bootstrap start session=$sessionId trigger=$trigger") - TransportManager.requestTransportServer() - com.rosetta.messenger.update.UpdateManager.requestSduServer() - fetchOwnProfile() - requestSynchronize() - subscribePushTokenIfAvailable() - lastBootstrappedSessionId = sessionId - addLog("✅ AUTH bootstrap complete session=$sessionId trigger=$trigger") - } - } - } - - private fun onAuthenticated() { - tryRunPostAuthBootstrap("state_authenticated") - } - - private fun finishSyncCycle(reason: String) { - postConnectionEvent(ConnectionEvent.SyncCompleted(reason)) - } - - /** - * Send FCM push token to server (SUBSCRIBE). - * Deduplicates: won't re-send the same token within one connection session. - * Called internally on AUTHENTICATED and can be called from - * [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken] - * when Firebase rotates the token mid-session. - * - * @param forceToken if non-null, use this token instead of reading SharedPreferences - * (used by onNewToken which already has the fresh token). - */ - fun subscribePushTokenIfAvailable(forceToken: String? = null) { - val context = appContext ?: return - val token = (forceToken - ?: context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) - .getString("fcm_token", null)) - ?.trim() - .orEmpty() - if (token.isEmpty()) return - - // Dedup: don't send the same token twice in one connection session - if (token == lastSubscribedToken) { - addLog("🔔 Push token already subscribed this session — skipped") - return - } - - val deviceId = appContext?.let { getOrCreateDeviceId(it) } ?: "" - val subPacket = PacketPushNotification().apply { - notificationsToken = token - action = PushNotificationAction.SUBSCRIBE - tokenType = PushTokenType.FCM - this.deviceId = deviceId - } - send(subPacket) - lastSubscribedToken = token - addLog("🔔 Push token SUBSCRIBE sent") - - // Сохраняем FCM токен в crash_reports для просмотра через rosettadev1 - try { - val dir = java.io.File(context.filesDir, "crash_reports") - if (!dir.exists()) dir.mkdirs() - val f = java.io.File(dir, "fcm_token.txt") - val ts = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date()) - f.writeText("=== FCM TOKEN ===\n\nTimestamp: $ts\nDeviceId: $deviceId\n\nToken:\n$token\n") - } catch (_: Throwable) {} - } - - private 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 = messageRepository - if (repository == null || !repository.isInitialized()) { - syncRequestInFlight = false - clearSyncRequestTimeout() - requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized") - return@launch - } - val protocolAccount = getProtocol().getPublicKey().orEmpty() - val repositoryAccount = repository.getCurrentAccountKey().orEmpty() - if (protocolAccount.isNotBlank() && - repositoryAccount.isNotBlank() && - repositoryAccount != protocolAccount - ) { - 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) - } - } - - private fun sendSynchronize(timestamp: Long) { - syncRequestInFlight = true - scheduleSyncRequestTimeout(timestamp) - val packet = PacketSync().apply { - status = SyncStatus.NOT_NEEDED - this.timestamp = timestamp - } - send(packet) - } - - /** - * Desktop parity: useSynchronize.ts usePacket(25, ...) - * BATCH_START → mark sync in progress (synchronous — no scope.launch) - * BATCH_END → wait for ALL message tasks to finish, save timestamp, request next batch - * NOT_NEEDED → sync complete, mark connected (synchronous — no scope.launch) - * - * CRITICAL: BATCH_START and NOT_NEEDED are handled synchronously in the WebSocket - * callback thread. This prevents a race condition where 0x06 message packets arrive - * and check syncBatchInProgress BEFORE the scope.launch coroutine for BATCH_START - * has been scheduled on Dispatchers.IO. - */ - private fun handleSyncPacket(packet: PacketSync) { - syncRequestInFlight = false - clearSyncRequestTimeout() - when (packet.status) { - SyncStatus.BATCH_START -> { - addLog("🔄 SYNC BATCH_START — incoming message batch") - // Synchronous — guarantees syncBatchInProgress=true before any - // subsequent 0x06 packets are dispatched by OkHttp's sequential callback. - setSyncInProgress(true) - inboundProcessingFailures.set(0) - inboundTasksInCurrentBatch.set(0) - } - SyncStatus.BATCH_END -> { - addLog("🔄 SYNC BATCH_END — waiting for tasks to finish (ts=${packet.timestamp})") - // BATCH_END requires suspend (whenInboundTasksFinish), so we launch a coroutine. - // syncBatchInProgress stays true until NOT_NEEDED arrives. - scope.launch { - syncBatchEndMutex.withLock { - val tasksFinished = whenInboundTasksFinish() - if (!tasksFinished) { - android.util.Log.w( - TAG, - "SYNC BATCH_END: queue unavailable, skipping cursor update for this step" - ) - val fallbackCursor = messageRepository?.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 = messageRepository?.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) - } - val repository = messageRepository - // Desktop parity: save the cursor provided by BATCH_END and request next - // chunk with the same cursor. - repository?.updateLastSyncTimestamp(packet.timestamp) - addLog("🔄 SYNC tasks done — cursor=${packet.timestamp}, requesting next batch") - sendSynchronize(packet.timestamp) - } - } - } - SyncStatus.NOT_NEEDED -> { - // Synchronous — immediately marks sync as complete. - // Desktop parity: NOT_NEEDED just sets state to CONNECTED, - // does NOT update last_sync timestamp (unnecessary since client - // was already up to date). - finishSyncCycle("✅ SYNC COMPLETE — no more messages to sync") - } - } - } - - private fun scheduleSyncRequestTimeout(cursor: Long) { - syncRequestTimeoutJob?.cancel() - syncRequestTimeoutJob = scope.launch { - delay(SYNC_REQUEST_TIMEOUT_MS) - if (!syncRequestInFlight || !isAuthenticated()) return@launch - syncRequestInFlight = false - addLog( - "⏱️ SYNC response timeout for cursor=$cursor, retrying request" - ) - requestSynchronize() - } - } - - private fun clearSyncRequestTimeout() { - syncRequestTimeoutJob?.cancel() - syncRequestTimeoutJob = null - } - - /** - * Retry messages stuck in WAITING status on reconnect. - * Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are - * rarely force-killed. On Android, the app can be killed mid-send, leaving messages - * in WAITING status in the DB. This method resends them after sync completes. - * - * Messages older than 80s (MESSAGE_MAX_TIME_TO_DELEVERED_S) are marked ERROR. - */ - private fun retryWaitingMessages() { - scope.launch { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) return@launch - try { - repository.retryWaitingMessages() - } catch (e: Exception) { - android.util.Log.e(TAG, "retryWaitingMessages failed", e) - } - } - } - - /** - * Desktop parity: after sync completes, resolve names/usernames for all dialogs - * that still have empty titles. Clears the one-shot guard first so that previously - * failed requests can be retried. - */ - private fun requestMissingUserInfo() { - scope.launch { - val repository = messageRepository - if (repository == null || !repository.isInitialized()) return@launch - try { - repository.clearUserInfoRequestCache() - repository.requestMissingUserInfo() - } catch (e: Exception) { - android.util.Log.e(TAG, "requestMissingUserInfo failed", e) - } - } - } - - private fun hasActiveInternet(): Boolean { - val context = appContext ?: 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) - } - - private fun stopWaitingForNetwork(reason: String? = null) { - val context = appContext ?: return - val cm = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return - val callback = synchronized(networkReconnectLock) { - val current = networkReconnectCallback - networkReconnectCallback = null - networkReconnectRegistered = false - networkReconnectTimeoutJob?.cancel() - networkReconnectTimeoutJob = null - current - } - if (callback != null) { - runCatching { cm.unregisterNetworkCallback(callback) } - if (!reason.isNullOrBlank()) { - addLog("📡 NETWORK WATCH STOP: $reason") - } - } - } - - private fun waitForNetworkAndReconnect(reason: String) { - if (hasActiveInternet()) { - stopWaitingForNetwork("network already available") - return - } - - val context = appContext ?: return - val cm = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager - ?: return - - val alreadyRegistered = synchronized(networkReconnectLock) { - if (networkReconnectRegistered) { - true - } else { - val callback = - object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - if (hasActiveInternet()) { - addLog("📡 NETWORK AVAILABLE → reconnect") - stopWaitingForNetwork("available") - postConnectionEvent( - ConnectionEvent.FastReconnect("network_available") - ) - } - } - - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities - ) { - if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { - addLog("📡 NETWORK CAPABILITIES READY → reconnect") - stopWaitingForNetwork("capabilities_changed") - postConnectionEvent( - ConnectionEvent.FastReconnect("network_capabilities_changed") - ) - } - } - } - networkReconnectCallback = callback - networkReconnectRegistered = 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(networkReconnectCallback!!) - } else { - val request = - NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - cm.registerNetworkCallback(request, networkReconnectCallback!!) - } - }.onFailure { error -> - addLog("⚠️ NETWORK WAIT register failed: ${error.message}") - stopWaitingForNetwork("register_failed") - postConnectionEvent( - ConnectionEvent.FastReconnect("network_wait_register_failed") - ) - } - - networkReconnectTimeoutJob?.cancel() - networkReconnectTimeoutJob = - scope.launch { - delay(NETWORK_WAIT_TIMEOUT_MS) - if (!hasActiveInternet()) { - addLog("⏱️ NETWORK WAIT timeout (${NETWORK_WAIT_TIMEOUT_MS}ms), reconnect fallback") - stopWaitingForNetwork("timeout") - postConnectionEvent( - ConnectionEvent.FastReconnect("network_wait_timeout") - ) - } - } - } - - /** - * Get or create Protocol instance - */ - fun getProtocol(): Protocol { - protocol?.let { return it } - - synchronized(protocolInstanceLock) { - protocol?.let { return it } - - val created = - Protocol( - serverAddress = SERVER_ADDRESS, - logger = { msg -> addLog(msg) }, - isNetworkAvailable = { hasActiveInternet() }, - onNetworkUnavailable = { waitForNetworkAndReconnect("protocol_connect") } - ) - protocol = created - addLog("🧩 Protocol singleton created: id=${System.identityHashCode(created)}") - return created - } - } - - /** - * Get connection state flow - */ - val state: StateFlow - get() = getProtocol().state - - /** - * Get last error flow - */ - val lastError: StateFlow - get() = getProtocol().lastError - - /** - * Connect to server - */ - fun connect() { - postConnectionEvent(ConnectionEvent.Connect(reason = "api_connect")) - } - - /** - * Trigger immediate reconnect on app foreground (skip waiting backoff timer). - */ - fun reconnectNowIfNeeded(reason: String = "foreground_resume") { - postConnectionEvent(ConnectionEvent.FastReconnect(reason = reason)) - } - - /** - * Desktop parity: синхронизация при каждом заходе в приложение. - * Desktop вызывает trySync() на каждый handshakeExchangeComplete. - * На Android — вызываем из onResume(), если уже AUTHENTICATED и не идёт sync. - * Дебаунс 5 секунд чтобы не спамить при быстром alt-tab. - */ - fun syncOnForeground() { - if (!isAuthenticated()) return - if (syncBatchInProgress) return - if (syncRequestInFlight) return - val now = System.currentTimeMillis() - if (now - lastForegroundSyncTime < 5_000) return - lastForegroundSyncTime = now - addLog("🔄 SYNC on foreground resume") - requestSynchronize() - } - - /** - * Manual sync trigger from UI. - * Rewinds lastSync a bit to safely re-fetch recent packets and re-starts sync. - */ - fun forceSynchronize(backtrackMs: Long = MANUAL_SYNC_BACKTRACK_MS) { - if (!isAuthenticated()) { - reconnectNowIfNeeded("manual_sync_button") - return - } - if (syncBatchInProgress) return - if (syncRequestInFlight) return - - scope.launch { - val repository = messageRepository - 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) - } - } - - /** - * Authenticate with server - */ - fun authenticate(publicKey: String, privateHash: String) { - postConnectionEvent( - ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash) - ) - } - - /** - * Restore auth handshake credentials from local account cache. - * Used when process is awakened by push and UI unlock flow wasn't executed yet. - */ - fun restoreAuthFromStoredCredentials( - preferredPublicKey: String? = null, - reason: String = "background_restore" - ): Boolean { - val accountManager = accountManager - if (accountManager == null) { - addLog("⚠️ restoreAuthFromStoredCredentials skipped: AccountManager is not bound") - return false - } - val publicKey = - preferredPublicKey?.trim().orEmpty().ifBlank { - accountManager.getLastLoggedPublicKey().orEmpty() - } - val privateHash = accountManager.getLastLoggedPrivateKeyHash().orEmpty() - if (publicKey.isBlank() || privateHash.isBlank()) { - addLog( - "⚠️ restoreAuthFromStoredCredentials skipped (pk=${publicKey.isNotBlank()} hash=${privateHash.isNotBlank()} reason=$reason)" - ) - return false - } - addLog("🔐 Restoring auth from cache reason=$reason pk=${shortKeyForLog(publicKey)}") - authenticate(publicKey, privateHash) - return true - } - - /** - * Запрашивает собственный профиль с сервера (username, name/title). - * Аналог Desktop: useUserInformation(ownPublicKey) → PacketSearch(0x03) - */ - private fun fetchOwnProfile() { - val packet = - ownProfileSyncService.buildOwnProfilePacket( - publicKey = getProtocol().getPublicKey(), - privateHash = getProtocol().getPrivateHash() - ) ?: return - send(packet) - } - - /** - * 🔍 Resolve publicKey → user title (like Desktop useUserInformation) - * Checks cache first, then sends PacketSearch and waits for response. - * Returns title or null on timeout/not found. - * @param timeoutMs max wait time for server response (default 3s) - */ - suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? { - return packetRouter.resolveUserName(publicKey = publicKey, timeoutMs = timeoutMs) - } - - /** - * 🔍 Get cached user info (no network request) - */ - fun getCachedUserName(publicKey: String): String? { - return packetRouter.getCachedUserName(publicKey) - } - - /** - * 🔍 Get full cached user info (no network request) - */ - fun notifyOwnProfileUpdated() { - ownProfileSyncService.notifyOwnProfileUpdated() - } - - fun getCachedUserInfo(publicKey: String): SearchUser? { - return packetRouter.getCachedUserInfo(publicKey) - } - - /** - * 🔍 Get cached user by username (no network request). - * Username compare is case-insensitive and ignores '@'. - */ - fun getCachedUserByUsername(username: String): SearchUser? { - return packetRouter.getCachedUserByUsername(username) - } - - /** - * 🔍 Resolve publicKey → full SearchUser (with server request if needed) - */ - suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? { - return packetRouter.resolveUserInfo(publicKey = publicKey, timeoutMs = timeoutMs) - } - - /** - * 🔍 Search users by query (usually username without '@'). - * Returns raw PacketSearch users list for the exact query. - */ - suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List { - return packetRouter.searchUsers(query = query, timeoutMs = timeoutMs) - } - - /** - * Accept a pending device login request. - */ - fun acceptDevice(deviceId: String) { - val packet = PacketDeviceResolve().apply { - this.deviceId = deviceId - this.solution = DeviceResolveSolution.ACCEPT - } - send(packet) - } - - /** - * Decline a pending device login request. - */ - fun declineDevice(deviceId: String) { - val packet = PacketDeviceResolve().apply { - this.deviceId = deviceId - this.solution = DeviceResolveSolution.DECLINE - } - send(packet) - } - - /** - * Send packet (simplified) - */ - fun send(packet: Packet) { - postConnectionEvent(ConnectionEvent.SendPacket(packet)) - } - - /** - * Send an outgoing message packet and register it for automatic retry. - */ - fun sendMessageWithRetry(packet: PacketMessage) { - send(packet) - retryQueueService.register(packet) - } - - /** - * iOS parity: mark an outgoing message as error in persistent storage. - */ - private suspend fun markOutgoingAsError(messageId: String, packet: PacketMessage) { - val repository = messageRepository ?: return - val opponentKey = - if (packet.fromPublicKey == repository.getCurrentAccountKey()) { - packet.toPublicKey - } else { - packet.fromPublicKey - } - val dialogKey = repository.getDialogKey(opponentKey) - repository.updateMessageDeliveryStatus(dialogKey, messageId, DeliveryStatus.ERROR) - } - - /** - * iOS parity: cancel retry and clean up state for a resolved outgoing message. - * Called when delivery ACK (0x08) is received. - */ - fun resolveOutgoingRetry(messageId: String) { - retryQueueService.resolve(messageId) - } - - /** - * Cancel all pending outgoing retry jobs (e.g., on disconnect). - */ - private fun cancelAllOutgoingRetries() { - retryQueueService.clear() - } - - /** - * Send packet (legacy name) - */ - fun sendPacket(packet: Packet) { - send(packet) - } - - /** - * Send call signaling packet (0x1A). - */ - fun sendCallSignal( - signalType: SignalType, - src: String = "", - dst: String = "", - sharedPublic: String = "", - callId: String = "", - joinToken: String = "" - ) { - addLog( - "📡 CALL TX type=$signalType src=${shortKeyForLog(src)} dst=${shortKeyForLog(dst)} " + - "sharedLen=${sharedPublic.length} callId=${shortKeyForLog(callId, 12)} join=${shortKeyForLog(joinToken, 12)}" - ) - send( - PacketSignalPeer().apply { - this.signalType = signalType - this.src = src - this.dst = dst - this.sharedPublic = sharedPublic - this.callId = callId - this.joinToken = joinToken - } - ) - } - - /** - * Send WebRTC signaling packet (0x1B). - */ - fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) { - addLog( - "📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " + - "preview='${shortTextForLog(sdpOrCandidate, 56)}'" - ) - send( - PacketWebRTC().apply { - this.signalType = signalType - this.sdpOrCandidate = sdpOrCandidate - } - ) - } - - /** - * Request ICE servers from server (0x1C). - */ - fun requestIceServers() { - addLog("📡 ICE TX request") - send(PacketIceServers()) - } - - /** - * Typed subscribe for call signaling packets (0x1A). - * Returns wrapper callback for subsequent unwait. - */ - 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)} dst=${shortKeyForLog(it.dst)} " + - "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) - } - - /** - * Typed subscribe for WebRTC packets (0x1B). - * Returns wrapper callback for subsequent unwait. - */ - 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) - } - - /** - * Typed subscribe for ICE servers packet (0x1C). - * Returns wrapper callback for subsequent unwait. - */ - 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) - } - - /** - * Register packet handler - */ - fun waitPacket(packetId: Int, callback: (Packet) -> Unit) { - packetSubscriptionRegistry.addCallback(packetId, callback) - } - - /** - * Unregister packet handler - */ - fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) { - packetSubscriptionRegistry.removeCallback(packetId, callback) - } - - /** - * SharedFlow fan-out stream for packet id. - */ - fun packetFlow(packetId: Int): SharedFlow { - return packetSubscriptionRegistry.flow(packetId) - } - - private fun buildHandshakeDevice(): HandshakeDevice { - val context = appContext - 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 - ) - } - - private fun getOrCreateDeviceId(context: Context): String { - val prefs = context.getSharedPreferences(DEVICE_PREFS, Context.MODE_PRIVATE) - val cached = prefs.getString(DEVICE_ID_KEY, null) - if (!cached.isNullOrBlank()) { - return cached - } - val newId = generateDeviceId() - prefs.edit().putString(DEVICE_ID_KEY, newId).apply() - return newId - } - - private fun generateDeviceId(): String { - val chars = "abcdefghijklmnopqrstuvwxyz0123456789" - val random = SecureRandom() - return buildString(DEVICE_ID_LENGTH) { - repeat(DEVICE_ID_LENGTH) { - append(chars[random.nextInt(chars.length)]) - } - } - } - - private fun shortKeyForLog(value: String, visible: Int = 8): String { - val trimmed = value.trim() - if (trimmed.isBlank()) return "" - return if (trimmed.length <= visible) trimmed else "${trimmed.take(visible)}…" - } - - private fun shortTextForLog(value: String, limit: Int = 80): String { - val normalized = value.replace('\n', ' ').replace('\r', ' ').trim() - if (normalized.isBlank()) return "" - return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…" - } - - /** - * Disconnect and clear - */ - fun disconnect() { - postConnectionEvent( - ConnectionEvent.Disconnect( - reason = "manual_disconnect", - clearCredentials = true - ) - ) - } - - /** - * Destroy instance completely - */ - fun destroy() { - stopWaitingForNetwork("destroy") - packetSubscriptionRegistry.destroy() - synchronized(protocolInstanceLock) { - protocol?.destroy() - protocol = null - } - messageRepository?.clearInitialization() - clearTypingState() - _devices.value = emptyList() - _pendingDeviceVerification.value = null - syncRequestInFlight = false - clearSyncRequestTimeout() - setSyncInProgress(false) - resyncRequiredAfterAccountInit = false - deferredAuthBootstrap = false - activeAuthenticatedSessionId = 0L - lastBootstrappedSessionId = 0L - scope.cancel() - } - - /** - * Check if authenticated - */ - fun isAuthenticated(): Boolean = protocol?.isAuthenticated() ?: false - - /** - * Check if connected - */ - fun isConnected(): Boolean = protocol?.isConnected() ?: false -} +object ProtocolManager : ProtocolRuntimeCore() diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt new file mode 100644 index 0000000..97d7a2a --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt @@ -0,0 +1,147 @@ +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 kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProtocolRuntime @Inject constructor( + private val core: ProtocolRuntimeCore, + private val messageRepository: MessageRepository, + private val groupRepository: GroupRepository, + private val accountManager: AccountManager +) : ProtocolRuntimePort { + init { + bindDependencies() + } + + override val state: StateFlow get() = core.state + val syncInProgress: StateFlow get() = core.syncInProgress + val pendingDeviceVerification: StateFlow get() = core.pendingDeviceVerification + val typingUsers: StateFlow> get() = core.typingUsers + val typingUsersByDialogSnapshot: StateFlow>> get() = + core.typingUsersByDialogSnapshot + override val debugLogs: StateFlow> get() = core.debugLogs + val ownProfileUpdated: StateFlow get() = core.ownProfileUpdated + + fun initialize(context: Context) { + bindDependencies() + core.initialize(context) + } + + fun initializeAccount(publicKey: String, privateKey: String) = + core.initializeAccount(publicKey, privateKey) + + fun connect() = core.connect() + + fun authenticate(publicKey: String, privateHash: String) = + core.authenticate(publicKey, privateHash) + + override fun reconnectNowIfNeeded(reason: String) = core.reconnectNowIfNeeded(reason) + + fun disconnect() = core.disconnect() + + override fun isAuthenticated(): Boolean = core.isAuthenticated() + + fun getPrivateHash(): String? = + runCatching { core.getProtocol().getPrivateHash() }.getOrNull() + + fun subscribePushTokenIfAvailable(forceToken: String? = null) = + core.subscribePushTokenIfAvailable(forceToken) + + override fun addLog(message: String) = core.addLog(message) + + fun enableUILogs(enabled: Boolean) = core.enableUILogs(enabled) + + fun clearLogs() = core.clearLogs() + + fun resolveOutgoingRetry(messageId: String) = core.resolveOutgoingRetry(messageId) + + fun getCachedUserByUsername(username: String): SearchUser? = + core.getCachedUserByUsername(username) + + fun getCachedUserName(publicKey: String): String? = + core.getCachedUserName(publicKey) + + override fun getCachedUserInfo(publicKey: String): SearchUser? = + core.getCachedUserInfo(publicKey) + + fun acceptDevice(deviceId: String) = core.acceptDevice(deviceId) + + fun declineDevice(deviceId: String) = core.declineDevice(deviceId) + + override fun send(packet: Packet) = core.send(packet) + + override fun sendPacket(packet: Packet) = core.sendPacket(packet) + + fun sendMessageWithRetry(packet: PacketMessage) = core.sendMessageWithRetry(packet) + + override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) = + core.waitPacket(packetId, callback) + + override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) = + core.unwaitPacket(packetId, callback) + + fun packetFlow(packetId: Int): SharedFlow = core.packetFlow(packetId) + + fun notifyOwnProfileUpdated() = core.notifyOwnProfileUpdated() + + override fun restoreAuthFromStoredCredentials( + preferredPublicKey: String?, + reason: String + ): Boolean = core.restoreAuthFromStoredCredentials(preferredPublicKey, reason) + + suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? = + core.resolveUserName(publicKey, timeoutMs) + + override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? = + core.resolveUserInfo(publicKey, timeoutMs) + + suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List = + core.searchUsers(query, timeoutMs) + + override fun requestIceServers() = core.requestIceServers() + + override fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit = + core.waitCallSignal(callback) + + override fun unwaitCallSignal(callback: (Packet) -> Unit) = + core.unwaitCallSignal(callback) + + override fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit = + core.waitWebRtcSignal(callback) + + override fun unwaitWebRtcSignal(callback: (Packet) -> Unit) = + core.unwaitWebRtcSignal(callback) + + override fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit = + core.waitIceServers(callback) + + override fun unwaitIceServers(callback: (Packet) -> Unit) = + core.unwaitIceServers(callback) + + override fun sendCallSignal( + signalType: SignalType, + src: String, + dst: String, + sharedPublic: String, + callId: String, + joinToken: String + ) = core.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken) + + override fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) = + core.sendWebRtcSignal(signalType, sdpOrCandidate) + + private fun bindDependencies() { + core.bindDependencies( + messageRepository = messageRepository, + groupRepository = groupRepository, + accountManager = accountManager + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeAccess.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeAccess.kt new file mode 100644 index 0000000..f827a96 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeAccess.kt @@ -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 + val debugLogs: StateFlow> + + 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 +} diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt new file mode 100644 index 0000000..4e1eab0 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeCore.kt @@ -0,0 +1,1434 @@ +package com.rosetta.messenger.network + +import android.content.Context +import com.rosetta.messenger.data.AccountManager +import com.rosetta.messenger.data.GroupRepository +import com.rosetta.messenger.data.MessageRepository +import com.rosetta.messenger.data.isPlaceholderAccountName +import com.rosetta.messenger.network.connection.AuthBootstrapCoordinator +import com.rosetta.messenger.network.connection.BootstrapCoordinator +import com.rosetta.messenger.network.connection.CallSignalBridge +import com.rosetta.messenger.network.connection.ConnectionOrchestrator +import com.rosetta.messenger.network.connection.DeviceRuntimeService +import com.rosetta.messenger.network.connection.NetworkReconnectWatcher +import com.rosetta.messenger.network.connection.OutgoingMessagePipelineService +import com.rosetta.messenger.network.connection.OwnProfileSyncService +import com.rosetta.messenger.network.connection.PacketRouter +import com.rosetta.messenger.network.connection.PresenceTypingService +import com.rosetta.messenger.network.connection.SyncCoordinator +import com.rosetta.messenger.session.IdentityStore +import com.rosetta.messenger.utils.MessageLogger +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.util.* +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * Singleton manager for Protocol instance + * Ensures single connection across the app + */ +open class ProtocolRuntimeCore { + private val TAG = "ProtocolManager" + private val MANUAL_SYNC_BACKTRACK_MS = 120_000L + private val SYNC_REQUEST_TIMEOUT_MS = 12_000L + private val MAX_DEBUG_LOGS = 600 + private val DEBUG_LOG_FLUSH_DELAY_MS = 60L + private val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L + private val TYPING_INDICATOR_TIMEOUT_MS = 3_000L + private val PROTOCOL_TRACE_FILE_NAME = "protocol_wire_log.txt" + private val PROTOCOL_TRACE_MAX_BYTES = 2_000_000L + private val PROTOCOL_TRACE_KEEP_BYTES = 1_200_000 + private val NETWORK_WAIT_TIMEOUT_MS = 20_000L + private val BOOTSTRAP_OWN_PROFILE_FALLBACK_MS = 2_500L + private val READY_PACKET_QUEUE_MAX = 500 + private val READY_PACKET_QUEUE_TTL_MS = 120_000L + + // Desktop parity: use the same primary WebSocket endpoint as desktop client. + private val SERVER_ADDRESS = "wss://wss.rosetta.im" + + @Volatile private var protocol: Protocol? = null + private var messageRepository: MessageRepository? = null + private var groupRepository: GroupRepository? = null + private var accountManager: AccountManager? = null + private var appContext: Context? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val protocolInstanceLock = Any() + private val packetSubscriptionRegistry = + PacketSubscriptionRegistry( + protocolProvider = ::getProtocol, + scope = scope, + addLog = ::addLog + ) + private val connectionSupervisor = + ProtocolConnectionSupervisor( + scope = scope, + onEvent = ::handleConnectionEvent, + onError = { error -> android.util.Log.e(TAG, "ConnectionSupervisor event failed", error) }, + addLog = ::addLog + ) + private val sessionGeneration = AtomicLong(0L) + private val readyPacketGate = + ReadyPacketGate( + maxSize = READY_PACKET_QUEUE_MAX, + ttlMs = READY_PACKET_QUEUE_TTL_MS + ) + private val bootstrapCoordinator = + BootstrapCoordinator( + readyPacketGate = readyPacketGate, + addLog = ::addLog, + shortKeyForLog = ::shortKeyForLog, + sendPacketDirect = { packet -> getProtocol().sendPacket(packet) } + ) + private val deviceRuntimeService = + DeviceRuntimeService( + getAppContext = { appContext }, + sendPacket = ::send + ) + private val connectionOrchestrator = + ConnectionOrchestrator( + hasActiveInternet = ::hasActiveInternet, + waitForNetworkAndReconnect = ::waitForNetworkAndReconnect, + stopWaitingForNetwork = { reason -> stopWaitingForNetwork(reason) }, + getProtocol = ::getProtocol, + persistHandshakeCredentials = { publicKey, privateHash -> + accountManager?.setLastLoggedPublicKey(publicKey) + accountManager?.setLastLoggedPrivateKeyHash(privateHash) + }, + buildHandshakeDevice = deviceRuntimeService::buildHandshakeDevice + ) + private val ownProfileSyncService = + OwnProfileSyncService( + isPlaceholderAccountName = ::isPlaceholderAccountName, + updateAccountName = { publicKey, name -> + accountManager?.updateAccountName(publicKey, name) + }, + updateAccountUsername = { publicKey, username -> + accountManager?.updateAccountUsername(publicKey, username) + } + ) + private val packetRouter by lazy { + PacketRouter( + sendSearchPacket = { packet -> send(packet) }, + privateHashProvider = { + try { + getProtocol().getPrivateHash() + } catch (_: Exception) { + null + } + } + ) + } + private val outgoingMessagePipelineService = + OutgoingMessagePipelineService( + scope = scope, + getRepository = { messageRepository }, + sendPacket = { packet -> send(packet) }, + isAuthenticated = ::isAuthenticated, + addLog = ::addLog + ) + private val presenceTypingService = + PresenceTypingService( + scope = scope, + typingIndicatorTimeoutMs = TYPING_INDICATOR_TIMEOUT_MS + ) + private val syncCoordinator = + SyncCoordinator( + scope = scope, + syncRequestTimeoutMs = SYNC_REQUEST_TIMEOUT_MS, + manualSyncBacktrackMs = MANUAL_SYNC_BACKTRACK_MS, + addLog = ::addLog, + isAuthenticated = ::isAuthenticated, + getRepository = { messageRepository }, + getProtocolPublicKey = { getProtocol().getPublicKey().orEmpty() }, + sendPacket = { packet -> send(packet) }, + onSyncCompleted = ::finishSyncCycle, + whenInboundTasksFinish = ::whenInboundTasksFinish + ) + private val authBootstrapCoordinator = + AuthBootstrapCoordinator( + scope = scope, + addLog = ::addLog + ) + private val networkReconnectWatcher = + NetworkReconnectWatcher( + scope = scope, + networkWaitTimeoutMs = NETWORK_WAIT_TIMEOUT_MS, + addLog = ::addLog, + onReconnectRequested = { reason -> + postConnectionEvent(ConnectionEvent.FastReconnect(reason)) + } + ) + private val callSignalBridge = + CallSignalBridge( + sendPacket = ::send, + waitPacket = ::waitPacket, + unwaitPacket = ::unwaitPacket, + addLog = ::addLog, + shortKeyForLog = ::shortKeyForLog, + shortTextForLog = ::shortTextForLog + ) + + @Volatile private var packetHandlersRegistered = false + @Volatile private var stateMonitoringStarted = false + @Volatile private var ownProfileFallbackJob: Job? = null + + // Guard: prevent duplicate FCM token subscribe within a single session + @Volatile + private var lastSubscribedToken: String? = null + + private val _debugLogs = MutableStateFlow>(emptyList()) + val debugLogs: StateFlow> = _debugLogs.asStateFlow() + private val debugLogsBuffer = ArrayDeque(MAX_DEBUG_LOGS) + private val debugLogsLock = Any() + private val protocolTraceLock = Any() + @Volatile private var debugFlushJob: Job? = null + private val debugFlushPending = AtomicBoolean(false) + @Volatile private var lastHeartbeatOkLogAtMs: Long = 0L + @Volatile private var suppressedHeartbeatOkLogs: Int = 0 + val typingUsers: StateFlow> = presenceTypingService.typingUsers + val typingUsersByDialogSnapshot: StateFlow>> = + presenceTypingService.typingUsersByDialogSnapshot + + val devices: StateFlow> = deviceRuntimeService.devices + val pendingDeviceVerification: StateFlow = + deviceRuntimeService.pendingDeviceVerification + + // Сигнал обновления own profile (username/name загружены с сервера) + val ownProfileUpdated: StateFlow = ownProfileSyncService.ownProfileUpdated + val syncInProgress: StateFlow = syncCoordinator.syncInProgress + + private fun ensureConnectionSupervisor() { + connectionSupervisor.start() + } + + private fun postConnectionEvent(event: ConnectionEvent) { + connectionSupervisor.post(event) + } + + private fun protocolToLifecycleState(state: ProtocolState): ConnectionLifecycleState = + bootstrapCoordinator.protocolToLifecycleState(state) + + private fun setConnectionLifecycleState(next: ConnectionLifecycleState, reason: String) { + if (_connectionLifecycleState.value == next) return + addLog("🧭 CONNECTION STATE: ${_connectionLifecycleState.value} -> $next ($reason)") + _connectionLifecycleState.value = next + } + + private fun recomputeConnectionLifecycleState(reason: String) { + val context = bootstrapContext + val nextState = + bootstrapCoordinator.recomputeLifecycleState( + context = context, + currentState = _connectionLifecycleState.value, + reason = reason + ) { state, updateReason -> + setConnectionLifecycleState(state, updateReason) + } + _connectionLifecycleState.value = nextState + } + + private fun clearReadyPacketQueue(reason: String) { + bootstrapCoordinator.clearReadyPacketQueue(reason) + } + + private fun enqueueReadyPacket(packet: Packet) { + val accountKey = + messageRepository?.getCurrentAccountKey()?.trim().orEmpty().ifBlank { + bootstrapContext.accountPublicKey + } + bootstrapCoordinator.enqueueReadyPacket( + packet = packet, + accountPublicKey = accountKey, + state = _connectionLifecycleState.value + ) + } + + private fun flushReadyPacketQueue(activeAccountKey: String, reason: String) { + bootstrapCoordinator.flushReadyPacketQueue(activeAccountKey, reason) + } + + private fun packetCanBypassReadyGate(packet: Packet): Boolean = + bootstrapCoordinator.packetCanBypassReadyGate(packet) + + private suspend fun handleConnectionEvent(event: ConnectionEvent) { + when (event) { + is ConnectionEvent.InitializeAccount -> { + val normalizedPublicKey = event.publicKey.trim() + val normalizedPrivateKey = event.privateKey.trim() + if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) { + addLog("⚠️ initializeAccount skipped: missing account credentials") + return + } + + val protocolState = getProtocol().state.value + addLog( + "🔐 initializeAccount pk=${shortKeyForLog(normalizedPublicKey)} keyLen=${normalizedPrivateKey.length} state=$protocolState" + ) + syncCoordinator.markSyncInProgress(false) + presenceTypingService.clear() + val repository = messageRepository + if (repository == null) { + addLog("❌ initializeAccount aborted: MessageRepository is not bound") + return + } + repository.initialize(normalizedPublicKey, normalizedPrivateKey) + + val sameAccount = + bootstrapContext.accountPublicKey.equals(normalizedPublicKey, ignoreCase = true) + if (!sameAccount) { + clearReadyPacketQueue("account_switch") + } + + bootstrapContext = + bootstrapContext.copy( + accountPublicKey = normalizedPublicKey, + accountInitialized = true, + syncCompleted = if (sameAccount) bootstrapContext.syncCompleted else false, + ownProfileResolved = if (sameAccount) bootstrapContext.ownProfileResolved else false + ) + recomputeConnectionLifecycleState("account_initialized") + + val shouldResync = + syncCoordinator.shouldResyncAfterAccountInit() || protocol?.isAuthenticated() == true + if (shouldResync) { + syncCoordinator.clearResyncRequired() + syncCoordinator.clearRequestState() + addLog( + "🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync" + ) + syncCoordinator.requestSynchronize() + } + if ( + protocol?.isAuthenticated() == true && + authBootstrapCoordinator.isBootstrapPending() + ) { + tryRunPostAuthBootstrap("account_initialized") + } + + scope.launch { + messageRepository?.checkAndSendVersionUpdateMessage() + } + } + is ConnectionEvent.Connect -> { + connectionOrchestrator.handleConnect(event.reason) + } + is ConnectionEvent.FastReconnect -> { + connectionOrchestrator.handleFastReconnect(event.reason) + } + is ConnectionEvent.Disconnect -> { + stopWaitingForNetwork(event.reason) + protocol?.disconnect() + if (event.clearCredentials) { + protocol?.clearCredentials() + } + messageRepository?.clearInitialization() + presenceTypingService.clear() + deviceRuntimeService.clear() + syncCoordinator.resetForDisconnect() + lastSubscribedToken = null + ownProfileFallbackJob?.cancel() + ownProfileFallbackJob = null + authBootstrapCoordinator.reset() + bootstrapContext = ConnectionBootstrapContext() + clearReadyPacketQueue("disconnect:${event.reason}") + recomputeConnectionLifecycleState("disconnect:${event.reason}") + } + is ConnectionEvent.Authenticate -> { + connectionOrchestrator.handleAuthenticate(event.publicKey, event.privateHash) + } + is ConnectionEvent.ProtocolStateChanged -> { + val previousProtocolState = bootstrapContext.protocolState + val newProtocolState = event.state + + if ( + newProtocolState == ProtocolState.AUTHENTICATED && + previousProtocolState != ProtocolState.AUTHENTICATED + ) { + lastSubscribedToken = null + stopWaitingForNetwork("authenticated") + ownProfileFallbackJob?.cancel() + val generation = sessionGeneration.incrementAndGet() + authBootstrapCoordinator.onAuthenticatedSessionStarted() + bootstrapContext = + bootstrapContext.copy( + protocolState = newProtocolState, + authenticated = true, + syncCompleted = false, + ownProfileResolved = false + ) + recomputeConnectionLifecycleState("protocol_authenticated") + ownProfileFallbackJob = + scope.launch { + delay(BOOTSTRAP_OWN_PROFILE_FALLBACK_MS) + postConnectionEvent( + ConnectionEvent.OwnProfileFallbackTimeout(generation) + ) + } + onAuthenticated() + return + } + + if ( + newProtocolState != ProtocolState.AUTHENTICATED && + newProtocolState != ProtocolState.HANDSHAKING + ) { + syncCoordinator.clearRequestState() + syncCoordinator.markSyncInProgress(false) + lastSubscribedToken = null + cancelAllOutgoingRetries() + ownProfileFallbackJob?.cancel() + ownProfileFallbackJob = null + authBootstrapCoordinator.reset() + bootstrapContext = + bootstrapContext.copy( + protocolState = newProtocolState, + authenticated = false, + syncCompleted = false, + ownProfileResolved = false + ) + recomputeConnectionLifecycleState("protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}") + return + } + + if (newProtocolState == ProtocolState.HANDSHAKING && bootstrapContext.authenticated) { + ownProfileFallbackJob?.cancel() + ownProfileFallbackJob = null + authBootstrapCoordinator.reset() + bootstrapContext = + bootstrapContext.copy( + protocolState = newProtocolState, + authenticated = false, + syncCompleted = false, + ownProfileResolved = false + ) + recomputeConnectionLifecycleState("protocol_re_handshaking") + return + } + + bootstrapContext = bootstrapContext.copy(protocolState = newProtocolState) + recomputeConnectionLifecycleState("protocol_state_${newProtocolState.name.lowercase(Locale.ROOT)}") + } + is ConnectionEvent.SendPacket -> { + val packet = event.packet + val lifecycle = _connectionLifecycleState.value + if (packetCanBypassReadyGate(packet) || lifecycle == ConnectionLifecycleState.READY) { + getProtocol().sendPacket(packet) + } else { + enqueueReadyPacket(packet) + if (!isAuthenticated()) { + if (!hasActiveInternet()) { + waitForNetworkAndReconnect("ready_gate_send") + } else { + getProtocol().reconnectNowIfNeeded("ready_gate_send") + } + } + } + } + is ConnectionEvent.SyncCompleted -> { + syncCoordinator.onSyncCompletedStateApplied() + addLog(event.reason) + retryWaitingMessages() + requestMissingUserInfo() + + bootstrapContext = bootstrapContext.copy(syncCompleted = true) + recomputeConnectionLifecycleState("sync_completed") + } + is ConnectionEvent.OwnProfileResolved -> { + val accountPublicKey = bootstrapContext.accountPublicKey + val matchesAccount = + accountPublicKey.isBlank() || + event.publicKey.equals(accountPublicKey, ignoreCase = true) + if (!matchesAccount) return + ownProfileFallbackJob?.cancel() + ownProfileFallbackJob = null + bootstrapContext = bootstrapContext.copy(ownProfileResolved = true) + IdentityStore.updateOwnProfile( + publicKey = event.publicKey, + resolved = true, + reason = "protocol_own_profile_resolved" + ) + recomputeConnectionLifecycleState("own_profile_resolved") + } + is ConnectionEvent.OwnProfileFallbackTimeout -> { + if (sessionGeneration.get() != event.sessionGeneration) return + if (!bootstrapContext.authenticated || bootstrapContext.ownProfileResolved) return + addLog( + "⏱️ Own profile fetch timeout — continuing bootstrap for ${shortKeyForLog(bootstrapContext.accountPublicKey)}" + ) + bootstrapContext = bootstrapContext.copy(ownProfileResolved = true) + val accountPublicKey = bootstrapContext.accountPublicKey + if (accountPublicKey.isNotBlank()) { + IdentityStore.updateOwnProfile( + publicKey = accountPublicKey, + resolved = true, + reason = "protocol_own_profile_fallback_timeout" + ) + } + recomputeConnectionLifecycleState("own_profile_fallback_timeout") + } + } + } + + // Keep heavy protocol/message UI logs disabled by default. + private var uiLogsEnabled = false + private val _connectionLifecycleState = MutableStateFlow(ConnectionLifecycleState.DISCONNECTED) + val connectionLifecycleState: StateFlow = _connectionLifecycleState.asStateFlow() + private var bootstrapContext = ConnectionBootstrapContext() + // Desktop parity: sequential task queue matching dialogQueue.ts (promise chain). + // Uses Channel to guarantee strict FIFO ordering (Mutex+lastInboundJob had a race + // condition: Dispatchers.IO doesn't guarantee FIFO, so the last-launched job could + // finish before earlier ones, causing whenInboundTasksFinish to return prematurely + // and BATCH_END to advance the sync timestamp while messages were still processing). + private val inboundTaskChannel = Channel Unit>(Channel.UNLIMITED) + // Tracks the tail of the sequential processing chain (like desktop's `tail` promise) + @Volatile private var inboundQueueDrainJob: Job? = null + + fun addLog(message: String) { + var normalizedMessage = message + val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK") + if (isHeartbeatOk) { + val now = System.currentTimeMillis() + if (now - lastHeartbeatOkLogAtMs < HEARTBEAT_OK_LOG_MIN_INTERVAL_MS) { + suppressedHeartbeatOkLogs++ + return + } + if (suppressedHeartbeatOkLogs > 0) { + normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)" + suppressedHeartbeatOkLogs = 0 + } + lastHeartbeatOkLogAtMs = now + } + val timestamp = + java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) + val line = "[$timestamp] $normalizedMessage" + if (shouldPersistProtocolTrace(normalizedMessage)) { + persistProtocolTraceLine(line) + } + if (!uiLogsEnabled) return + synchronized(debugLogsLock) { + if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) { + debugLogsBuffer.removeFirst() + } + debugLogsBuffer.addLast(line) + } + flushDebugLogsThrottled() + } + + /** + * Keep crash_reports trace lightweight when UI logs are disabled. + * This avoids excessive disk writes during long sync sessions. + */ + private fun shouldPersistProtocolTrace(message: String): Boolean { + if (uiLogsEnabled) return true + if (message.startsWith("❌") || message.startsWith("⚠️")) return true + if (message.contains("STATE CHANGE")) return true + if (message.contains("CONNECTION FULLY ESTABLISHED")) return true + if (message.contains("HANDSHAKE COMPLETE")) return true + if (message.contains("SYNC COMPLETE")) return true + if (message.startsWith("🔌 CONNECT CALLED") || message.startsWith("🔌 Connecting to")) return true + if (message.startsWith("✅ WebSocket OPEN")) return true + if (message.startsWith("📡 NETWORK")) return true + return false + } + + private fun persistProtocolTraceLine(line: String) { + val context = appContext ?: return + runCatching { + val dir = File(context.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val traceFile = File(dir, PROTOCOL_TRACE_FILE_NAME) + synchronized(protocolTraceLock) { + if (traceFile.exists() && traceFile.length() > PROTOCOL_TRACE_MAX_BYTES) { + val tail = runCatching { + traceFile.readText(Charsets.UTF_8).takeLast(PROTOCOL_TRACE_KEEP_BYTES) + }.getOrDefault("") + traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8) + } + traceFile.appendText("$line\n", Charsets.UTF_8) + } + } + } + + fun enableUILogs(enabled: Boolean) { + uiLogsEnabled = enabled + MessageLogger.setEnabled(enabled) + if (enabled) { + val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() } + _debugLogs.value = snapshot + } else { + _debugLogs.value = emptyList() + } + } + + fun clearLogs() { + synchronized(debugLogsLock) { + debugLogsBuffer.clear() + } + suppressedHeartbeatOkLogs = 0 + lastHeartbeatOkLogAtMs = 0L + _debugLogs.value = emptyList() + } + + private fun flushDebugLogsThrottled() { + debugFlushPending.set(true) + if (debugFlushJob?.isActive == true) return + debugFlushJob = + scope.launch { + while (debugFlushPending.getAndSet(false)) { + delay(DEBUG_LOG_FLUSH_DELAY_MS) + val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() } + _debugLogs.value = snapshot + } + } + } + + private fun markInboundProcessingFailure(reason: String, error: Throwable? = null) { + syncCoordinator.markInboundProcessingFailure() + if (error != null) { + android.util.Log.e(TAG, reason, error) + addLog("❌ $reason: ${error.message ?: error.javaClass.simpleName}") + } else { + android.util.Log.w(TAG, reason) + addLog("⚠️ $reason") + } + } + + /** + * Inject process-wide dependencies from DI container. + */ + fun bindDependencies( + messageRepository: MessageRepository, + groupRepository: GroupRepository, + accountManager: AccountManager + ) { + this.messageRepository = messageRepository + this.groupRepository = groupRepository + this.accountManager = accountManager + } + + /** + * Backward-compatible alias kept while migrating call sites. + */ + fun bindRepositories( + messageRepository: MessageRepository, + groupRepository: GroupRepository + ) { + this.messageRepository = messageRepository + this.groupRepository = groupRepository + } + + /** + * Инициализация с контекстом для доступа к MessageRepository + */ + fun initialize(context: Context) { + appContext = context.applicationContext + if (messageRepository == null || groupRepository == null || accountManager == null) { + addLog("⚠️ initialize called before dependencies were bound via DI") + } + ensureConnectionSupervisor() + if (!packetHandlersRegistered) { + setupPacketHandlers() + packetHandlersRegistered = true + } + if (!stateMonitoringStarted) { + setupStateMonitoring() + stateMonitoringStarted = true + } + } + + /** + * 🔍 Мониторинг состояния соединения + */ + private fun setupStateMonitoring() { + scope.launch { + getProtocol().state.collect { newState -> + postConnectionEvent(ConnectionEvent.ProtocolStateChanged(newState)) + } + } + } + + /** + * 🔥 Инициализация аккаунта - КРИТИЧНО для получения сообщений! + * Должен вызываться после авторизации пользователя + */ + fun initializeAccount(publicKey: String, privateKey: String) { + postConnectionEvent( + ConnectionEvent.InitializeAccount(publicKey = publicKey, privateKey = privateKey) + ) + } + + /** + * Настройка обработчиков пакетов + */ + private fun setupPacketHandlers() { + // Обработчик входящих сообщений (0x06) + // Desktop parity: desktop client does not send PacketDelivery manually. + waitPacket(0x06) { packet -> + val messagePacket = packet as PacketMessage + + launchInboundPacketTask { + val repository = messageRepository + if (repository == null || !repository.isInitialized()) { + syncCoordinator.requireResyncAfterAccountInit("⏳ Incoming message before account init, scheduling re-sync") + markInboundProcessingFailure("Incoming packet skipped before account init") + return@launchInboundPacketTask + } + val processed = repository.handleIncomingMessage(messagePacket) + if (!processed) { + markInboundProcessingFailure( + "Message processing failed for ${messagePacket.messageId.take(8)}" + ) + return@launchInboundPacketTask + } + if (!syncCoordinator.isBatchInProgress()) { + repository.updateLastSyncTimestamp(messagePacket.timestamp) + } + } + } + + // Обработчик доставки (0x08) + waitPacket(0x08) { packet -> + val deliveryPacket = packet as PacketDelivery + + launchInboundPacketTask { + val repository = messageRepository + if (repository == null || !repository.isInitialized()) { + syncCoordinator.requireResyncAfterAccountInit("⏳ Delivery status before account init, scheduling re-sync") + markInboundProcessingFailure("Delivery packet skipped before account init") + return@launchInboundPacketTask + } + try { + repository.handleDelivery(deliveryPacket) + resolveOutgoingRetry(deliveryPacket.messageId) + } catch (e: Exception) { + markInboundProcessingFailure("Delivery processing failed", e) + return@launchInboundPacketTask + } + if (!syncCoordinator.isBatchInProgress()) { + repository.updateLastSyncTimestamp(System.currentTimeMillis()) + } + } + } + + // Обработчик прочтения (0x07) + // В Desktop PacketRead не содержит messageId - сообщает что собеседник прочитал сообщения + waitPacket(0x07) { packet -> + val readPacket = packet as PacketRead + + launchInboundPacketTask { + val repository = messageRepository + if (repository == null || !repository.isInitialized()) { + syncCoordinator.requireResyncAfterAccountInit("⏳ Read status before account init, scheduling re-sync") + markInboundProcessingFailure("Read packet skipped before account init") + return@launchInboundPacketTask + } + val ownKey = getProtocol().getPublicKey().orEmpty() + if (ownKey.isBlank()) { + syncCoordinator.requireResyncAfterAccountInit("⏳ Read status before protocol account init, scheduling re-sync") + markInboundProcessingFailure("Read packet skipped before protocol account init") + return@launchInboundPacketTask + } + try { + repository.handleRead(readPacket) + } catch (e: Exception) { + markInboundProcessingFailure("Read processing failed", e) + return@launchInboundPacketTask + } + if (!syncCoordinator.isBatchInProgress()) { + // Desktop parity: + // own direct read sync (from=me,to=peer) does not advance sync cursor. + val isOwnDirectReadSync = + readPacket.fromPublicKey.trim() == ownKey && + !isGroupDialogKey(readPacket.toPublicKey) + if (!isOwnDirectReadSync) { + repository.updateLastSyncTimestamp(System.currentTimeMillis()) + } + } + } + } + + // 🔐 New device login attempt (0x09) — desktop parity (system Safe message) + waitPacket(0x09) { packet -> + val devicePacket = packet as PacketDeviceNew + + addLog( + "🔐 DEVICE LOGIN ATTEMPT: ip=${devicePacket.ipAddress}, device=${devicePacket.device.deviceName}, os=${devicePacket.device.deviceOs}" + ) + + launchInboundPacketTask { + messageRepository?.addDeviceLoginSystemMessage( + ipAddress = devicePacket.ipAddress, + deviceId = devicePacket.device.deviceId, + deviceName = devicePacket.device.deviceName, + deviceOs = devicePacket.device.deviceOs + ) + } + } + + // 🔄 Обработчик батчевой синхронизации (0x19) + waitPacket(0x19) { packet -> + syncCoordinator.handleSyncPacket(packet as PacketSync) + } + + // 👥 Обработчик синхронизации групп (0x14) + // Desktop parity: во время sync сервер отправляет PacketGroupJoin с groupString. + waitPacket(0x14) { packet -> + val joinPacket = packet as PacketGroupJoin + + launchInboundPacketTask { + val repository = messageRepository + val groups = groupRepository + val account = repository?.getCurrentAccountKey() + val privateKey = repository?.getCurrentPrivateKey() + if (groups == null || account.isNullOrBlank() || privateKey.isNullOrBlank()) { + return@launchInboundPacketTask + } + try { + val result = groups.synchronizeJoinedGroup( + accountPublicKey = account, + accountPrivateKey = privateKey, + packet = joinPacket + ) + if (result?.success == true) { + addLog("👥 GROUP synced: ${result.dialogPublicKey}") + } + } catch (e: Exception) { + android.util.Log.w(TAG, "Failed to sync group packet", e) + } + } + } + + // 🟢 Обработчик онлайн-статуса (0x05) + waitPacket(0x05) { packet -> + val onlinePacket = packet as PacketOnlineState + + scope.launch { + if (messageRepository != null) { + onlinePacket.publicKeysState.forEach { item -> + val isOnline = item.state == OnlineState.ONLINE + messageRepository?.updateOnlineStatus(item.publicKey, isOnline) + } + } + } + } + + // Обработчик typing (0x0B) + waitPacket(0x0B) { packet -> + presenceTypingService.handleTypingPacket(packet as PacketTyping) { + getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { + messageRepository?.getCurrentAccountKey()?.trim().orEmpty() + } + } + } + + // 📱 Обработчик списка устройств (0x17) + waitPacket(0x17) { packet -> + deviceRuntimeService.handleDeviceList(packet as PacketDeviceList) + } + + // 🔥 Обработчик поиска/user info (0x03) + // Обновляет информацию о пользователе в диалогах когда приходит ответ от сервера + // + обновляет own profile (username/name) аналогично Desktop useUserInformation() + waitPacket(0x03) { packet -> + val searchPacket = packet as PacketSearch + + scope.launch(Dispatchers.IO) { + val ownPublicKey = + getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { + messageRepository?.getCurrentAccountKey()?.trim().orEmpty() + } + + packetRouter.onSearchPacket(searchPacket) { user -> + val normalizedUserPublicKey = user.publicKey.trim() + messageRepository?.updateDialogUserInfo( + normalizedUserPublicKey, + user.title, + user.username, + user.verified + ) + + val ownProfileResolved = + ownProfileSyncService.applyOwnProfileFromSearch( + ownPublicKey = ownPublicKey, + user = user + ) + if (ownProfileResolved) { + postConnectionEvent(ConnectionEvent.OwnProfileResolved(user.publicKey)) + } + } + } + } + + // 🚀 Обработчик транспортного сервера (0x0F) + waitPacket(0x0F) { packet -> + val transportPacket = packet as PacketRequestTransport + TransportManager.setTransportServer(transportPacket.transportServer) + } + } + + /** + * Desktop parity: sequential task queue (like dialogQueue.ts runTaskInQueue / whenFinish). + * + * Desktop uses a promise chain: `tail = tail.then(fn).catch(...)` which guarantees + * strict FIFO ordering and `whenFinish = () => tail` returns a promise that resolves + * only after ALL queued tasks complete. + * + * We reproduce this with a Channel Unit> (UNLIMITED buffer) consumed + * by a single coroutine. Tasks are executed strictly in the order they were submitted, + * and `whenInboundTasksFinish()` waits for the queue to drain completely. + */ + private fun ensureInboundQueueDrainRunning() { + if (inboundQueueDrainJob?.isActive == true) return + inboundQueueDrainJob = scope.launch { + for (task in inboundTaskChannel) { + try { + task() + } catch (t: Throwable) { + markInboundProcessingFailure("Dialog queue error", t) + } + } + } + } + + private fun launchInboundPacketTask(block: suspend () -> Unit): Boolean { + ensureInboundQueueDrainRunning() + syncCoordinator.trackInboundTaskQueued() + val result = inboundTaskChannel.trySend(block) + if (result.isFailure) { + markInboundProcessingFailure( + "Failed to enqueue inbound task", + result.exceptionOrNull() + ) + return false + } + return true + } + + /** + * Desktop parity: equivalent of `await whenFinish()` in useSynchronize.ts. + * Sends a sentinel task into the sequential queue and suspends until it executes. + * Since the queue is strictly FIFO, when the sentinel runs, all previously + * submitted tasks are guaranteed to have completed. + */ + private suspend fun whenInboundTasksFinish(): Boolean { + val done = CompletableDeferred() + if (!launchInboundPacketTask { done.complete(Unit) }) { + return false + } + done.await() + return true + } + + private fun isGroupDialogKey(value: String): Boolean { + val normalized = value.trim().lowercase(Locale.ROOT) + return normalized.startsWith("#group:") || normalized.startsWith("group:") + } + + fun getTypingUsersForDialog(dialogKey: String): Set { + return presenceTypingService.getTypingUsersForDialog(dialogKey) + } + + private fun canRunPostAuthBootstrap(): Boolean { + val repository = messageRepository ?: return false + if (!repository.isInitialized()) return false + val repositoryAccount = repository.getCurrentAccountKey()?.trim().orEmpty() + if (repositoryAccount.isBlank()) return false + val protocolAccount = getProtocol().getPublicKey()?.trim().orEmpty() + if (protocolAccount.isBlank()) return true + return repositoryAccount.equals(protocolAccount, ignoreCase = true) + } + + private fun tryRunPostAuthBootstrap(trigger: String) { + authBootstrapCoordinator.tryRun( + trigger = trigger, + canRun = ::canRunPostAuthBootstrap, + onDeferred = { + val repositoryAccount = + messageRepository?.getCurrentAccountKey()?.let { shortKeyForLog(it) } + ?: "" + val protocolAccount = + getProtocol().getPublicKey()?.let { shortKeyForLog(it) } + ?: "" + addLog( + "⏳ AUTH bootstrap deferred trigger=$trigger repo=$repositoryAccount proto=$protocolAccount" + ) + } + ) { + syncCoordinator.markSyncInProgress(false) + TransportManager.requestTransportServer() + com.rosetta.messenger.update.UpdateManager.requestSduServer() + fetchOwnProfile() + syncCoordinator.requestSynchronize() + subscribePushTokenIfAvailable() + } + } + + private fun onAuthenticated() { + tryRunPostAuthBootstrap("state_authenticated") + } + + private fun finishSyncCycle(reason: String) { + postConnectionEvent(ConnectionEvent.SyncCompleted(reason)) + } + + /** + * Send FCM push token to server (SUBSCRIBE). + * Deduplicates: won't re-send the same token within one connection session. + * Called internally on AUTHENTICATED and can be called from + * [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken] + * when Firebase rotates the token mid-session. + * + * @param forceToken if non-null, use this token instead of reading SharedPreferences + * (used by onNewToken which already has the fresh token). + */ + fun subscribePushTokenIfAvailable(forceToken: String? = null) { + val context = appContext ?: return + val token = (forceToken + ?: context.getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE) + .getString("fcm_token", null)) + ?.trim() + .orEmpty() + if (token.isEmpty()) return + + // Dedup: don't send the same token twice in one connection session + if (token == lastSubscribedToken) { + addLog("🔔 Push token already subscribed this session — skipped") + return + } + + val deviceId = deviceRuntimeService.resolvePushDeviceId() + val subPacket = PacketPushNotification().apply { + notificationsToken = token + action = PushNotificationAction.SUBSCRIBE + tokenType = PushTokenType.FCM + this.deviceId = deviceId + } + send(subPacket) + lastSubscribedToken = token + addLog("🔔 Push token SUBSCRIBE sent") + + // Сохраняем FCM токен в crash_reports для просмотра через rosettadev1 + try { + val dir = java.io.File(context.filesDir, "crash_reports") + if (!dir.exists()) dir.mkdirs() + val f = java.io.File(dir, "fcm_token.txt") + val ts = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date()) + f.writeText("=== FCM TOKEN ===\n\nTimestamp: $ts\nDeviceId: $deviceId\n\nToken:\n$token\n") + } catch (_: Throwable) {} + } + + /** + * Retry messages stuck in WAITING status on reconnect. + * Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are + * rarely force-killed. On Android, the app can be killed mid-send, leaving messages + * in WAITING status in the DB. This method resends them after sync completes. + * + * Messages older than 80s (MESSAGE_MAX_TIME_TO_DELEVERED_S) are marked ERROR. + */ + private fun retryWaitingMessages() { + scope.launch { + val repository = messageRepository + if (repository == null || !repository.isInitialized()) return@launch + try { + repository.retryWaitingMessages() + } catch (e: Exception) { + android.util.Log.e(TAG, "retryWaitingMessages failed", e) + } + } + } + + /** + * Desktop parity: after sync completes, resolve names/usernames for all dialogs + * that still have empty titles. Clears the one-shot guard first so that previously + * failed requests can be retried. + */ + private fun requestMissingUserInfo() { + scope.launch { + val repository = messageRepository + if (repository == null || !repository.isInitialized()) return@launch + try { + repository.clearUserInfoRequestCache() + repository.requestMissingUserInfo() + } catch (e: Exception) { + android.util.Log.e(TAG, "requestMissingUserInfo failed", e) + } + } + } + + private fun hasActiveInternet(): Boolean { + return networkReconnectWatcher.hasActiveInternet(appContext) + } + + private fun stopWaitingForNetwork(reason: String? = null) { + networkReconnectWatcher.stop(appContext, reason) + } + + private fun waitForNetworkAndReconnect(reason: String) { + networkReconnectWatcher.waitForNetwork(appContext, reason) + } + + /** + * Get or create Protocol instance + */ + fun getProtocol(): Protocol { + protocol?.let { return it } + + synchronized(protocolInstanceLock) { + protocol?.let { return it } + + val created = + Protocol( + serverAddress = SERVER_ADDRESS, + logger = { msg -> addLog(msg) }, + isNetworkAvailable = { hasActiveInternet() }, + onNetworkUnavailable = { waitForNetworkAndReconnect("protocol_connect") } + ) + protocol = created + addLog("🧩 Protocol singleton created: id=${System.identityHashCode(created)}") + return created + } + } + + /** + * Get connection state flow + */ + val state: StateFlow + get() = getProtocol().state + + /** + * Get last error flow + */ + val lastError: StateFlow + get() = getProtocol().lastError + + /** + * Connect to server + */ + fun connect() { + postConnectionEvent(ConnectionEvent.Connect(reason = "api_connect")) + } + + /** + * Trigger immediate reconnect on app foreground (skip waiting backoff timer). + */ + fun reconnectNowIfNeeded(reason: String = "foreground_resume") { + postConnectionEvent(ConnectionEvent.FastReconnect(reason = reason)) + } + + /** + * Desktop parity: синхронизация при каждом заходе в приложение. + * Desktop вызывает trySync() на каждый handshakeExchangeComplete. + * На Android — вызываем из onResume(), если уже AUTHENTICATED и не идёт sync. + * Дебаунс 5 секунд чтобы не спамить при быстром alt-tab. + */ + fun syncOnForeground() { + syncCoordinator.syncOnForeground() + } + + /** + * Manual sync trigger from UI. + * Rewinds lastSync a bit to safely re-fetch recent packets and re-starts sync. + */ + fun forceSynchronize(backtrackMs: Long = MANUAL_SYNC_BACKTRACK_MS) { + if (!isAuthenticated()) { + reconnectNowIfNeeded("manual_sync_button") + return + } + syncCoordinator.forceSynchronize(backtrackMs) + } + + /** + * Authenticate with server + */ + fun authenticate(publicKey: String, privateHash: String) { + postConnectionEvent( + ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash) + ) + } + + /** + * Restore auth handshake credentials from local account cache. + * Used when process is awakened by push and UI unlock flow wasn't executed yet. + */ + fun restoreAuthFromStoredCredentials( + preferredPublicKey: String? = null, + reason: String = "background_restore" + ): Boolean { + val accountManager = accountManager + if (accountManager == null) { + addLog("⚠️ restoreAuthFromStoredCredentials skipped: AccountManager is not bound") + return false + } + val publicKey = + preferredPublicKey?.trim().orEmpty().ifBlank { + accountManager.getLastLoggedPublicKey().orEmpty() + } + val privateHash = accountManager.getLastLoggedPrivateKeyHash().orEmpty() + if (publicKey.isBlank() || privateHash.isBlank()) { + addLog( + "⚠️ restoreAuthFromStoredCredentials skipped (pk=${publicKey.isNotBlank()} hash=${privateHash.isNotBlank()} reason=$reason)" + ) + return false + } + addLog("🔐 Restoring auth from cache reason=$reason pk=${shortKeyForLog(publicKey)}") + authenticate(publicKey, privateHash) + return true + } + + /** + * Запрашивает собственный профиль с сервера (username, name/title). + * Аналог Desktop: useUserInformation(ownPublicKey) → PacketSearch(0x03) + */ + private fun fetchOwnProfile() { + val packet = + ownProfileSyncService.buildOwnProfilePacket( + publicKey = getProtocol().getPublicKey(), + privateHash = getProtocol().getPrivateHash() + ) ?: return + send(packet) + } + + /** + * 🔍 Resolve publicKey → user title (like Desktop useUserInformation) + * Checks cache first, then sends PacketSearch and waits for response. + * Returns title or null on timeout/not found. + * @param timeoutMs max wait time for server response (default 3s) + */ + suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? { + return packetRouter.resolveUserName(publicKey = publicKey, timeoutMs = timeoutMs) + } + + /** + * 🔍 Get cached user info (no network request) + */ + fun getCachedUserName(publicKey: String): String? { + return packetRouter.getCachedUserName(publicKey) + } + + /** + * 🔍 Get full cached user info (no network request) + */ + fun notifyOwnProfileUpdated() { + ownProfileSyncService.notifyOwnProfileUpdated() + } + + fun getCachedUserInfo(publicKey: String): SearchUser? { + return packetRouter.getCachedUserInfo(publicKey) + } + + /** + * 🔍 Get cached user by username (no network request). + * Username compare is case-insensitive and ignores '@'. + */ + fun getCachedUserByUsername(username: String): SearchUser? { + return packetRouter.getCachedUserByUsername(username) + } + + /** + * 🔍 Resolve publicKey → full SearchUser (with server request if needed) + */ + suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? { + return packetRouter.resolveUserInfo(publicKey = publicKey, timeoutMs = timeoutMs) + } + + /** + * 🔍 Search users by query (usually username without '@'). + * Returns raw PacketSearch users list for the exact query. + */ + suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List { + return packetRouter.searchUsers(query = query, timeoutMs = timeoutMs) + } + + /** + * Accept a pending device login request. + */ + fun acceptDevice(deviceId: String) { + deviceRuntimeService.acceptDevice(deviceId) + } + + /** + * Decline a pending device login request. + */ + fun declineDevice(deviceId: String) { + deviceRuntimeService.declineDevice(deviceId) + } + + /** + * Send packet (simplified) + */ + fun send(packet: Packet) { + postConnectionEvent(ConnectionEvent.SendPacket(packet)) + } + + /** + * Send an outgoing message packet and register it for automatic retry. + */ + fun sendMessageWithRetry(packet: PacketMessage) { + outgoingMessagePipelineService.sendWithRetry(packet) + } + + /** + * iOS parity: cancel retry and clean up state for a resolved outgoing message. + * Called when delivery ACK (0x08) is received. + */ + fun resolveOutgoingRetry(messageId: String) { + outgoingMessagePipelineService.resolveOutgoingRetry(messageId) + } + + /** + * Cancel all pending outgoing retry jobs (e.g., on disconnect). + */ + private fun cancelAllOutgoingRetries() { + outgoingMessagePipelineService.clearRetryQueue() + } + + /** + * Send packet (legacy name) + */ + fun sendPacket(packet: Packet) { + send(packet) + } + + /** + * Send call signaling packet (0x1A). + */ + fun sendCallSignal( + signalType: SignalType, + src: String = "", + dst: String = "", + sharedPublic: String = "", + callId: String = "", + joinToken: String = "" + ) { + callSignalBridge.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken) + } + + /** + * Send WebRTC signaling packet (0x1B). + */ + fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) { + callSignalBridge.sendWebRtcSignal(signalType, sdpOrCandidate) + } + + /** + * Request ICE servers from server (0x1C). + */ + fun requestIceServers() { + callSignalBridge.requestIceServers() + } + + /** + * Typed subscribe for call signaling packets (0x1A). + * Returns wrapper callback for subsequent unwait. + */ + fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit { + return callSignalBridge.waitCallSignal(callback) + } + + fun unwaitCallSignal(callback: (Packet) -> Unit) { + callSignalBridge.unwaitCallSignal(callback) + } + + /** + * Typed subscribe for WebRTC packets (0x1B). + * Returns wrapper callback for subsequent unwait. + */ + fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit { + return callSignalBridge.waitWebRtcSignal(callback) + } + + fun unwaitWebRtcSignal(callback: (Packet) -> Unit) { + callSignalBridge.unwaitWebRtcSignal(callback) + } + + /** + * Typed subscribe for ICE servers packet (0x1C). + * Returns wrapper callback for subsequent unwait. + */ + fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit { + return callSignalBridge.waitIceServers(callback) + } + + fun unwaitIceServers(callback: (Packet) -> Unit) { + callSignalBridge.unwaitIceServers(callback) + } + + /** + * Register packet handler + */ + fun waitPacket(packetId: Int, callback: (Packet) -> Unit) { + packetSubscriptionRegistry.addCallback(packetId, callback) + } + + /** + * Unregister packet handler + */ + fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) { + packetSubscriptionRegistry.removeCallback(packetId, callback) + } + + /** + * SharedFlow fan-out stream for packet id. + */ + fun packetFlow(packetId: Int): SharedFlow { + return packetSubscriptionRegistry.flow(packetId) + } + + private fun shortKeyForLog(value: String, visible: Int = 8): String { + val trimmed = value.trim() + if (trimmed.isBlank()) return "" + return if (trimmed.length <= visible) trimmed else "${trimmed.take(visible)}…" + } + + private fun shortTextForLog(value: String, limit: Int = 80): String { + val normalized = value.replace('\n', ' ').replace('\r', ' ').trim() + if (normalized.isBlank()) return "" + return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…" + } + + /** + * Disconnect and clear + */ + fun disconnect() { + postConnectionEvent( + ConnectionEvent.Disconnect( + reason = "manual_disconnect", + clearCredentials = true + ) + ) + } + + /** + * Destroy instance completely + */ + fun destroy() { + stopWaitingForNetwork("destroy") + packetSubscriptionRegistry.destroy() + synchronized(protocolInstanceLock) { + protocol?.destroy() + protocol = null + } + messageRepository?.clearInitialization() + presenceTypingService.clear() + deviceRuntimeService.clear() + syncCoordinator.resetForDisconnect() + authBootstrapCoordinator.reset() + scope.cancel() + } + + /** + * Check if authenticated + */ + fun isAuthenticated(): Boolean = protocol?.isAuthenticated() ?: false + + /** + * Check if connected + */ + fun isConnected(): Boolean = protocol?.isConnected() ?: false +} diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index a3e4122..210d468 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -121,7 +121,7 @@ object TransportManager { */ fun requestTransportServer() { 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) { 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) @@ -275,15 +275,15 @@ object TransportManager { 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 } } catch (e: CancellationException) { - ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}") + ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}") throw e } catch (e: Exception) { - ProtocolManager.addLog( + ProtocolRuntimeAccess.get().addLog( "❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" ) throw e @@ -309,7 +309,7 @@ object TransportManager { transportServer: String? = null ): String = withContext(Dispatchers.IO) { val server = getActiveServer(transportServer) - ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server") + ProtocolRuntimeAccess.get().addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server") // Добавляем в список скачиваний _downloading.value = _downloading.value + TransportState(id, 0) @@ -336,7 +336,7 @@ object TransportManager { _downloading.value = _downloading.value.map { if (it.id == id) it.copy(progress = 100) else it } - ProtocolManager.addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}") + ProtocolRuntimeAccess.get().addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}") return@withRetry content } @@ -383,14 +383,14 @@ object TransportManager { if (it.id == id) it.copy(progress = 100) else it } - ProtocolManager.addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead") + ProtocolRuntimeAccess.get().addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead") content } finally { tempFile.delete() } } } catch (e: Exception) { - ProtocolManager.addLog( + ProtocolRuntimeAccess.get().addLog( "❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" ) throw e @@ -457,7 +457,7 @@ object TransportManager { transportServer: String? = null ): File = withContext(Dispatchers.IO) { val server = getActiveServer(transportServer) - ProtocolManager.addLog( + ProtocolRuntimeAccess.get().addLog( "📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes" ) @@ -541,13 +541,13 @@ object TransportManager { _downloading.value = _downloading.value.map { if (it.id == id) it.copy(progress = 100) else it } - ProtocolManager.addLog( + ProtocolRuntimeAccess.get().addLog( "✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead" ) targetFile } } catch (e: Exception) { - ProtocolManager.addLog( + ProtocolRuntimeAccess.get().addLog( "❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}" ) throw e diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/AuthBootstrapCoordinator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/AuthBootstrapCoordinator.kt new file mode 100644 index 0000000..da367d5 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/AuthBootstrapCoordinator.kt @@ -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}" + ) + } + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/CallSignalBridge.kt b/app/src/main/java/com/rosetta/messenger/network/connection/CallSignalBridge.kt new file mode 100644 index 0000000..ac48884 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/CallSignalBridge.kt @@ -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) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/DeviceRuntimeService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/DeviceRuntimeService.kt new file mode 100644 index 0000000..c9e22dc --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/DeviceRuntimeService.kt @@ -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> = verificationService.devices + val pendingDeviceVerification: StateFlow = + 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)]) + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/DeviceVerificationService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/DeviceVerificationService.kt new file mode 100644 index 0000000..1bee3ff --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/DeviceVerificationService.kt @@ -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>(emptyList()) + val devices: StateFlow> = _devices.asStateFlow() + + private val _pendingDeviceVerification = MutableStateFlow(null) + val pendingDeviceVerification: StateFlow = _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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/NetworkReconnectWatcher.kt b/app/src/main/java/com/rosetta/messenger/network/connection/NetworkReconnectWatcher.kt new file mode 100644 index 0000000..a536e4c --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/NetworkReconnectWatcher.kt @@ -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") + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt new file mode 100644 index 0000000..da2680d --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt @@ -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) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/PresenceTypingService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/PresenceTypingService.kt new file mode 100644 index 0000000..9a984a5 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/PresenceTypingService.kt @@ -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>(emptySet()) + val typingUsers: StateFlow> = _typingUsers.asStateFlow() + + private val _typingUsersByDialogSnapshot = + MutableStateFlow>>(emptyMap()) + val typingUsersByDialogSnapshot: StateFlow>> = + _typingUsersByDialogSnapshot.asStateFlow() + + private val typingStateLock = Any() + private val typingUsersByDialog = mutableMapOf>() + private val typingTimeoutJobs = ConcurrentHashMap() + + fun getTypingUsersForDialog(dialogKey: String): Set { + 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) + } + } +} + diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/SyncCoordinator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/SyncCoordinator.kt new file mode 100644 index 0000000..c8a1b0a --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/connection/SyncCoordinator.kt @@ -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 = _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 + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt index 0bddd8d..96ed3da 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt @@ -53,7 +53,8 @@ import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import com.rosetta.messenger.R -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.network.DeviceResolveSolution import com.rosetta.messenger.network.Packet import com.rosetta.messenger.network.PacketDeviceResolve @@ -68,7 +69,7 @@ fun DeviceConfirmScreen( onExit: () -> Unit ) { val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val view = LocalView.current if (!view.isInEditMode) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt index abf79c3..dc587d3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt @@ -28,7 +28,8 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.DecryptedAccount -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.network.PacketUserInfo import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.settings.ProfilePhotoPicker @@ -74,7 +75,7 @@ fun SetProfileScreen( onSkip: () -> Unit ) { val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val accountManager = remember(uiDeps) { uiDeps.accountManager() } val scope = rememberCoroutineScope() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index edff05a..1f7011c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -85,7 +85,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec @@ -93,7 +93,8 @@ import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import com.rosetta.messenger.R -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository @@ -340,9 +341,9 @@ fun ChatDetailScreen( registerClearSelection: (() -> Unit) -> Unit = {}, onVoiceWaveGestureChanged: (Boolean) -> Unit = {} ) { - val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") + val viewModel: ChatViewModel = hiltViewModel(key = "chat_${user.publicKey}") val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } @@ -734,7 +735,7 @@ fun ChatDetailScreen( } // 📨 Forward: список диалогов для выбора (загружаем из базы) - val chatsListViewModel: ChatsListViewModel = viewModel() + val chatsListViewModel: ChatsListViewModel = hiltViewModel() val dialogsList by chatsListViewModel.dialogs.collectAsState() val groupRepository = remember(uiDeps) { uiDeps.groupRepository() } val groupMembersCacheKey = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index c663c84..64f11d5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -7,13 +7,13 @@ import android.media.MediaMetadataRetriever import android.os.SystemClock import android.util.Base64 import android.webkit.MimeTypeMap -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.data.ForwardManager +import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.di.ProtocolGateway -import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.* @@ -40,6 +40,8 @@ import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.json.JSONArray import org.json.JSONObject +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject /** * ViewModel для экрана чата - оптимизированная версия 🚀 Особенности: @@ -49,7 +51,12 @@ import org.json.JSONObject * - Кэширование расшифрованных сообщений * - Flow для реактивных обновлений без блокировки UI */ -class ChatViewModel(application: Application) : AndroidViewModel(application) { +@HiltViewModel +class ChatViewModel @Inject constructor( + private val app: Application, + private val protocolGateway: ProtocolGateway, + private val messageRepository: MessageRepository +) : ViewModel() { companion object { private const val TAG = "ChatViewModel" @@ -117,17 +124,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // Database - private val database = RosettaDatabase.getDatabase(application) + private val database = RosettaDatabase.getDatabase(app) private val dialogDao = database.dialogDao() private val messageDao = database.messageDao() private val searchIndexDao = database.messageSearchIndexDao() private val groupDao = database.groupDao() private val pinnedMessageDao = database.pinnedMessageDao() - private val uiDeps = UiDependencyAccess.get(application) - private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway() - // MessageRepository для подписки на события новых сообщений - private val messageRepository = uiDeps.messageRepository() private val sendTextMessageUseCase = SendTextMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) }) private val sendMediaMessageUseCase = @@ -284,7 +287,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private var hasMoreMessages = true private var isLoadingMessages = false private val initialPageSize: Int by lazy { - when (DevicePerformanceClass.get(getApplication())) { + when (DevicePerformanceClass.get(app)) { PerformanceClass.LOW -> INITIAL_PAGE_SIZE_LOW PerformanceClass.AVERAGE -> INITIAL_PAGE_SIZE_AVERAGE PerformanceClass.HIGH -> INITIAL_PAGE_SIZE_HIGH @@ -2235,7 +2238,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) { val fileBlob = AttachmentFileManager.readAttachment( - context = getApplication(), + context = app, attachmentId = attachmentId, publicKey = publicKey, privateKey = privateKey @@ -2309,7 +2312,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private fun replyLog(msg: String) { try { - val ctx = getApplication() + val ctx = app val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date()) val dir = java.io.File(ctx.filesDir, "crash_reports") if (!dir.exists()) dir.mkdirs() @@ -3895,7 +3898,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } val forwardRewriteResult = prepareForwardAttachmentRewrites( - context = getApplication(), + context = app, sourceMessages = forwardSources, encryptionContext = encryptionContext, privateKey = command.senderPrivateKey, @@ -4131,7 +4134,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch(Dispatchers.IO) { try { - val context = getApplication() + val context = app val isSavedMessages = (sender == recipientPublicKey) val db = RosettaDatabase.getDatabase(context) val dialogDao = db.dialogDao() @@ -4385,7 +4388,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val recipient = opponentKey val sender = myPublicKey val privateKey = myPrivateKey - val context = getApplication() + val context = app if (recipient == null || sender == null || privateKey == null) { protocolGateway.addLog( @@ -4597,7 +4600,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) { var packetSentToProtocol = false try { - val context = getApplication() + val context = app val pipelineStartedAt = System.currentTimeMillis() logPhotoPipeline( messageId, @@ -4889,7 +4892,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 💾 Сохраняем изображение в файл локально (как в desktop) AttachmentFileManager.saveAttachment( - context = getApplication(), + context = app, blob = imageBase64, attachmentId = attachmentId, publicKey = sender, @@ -4985,7 +4988,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val recipient = opponentKey val sender = myPublicKey val privateKey = myPrivateKey - val context = getApplication() + val context = app val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8) if (recipient == null || sender == null || privateKey == null) { @@ -5383,7 +5386,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Сохраняем в файл локально AttachmentFileManager.saveAttachment( - context = getApplication(), + context = app, blob = imageData.base64, attachmentId = attachmentId, publicKey = sender, @@ -5545,8 +5548,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { try { // 💾 Сохраняем файл локально чтобы отправитель мог его открыть try { - val app = getApplication() - val downloadsDir = java.io.File(app.filesDir, "rosetta_downloads").apply { mkdirs() } + val appContext = app + val downloadsDir = java.io.File(appContext.filesDir, "rosetta_downloads").apply { mkdirs() } val localFile = java.io.File(downloadsDir, fileName) if (!localFile.exists()) { val base64Data = if (fileBase64.contains(",")) { @@ -5793,7 +5796,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val recipient = opponentKey val sender = myPublicKey val privateKey = myPrivateKey - val context = getApplication() + val context = app if (recipient == null || sender == null || privateKey == null) { return @@ -5927,7 +5930,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) { var packetSentToProtocol = false try { - val application = getApplication() + val application = app val encryptionContext = buildEncryptionContext( @@ -6166,7 +6169,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Для отправителя сохраняем voice blob локально в encrypted cache. runCatching { AttachmentFileManager.saveAttachment( - context = getApplication(), + context = app, blob = normalizedVoiceHex, attachmentId = attachmentId, publicKey = sender, @@ -6272,7 +6275,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (myAvatar == null) { withContext(Dispatchers.Main) { android.widget.Toast.makeText( - getApplication(), + app, "No avatar to send", android.widget.Toast.LENGTH_SHORT ) @@ -6285,14 +6288,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Читаем и расшифровываем аватар val avatarBlob = com.rosetta.messenger.utils.AvatarFileManager.readAvatar( - getApplication(), + app, myAvatar.avatar ) if (avatarBlob == null || avatarBlob.isEmpty()) { withContext(Dispatchers.Main) { android.widget.Toast.makeText( - getApplication(), + app, "Failed to read avatar", android.widget.Toast.LENGTH_SHORT ) @@ -6417,7 +6420,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 💾 Сохраняем аватар в файл локально (как IMAGE - с приватным ключом) AttachmentFileManager.saveAttachment( - context = getApplication(), + context = app, blob = avatarBlob, attachmentId = avatarAttachmentId, publicKey = sender, @@ -6481,7 +6484,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) android.widget.Toast.makeText( - getApplication(), + app, "Failed to send avatar: ${e.message}", android.widget.Toast.LENGTH_SHORT ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 025a7f2..1a7c2b4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -69,7 +69,8 @@ import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.di.ProtocolGateway -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallUiState @@ -92,6 +93,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource +import androidx.hilt.navigation.compose.hiltViewModel import compose.icons.TablerIcons import compose.icons.tablericons.* import com.rosetta.messenger.ui.icons.TelegramIcons @@ -307,7 +309,7 @@ fun ChatsListScreen( onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, pinnedChats: Set = emptySet(), onTogglePin: (String) -> Unit = {}, - chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), + chatsViewModel: ChatsListViewModel = hiltViewModel(), avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, callUiState: CallUiState = CallUiState(), isCallOverlayExpanded: Boolean = true, @@ -325,7 +327,7 @@ fun ChatsListScreen( val view = androidx.compose.ui.platform.LocalView.current val context = androidx.compose.ui.platform.LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val accountManager = remember(uiDeps) { uiDeps.accountManager() } val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 0bbe17f..49aab1d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -2,14 +2,13 @@ package com.rosetta.messenger.ui.chats import android.app.Application import androidx.compose.runtime.Immutable -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.DraftManager import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.di.ProtocolGateway -import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.database.BlacklistEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.PacketOnlineSubscribe @@ -28,6 +27,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject /** UI модель диалога с расшифрованным lastMessage */ @Immutable @@ -71,14 +72,16 @@ data class ChatsUiState( } /** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */ -class ChatsListViewModel(application: Application) : AndroidViewModel(application) { +@HiltViewModel +class ChatsListViewModel @Inject constructor( + private val app: Application, + private val protocolGateway: ProtocolGateway, + private val messageRepository: MessageRepository, + private val groupRepository: GroupRepository +) : ViewModel() { - private val database = RosettaDatabase.getDatabase(application) + private val database = RosettaDatabase.getDatabase(app) private val dialogDao = database.dialogDao() - private val uiDeps = UiDependencyAccess.get(application) - private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway() - private val messageRepository: MessageRepository = uiDeps.messageRepository() - private val groupRepository: GroupRepository = uiDeps.groupRepository() private var currentAccount: String = "" private var currentPrivateKey: String? = null @@ -162,8 +165,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio private fun rosettaDev1Log(msg: String) { runCatching { - val app = getApplication() - val dir = java.io.File(app.filesDir, "crash_reports") + val appContext = app + val dir = java.io.File(appContext.filesDir, "crash_reports") if (!dir.exists()) dir.mkdirs() val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n") @@ -1093,7 +1096,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio viewModelScope.launch(Dispatchers.IO) { try { val sharedPrefs = - getApplication() + app .getSharedPreferences("rosetta", Application.MODE_PRIVATE) val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: "" diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt index 2b18470..4bdd741 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt @@ -16,7 +16,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.network.ProtocolState import compose.icons.TablerIcons import compose.icons.tablericons.* @@ -30,7 +31,7 @@ fun ConnectionLogsScreen( onBack: () -> Unit ) { val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val logs by protocolGateway.debugLogs.collectAsState() val protocolState by protocolGateway.state.collectAsState() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index d1f239a..ca220e4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -122,7 +122,8 @@ import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.di.ProtocolGateway -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType @@ -146,7 +147,7 @@ import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer import com.rosetta.messenger.ui.settings.ProfilePhotoPicker import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.ImageCropHelper -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -324,7 +325,7 @@ fun GroupInfoScreen( onSwipeBackEnabledChanged: (Boolean) -> Unit = {} ) { val context = androidx.compose.ui.platform.LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } @@ -385,7 +386,7 @@ fun GroupInfoScreen( val database = remember { RosettaDatabase.getDatabase(context) } val groupDao = remember { database.groupDao() } val messageDao = remember { database.messageDao() } - val chatsListViewModel: ChatsListViewModel = viewModel() + val chatsListViewModel: ChatsListViewModel = hiltViewModel() val forwardDialogs by chatsListViewModel.dialogs.collectAsState() val normalizedGroupId = remember(groupUser.publicKey) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index a07a0b5..9e52712 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -77,7 +77,8 @@ import androidx.core.view.WindowInsetsCompat import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.R -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.database.DialogDao @@ -123,7 +124,7 @@ fun GroupSetupScreen( ) { val scope = rememberCoroutineScope() val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } val groupRepository = remember(uiDeps) { uiDeps.groupRepository() } val view = LocalView.current diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt index 8f628a1..ca7947f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt @@ -21,7 +21,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -42,7 +43,7 @@ fun RequestsListScreen( avatarRepository: AvatarRepository? = null ) { val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val chatsState by chatsViewModel.chatsState.collectAsState() val syncInProgress by protocolGateway.syncInProgress.collectAsState() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index c9b01cf..0f9dcc1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -54,10 +54,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.airbnb.lottie.compose.* import com.rosetta.messenger.R -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.network.ProtocolState @@ -104,7 +105,7 @@ fun SearchScreen( ) { // Context и View для мгновенного закрытия клавиатуры val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val accountManager = remember(uiDeps) { uiDeps.accountManager() } val view = LocalView.current val focusManager = LocalFocusManager.current @@ -139,7 +140,7 @@ fun SearchScreen( var isContentReady by remember { mutableStateOf(false) } // Search ViewModel - правильное создание через viewModel() - val searchViewModel: SearchUsersViewModel = viewModel() + val searchViewModel: SearchUsersViewModel = hiltViewModel() val searchQuery by searchViewModel.searchQuery.collectAsState() val searchResults by searchViewModel.searchResults.collectAsState() val isSearching by searchViewModel.isSearching.collectAsState() @@ -995,7 +996,7 @@ private fun MessagesTabContent( onUserSelect: (SearchUser) -> Unit ) { val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } var results by remember { mutableStateOf>(emptyList()) } var isSearching by remember { mutableStateOf(false) } @@ -1484,7 +1485,7 @@ private fun MediaTabContent( onOpenImageViewer: (images: List, initialIndex: Int, privateKey: String) -> Unit = { _, _, _ -> } ) { val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } var mediaItems by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt index ba81dd2..2f675c5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt @@ -1,15 +1,15 @@ package com.rosetta.messenger.ui.chats -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.di.ProtocolGateway -import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.SearchUser import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -22,9 +22,10 @@ import kotlinx.coroutines.launch * ViewModel для поиска пользователей через протокол * Работает аналогично SearchBar в React Native приложении */ -class SearchUsersViewModel(application: Application) : AndroidViewModel(application) { - private val protocolGateway: ProtocolGateway = - UiDependencyAccess.get(application).protocolGateway() +@HiltViewModel +class SearchUsersViewModel @Inject constructor( + private val protocolGateway: ProtocolGateway +) : ViewModel() { // Состояние поиска private val _searchQuery = MutableStateFlow("") diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index dd33f28..c7ee980 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -63,7 +63,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.PopupProperties -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.GroupStatus import com.rosetta.messenger.network.MessageAttachment @@ -1693,7 +1694,7 @@ private fun GroupInviteInlineCard( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val groupRepository = remember(uiDeps) { uiDeps.groupRepository() } val normalizedInvite = remember(inviteText) { inviteText.trim() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt index f67a64a..de38bb3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -72,7 +72,8 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.ui.graphics.graphicsLayer -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors /** * 📷 In-App Camera Screen - как в Telegram @@ -91,7 +92,7 @@ fun InAppCameraScreen( val view = LocalView.current val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } // Camera state diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt index 37b5cd4..e92ce94 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt @@ -29,7 +29,8 @@ import compose.icons.TablerIcons import compose.icons.tablericons.ChevronLeft import com.rosetta.messenger.R import com.rosetta.messenger.data.PreferencesManager -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -57,7 +58,7 @@ fun AppIconScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val prefs = remember(uiDeps) { uiDeps.preferencesManager() } var currentIcon by remember { mutableStateOf("default") } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt index c00a9a8..c8eea5d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt @@ -27,7 +27,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.ui.icons.TelegramIcons import compose.icons.TablerIcons import compose.icons.tablericons.ChevronLeft @@ -40,7 +41,7 @@ fun NotificationsScreen( onBack: () -> Unit ) { val context = LocalContext.current - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true) val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 37502e1..1695f53 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -75,7 +75,7 @@ import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants @@ -85,7 +85,8 @@ import com.rosetta.messenger.R import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.data.MessageRepository -import com.rosetta.messenger.di.UiDependencyAccess +import com.rosetta.messenger.di.UiEntryPoint +import dagger.hilt.android.EntryPointAccessors import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType @@ -252,11 +253,11 @@ fun OtherProfileScreen( } // 🔥 ChatsListViewModel для блокировки/разблокировки - val chatsListViewModel: ChatsListViewModel = viewModel() + val chatsListViewModel: ChatsListViewModel = hiltViewModel() val coroutineScope = rememberCoroutineScope() // 🔕 Mute state - val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val uiDeps = remember(context) { EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java) } val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } var notificationsEnabled by remember { mutableStateOf(true) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 5e3608a..9470e15 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -82,6 +82,7 @@ import androidx.compose.ui.res.painterResource import com.rosetta.messenger.R import com.rosetta.messenger.ui.icons.TelegramIcons import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -285,7 +286,7 @@ fun ProfileScreen( onNavigateToLogs: () -> Unit = {}, onNavigateToBiometric: () -> Unit = {}, onNavigateToMyQr: () -> Unit = {}, - viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), + viewModel: ProfileViewModel = hiltViewModel(), avatarRepository: AvatarRepository? = null, dialogDao: com.rosetta.messenger.database.DialogDao? = null, backgroundBlurColorId: String = "avatar" diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileViewModel.kt index 7776482..b45893d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileViewModel.kt @@ -1,14 +1,14 @@ package com.rosetta.messenger.ui.settings -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.di.ProtocolGateway -import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.network.PacketResult import com.rosetta.messenger.network.PacketUserInfo +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -23,11 +23,11 @@ data class ProfileState( val logs: List = emptyList() ) -class ProfileViewModel(application: Application) : AndroidViewModel(application) { - - private val uiDeps = UiDependencyAccess.get(application) - private val accountManager: AccountManager = uiDeps.accountManager() - private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway() +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val accountManager: AccountManager, + private val protocolGateway: ProtocolGateway +) : ViewModel() { private val _state = MutableStateFlow(ProfileState()) val state: StateFlow = _state diff --git a/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt b/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt index 59e8002..b4d8535 100644 --- a/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt +++ b/app/src/main/java/com/rosetta/messenger/update/UpdateManager.kt @@ -10,7 +10,7 @@ import android.util.Log import androidx.core.content.FileProvider import com.rosetta.messenger.BuildConfig import com.rosetta.messenger.network.PacketRequestUpdate -import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.ProtocolRuntimeAccess import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -96,7 +96,7 @@ object UpdateManager { appContext?.let { restorePersistedState(it) } sduLog("Registering waitPacket(0x0A) listener...") - ProtocolManager.waitPacket(0x0A) { packet -> + ProtocolRuntimeAccess.get().waitPacket(0x0A) { packet -> sduLog("Received packet 0x0A, type=${packet::class.simpleName}") if (packet is PacketRequestUpdate) { val server = packet.updateServer @@ -126,7 +126,7 @@ object UpdateManager { val packet = PacketRequestUpdate().apply { updateServer = "" } - ProtocolManager.sendPacket(packet) + ProtocolRuntimeAccess.get().sendPacket(packet) sduLog("PacketRequestUpdate sent") } diff --git a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt index fc36f51..3261b95 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/MediaUtils.kt @@ -9,7 +9,7 @@ import android.net.Uri import android.os.Build import android.util.Base64 import androidx.exifinterface.media.ExifInterface -import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.ProtocolRuntimeAccess import com.vanniktech.blurhash.BlurHash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -33,7 +33,7 @@ object MediaUtils { const val MAX_FILE_SIZE_MB = 20 private fun logImage(message: String) { - ProtocolManager.addLog("🧪 IMG-UTIL | $message") + ProtocolRuntimeAccess.get().addLog("🧪 IMG-UTIL | $message") } /** diff --git a/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt b/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt index 19a84d8..b6e7360 100644 --- a/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt +++ b/app/src/main/java/com/rosetta/messenger/utils/MessageLogger.kt @@ -1,7 +1,7 @@ package com.rosetta.messenger.utils import android.util.Log -import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.ProtocolRuntimeAccess /** * Утилита для логирования сообщений @@ -9,7 +9,7 @@ import com.rosetta.messenger.network.ProtocolManager * * Логи отображаются: * 1. В Logcat (всегда в debug) - * 2. В Debug Logs внутри чата (через ProtocolManager.debugLogs) + * 2. В Debug Logs внутри чата (через ProtocolRuntimeAccess.get().debugLogs) */ object MessageLogger { private const val TAG = "RosettaMsg" @@ -25,7 +25,7 @@ object MessageLogger { * Добавить лог в UI (Debug Logs в чате) */ private fun addToUI(message: String) { - ProtocolManager.addLog(message) + ProtocolRuntimeAccess.get().addLog(message) } /**