From 0a9297c03d4fc98509d6c2285b95ca1bae614a2b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 21:28:03 +0300 Subject: [PATCH] android: show connecting status in chats header via realtime state --- android/CHANGELOG.md | 7 ++++++ .../data/realtime/WsRealtimeManager.kt | 23 ++++++++++++++++++- .../domain/realtime/RealtimeManager.kt | 3 +++ .../realtime/model/RealtimeConnectionState.kt | 8 +++++++ .../messenger/ui/chats/ChatListScreen.kt | 8 ++++++- .../messenger/ui/chats/ChatListUiState.kt | 1 + .../messenger/ui/chats/ChatListViewModel.kt | 17 ++++++++++++++ 7 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeConnectionState.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 579d2be..ef8e737 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -514,3 +514,10 @@ - Removed `Archived` top tab from chats list UI. - Added search action in top app bar and unified single search field with leading search icon. - Kept archive as dedicated row inside chats list; opening archive now happens from that row and back navigation appears in app bar while archive is active. + +### Step 83 - Chats header realtime connection status +- Added realtime connection state stream (`Disconnected/Connecting/Reconnecting/Connected`) to `RealtimeManager`. +- Wired websocket lifecycle into that state in `WsRealtimeManager`. +- Bound chats top bar title to realtime state: + - shows `Connecting...` while reconnect/initial connect is in progress, + - shows regular page title once connected. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/WsRealtimeManager.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/WsRealtimeManager.kt index 9fd2f16..aea5c55 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/WsRealtimeManager.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/realtime/WsRealtimeManager.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient @@ -19,6 +21,7 @@ import ru.daemonlord.messenger.BuildConfig import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.di.RefreshClient import ru.daemonlord.messenger.domain.realtime.RealtimeManager +import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -42,16 +45,20 @@ class WsRealtimeManager @Inject constructor( private var heartbeatJob: Job? = null override val events: Flow = eventFlow.asSharedFlow() + private val _connectionState = MutableStateFlow(RealtimeConnectionState.Disconnected) + override val connectionState: StateFlow = _connectionState override fun connect() { if (isConnected.get()) return manualDisconnect.set(false) + _connectionState.value = RealtimeConnectionState.Connecting scope.launch { openSocket() } } override fun disconnect() { manualDisconnect.set(true) isConnected.set(false) + _connectionState.value = RealtimeConnectionState.Disconnected heartbeatJob?.cancel() heartbeatJob = null socket?.close(1000, "Client disconnect") @@ -59,7 +66,10 @@ class WsRealtimeManager @Inject constructor( } private suspend fun openSocket() { - val accessToken = tokenRepository.getTokens()?.accessToken ?: return + val accessToken = tokenRepository.getTokens()?.accessToken ?: run { + _connectionState.value = RealtimeConnectionState.Disconnected + return + } val wsUrl = BuildConfig.API_BASE_URL .replace("http://", "ws://") .replace("https://", "wss://") @@ -72,6 +82,7 @@ class WsRealtimeManager @Inject constructor( private fun scheduleReconnect() { if (manualDisconnect.get()) return + _connectionState.value = RealtimeConnectionState.Reconnecting scope.launch { delay(reconnectDelayMs) reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS) @@ -98,6 +109,7 @@ class WsRealtimeManager @Inject constructor( private val listener = object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { isConnected.set(true) + _connectionState.value = RealtimeConnectionState.Connected reconnectDelayMs = INITIAL_RECONNECT_MS startHeartbeat(webSocket) } @@ -111,18 +123,27 @@ class WsRealtimeManager @Inject constructor( override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { isConnected.set(false) + if (!manualDisconnect.get()) { + _connectionState.value = RealtimeConnectionState.Reconnecting + } heartbeatJob?.cancel() webSocket.close(code, reason) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { isConnected.set(false) + if (!manualDisconnect.get()) { + _connectionState.value = RealtimeConnectionState.Reconnecting + } heartbeatJob?.cancel() scheduleReconnect() } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { isConnected.set(false) + if (!manualDisconnect.get()) { + _connectionState.value = RealtimeConnectionState.Reconnecting + } heartbeatJob?.cancel() scheduleReconnect() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/RealtimeManager.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/RealtimeManager.kt index d03daa5..b2c7737 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/RealtimeManager.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/RealtimeManager.kt @@ -1,10 +1,13 @@ package ru.daemonlord.messenger.domain.realtime import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent interface RealtimeManager { val events: Flow + val connectionState: StateFlow fun connect() fun disconnect() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeConnectionState.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeConnectionState.kt new file mode 100644 index 0000000..ab6b4f9 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/model/RealtimeConnectionState.kt @@ -0,0 +1,8 @@ +package ru.daemonlord.messenger.domain.realtime.model + +enum class RealtimeConnectionState { + Disconnected, + Connecting, + Reconnecting, + Connected, +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index d76c753..e7d5892 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -190,7 +190,13 @@ fun ChatListScreen( } }, title = { - Text(if (state.selectedTab == ChatTab.ARCHIVED) "Archived" else "Chats") + Text( + when { + state.isConnecting -> "Connecting..." + state.selectedTab == ChatTab.ARCHIVED -> "Archived" + else -> "Chats" + } + ) }, actions = { IconButton(onClick = { searchExpanded = !searchExpanded }) { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt index 8c1147d..f75987f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt @@ -13,6 +13,7 @@ data class ChatListUiState( val searchQuery: String = "", val isLoading: Boolean = true, val isRefreshing: Boolean = false, + val isConnecting: Boolean = false, val errorMessage: String? = null, val chats: List = emptyList(), val archivedChatsCount: Int = 0, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt index 21569e9..8307945 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -22,6 +22,8 @@ import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import ru.daemonlord.messenger.domain.realtime.RealtimeManager +import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase import javax.inject.Inject @@ -32,6 +34,7 @@ class ChatListViewModel @Inject constructor( private val refreshChatsUseCase: RefreshChatsUseCase, private val joinByInviteUseCase: JoinByInviteUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, + private val realtimeManager: RealtimeManager, private val chatRepository: ChatRepository, private val accountRepository: AccountRepository, private val messageRepository: MessageRepository, @@ -46,6 +49,7 @@ class ChatListViewModel @Inject constructor( init { handleRealtimeEventsUseCase.start() + observeConnectionState() observeChatStream() } @@ -314,6 +318,19 @@ class ChatListViewModel @Inject constructor( } } + private fun observeConnectionState() { + viewModelScope.launch { + realtimeManager.connectionState.collectLatest { state -> + _uiState.update { + it.copy( + isConnecting = state == RealtimeConnectionState.Connecting || + state == RealtimeConnectionState.Reconnecting, + ) + } + } + } + } + private fun createChatInternal( type: String, title: String,