Skip to content

Commit a5c2c1e

Browse files
committed
Introduce connection status stabilizer to prevent state flickering
1 parent ab1f46b commit a5c2c1e

4 files changed

Lines changed: 226 additions & 2 deletions

File tree

data/src/main/java/org/monogram/data/infra/ConnectionManager.kt

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,17 @@ class ConnectionManager(
5555
val connectionStateFlow = _connectionStateFlow.asStateFlow()
5656

5757
private var retryJob: Job? = null
58+
private var pendingStatusJob: Job? = null
5859
private var proxyModeWatcherJob: Job? = null
5960
private var autoBestJob: Job? = null
6061
private var watchdogJob: Job? = null
6162
private var networkCallback: ConnectivityManager.NetworkCallback? = null
6263
private var reconnectAttempts = 0
6364
private var lastRetryAtMs = 0L
6465
private var lastStateChangeAtMs = System.currentTimeMillis()
66+
private var pendingStatusDueAtMs: Long? = null
6567
private val proxyRuleMutex = Mutex()
68+
private val statusStabilizer = ConnectionStatusStabilizer(ConnectionStatus.Connecting)
6669

6770
private val minRetryIntervalMs = 1_200L
6871
private val maxRetryDelayMs = 60_000L
@@ -101,9 +104,47 @@ class ConnectionManager(
101104
else -> ConnectionStatus.Connecting
102105
}
103106

107+
val now = System.currentTimeMillis()
108+
val stabilized = statusStabilizer.onStatus(
109+
rawStatus = status,
110+
nowMs = now,
111+
hasActiveNetwork = hasActiveNetwork()
112+
)
113+
val publishedStatus = stabilized.status
114+
if (publishedStatus == null) {
115+
schedulePendingStatus(stabilized.pendingDueAtMs, source)
116+
Log.d(
117+
tag,
118+
"Connection state transient: ${_connectionStateFlow.value} -> $status ($source)"
119+
)
120+
return
121+
}
122+
123+
pendingStatusJob?.cancel()
124+
pendingStatusJob = null
125+
pendingStatusDueAtMs = null
126+
publishConnectionStatus(publishedStatus, source, now)
127+
}
128+
129+
private fun schedulePendingStatus(dueAtMs: Long?, source: String) {
130+
if (dueAtMs == null) return
131+
if (pendingStatusDueAtMs == dueAtMs && pendingStatusJob?.isActive == true) return
132+
133+
pendingStatusDueAtMs = dueAtMs
134+
pendingStatusJob?.cancel()
135+
pendingStatusJob = scope.launch(dispatchers.default) {
136+
delay((dueAtMs - System.currentTimeMillis()).coerceAtLeast(0L))
137+
pendingStatusDueAtMs = null
138+
val now = System.currentTimeMillis()
139+
val status = statusStabilizer.publishPendingIfDue(now) ?: return@launch
140+
publishConnectionStatus(status, "delayed:$source", now)
141+
}
142+
}
143+
144+
private fun publishConnectionStatus(status: ConnectionStatus, source: String, now: Long) {
104145
val previous = _connectionStateFlow.value
105146
if (previous != status) {
106-
lastStateChangeAtMs = System.currentTimeMillis()
147+
lastStateChangeAtMs = now
107148
Log.d(tag, "Connection state changed: $previous -> $status ($source)")
108149
}
109150

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package org.monogram.data.infra
2+
3+
import org.monogram.domain.repository.ConnectionStatus
4+
5+
internal class ConnectionStatusStabilizer(
6+
initialStatus: ConnectionStatus,
7+
private val connectingGraceMs: Long = 3_000L,
8+
private val updatingGraceMs: Long = 4_000L
9+
) {
10+
private var publishedStatus: ConnectionStatus = initialStatus
11+
private var hasPublishedConnected: Boolean = initialStatus is ConnectionStatus.Connected
12+
private var pendingStatus: PendingStatus? = null
13+
14+
fun onStatus(
15+
rawStatus: ConnectionStatus,
16+
nowMs: Long,
17+
hasActiveNetwork: Boolean
18+
): StabilizedStatus {
19+
val status = if (!hasActiveNetwork && rawStatus is ConnectionStatus.Connecting) {
20+
ConnectionStatus.WaitingForNetwork
21+
} else {
22+
rawStatus
23+
}
24+
25+
if (status is ConnectionStatus.Connected) {
26+
hasPublishedConnected = true
27+
pendingStatus = null
28+
publishedStatus = status
29+
return StabilizedStatus(status = status, pendingDueAtMs = null)
30+
}
31+
32+
if (status is ConnectionStatus.WaitingForNetwork || status is ConnectionStatus.ConnectingToProxy) {
33+
pendingStatus = null
34+
publishedStatus = status
35+
return StabilizedStatus(status = status, pendingDueAtMs = null)
36+
}
37+
38+
if (!hasPublishedConnected || publishedStatus !is ConnectionStatus.Connected) {
39+
pendingStatus = null
40+
publishedStatus = status
41+
return StabilizedStatus(status = status, pendingDueAtMs = null)
42+
}
43+
44+
val graceMs = graceMsFor(status)
45+
val pending = pendingStatus
46+
?.takeIf { it.status == status }
47+
?: PendingStatus(status = status, firstSeenAtMs = nowMs, graceMs = graceMs)
48+
.also { pendingStatus = it }
49+
50+
val dueAtMs = pending.firstSeenAtMs + pending.graceMs
51+
return if (nowMs >= dueAtMs) {
52+
pendingStatus = null
53+
publishedStatus = status
54+
StabilizedStatus(status = status, pendingDueAtMs = null)
55+
} else {
56+
StabilizedStatus(status = null, pendingDueAtMs = dueAtMs)
57+
}
58+
}
59+
60+
fun publishPendingIfDue(nowMs: Long): ConnectionStatus? {
61+
val pending = pendingStatus ?: return null
62+
if (nowMs < pending.firstSeenAtMs + pending.graceMs) return null
63+
64+
pendingStatus = null
65+
publishedStatus = pending.status
66+
return pending.status
67+
}
68+
69+
fun hasPendingStatus(): Boolean = pendingStatus != null
70+
71+
private fun graceMsFor(status: ConnectionStatus): Long = when (status) {
72+
ConnectionStatus.Connecting -> connectingGraceMs
73+
ConnectionStatus.Updating -> updatingGraceMs
74+
else -> 0L
75+
}
76+
77+
data class StabilizedStatus(
78+
val status: ConnectionStatus?,
79+
val pendingDueAtMs: Long?
80+
)
81+
82+
private data class PendingStatus(
83+
val status: ConnectionStatus,
84+
val firstSeenAtMs: Long,
85+
val graceMs: Long
86+
)
87+
}

data/src/test/java/org/monogram/data/chats/ChatListManagerTest.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ class ChatListManagerTest {
1616

1717
val manager = ChatListManager(cache) {}
1818
val result = manager.rebuildChatList(limit = 10) { chat, order, isPinned ->
19-
ChatModel(id = chat.id, title = chat.title, order = order, isPinned = isPinned)
19+
ChatModel(
20+
id = chat.id,
21+
title = chat.title,
22+
unreadCount = 0,
23+
order = order,
24+
isPinned = isPinned
25+
)
2026
}
2127

2228
assertEquals(listOf(2L, 1L), result.map { it.id })
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package org.monogram.data.infra
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertNull
5+
import org.junit.Test
6+
import org.monogram.domain.repository.ConnectionStatus
7+
8+
class ConnectionStatusStabilizerTest {
9+
@Test
10+
fun `updating after connected is hidden inside grace window`() {
11+
val stabilizer = connectedStabilizer()
12+
13+
val result = stabilizer.onStatus(
14+
rawStatus = ConnectionStatus.Updating,
15+
nowMs = 1_000L,
16+
hasActiveNetwork = true
17+
)
18+
19+
assertNull(result.status)
20+
assertEquals(
21+
ConnectionStatus.Connected,
22+
stabilizer.onStatus(ConnectionStatus.Connected, 2_000L, true).status
23+
)
24+
}
25+
26+
@Test
27+
fun `connecting after connected is published after grace window`() {
28+
val stabilizer = connectedStabilizer()
29+
30+
val pending = stabilizer.onStatus(ConnectionStatus.Connecting, 1_000L, true)
31+
32+
assertNull(pending.status)
33+
assertEquals(ConnectionStatus.Connecting, stabilizer.publishPendingIfDue(4_000L))
34+
}
35+
36+
@Test
37+
fun `waiting for network is published immediately after connected`() {
38+
val stabilizer = connectedStabilizer()
39+
40+
val result = stabilizer.onStatus(
41+
rawStatus = ConnectionStatus.WaitingForNetwork,
42+
nowMs = 1_000L,
43+
hasActiveNetwork = true
44+
)
45+
46+
assertEquals(ConnectionStatus.WaitingForNetwork, result.status)
47+
}
48+
49+
@Test
50+
fun `connecting to proxy is published immediately after connected`() {
51+
val stabilizer = connectedStabilizer()
52+
53+
val result = stabilizer.onStatus(
54+
rawStatus = ConnectionStatus.ConnectingToProxy,
55+
nowMs = 1_000L,
56+
hasActiveNetwork = true
57+
)
58+
59+
assertEquals(ConnectionStatus.ConnectingToProxy, result.status)
60+
}
61+
62+
@Test
63+
fun `transient states are published before first connected`() {
64+
val stabilizer = ConnectionStatusStabilizer(ConnectionStatus.Connecting)
65+
66+
val result = stabilizer.onStatus(
67+
rawStatus = ConnectionStatus.Updating,
68+
nowMs = 1_000L,
69+
hasActiveNetwork = true
70+
)
71+
72+
assertEquals(ConnectionStatus.Updating, result.status)
73+
}
74+
75+
@Test
76+
fun `connecting without active network becomes waiting for network`() {
77+
val stabilizer = connectedStabilizer()
78+
79+
val result = stabilizer.onStatus(
80+
rawStatus = ConnectionStatus.Connecting,
81+
nowMs = 1_000L,
82+
hasActiveNetwork = false
83+
)
84+
85+
assertEquals(ConnectionStatus.WaitingForNetwork, result.status)
86+
}
87+
88+
private fun connectedStabilizer(): ConnectionStatusStabilizer =
89+
ConnectionStatusStabilizer(ConnectionStatus.Connected)
90+
}

0 commit comments

Comments
 (0)