refactor(time): Centralize time handling with kotlinx-datetime (#4545)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-13 20:01:07 -06:00 committed by GitHub
parent da04448dee
commit 5ca2ab4695
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 993 additions and 663 deletions

View file

@ -33,6 +33,8 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.bearing
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.ui.component.precisionBitsToMeters
import org.meshtastic.proto.Config
@ -47,7 +49,6 @@ import kotlin.math.sqrt
private const val ALIGNMENT_TOLERANCE_DEGREES = 5f
private const val FULL_CIRCLE_DEGREES = 360f
private const val BEARING_FORMAT = "%.0f°"
private const val MILLIS_PER_SECOND = 1000
private const val SECONDS_PER_HOUR = 3600
private const val SECONDS_PER_MINUTE = 60
private const val HUNDRED = 100f
@ -192,17 +193,12 @@ constructor(
val loc = locationState.location ?: return heading
val baseHeading = heading ?: return null
val geomagnetic =
GeomagneticField(
loc.latitude.toFloat(),
loc.longitude.toFloat(),
loc.altitude.toFloat(),
System.currentTimeMillis(),
)
GeomagneticField(loc.latitude.toFloat(), loc.longitude.toFloat(), loc.altitude.toFloat(), nowMillis)
return (baseHeading + geomagnetic.declination + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES
}
private fun formatElapsed(timestampSec: Long): String {
val nowSec = System.currentTimeMillis() / MILLIS_PER_SECOND
val nowSec = nowSeconds
val diff = maxOf(0, nowSec - timestampSec)
val hours = diff / SECONDS_PER_HOUR
val minutes = (diff % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE

View file

@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.AppTheme
@ -56,7 +57,7 @@ fun CooldownIconButton(
progress.snapTo(0f)
return@LaunchedEffect
}
val timeSinceLast = System.currentTimeMillis() - cooldownTimestamp
val timeSinceLast = nowMillis - cooldownTimestamp
if (timeSinceLast < cooldownDuration) {
val remainingTime = cooldownDuration - timeSinceLast
progress.snapTo(remainingTime / cooldownDuration.toFloat())
@ -106,7 +107,7 @@ fun CooldownOutlinedIconButton(
progress.snapTo(0f)
return@LaunchedEffect
}
val timeSinceLast = System.currentTimeMillis() - cooldownTimestamp
val timeSinceLast = nowMillis - cooldownTimestamp
if (timeSinceLast < cooldownDuration) {
val remainingTime = cooldownDuration - timeSinceLast
progress.snapTo(remainingTime / cooldownDuration.toFloat())
@ -146,7 +147,7 @@ fun CooldownOutlinedIconButton(
@Composable
private fun CooldownOutlinedIconButtonPreview() {
AppTheme {
CooldownOutlinedIconButton(onClick = {}, cooldownTimestamp = System.currentTimeMillis() - 15000L) {
CooldownOutlinedIconButton(onClick = {}, cooldownTimestamp = nowMillis - 15000L) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.ui.R
@ -34,13 +35,14 @@ import org.meshtastic.core.ui.util.formatAgo
fun LastHeardInfo(
modifier: Modifier = Modifier,
lastHeard: Int,
showLabel: Boolean = true,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
IconInfo(
modifier = modifier,
icon = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
contentDescription = stringResource(Res.string.node_sort_last_heard),
label = stringResource(Res.string.node_sort_last_heard),
label = if (showLabel) stringResource(Res.string.node_sort_last_heard) else null,
text = formatAgo(lastHeard),
contentColor = contentColor,
)
@ -49,5 +51,5 @@ fun LastHeardInfo(
@PreviewLightDark
@Composable
private fun LastHeardInfoPreview() {
AppTheme { LastHeardInfo(lastHeard = (System.currentTimeMillis() / 1000).toInt() - 8600) }
AppTheme { LastHeardInfo(lastHeard = nowSeconds.toInt() - 8600) }
}

View file

@ -30,6 +30,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.neighbor_info
@ -83,7 +84,7 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.requestNeighborInfo(packetId, destNum)
_lastRequestNeighborTimes.update { it + (destNum to System.currentTimeMillis()) }
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
Res.string.requesting_from,
@ -146,7 +147,7 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
try {
val packetId = serviceRepository.meshService?.packetId ?: return@launch
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
_lastTracerouteTimes.update { it + (destNum to System.currentTimeMillis()) }
_lastTracerouteTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.traceroute, longName)),
)

View file

@ -61,6 +61,9 @@ import com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.model.util.toInstant
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delete
@ -70,7 +73,7 @@ import org.meshtastic.core.strings.snr
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import java.text.DateFormat
import java.util.Date
import kotlin.time.Duration.Companion.days
object CommonCharts {
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
@ -99,15 +102,15 @@ object CommonCharts {
/** A dynamic [CartesianValueFormatter] that adjusts the time format based on the visible X range. */
val dynamicTimeFormatter = CartesianValueFormatter { context, value, _ ->
val date = Date((value * MS_PER_SEC.toDouble()).toLong())
val date = (value * MS_PER_SEC.toDouble()).toLong().toInstant().toDate()
val xLength = context.ranges.xLength
val zoom = if (context is CartesianDrawingContext) context.zoom else 1f
val visibleSpan = xLength / zoom
when {
visibleSpan <= 3600 -> TIME_SECONDS_FORMAT.format(date) // < 1 hour visible
visibleSpan <= 86400 * 2 -> TIME_MINUTE_FORMAT.format(date) // < 2 days visible
visibleSpan <= 86400 * 14 -> {
visibleSpan <= TimeConstants.ONE_HOUR.inWholeSeconds -> TIME_SECONDS_FORMAT.format(date) // < 1 hour visible
visibleSpan <= 2.days.inWholeSeconds -> TIME_MINUTE_FORMAT.format(date) // < 2 days visible
visibleSpan <= 14.days.inWholeSeconds -> {
// < 2 weeks visible: separate date and time with a newline
val dateStr = DATE_FORMAT.format(date)
val timeStr = TIME_MINUTE_FORMAT.format(date)

View file

@ -66,6 +66,7 @@ import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_util_definition
import org.meshtastic.core.strings.air_utilization
@ -352,7 +353,7 @@ private fun DeviceMetricsChart(
@PreviewLightDark
@Composable
private fun DeviceMetricsChartPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val now = nowSeconds.toInt()
val telemetries =
List(20) { i ->
Telemetry(
@ -468,7 +469,7 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
@PreviewLightDark
@Composable
private fun DeviceMetricsCardPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val now = nowSeconds.toInt()
val telemetry =
Telemetry(
time = now,
@ -488,7 +489,7 @@ private fun DeviceMetricsCardPreview() {
@PreviewLightDark
@Composable
private fun DeviceMetricsScreenPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val now = nowSeconds.toInt()
val telemetries =
List(24) { i ->
Telemetry(

View file

@ -51,6 +51,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.env_metrics_log
@ -431,8 +432,7 @@ private fun PreviewEnvironmentMetricsContent() {
radiation = 0.15f,
gas_resistance = 1200.0f,
)
val fakeTelemetry =
Telemetry(time = (System.currentTimeMillis() / 1000).toInt(), environment_metrics = fakeEnvMetrics)
val fakeTelemetry = Telemetry(time = nowSeconds.toInt(), environment_metrics = fakeEnvMetrics)
MaterialTheme {
Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) }
}

View file

@ -59,6 +59,7 @@ import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.disk_free_indexed
import org.meshtastic.core.strings.free_memory
@ -129,7 +130,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
@Composable
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) {
val hostMetrics = telemetry.host_metrics
val time = telemetry.time * CommonCharts.MS_PER_SEC
val time = telemetry.time.toLong() * CommonCharts.MS_PER_SEC
Card(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
@ -281,6 +282,6 @@ private fun HostMetricsItemPreview() {
load15 = 19,
user_string = "test",
)
val logs = Telemetry(time = (System.currentTimeMillis() / 1000).toInt(), host_metrics = hostMetrics)
val logs = Telemetry(time = nowSeconds.toInt(), host_metrics = hostMetrics)
AppTheme { HostMetricsItem(telemetry = logs) }
}

View file

@ -56,6 +56,9 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.model.util.toInstant
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
@ -207,7 +210,7 @@ constructor(
combine(_state, environmentState) { state, envState ->
val stateOldest = state.oldestTimestampSeconds()
val envOldest = envState.environmentMetrics.minOfOrNull { (it.time ?: 0).toLong() }?.takeIf { it > 0 }
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: (System.currentTimeMillis() / 1000L)
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: nowSeconds
TimeFrame.entries.filter { it.isAvailable(oldest) }
}
.stateInWhileSubscribed(TimeFrame.entries)
@ -519,7 +522,7 @@ constructor(
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
positions.forEach { position ->
val rxDateTime = dateFormat.format((position.time ?: 0).toLong() * 1000L)
val rxDateTime = dateFormat.format(((position.time ?: 0).toLong() * 1000L).toInstant().toDate())
val latitude = (position.latitude_i ?: 0) * 1e-7
val longitude = (position.longitude_i ?: 0) * 1e-7
val altitude = position.altitude

View file

@ -58,6 +58,8 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.model.util.toInstant
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.ble_devices
import org.meshtastic.core.strings.no_pax_metrics_logs
@ -72,7 +74,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.detail.NodeRequestEffect
import java.text.DateFormat
import java.util.Date
import org.meshtastic.proto.Paxcount as ProtoPaxcount
private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
@ -198,7 +199,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
val graphData =
paxMetrics
.map {
val t = (it.first.received_date / 1000).toInt()
val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt()
Triple(t, it.second.ble ?: 0, it.second.wifi ?: 0)
}
.sortedBy { it.first }
@ -212,7 +213,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
titleRes = Res.string.pax_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = paxMetrics,
timeProvider = { (it.first.received_date / 1000).toDouble() },
timeProvider = { (it.first.received_date / CommonCharts.MS_PER_SEC).toDouble() },
snackbarHostState = snackbarHostState,
onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) },
controlPart = {
@ -254,8 +255,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
log = log,
pax = pax,
dateFormat = dateFormat,
isSelected = (log.received_date / 1000).toDouble() == selectedX,
onClick = { onCardClick((log.received_date / 1000).toDouble()) },
isSelected = (log.received_date / CommonCharts.MS_PER_SEC).toDouble() == selectedX,
onClick = { onCardClick((log.received_date / CommonCharts.MS_PER_SEC).toDouble()) },
)
}
}
@ -297,7 +298,7 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isS
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = dateFormat.format(Date(log.received_date)),
text = dateFormat.format(log.received_date.toInstant().toDate()),
style = MaterialTheme.typography.titleMediumEmphasized,
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),

View file

@ -64,6 +64,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.meshtastic.core.strings.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alt
@ -270,7 +271,7 @@ private val testPosition =
longitude_i = -953698040,
altitude = 1230,
sats_in_view = 7,
time = (System.currentTimeMillis() / 1000).toInt(),
time = nowSeconds.toInt(),
)
@Preview(showBackground = true)

View file

@ -52,6 +52,7 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.strings.traceroute
@ -279,7 +280,7 @@ private fun TracerouteItemPreview() {
val time =
DateUtils.formatDateTime(
LocalContext.current,
System.currentTimeMillis(),
nowMillis,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
AppTheme {

View file

@ -17,6 +17,7 @@
package org.meshtastic.feature.node.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.all_time
import org.meshtastic.core.strings.one_hour_short
@ -35,7 +36,7 @@ enum class TimeFrame(val strRes: StringResource, val seconds: Long) {
ALL_TIME(Res.string.all_time, 0),
;
fun timeThreshold(now: Long = System.currentTimeMillis() / 1000L): Long {
fun timeThreshold(now: Long = nowSeconds): Long {
if (this == ALL_TIME) return 0
return now - seconds
}
@ -44,7 +45,7 @@ enum class TimeFrame(val strRes: StringResource, val seconds: Long) {
* Checks if this time frame is relevant given the oldest available data point. We show the option if the data
* extends at least into this timeframe.
*/
fun isAvailable(oldestTimestampSeconds: Long, now: Long = System.currentTimeMillis() / 1000L): Boolean {
fun isAvailable(oldestTimestampSeconds: Long, now: Long = nowSeconds): Boolean {
if (this == ALL_TIME || this == ONE_HOUR) return true
val rangeSeconds = now - oldestTimestampSeconds
return rangeSeconds >= seconds

View file

@ -19,6 +19,7 @@ package org.meshtastic.feature.node.metrics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry
@ -26,7 +27,7 @@ class EnvironmentMetricsStateTest {
@Test
fun `environmentMetricsForGraphing correctly calculates times`() {
val now = (System.currentTimeMillis() / 1000).toInt()
val now = nowSeconds.toInt()
val metrics =
listOf(
Telemetry(time = now - 100, environment_metrics = EnvironmentMetrics(temperature = 20f)),
@ -42,7 +43,7 @@ class EnvironmentMetricsStateTest {
@Test
fun `environmentMetricsForGraphing handles valid zero temperatures`() {
val now = (System.currentTimeMillis() / 1000).toInt()
val now = nowSeconds.toInt()
val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f)))
val state = EnvironmentMetricsState(metrics)
val result = state.environmentMetricsForGraphing()