android: show connecting status in chats header via realtime state
Some checks failed
Android CI / android (push) Failing after 5m0s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 21:28:03 +03:00
parent 3b3c740ae0
commit 0a9297c03d
7 changed files with 65 additions and 2 deletions

View File

@@ -514,3 +514,10 @@
- Removed `Archived` top tab from chats list UI. - Removed `Archived` top tab from chats list UI.
- Added search action in top app bar and unified single search field with leading search icon. - 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. - 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.

View File

@@ -8,6 +8,8 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -19,6 +21,7 @@ import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.di.RefreshClient import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.domain.realtime.RealtimeManager import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@@ -42,16 +45,20 @@ class WsRealtimeManager @Inject constructor(
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow() override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow()
private val _connectionState = MutableStateFlow(RealtimeConnectionState.Disconnected)
override val connectionState: StateFlow<RealtimeConnectionState> = _connectionState
override fun connect() { override fun connect() {
if (isConnected.get()) return if (isConnected.get()) return
manualDisconnect.set(false) manualDisconnect.set(false)
_connectionState.value = RealtimeConnectionState.Connecting
scope.launch { openSocket() } scope.launch { openSocket() }
} }
override fun disconnect() { override fun disconnect() {
manualDisconnect.set(true) manualDisconnect.set(true)
isConnected.set(false) isConnected.set(false)
_connectionState.value = RealtimeConnectionState.Disconnected
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatJob = null heartbeatJob = null
socket?.close(1000, "Client disconnect") socket?.close(1000, "Client disconnect")
@@ -59,7 +66,10 @@ class WsRealtimeManager @Inject constructor(
} }
private suspend fun openSocket() { 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 val wsUrl = BuildConfig.API_BASE_URL
.replace("http://", "ws://") .replace("http://", "ws://")
.replace("https://", "wss://") .replace("https://", "wss://")
@@ -72,6 +82,7 @@ class WsRealtimeManager @Inject constructor(
private fun scheduleReconnect() { private fun scheduleReconnect() {
if (manualDisconnect.get()) return if (manualDisconnect.get()) return
_connectionState.value = RealtimeConnectionState.Reconnecting
scope.launch { scope.launch {
delay(reconnectDelayMs) delay(reconnectDelayMs)
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS) reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
@@ -98,6 +109,7 @@ class WsRealtimeManager @Inject constructor(
private val listener = object : WebSocketListener() { private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
isConnected.set(true) isConnected.set(true)
_connectionState.value = RealtimeConnectionState.Connected
reconnectDelayMs = INITIAL_RECONNECT_MS reconnectDelayMs = INITIAL_RECONNECT_MS
startHeartbeat(webSocket) startHeartbeat(webSocket)
} }
@@ -111,18 +123,27 @@ class WsRealtimeManager @Inject constructor(
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected.set(false) isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel() heartbeatJob?.cancel()
webSocket.close(code, reason) webSocket.close(code, reason)
} }
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected.set(false) isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel() heartbeatJob?.cancel()
scheduleReconnect() scheduleReconnect()
} }
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
isConnected.set(false) isConnected.set(false)
if (!manualDisconnect.get()) {
_connectionState.value = RealtimeConnectionState.Reconnecting
}
heartbeatJob?.cancel() heartbeatJob?.cancel()
scheduleReconnect() scheduleReconnect()
} }

View File

@@ -1,10 +1,13 @@
package ru.daemonlord.messenger.domain.realtime package ru.daemonlord.messenger.domain.realtime
import kotlinx.coroutines.flow.Flow 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 import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
interface RealtimeManager { interface RealtimeManager {
val events: Flow<RealtimeEvent> val events: Flow<RealtimeEvent>
val connectionState: StateFlow<RealtimeConnectionState>
fun connect() fun connect()
fun disconnect() fun disconnect()
} }

View File

@@ -0,0 +1,8 @@
package ru.daemonlord.messenger.domain.realtime.model
enum class RealtimeConnectionState {
Disconnected,
Connecting,
Reconnecting,
Connected,
}

View File

@@ -190,7 +190,13 @@ fun ChatListScreen(
} }
}, },
title = { title = {
Text(if (state.selectedTab == ChatTab.ARCHIVED) "Archived" else "Chats") Text(
when {
state.isConnecting -> "Connecting..."
state.selectedTab == ChatTab.ARCHIVED -> "Archived"
else -> "Chats"
}
)
}, },
actions = { actions = {
IconButton(onClick = { searchExpanded = !searchExpanded }) { IconButton(onClick = { searchExpanded = !searchExpanded }) {

View File

@@ -13,6 +13,7 @@ data class ChatListUiState(
val searchQuery: String = "", val searchQuery: String = "",
val isLoading: Boolean = true, val isLoading: Boolean = true,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val isConnecting: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val chats: List<ChatItem> = emptyList(), val chats: List<ChatItem> = emptyList(),
val archivedChatsCount: Int = 0, val archivedChatsCount: Int = 0,

View File

@@ -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.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.repository.MessageRepository 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 ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import javax.inject.Inject import javax.inject.Inject
@@ -32,6 +34,7 @@ class ChatListViewModel @Inject constructor(
private val refreshChatsUseCase: RefreshChatsUseCase, private val refreshChatsUseCase: RefreshChatsUseCase,
private val joinByInviteUseCase: JoinByInviteUseCase, private val joinByInviteUseCase: JoinByInviteUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val realtimeManager: RealtimeManager,
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val messageRepository: MessageRepository, private val messageRepository: MessageRepository,
@@ -46,6 +49,7 @@ class ChatListViewModel @Inject constructor(
init { init {
handleRealtimeEventsUseCase.start() handleRealtimeEventsUseCase.start()
observeConnectionState()
observeChatStream() 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( private fun createChatInternal(
type: String, type: String,
title: String, title: String,