android: show connecting status in chats header via realtime state
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.realtime.model
|
||||||
|
|
||||||
|
enum class RealtimeConnectionState {
|
||||||
|
Disconnected,
|
||||||
|
Connecting,
|
||||||
|
Reconnecting,
|
||||||
|
Connected,
|
||||||
|
}
|
||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user