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.
- 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.

View File

@@ -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<RealtimeEvent> = eventFlow.asSharedFlow()
private val _connectionState = MutableStateFlow(RealtimeConnectionState.Disconnected)
override val connectionState: StateFlow<RealtimeConnectionState> = _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()
}

View File

@@ -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<RealtimeEvent>
val connectionState: StateFlow<RealtimeConnectionState>
fun connect()
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 = {
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 }) {

View File

@@ -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<ChatItem> = emptyList(),
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.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,