refactor(car): consolidate shared utilities — eliminate duplicated logic

- Replace ConnectionStatus enum with core:model/ConnectionState directly
- Fix signal quality thresholds: use core's LoRa-correct SNR (-7/-15)
  and RSSI (-115/-126) instead of wrong 10/5/0 thresholds
- Replace manual formatLastHeard() with DateFormatter.formatRelativeTime()
  from core:common (already a dependency)
- Replace MeshStatusPanel time formatting with DateFormatter
- Remove unused time format string resources (car_time_just_now, etc.)
- Handle DeviceSleep as disconnected state in car UI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-21 20:24:25 -05:00
parent 0959453c35
commit d5097f57de
7 changed files with 34 additions and 73 deletions

View File

@@ -16,20 +16,16 @@
*/
package org.meshtastic.feature.car.model
import org.meshtastic.core.model.ConnectionState
data class CarSessionState(
val connectionStatus: ConnectionStatus,
val connectionStatus: ConnectionState,
val onlineNodeCount: Int,
val lastMessageTime: Long?,
val activeEmergencies: List<EmergencyAlert>,
val meshName: String?,
)
enum class ConnectionStatus {
CONNECTED,
CONNECTING,
DISCONNECTED,
}
data class MessagingUiState(
val channels: List<ChannelUi>,
val selectedChannelIndex: Int,

View File

@@ -20,8 +20,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.feature.car.model.CarSessionState
import org.meshtastic.feature.car.model.ConnectionStatus
/**
* Manages persistent mesh status state for the car display. Provides connection status, node count, and last message
@@ -33,7 +34,7 @@ class MeshStatusPanel {
private val _state =
MutableStateFlow(
CarSessionState(
connectionStatus = ConnectionStatus.DISCONNECTED,
connectionStatus = ConnectionState.Disconnected,
onlineNodeCount = 0,
lastMessageTime = null,
activeEmergencies = emptyList(),
@@ -42,7 +43,7 @@ class MeshStatusPanel {
)
val state: StateFlow<CarSessionState> = _state.asStateFlow()
fun updateConnectionStatus(status: ConnectionStatus) {
fun updateConnectionStatus(status: ConnectionState) {
_state.value = _state.value.copy(connectionStatus = status)
}
@@ -61,29 +62,15 @@ class MeshStatusPanel {
fun getStatusTitle(): String {
val state = _state.value
return when (state.connectionStatus) {
ConnectionStatus.CONNECTED -> "${state.onlineNodeCount} nodes online"
ConnectionStatus.CONNECTING -> "Connecting..."
ConnectionStatus.DISCONNECTED -> "Disconnected"
ConnectionState.Connected -> "${state.onlineNodeCount} nodes online"
ConnectionState.Connecting -> "Connecting..."
else -> "Disconnected"
}
}
fun getStatusSubtitle(): String? {
val state = _state.value
val lastMsg = state.lastMessageTime ?: return null
val elapsed = System.currentTimeMillis() - lastMsg
val timeAgo =
when {
elapsed < MILLIS_PER_MINUTE -> "just now"
elapsed < MILLIS_PER_HOUR -> "${elapsed / MILLIS_PER_MINUTE}m ago"
elapsed < MILLIS_PER_DAY -> "${elapsed / MILLIS_PER_HOUR}h ago"
else -> "${elapsed / MILLIS_PER_DAY}d ago"
}
return "Last msg: $timeAgo"
}
companion object {
private const val MILLIS_PER_MINUTE = 60_000L
private const val MILLIS_PER_HOUR = 3_600_000L
private const val MILLIS_PER_DAY = 86_400_000L
val lastMsg = state.lastMessageTime?.takeIf { it != 0L } ?: return null
return "Last msg: ${DateFormatter.formatRelativeTime(lastMsg)}"
}
}

View File

@@ -20,7 +20,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.meshtastic.feature.car.model.ConnectionStatus
import org.meshtastic.core.model.ConnectionState
/** Wires MeshStatusPanel to data sources during a car session. Attach in onCreateScreen, detach in onDestroy. */
class MeshStatusSessionWiring(private val panel: MeshStatusPanel) {
@@ -30,7 +30,7 @@ class MeshStatusSessionWiring(private val panel: MeshStatusPanel) {
fun attach(
scope: CoroutineScope,
connectionFlow: Flow<ConnectionStatus>,
connectionFlow: Flow<ConnectionState>,
nodeCountFlow: Flow<Int>,
lastMessageTimeFlow: Flow<Long>,
meshNameFlow: Flow<String?>,

View File

@@ -39,8 +39,8 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.feature.car.R
import org.meshtastic.feature.car.model.ConnectionStatus
import org.meshtastic.feature.car.model.NodeUi
import org.meshtastic.feature.car.model.SignalQuality
import org.meshtastic.feature.car.service.CarStateCoordinator
@@ -73,7 +73,7 @@ class HomeScreen(carContext: CarContext, private val stateCoordinator: CarStateC
@Suppress("ReturnCount")
override fun onGetTemplate(): Template {
val connectionStatus = stateCoordinator.sessionState.value.connectionStatus
if (connectionStatus == ConnectionStatus.DISCONNECTED) {
if (connectionStatus == ConnectionState.Disconnected || connectionStatus == ConnectionState.DeviceSleep) {
return buildDisconnectedTemplate()
}
val messaging = stateCoordinator.messagingState.value

View File

@@ -24,6 +24,7 @@ import androidx.car.app.model.Pane
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.feature.car.R
import org.meshtastic.feature.car.model.NodeUi
import org.meshtastic.feature.car.model.SignalQuality
@@ -107,23 +108,6 @@ class NodeDetailScreen(
private fun formatLastHeard(epochMillis: Long): String {
if (epochMillis == 0L) return carContext.getString(R.string.car_time_never)
val elapsed = System.currentTimeMillis() - epochMillis
return when {
elapsed < MILLIS_PER_MINUTE -> carContext.getString(R.string.car_time_just_now)
elapsed < MILLIS_PER_HOUR ->
carContext.getString(R.string.car_time_minutes_ago, (elapsed / MILLIS_PER_MINUTE).toInt())
elapsed < MILLIS_PER_DAY ->
carContext.getString(R.string.car_time_hours_ago, (elapsed / MILLIS_PER_HOUR).toInt())
else -> carContext.getString(R.string.car_time_days_ago, (elapsed / MILLIS_PER_DAY).toInt())
}
}
companion object {
private const val MILLIS_PER_MINUTE = 60_000L
private const val MILLIS_PER_HOUR = 3_600_000L
private const val MILLIS_PER_DAY = 86_400_000L
return DateFormatter.formatRelativeTime(epochMillis)
}
}

View File

@@ -41,7 +41,6 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.car.model.CarSessionState
import org.meshtastic.feature.car.model.ChannelUi
import org.meshtastic.feature.car.model.ConnectionStatus
import org.meshtastic.feature.car.model.ConversationUi
import org.meshtastic.feature.car.model.MessagingUiState
import org.meshtastic.feature.car.model.NodeDashboardUiState
@@ -80,7 +79,7 @@ class CarStateCoordinator(
private val _sessionState =
MutableStateFlow(
CarSessionState(
connectionStatus = ConnectionStatus.DISCONNECTED,
connectionStatus = ConnectionState.Disconnected,
onlineNodeCount = 0,
lastMessageTime = null,
activeEmergencies = emptyList(),
@@ -179,13 +178,7 @@ class CarStateCoordinator(
private fun collectConnectionState() {
scope.launch {
serviceRepository.connectionState.collect { state ->
val status =
when (state) {
ConnectionState.Connected -> ConnectionStatus.CONNECTED
ConnectionState.Connecting -> ConnectionStatus.CONNECTING
else -> ConnectionStatus.DISCONNECTED
}
_sessionState.value = _sessionState.value.copy(connectionStatus = status)
_sessionState.value = _sessionState.value.copy(connectionStatus = state)
}
}
}
@@ -272,7 +265,7 @@ class CarStateCoordinator(
nodeNum = num,
longName = user.long_name.ifEmpty { "Unknown" },
shortName = user.short_name.ifEmpty { "?" },
signalQuality = snrToSignalQuality(snr),
signalQuality = determineSignalQuality(snr, rssi),
batteryPercent = batteryLevel?.takeIf { it in 1..BATTERY_MAX_PERCENT },
isOnline = isOnline,
lastHeard = lastHeard.toLong() * SECONDS_TO_MILLIS,
@@ -286,15 +279,20 @@ class CarStateCoordinator(
private const val DATA_TYPE_TEXT = 1
private const val SECONDS_TO_MILLIS = 1000L
private const val BATTERY_MAX_PERCENT = 100
private const val SNR_EXCELLENT = 10f
private const val SNR_GOOD = 5f
private const val SNR_FAIR = 0f
private fun snrToSignalQuality(snr: Float): SignalQuality = when {
snr == Float.MAX_VALUE -> SignalQuality.UNKNOWN
snr >= SNR_EXCELLENT -> SignalQuality.EXCELLENT
snr >= SNR_GOOD -> SignalQuality.GOOD
snr >= SNR_FAIR -> SignalQuality.FAIR
// Thresholds aligned with core/ui LoraSignalIndicator.kt
private const val SNR_GOOD_THRESHOLD = -7f
private const val SNR_FAIR_THRESHOLD = -15f
private const val RSSI_GOOD_THRESHOLD = -115
private const val RSSI_FAIR_THRESHOLD = -126
@Suppress("MagicNumber")
private fun determineSignalQuality(snr: Float, rssi: Int): SignalQuality = when {
snr == Float.MAX_VALUE || rssi == Int.MAX_VALUE -> SignalQuality.UNKNOWN
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.EXCELLENT
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> SignalQuality.GOOD
snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.GOOD
snr > SNR_FAIR_THRESHOLD -> SignalQuality.FAIR
else -> SignalQuality.POOR
}
}

View File

@@ -36,10 +36,6 @@
<string name="car_status_status">Status</string>
<string name="car_tab_messages">Messages</string>
<string name="car_tab_nodes">Nodes</string>
<string name="car_time_days_ago">%dd ago</string>
<string name="car_time_hours_ago">%dh ago</string>
<string name="car_time_just_now">Just now</string>
<string name="car_time_minutes_ago">%dm ago</string>
<string name="car_time_never">Never</string>
<string name="car_unread_badge">%d unread</string>
<string name="car_voice_reply">Reply</string>