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

@ -35,8 +35,9 @@ import kotlinx.coroutines.launch
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.toJavaDuration
/**
* The main application class for Meshtastic.
@ -67,11 +68,7 @@ class MeshUtilApplication :
private fun scheduleMeshLogCleanup() {
val cleanupRequest =
PeriodicWorkRequestBuilder<MeshLogCleanupWorker>(
repeatInterval = 1,
repeatIntervalTimeUnit = TimeUnit.HOURS,
)
.build()
PeriodicWorkRequestBuilder<MeshLogCleanupWorker>(repeatInterval = 1.hours.toJavaDuration()).build()
WorkManager.getInstance(this)
.enqueueUniquePeriodicWork(

View file

@ -15,6 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.concurrent
import org.meshtastic.core.model.util.nowMillis
/** A deferred execution object (with various possible implementations) */
interface Continuation<in T> {
abstract fun resume(res: Result<T>)
@ -63,10 +66,10 @@ class SyncContinuation<T> : Continuation<T> {
fun await(timeoutMsecs: Long = 0): T {
lock.lock()
try {
val startT = System.currentTimeMillis()
val startT = nowMillis
while (result == null) {
if (timeoutMsecs > 0) {
val remaining = timeoutMsecs - (System.currentTimeMillis() - startT)
val remaining = timeoutMsecs - (nowMillis - startT)
if (remaining <= 0) {
throw Exception("SyncContinuation timeout")
}

View file

@ -26,6 +26,7 @@ import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
@ -146,7 +147,7 @@ constructor(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
@ -162,7 +163,7 @@ constructor(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
@ -177,13 +178,13 @@ constructor(
Neighbor(
node_id = numIn + 1,
snr = 10.0f,
last_rx_time = (System.currentTimeMillis() / 1000).toInt(),
last_rx_time = nowSeconds.toInt(),
node_broadcast_interval_secs = 60,
),
Neighbor(
node_id = numIn + 2,
snr = 12.0f,
last_rx_time = (System.currentTimeMillis() / 1000).toInt(),
last_rx_time = nowSeconds.toInt(),
node_broadcast_interval_secs = 60,
),
),
@ -200,7 +201,7 @@ constructor(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
@ -210,7 +211,7 @@ constructor(
latitude_i = org.meshtastic.core.model.Position.degI(32.776665),
longitude_i = org.meshtastic.core.model.Position.degI(-96.796989),
altitude = 150,
time = (System.currentTimeMillis() / 1000).toInt(),
time = nowSeconds.toInt(),
precision_bits = 15,
)
.encode()
@ -225,14 +226,14 @@ constructor(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
portnum = PortNum.TELEMETRY_APP,
payload =
Telemetry(
time = (System.currentTimeMillis() / 1000).toInt(),
time = nowSeconds.toInt(),
device_metrics =
DeviceMetrics(
battery_level = 85,
@ -254,7 +255,7 @@ constructor(
id = packetIdSequence.next(),
from = numIn,
to = 0xffffffff.toInt(), // broadcast
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded =
Data(
@ -271,7 +272,7 @@ constructor(
id = packetIdSequence.next(),
from = fromIn,
to = toIn,
rx_time = (System.currentTimeMillis() / 1000).toInt(),
rx_time = nowSeconds.toInt(),
rx_snr = 1.5f,
decoded = data,
),
@ -326,7 +327,7 @@ constructor(
latitude_i = org.meshtastic.core.model.Position.degI(lat),
longitude_i = org.meshtastic.core.model.Position.degI(lon),
altitude = 35,
time = (System.currentTimeMillis() / 1000).toInt(),
time = nowSeconds.toInt(),
precision_bits = Random.nextInt(10, 19),
),
),
@ -345,6 +346,7 @@ constructor(
makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas
makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson
FromRadio(config = Config(lora = defaultLoRaConfig)),
FromRadio(config = Config(lora = defaultLoRaConfig)),
FromRadio(channel = defaultChannel),
FromRadio(config_complete_id = configId),

View file

@ -52,6 +52,7 @@ import no.nordicsemi.kotlin.ble.client.exception.InvalidAttributeException
import no.nordicsemi.kotlin.ble.core.CharacteristicProperty
import no.nordicsemi.kotlin.ble.core.ConnectionState
import no.nordicsemi.kotlin.ble.core.WriteType
import org.meshtastic.core.model.util.nowMillis
import java.util.UUID
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.toKotlinUuid
@ -170,19 +171,19 @@ constructor(
private fun connect() {
connectionScope.launch {
try {
connectionStartTime = System.currentTimeMillis()
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started at $connectionStartTime" }
peripheral = retryCall { findAndConnectPeripheral() }
peripheral?.let {
val connectionTime = System.currentTimeMillis() - connectionStartTime
val connectionTime = nowMillis - connectionStartTime
Logger.i { "[$address] BLE peripheral connected in ${connectionTime}ms" }
onConnected()
observePeripheralChanges()
discoverServicesAndSetupCharacteristics(it)
}
} catch (e: Exception) {
val failureTime = System.currentTimeMillis() - connectionStartTime
val failureTime = nowMillis - connectionStartTime
// BLE connection errors are common and often transient
Logger.w(e) { "[$address] Failed to connect to peripheral after ${failureTime}ms" }
service.onDisconnect(BleError.from(e))
@ -230,7 +231,7 @@ constructor(
val uptime =
if (connectionStartTime > 0) {
System.currentTimeMillis() - connectionStartTime
nowMillis - connectionStartTime
} else {
0
}
@ -417,7 +418,7 @@ constructor(
runBlocking {
val uptime =
if (connectionStartTime > 0) {
System.currentTimeMillis() - connectionStartTime
nowMillis - connectionStartTime
} else {
0
}

View file

@ -47,6 +47,7 @@ import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Heartbeat
@ -146,7 +147,7 @@ constructor(
private var lastHeartbeatMillis = 0L
fun keepAlive(now: Long = System.currentTimeMillis()) {
fun keepAlive(now: Long = nowMillis) {
if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
if (radioIf is SerialInterface) {
Logger.i { "Sending ToRadio heartbeat" }

View file

@ -22,6 +22,7 @@ import com.geeksville.mesh.repository.usb.SerialConnectionListener
import com.geeksville.mesh.repository.usb.UsbRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.meshtastic.core.model.util.nowMillis
import java.util.concurrent.atomic.AtomicReference
/** An interface that assumes we are talking to a meshtastic device via USB serial */
@ -49,7 +50,7 @@ constructor(
if (device == null) {
Logger.e { "[$address] Serial device not found at address" }
} else {
val connectStart = System.currentTimeMillis()
val connectStart = nowMillis
Logger.i { "[$address] Opening serial device: $device" }
var packetsReceived = 0
@ -57,7 +58,7 @@ constructor(
var connectionStartTime = 0L
val onConnect: () -> Unit = {
connectionStartTime = System.currentTimeMillis()
connectionStartTime = nowMillis
val connectionTime = connectionStartTime - connectStart
Logger.i { "[$address] Serial device connected in ${connectionTime}ms" }
super.connect()
@ -90,7 +91,7 @@ constructor(
override fun onDisconnected(thrown: Exception?) {
val uptime =
if (connectionStartTime > 0) {
System.currentTimeMillis() - connectionStartTime
nowMillis - connectionStartTime
} else {
0
}

View file

@ -25,6 +25,7 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import java.io.BufferedInputStream
@ -104,7 +105,7 @@ constructor(
if (s != null) {
val uptime =
if (connectionStartTime > 0) {
System.currentTimeMillis() - connectionStartTime
nowMillis - connectionStartTime
} else {
0
}
@ -130,7 +131,7 @@ constructor(
} catch (ex: IOException) {
val uptime =
if (connectionStartTime > 0) {
System.currentTimeMillis() - connectionStartTime
nowMillis - connectionStartTime
} else {
0
}
@ -140,7 +141,7 @@ constructor(
} catch (ex: Throwable) {
val uptime =
if (connectionStartTime > 0) {
System.currentTimeMillis() - connectionStartTime
nowMillis - connectionStartTime
} else {
0
}
@ -175,7 +176,7 @@ constructor(
// Create a socket to make the connection with the server
private suspend fun startConnect() = withContext(dispatchers.io) {
val attemptStart = System.currentTimeMillis()
val attemptStart = nowMillis
Logger.i { "[$address] TCP connection attempt starting..." }
val parts = address.split(":", limit = 2)
@ -190,8 +191,8 @@ constructor(
socket.soTimeout = SOCKET_TIMEOUT
this@TCPInterface.socket = socket
val connectTime = System.currentTimeMillis() - attemptStart
connectionStartTime = System.currentTimeMillis()
val connectTime = nowMillis - attemptStart
connectionStartTime = nowMillis
Logger.i {
"[$address] TCP socket connected in ${connectTime}ms - " +
"Local: ${socket.localSocketAddress}, Remote: ${socket.remoteSocketAddress}"

View file

@ -22,11 +22,12 @@ import com.geeksville.mesh.util.ignoreException
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.util.SerialInputOutputManager
import org.meshtastic.core.model.util.await
import java.nio.BufferOverflowException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import kotlin.time.Duration.Companion.seconds
internal class SerialConnectionImpl(
private val usbManagerLazy: dagger.Lazy<UsbManager?>,
@ -62,7 +63,7 @@ internal class SerialConnectionImpl(
// Allow a short amount of time for the manager to quit (so the port can be cleanly closed)
if (waitForStopped) {
Logger.d { "Waiting for USB manager to stop..." }
closedLatch.await(1, TimeUnit.SECONDS)
closedLatch.await(1.seconds)
}
}
}

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.MeshServiceNotifications
import javax.inject.Inject
@ -48,7 +49,7 @@ class MarkAsReadReceiver : BroadcastReceiver() {
val pendingResult = goAsync()
scope.launch {
try {
packetRepository.clearUnreadCount(contactKey, System.currentTimeMillis())
packetRepository.clearUnreadCount(contactKey, nowMillis)
meshServiceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()

View file

@ -31,6 +31,7 @@ import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
@ -160,7 +161,7 @@ constructor(
replyId = action.replyId,
userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL,
emoji = action.emoji,
timestamp = System.currentTimeMillis(),
timestamp = nowMillis,
snr = 0f,
rssi = 0,
hopsAway = 0,

View file

@ -33,6 +33,8 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
@ -64,7 +66,7 @@ constructor(
private val radioConfigRepository: RadioConfigRepository?,
) {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(System.currentTimeMillis()).nextLong().absoluteValue)
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
private val offlineSentPackets = CopyOnWriteArrayList<DataPacket>()
val tracerouteStartTimes = ConcurrentHashMap<Int, Long>()
@ -175,7 +177,7 @@ constructor(
emoji = p.emoji,
),
)
p.time = System.currentTimeMillis()
p.time = nowMillis
packetHandler?.sendToRadio(meshPacket)
}
@ -240,7 +242,7 @@ constructor(
latitude_i = Position.degI(currentPosition.latitude),
longitude_i = Position.degI(currentPosition.longitude),
altitude = currentPosition.altitude,
time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt(),
time = nowSeconds.toInt(),
)
packetHandler?.sendToRadio(
buildMeshPacket(
@ -292,7 +294,7 @@ constructor(
}
fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteStartTimes[requestId] = System.currentTimeMillis()
tracerouteStartTimes[requestId] = nowMillis
packetHandler?.sendToRadio(
buildMeshPacket(
to = destNum,
@ -343,7 +345,7 @@ constructor(
}
fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoStartTimes[requestId] = System.currentTimeMillis()
neighborInfoStartTimes[requestId] = nowMillis
val myNum = nodeManager?.myNodeNum ?: 0
if (destNum == myNum) {
val neighborInfoToSend =
@ -360,7 +362,7 @@ constructor(
Neighbor(
node_id = 0, // Dummy node ID that can be intercepted
snr = 0f,
last_rx_time = (System.currentTimeMillis() / TIME_MS_TO_S).toInt(),
last_rx_time = nowSeconds.toInt(),
node_broadcast_interval_secs = oneHour,
),
),
@ -471,7 +473,6 @@ constructor(
companion object {
private const val PACKET_ID_MASK = 0xffffffffL
private const val PACKET_ID_SHIFT_BITS = 32
private const val TIME_MS_TO_S = 1000L
private const val ADMIN_CHANNEL_NAME = "admin"
private const val NODE_ID_PREFIX = "!"

View file

@ -34,6 +34,8 @@ import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
@ -48,7 +50,9 @@ import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
@ -140,7 +144,7 @@ constructor(
}
serviceBroadcasts.broadcastConnection()
Logger.d { "Starting connect" }
connectTimeMsec = System.currentTimeMillis()
connectTimeMsec = nowMillis
scope.handledLaunch { nodeRepository.clearMyNodeInfo() }
startConfigOnly()
}
@ -152,12 +156,12 @@ constructor(
mqttManager.stop()
if (connectTimeMsec != 0L) {
val now = System.currentTimeMillis()
val now = nowMillis
val duration = now - connectTimeMsec
connectTimeMsec = 0L
analytics.track(
EVENT_CONNECTED_SECONDS,
DataPair(EVENT_CONNECTED_SECONDS, duration / MILLISECONDS_IN_SECOND),
DataPair(EVENT_CONNECTED_SECONDS, duration.milliseconds.toDouble(DurationUnit.SECONDS)),
)
}
@ -207,9 +211,7 @@ constructor(
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set time
commandSender.sendAdmin(myNodeNum) {
AdminMessage(set_time_only = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt())
}
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
}
fun onNodeDbReady() {
@ -268,7 +270,6 @@ constructor(
companion object {
private const val CONFIG_ONLY_NONCE = 69420
private const val NODE_INFO_NONCE = 69421
private const val MILLISECONDS_IN_SECOND = 1000.0
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"

View file

@ -39,6 +39,8 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.service.MeshServiceNotifications
@ -325,7 +327,7 @@ constructor(
val payload = packet.decoded?.payload ?: return
val u = Waypoint.ADAPTER.decode(payload)
if (u.locked_to != 0 && u.locked_to != packet.from) return
val currentSecond = (System.currentTimeMillis() / MILLISECONDS_IN_SECOND).toInt()
val currentSecond = nowSeconds.toInt()
rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond)
}
@ -443,7 +445,7 @@ constructor(
isRemote -> shouldDisplay = true
}
if (shouldDisplay) {
val now = System.currentTimeMillis() / MILLISECONDS_IN_SECOND
val now = nowSeconds
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
batteryPercentCooldowns[fromNum] = now
@ -461,7 +463,7 @@ constructor(
}
handleAckNak(
packet.decoded?.request_id ?: 0,
dataMapper.toNodeID(packet.from),
nodeManager.toNodeID(packet.from),
r.error_reason?.value ?: 0,
dataPacket.relayNode,
)
@ -599,7 +601,7 @@ constructor(
packetId = dataPacket.id,
port_num = dataPacket.dataType,
contact_key = contactKey,
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = fromLocal || isFiltered,
data = dataPacket,
snr = dataPacket.snr,
@ -693,8 +695,8 @@ constructor(
private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
val decoded = packet.decoded ?: return@handledLaunch
val emoji = decoded.payload.toByteArray().decodeToString()
val fromId = dataMapper.toNodeID(packet.from)
val toId = dataMapper.toNodeID(packet.to)
val fromId = nodeManager.toNodeID(packet.from)
val toId = nodeManager.toNodeID(packet.to)
val reaction =
ReactionEntity(
@ -702,7 +704,7 @@ constructor(
replyId = decoded.reply_id,
userId = fromId,
emoji = emoji,
timestamp = System.currentTimeMillis(),
timestamp = nowMillis,
snr = packet.rx_snr,
rssi = packet.rx_rssi,
hopsAway =
@ -790,7 +792,6 @@ constructor(
}
companion object {
private const val MILLISECONDS_IN_SECOND = 1000L
private const val HOPS_AWAY_UNAVAILABLE = -1
private const val BATTERY_PERCENT_UNSUPPORTED = 0.0

View file

@ -30,6 +30,8 @@ import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
@ -41,7 +43,6 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@Suppress("TooManyFunctions")
@Singleton
@ -126,7 +127,7 @@ constructor(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = type,
received_date = System.currentTimeMillis(),
received_date = nowMillis,
raw_message = message,
fromRadio = proto,
),
@ -136,7 +137,7 @@ constructor(
fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
val rxTime =
if (packet.rx_time == 0) {
(System.currentTimeMillis().milliseconds.inWholeSeconds).toInt()
nowSeconds.toInt()
} else {
packet.rx_time
}
@ -186,7 +187,7 @@ constructor(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "Packet",
received_date = System.currentTimeMillis(),
received_date = nowMillis,
raw_message = packet.toString(),
fromNum = packet.from,
portNum = decoded.portnum.value,
@ -201,9 +202,7 @@ constructor(
myNodeNum?.let { myNum ->
val from = packet.from
val isOtherNode = myNum != from
nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) {
it.lastHeard = (System.currentTimeMillis().milliseconds.inWholeSeconds).toInt()
}
nodeManager.updateNodeInfo(myNum, withBroadcast = isOtherNode) { it.lastHeard = nowSeconds.toInt() }
nodeManager.updateNodeInfo(from, withBroadcast = false, channel = packet.channel) {
it.lastHeard = packet.rx_time
it.viaMqtt = packet.via_mqtt == true

View file

@ -21,6 +21,7 @@ import com.meshtastic.core.strings.getString
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.unknown_username
@ -74,7 +75,7 @@ constructor(
val responseText =
if (start != null) {
val elapsedMs = System.currentTimeMillis() - start
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Neighbor info $requestId complete in $seconds s" }
String.format(Locale.US, "%s\n\nDuration: %.1f s", formatted, seconds)

View file

@ -32,6 +32,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
@ -186,12 +187,7 @@ constructor(
}
}
fun handleReceivedPosition(
fromNum: Int,
myNodeNum: Int,
p: ProtoPosition,
defaultTime: Long = System.currentTimeMillis(),
) {
fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long = nowMillis) {
if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) {
Logger.d { "Ignoring nop position update for the local node" }
} else {

View file

@ -52,6 +52,7 @@ import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.SERVICE_NOTIFY_ID
@ -78,6 +79,8 @@ import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.minutes
/**
* Manages the creation and display of all app notifications.
@ -86,6 +89,7 @@ import javax.inject.Inject
* notifications for various events like new messages, alerts, and service status changes.
*/
@Suppress("TooManyFunctions", "LongParameterList")
@Singleton
class MeshServiceNotificationsImpl
@Inject
constructor(
@ -97,7 +101,6 @@ constructor(
private val notificationManager = context.getSystemService<NotificationManager>()!!
companion object {
private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000
const val MAX_BATTERY_LEVEL = 100
private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
private const val MAX_HISTORY_MESSAGES = 10
@ -107,6 +110,8 @@ constructor(
private const val SUMMARY_ID = 1
private const val PERSON_ICON_SIZE = 128
private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
private const val STATS_UPDATE_MINUTES = 15
private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES.minutes
}
/**
@ -279,7 +284,7 @@ constructor(
hasLocalStats -> {
val localStatsMessage = telemetry?.local_stats?.formatToString()
cachedTelemetry = telemetry
nextStatsUpdateMillis = System.currentTimeMillis() + FIFTEEN_MINUTES_IN_MILLIS
nextStatsUpdateMillis = nowMillis + STATS_UPDATE_INTERVAL.inWholeMilliseconds
localStatsMessage
}
cachedTelemetry == null && hasDeviceMetrics -> {
@ -287,7 +292,7 @@ constructor(
if (cachedLocalStats == null) {
cachedTelemetry = telemetry
}
nextStatsUpdateMillis = System.currentTimeMillis()
nextStatsUpdateMillis = nowMillis
deviceMetricsMessage
}
else -> null
@ -471,7 +476,7 @@ constructor(
}
nextUpdateAt
?.takeIf { it > System.currentTimeMillis() }
?.takeIf { it > nowMillis }
?.let {
builder.setWhen(it)
builder.setUsesChronometer(true)
@ -579,7 +584,7 @@ constructor(
isSilent: Boolean,
): Notification {
val person = Person.Builder().setName(name).build()
val style = NotificationCompat.MessagingStyle(person).addMessage(message, System.currentTimeMillis(), person)
val style = NotificationCompat.MessagingStyle(person).addMessage(message, nowMillis, person)
val builder =
commonBuilder(NotificationType.Waypoint, createOpenWaypointIntent(waypointId))
@ -588,7 +593,7 @@ constructor(
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(System.currentTimeMillis())
.setWhen(nowMillis)
.setShowWhen(true)
if (isSilent) {
@ -600,7 +605,7 @@ constructor(
private fun createAlertNotification(contactKey: String, name: String, alert: String): Notification {
val person = Person.Builder().setName(name).build()
val style = NotificationCompat.MessagingStyle(person).addMessage(alert, System.currentTimeMillis(), person)
val style = NotificationCompat.MessagingStyle(person).addMessage(alert, nowMillis, person)
return commonBuilder(NotificationType.Alert, createOpenMessageIntent(contactKey))
.setPriority(NotificationCompat.PRIORITY_HIGH)
@ -617,7 +622,7 @@ constructor(
.setCategory(Notification.CATEGORY_STATUS)
.setAutoCancel(true)
.setContentTitle(title)
.setWhen(System.currentTimeMillis())
.setWhen(nowMillis)
.setShowWhen(true)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
@ -639,7 +644,7 @@ constructor(
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setWhen(System.currentTimeMillis())
.setWhen(nowMillis)
.setShowWhen(true)
.build()
}
@ -738,7 +743,7 @@ constructor(
Intent(context, ReactionReceiver::class.java).apply {
action = REACT_ACTION
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
putExtra(ReactionReceiver.EXTRA_PACKET_ID, packetId)
putExtra(ReactionReceiver.EXTRA_REPLY_ID, packetId)
putExtra(ReactionReceiver.EXTRA_TO_ID, toId)
putExtra(ReactionReceiver.EXTRA_CHANNEL_INDEX, channelIndex)
putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")

View file

@ -26,6 +26,7 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.service.TracerouteResponse
import org.meshtastic.core.strings.Res
@ -83,8 +84,8 @@ constructor(
val start = commandSender.tracerouteStartTimes.remove(requestId)
val responseText =
if (start != null) {
val elapsedMs = System.currentTimeMillis() - start
val seconds = elapsedMs / MILLISECONDS_IN_SECOND
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" }
val durationText = getString(Res.string.traceroute_duration, "%.1f".format(Locale.US, seconds))
"$full\n\n$durationText"
@ -108,6 +109,6 @@ constructor(
}
companion object {
private const val MILLISECONDS_IN_SECOND = 1000.0
private const val MILLIS_PER_SECOND = 1000.0
}
}

View file

@ -33,6 +33,7 @@ import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.service.ConnectionState
@ -45,6 +46,8 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Suppress("TooManyFunctions")
@Singleton
@ -59,7 +62,7 @@ constructor(
) {
companion object {
private const val TIMEOUT_MS = 5000L // Increased from 250ms to be more tolerant
private val TIMEOUT = 5.seconds // Increased from 250ms to be more tolerant
}
private var queueJob: Job? = null
@ -89,7 +92,7 @@ constructor(
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "Packet",
received_date = System.currentTimeMillis(),
received_date = nowMillis,
raw_message = packet.toString(),
fromNum = packet.from ?: 0,
portNum = packet.decoded?.portnum?.value ?: 0,
@ -148,7 +151,7 @@ constructor(
// send packet to the radio and wait for response
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT_MS) { response.await() }
val success = withTimeout(TIMEOUT) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
} catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
@ -173,11 +176,11 @@ constructor(
}
@Suppress("MagicNumber")
private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1000) {
private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1.seconds) {
var dataPacket: DataPacket? = null
while (dataPacket == null) {
dataPacket = packetRepository.get().getPacketById(packetId)?.data
if (dataPacket == null) delay(100)
if (dataPacket == null) delay(100.milliseconds)
}
dataPacket
}

View file

@ -19,91 +19,48 @@ package com.geeksville.mesh.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import co.touchlab.kermit.Logger
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.PortNum
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import javax.inject.Inject
@AndroidEntryPoint
class ReactionReceiver : BroadcastReceiver() {
@Inject lateinit var commandSender: MeshCommandSender
@Inject lateinit var meshServiceNotifications: MeshServiceNotifications
@Inject lateinit var serviceRepository: ServiceRepository
@Inject lateinit var packetRepository: PacketRepository
@Inject lateinit var nodeManager: MeshNodeManager
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
const val REACT_ACTION = "com.geeksville.mesh.REACT_ACTION"
const val EXTRA_PACKET_ID = "packetId"
const val EXTRA_EMOJI = "emoji"
const val EXTRA_CONTACT_KEY = "contactKey"
const val EXTRA_TO_ID = "toId"
const val EXTRA_CHANNEL_INDEX = "channelIndex"
private const val EMOJI_INDICATOR = 1
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Suppress("TooGenericExceptionCaught", "ReturnCount")
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != REACT_ACTION) return
val pendingResult = goAsync()
val contactKey = intent.getStringExtra(EXTRA_CONTACT_KEY) ?: return
val reaction = intent.getStringExtra(EXTRA_EMOJI) ?: intent.getStringExtra(EXTRA_REACTION) ?: return
val replyId = intent.getIntExtra(EXTRA_REPLY_ID, intent.getIntExtra(EXTRA_PACKET_ID, 0))
scope.launch {
try {
val packetId = intent.getIntExtra(EXTRA_PACKET_ID, 0)
val emoji = intent.getStringExtra(EXTRA_EMOJI)
val toId = intent.getStringExtra(EXTRA_TO_ID)
val channelIndex = intent.getIntExtra(EXTRA_CHANNEL_INDEX, 0)
val contactKey = intent.getStringExtra(EXTRA_CONTACT_KEY)
@Suppress("ComplexCondition")
if (packetId == 0 || emoji.isNullOrEmpty() || toId.isNullOrEmpty() || contactKey.isNullOrEmpty()) {
return@launch
}
// Reactions are text messages with a replyId and emoji set
val reactionPacket =
DataPacket(
to = toId,
channel = channelIndex,
bytes = emoji.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
replyId = packetId,
wantAck = true,
emoji = EMOJI_INDICATOR,
)
commandSender.sendData(reactionPacket)
val reaction =
ReactionEntity(
myNodeNum = nodeManager.myNodeNum ?: 0,
replyId = packetId,
userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL,
emoji = emoji,
timestamp = System.currentTimeMillis(),
packetId = reactionPacket.id,
status = MessageStatus.QUEUED,
to = toId,
channel = channelIndex,
)
packetRepository.insertReaction(reaction)
// Dismiss the notification after reacting
meshServiceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
serviceRepository.onServiceAction(ServiceAction.Reaction(reaction, replyId, contactKey))
} catch (e: Exception) {
Logger.e(e) { "Error sending reaction" }
}
}
}
companion object {
const val REACT_ACTION = "com.geeksville.mesh.REACT_ACTION"
const val EXTRA_CONTACT_KEY = "extra_contact_key"
const val EXTRA_REACTION = "extra_reaction"
const val EXTRA_REPLY_ID = "extra_reply_id"
const val EXTRA_PACKET_ID = "extra_packet_id"
const val EXTRA_TO_ID = "extra_to_id"
const val EXTRA_CHANNEL_INDEX = "extra_channel_index"
const val EXTRA_EMOJI = "extra_emoji"
}
}

View file

@ -40,6 +40,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -66,8 +67,10 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
import org.meshtastic.core.strings.cancel
@ -104,7 +107,7 @@ import org.meshtastic.core.ui.icon.VolumeUpTwoTone
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.proto.ChannelSet
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.days
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -172,7 +175,7 @@ fun ContactsScreen(
.filter { it.contactKey in selectedContactKeys }
}
// Get message count directly from repository for selected contacts
var selectedCount by remember { mutableStateOf(0) }
var selectedCount by remember { mutableIntStateOf(0) }
LaunchedEffect(selectedContactKeys.size, selectedContactKeys.joinToString(",")) {
selectedCount = viewModel.getTotalMessageCount(selectedContactKeys.toList())
}
@ -323,14 +326,14 @@ private fun MuteNotificationsDialog(
val muteOptions = remember {
listOf(
Res.string.unmute to 0L,
Res.string.mute_8_hours to TimeUnit.HOURS.toMillis(8),
Res.string.mute_1_week to TimeUnit.DAYS.toMillis(7),
Res.string.mute_8_hours to TimeConstants.EIGHT_HOURS.inWholeMilliseconds,
Res.string.mute_1_week to 7.days.inWholeMilliseconds,
Res.string.mute_always to Long.MAX_VALUE,
)
}
// State to hold the selected mute duration index
var selectedOptionIndex by remember { mutableStateOf(2) } // Default to "Always"
var selectedOptionIndex by remember { mutableIntStateOf(2) } // Default to "Always"
MeshtasticDialog(
onDismiss = onDismiss, // Dismiss the dialog when clicked outside
@ -347,7 +350,7 @@ private fun MuteNotificationsDialog(
// Show current mute status
selectedContactKeys.forEach { contactKey ->
contactSettings[contactKey]?.let { settings ->
val now = System.currentTimeMillis()
val now = nowMillis
val statusText =
when {
settings.muteUntil > 0 && settings.muteUntil != Long.MAX_VALUE -> {

View file

@ -105,6 +105,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
freeCompilerArgs.addAll(
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-Xcontext-parameters",
"-Xannotation-default-target=param-property"
)

View file

@ -26,8 +26,9 @@ import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@ -210,7 +211,7 @@ constructor(
* automatically healed from newer JSON snapshots even if their timestamp is recent.
*/
private fun DeviceHardwareEntity.isStale(): Boolean =
isIncomplete() || (System.currentTimeMillis() - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
isIncomplete() || (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
private fun loadQuirks(): List<BootloaderOtaQuirk> {
val quirks = bootloaderOtaQuirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset()
@ -252,6 +253,6 @@ constructor(
}
companion object {
private val CACHE_EXPIRATION_TIME_MS = TimeUnit.DAYS.toMillis(1)
private val CACHE_EXPIRATION_TIME_MS = TimeConstants.ONE_DAY.inWholeMilliseconds
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import co.touchlab.kermit.Logger
@ -26,8 +25,9 @@ import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@ -124,10 +124,9 @@ constructor(
}
/** Extension function to check if the cached entity is stale. */
private fun FirmwareReleaseEntity.isStale(): Boolean =
(System.currentTimeMillis() - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
private fun FirmwareReleaseEntity.isStale(): Boolean = (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
companion object {
private val CACHE_EXPIRATION_TIME_MS = TimeUnit.HOURS.toMillis(1)
private val CACHE_EXPIRATION_TIME_MS = TimeConstants.ONE_HOUR.inWholeMilliseconds
}
}

View file

@ -26,6 +26,8 @@ import kotlinx.coroutines.withContext
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
@ -126,9 +128,9 @@ constructor(
val cutoffTimestamp =
if (retentionDays == MeshLogPrefs.ONE_HOUR_RETENTION_DAYS) {
System.currentTimeMillis() - (60 * 60 * 1000L)
nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds
} else {
System.currentTimeMillis() - (retentionDays * 24 * 60 * 60 * 1000L)
nowMillis - (retentionDays * TimeConstants.ONE_DAY.inWholeMilliseconds)
}
dbManager.currentDb.value.meshLogDao().deleteOlderThan(cutoffTimestamp)
}

View file

@ -28,6 +28,7 @@ import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
class DeviceHardwareRepositoryTest {
@ -120,6 +121,6 @@ class DeviceHardwareRepositoryTest {
requiresDfu = false,
supportLevel = 0,
tags = emptyList(),
lastUpdated = System.currentTimeMillis(),
lastUpdated = nowMillis,
)
}

View file

@ -30,6 +30,7 @@ import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.proto.Data
import org.meshtastic.proto.EnvironmentMetrics
@ -67,7 +68,7 @@ class MeshLogRepositoryTest {
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "telemetry",
received_date = System.currentTimeMillis(),
received_date = nowMillis,
raw_message = "",
fromRadio = FromRadio(packet = meshPacket),
)
@ -94,7 +95,7 @@ class MeshLogRepositoryTest {
MeshLog(
uuid = UUID.randomUUID().toString(),
message_type = "telemetry",
received_date = System.currentTimeMillis(),
received_date = nowMillis,
raw_message = "",
fromRadio = FromRadio(packet = meshPacket),
)

View file

@ -37,6 +37,7 @@ import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.PortNum
@RunWith(AndroidJUnit4::class)
@ -71,7 +72,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"),
)
@ -153,7 +154,7 @@ class PacketDaoTest {
@Test
fun test_clearUnreadCount() = runBlocking {
val timestamp = System.currentTimeMillis()
val timestamp = nowMillis
testContactKeys.forEach { contactKey ->
packetDao.clearUnreadCount(contactKey, timestamp)
val unreadCount = packetDao.getUnreadCount(contactKey)
@ -180,7 +181,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test").copy(id = packetId),
packetId = packetId,
@ -203,7 +204,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hashByteString,
@ -227,7 +228,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hashByteString,
@ -262,7 +263,7 @@ class PacketDaoTest {
replyId = 123,
userId = "sender",
emoji = "👍",
timestamp = System.currentTimeMillis(),
timestamp = nowMillis,
sfpp_hash = hashByteString,
)
@ -290,7 +291,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = true,
data = DataPacket(to = "target", channel = 0, text = "Hello").copy(id = initialId),
packetId = initialId,
@ -322,7 +323,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = true,
data = DataPacket(to = toId, channel = 0, text = "Match me").copy(from = fromId, id = packetId),
packetId = packetId,
@ -356,7 +357,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = filteredContactKey,
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered message"),
filtered = true,
@ -380,7 +381,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + i,
received_time = nowMillis + i,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered $i"),
filtered = true,
@ -428,7 +429,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + index,
received_time = nowMillis + index,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, text),
filtered = false,
@ -443,7 +444,7 @@ class PacketDaoTest {
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = System.currentTimeMillis() + normalMessages.size + index,
received_time = nowMillis + normalMessages.size + index,
read = true, // Filtered messages are marked as read
data = DataPacket(DataPacket.ID_BROADCAST, 0, text),
filtered = true,

View file

@ -34,6 +34,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.nowMillis
import java.io.File
import java.security.MessageDigest
import javax.inject.Inject
@ -115,7 +116,7 @@ class DatabaseManager @Inject constructor(private val app: Application, private
inline fun <T> withDb(block: (MeshtasticDatabase) -> T): T = block(currentDb.value)
private fun markLastUsed(dbName: String) {
prefs.edit().putLong(lastUsedKey(dbName), System.currentTimeMillis()).apply()
prefs.edit().putLong(lastUsedKey(dbName), nowMillis).apply()
}
private fun lastUsed(dbName: String): Long {

View file

@ -31,6 +31,7 @@ import org.meshtastic.core.database.entity.PacketEntity
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.ChannelSettings
@Suppress("TooManyFunctions")
@ -347,7 +348,7 @@ interface PacketDao {
} else if (until == 0L) { // unmute
0L
} else {
System.currentTimeMillis() + until
nowMillis + until
}
getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil)

View file

@ -22,6 +22,7 @@ import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.util.nowMillis
@Serializable
@Entity(tableName = "device_hardware")
@ -34,7 +35,7 @@ data class DeviceHardwareEntity(
val hwModel: Int,
@ColumnInfo(name = "hw_model_slug") val hwModelSlug: String,
val images: List<String>?,
@ColumnInfo(name = "last_updated") val lastUpdated: Long = System.currentTimeMillis(),
@ColumnInfo(name = "last_updated") val lastUpdated: Long = nowMillis,
@ColumnInfo(name = "partition_scheme") val partitionScheme: String? = null,
@PrimaryKey @ColumnInfo(name = "platformio_target") val platformioTarget: String,
@ColumnInfo(name = "requires_dfu") val requiresDfu: Boolean?,
@ -51,7 +52,7 @@ fun NetworkDeviceHardware.asEntity() = DeviceHardwareEntity(
hwModel = hwModel,
hwModelSlug = hwModelSlug,
images = images,
lastUpdated = System.currentTimeMillis(),
lastUpdated = nowMillis,
partitionScheme = partitionScheme,
platformioTarget = platformioTarget,
requiresDfu = requiresDfu,

View file

@ -22,6 +22,7 @@ import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.model.NetworkFirmwareRelease
import org.meshtastic.core.model.util.nowMillis
@Serializable
@Entity(tableName = "firmware_release")
@ -31,7 +32,7 @@ data class FirmwareReleaseEntity(
@ColumnInfo(name = "release_notes") val releaseNotes: String = "",
@ColumnInfo(name = "title") val title: String = "",
@ColumnInfo(name = "zip_url") val zipUrl: String = "",
@ColumnInfo(name = "last_updated") val lastUpdated: Long = System.currentTimeMillis(),
@ColumnInfo(name = "last_updated") val lastUpdated: Long = nowMillis,
@ColumnInfo(name = "release_type") val releaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE,
)
@ -41,7 +42,7 @@ fun NetworkFirmwareRelease.asEntity(releaseType: FirmwareReleaseType) = Firmware
releaseNotes = releaseNotes,
title = title,
zipUrl = zipUrl,
lastUpdated = System.currentTimeMillis(),
lastUpdated = nowMillis,
releaseType = releaseType,
)
@ -61,7 +62,7 @@ data class FirmwareRelease(
val releaseNotes: String = "",
val title: String = "",
val zipUrl: String = "",
val lastUpdated: Long = System.currentTimeMillis(),
val lastUpdated: Long = nowMillis,
val releaseType: FirmwareReleaseType = FirmwareReleaseType.STABLE,
)

View file

@ -30,6 +30,8 @@ import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
@ -100,7 +102,7 @@ data class NodeWithRelations(
data class MetadataEntity(
@PrimaryKey val num: Int,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: DeviceMetadata,
val timestamp: Long = System.currentTimeMillis(),
val timestamp: Long = nowMillis,
)
@Suppress("MagicNumber")
@ -181,7 +183,7 @@ data class NodeEntity(
val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString()
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
fun currentTime() = nowSeconds.toInt()
}
fun toModel() = Node(

View file

@ -28,6 +28,7 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.getShortDateTime
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.User
data class PacketEntity(
@ -126,7 +127,7 @@ data class ContactSettings(
@ColumnInfo(name = "filtering_disabled", defaultValue = "0") val filteringDisabled: Boolean = false,
) {
val isMuted
get() = System.currentTimeMillis() <= muteUntil
get() = nowMillis <= muteUntil
}
data class Reaction(

View file

@ -31,6 +31,7 @@ import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
@ -155,7 +156,7 @@ class MigrationTest {
myNodeNum = 42424242,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "$channel!broadcast",
received_time = System.currentTimeMillis(),
received_time = nowMillis,
read = false,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = channel, text = text),
),

View file

@ -58,6 +58,7 @@ dependencies {
api(libs.androidx.annotation)
api(libs.kotlinx.serialization.json)
api(libs.kotlinx.datetime)
implementation(libs.kermit)
implementation(libs.zxing.core)

View file

@ -27,6 +27,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Waypoint
@ -54,7 +55,7 @@ data class DataPacket(
// A port number for this packet
var dataType: Int,
var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost
var time: Long = System.currentTimeMillis(), // msecs since 1970
var time: Long = nowMillis, // msecs since 1970
var id: Int = 0, // 0 means unassigned
var status: MessageStatus? = MessageStatus.UNKNOWN,
var hopLimit: Int = 0,
@ -128,7 +129,7 @@ data class DataPacket(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
channel = channel,
replyId = replyId ?: 0,
replyId = replyId,
)
/** If this is a text message, return the string, otherwise null */

View file

@ -22,6 +22,7 @@ import kotlinx.parcelize.Parcelize
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.model.util.bearing
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
@ -84,7 +85,7 @@ data class Position(
fun degI(d: Double) = (d * 1e7).toInt()
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
fun currentTime() = nowSeconds.toInt()
}
/**
@ -134,7 +135,7 @@ data class DeviceMetrics(
) : Parcelable {
companion object {
@Suppress("MagicNumber")
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
fun currentTime() = nowSeconds.toInt()
}
/** Create our model object from a protobuf. */
@ -168,7 +169,7 @@ data class EnvironmentMetrics(
) : Parcelable {
@Suppress("MagicNumber")
companion object {
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
fun currentTime() = nowSeconds.toInt()
fun fromTelemetryProto(proto: org.meshtastic.proto.EnvironmentMetrics, time: Int): EnvironmentMetrics =
EnvironmentMetrics(

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,60 +14,89 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY
import java.text.DateFormat
import java.util.Date
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
private const val ONLINE_WINDOW_HOURS = 2
private val ONLINE_WINDOW_HOURS = 2.hours
private val DAY_DURATION = 24.hours
// return time if within 24 hours, otherwise date
/**
* Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string
* representing the date.
*
* @param time The time in milliseconds
* @return Formatted date or time string, or null if time is 0
*/
fun getShortDate(time: Long): String? {
val date = if (time != 0L) Date(time) else return null
val isWithin24Hours = System.currentTimeMillis() - date.time <= TimeUnit.DAYS.toMillis(1)
if (time == 0L) return null
val instant = time.toInstant()
val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
return if (isWithin24Hours) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
} else {
DateFormat.getDateInstance(DateFormat.SHORT).format(date)
DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate())
}
}
// return time if within 24 hours, otherwise date/time
/**
* Returns a short string representing the time if it's within the last 24 hours, otherwise returns a combined short
* date/time string.
*
* @param time The time in milliseconds
* @return Formatted date/time string
*/
fun getShortDateTime(time: Long): String {
val date = Date(time)
val isWithin24Hours = System.currentTimeMillis() - date.time <= TimeUnit.DAYS.toMillis(1)
val instant = time.toInstant()
val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
return if (isWithin24Hours) {
DateFormat.getTimeInstance(DateFormat.SHORT).format(date)
DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
} else {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date)
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(instant.toDate())
}
}
/**
* Formats a duration in seconds as a human-readable uptime string (e.g., "1d 2h 3m 4s").
*
* @param seconds The duration in seconds.
* @return A formatted uptime string.
*/
fun formatUptime(seconds: Int): String = formatUptime(seconds.toLong())
/**
* Formats a duration in seconds as a human-readable uptime string (e.g., "1d 2h 3m 4s").
*
* @param seconds The duration in seconds.
* @return A formatted uptime string.
*/
private fun formatUptime(seconds: Long): String {
val days = TimeUnit.SECONDS.toDays(seconds)
val hours = TimeUnit.SECONDS.toHours(seconds) % TimeUnit.DAYS.toHours(1)
val minutes = TimeUnit.SECONDS.toMinutes(seconds) % TimeUnit.HOURS.toMinutes(1)
val secs = seconds % TimeUnit.MINUTES.toSeconds(1)
return listOfNotNull(
"${days}d".takeIf { days > 0 },
"${hours}h".takeIf { hours > 0 },
"${minutes}m".takeIf { minutes > 0 },
"${secs}s".takeIf { secs > 0 },
)
.joinToString(" ")
if (seconds == 0L) return "0s"
return seconds.seconds.toComponents { days, hours, minutes, secs, _ ->
listOfNotNull(
"${days}d".takeIf { days > 0 },
"${hours}h".takeIf { hours > 0 },
"${minutes}m".takeIf { minutes > 0 },
"${secs}s".takeIf { secs > 0 },
)
.joinToString(" ")
}
}
fun onlineTimeThreshold(): Int {
val currentSeconds = System.currentTimeMillis() / TimeUnit.SECONDS.toMillis(1)
return (currentSeconds - TimeUnit.HOURS.toSeconds(ONLINE_WINDOW_HOURS.toLong())).toInt()
}
/**
* Calculates the threshold in seconds for considering a node "online".
*
* @return The epoch seconds threshold.
*/
fun onlineTimeThreshold(): Int = (nowInstant - ONLINE_WINDOW_HOURS).epochSeconds.toInt()
/**
* Calculates the remaining mute time in days and hours.
@ -76,9 +105,8 @@ fun onlineTimeThreshold(): Int {
* @return Pair of (days, hours), where days is Int and hours is Double
*/
fun formatMuteRemainingTime(remainingMillis: Long): Pair<Int, Double> {
if (remainingMillis <= 0) return Pair(0, 0.0)
val totalHours = remainingMillis.toDouble() / TimeUnit.HOURS.toMillis(1)
val days = (totalHours / TimeUnit.DAYS.toHours(1)).toInt()
val hours = totalHours % TimeUnit.DAYS.toHours(1)
return Pair(days, hours)
val duration = remainingMillis.milliseconds
if (duration <= Duration.ZERO) return 0 to 0.0
val totalHours = duration.toDouble(DurationUnit.HOURS)
return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY)
}

View file

@ -0,0 +1,163 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import android.os.Build
import androidx.annotation.RequiresApi
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaZoneId
import kotlinx.datetime.toLocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.zone.ZoneOffsetTransitionRule
import java.util.Locale
import kotlin.math.abs
/** Generates a POSIX time zone string from a [TimeZone]. */
@RequiresApi(Build.VERSION_CODES.O)
fun TimeZone.toPosixString(): String = this.toJavaZoneId().toPosixString()
/**
* Generates a POSIX time zone string from a [ZoneId]. Uses the specification found
* [here](https://www.postgresql.org/docs/current/datetime-posix-timezone-specs.html).
*/
@RequiresApi(Build.VERSION_CODES.O)
@Suppress("ReturnCount", "MagicNumber")
fun ZoneId.toPosixString(): String {
val rules = this.rules
if (rules.isFixedOffset || rules.transitionRules.isEmpty()) {
val now = java.time.Instant.ofEpochMilli(nowMillis)
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
val springRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds }
val fallRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds }
if (springRule == null || fallRule == null) {
val now = java.time.Instant.ofEpochMilli(nowMillis)
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
return buildString {
val stdAbbrev = getTransitionAbbreviation(this@toPosixString, fallRule)
val dstAbbrev = getTransitionAbbreviation(this@toPosixString, springRule)
append(formatAbbreviation(stdAbbrev))
append(formatPosixOffset(springRule.offsetBefore))
append(formatAbbreviation(dstAbbrev))
if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) {
append(formatPosixOffset(springRule.offsetAfter))
}
append(formatTransitionRule(springRule))
append(formatTransitionRule(fallRule))
}
}
/** Formats the time zone short name for a [ZonedDateTime]. */
@RequiresApi(Build.VERSION_CODES.O)
internal fun ZonedDateTime.timeZoneShortName(): String {
val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH)
val shortName = format(formatter)
return if (shortName.startsWith("GMT")) "GMT" else shortName
}
/** Formats an abbreviation for POSIX. If it contains non-letters, it's wrapped in angle brackets. */
private fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
/** Gets the abbreviation for a given zone and transition rule. */
@RequiresApi(Build.VERSION_CODES.O)
internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
val year = nowInstant.toLocalDateTime(systemTimeZone).year
val transition = rule.createTransition(year)
return ZonedDateTime.ofInstant(transition.instant, zone).timeZoneShortName()
}
/** Formats a [ZoneOffset] for use in a POSIX string. */
@RequiresApi(Build.VERSION_CODES.O)
@Suppress("MagicNumber")
internal fun formatPosixOffset(offset: ZoneOffset): String {
val offsetSeconds = -offset.totalSeconds
val hours = offsetSeconds / 3600
val remainingSeconds = abs(offsetSeconds) % 3600
val minutes = remainingSeconds / 60
val seconds = remainingSeconds % 60
return buildString {
if (offsetSeconds < 0 && hours == 0) append("-")
append(hours)
if (minutes != 0 || seconds != 0) {
append(":%02d".format(Locale.ENGLISH, minutes))
if (seconds != 0) {
append(":%02d".format(Locale.ENGLISH, seconds))
}
}
}
}
/** Formats a [ZoneOffsetTransitionRule] for use in a POSIX string. */
@RequiresApi(Build.VERSION_CODES.O)
@Suppress("MagicNumber")
internal fun formatTransitionRule(rule: ZoneOffsetTransitionRule): String {
val month = rule.month.value
val dayOfWeek = rule.dayOfWeek.value % 7
val dayIndicator = rule.dayOfMonthIndicator
val occurrence =
when {
dayIndicator < 0 -> 5
dayIndicator > rule.month.length(false) - 7 -> 5
else -> ((dayIndicator - 1) / 7) + 1
}
val wallTime =
when (rule.timeDefinition) {
ZoneOffsetTransitionRule.TimeDefinition.UTC ->
rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong())
ZoneOffsetTransitionRule.TimeDefinition.STANDARD -> {
if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) {
rule.localTime
} else {
rule.localTime.plusSeconds(
(rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong(),
)
}
}
else -> rule.localTime
}
return buildString {
append(",M$month.$occurrence.$dayOfWeek")
if (wallTime.hour != 2 || wallTime.minute != 0 || wallTime.second != 0) {
append("/${wallTime.hour}")
if (wallTime.minute != 0 || wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.minute))
if (wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.second))
}
}
}
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
/** Common time-related constants. */
object TimeConstants {
val ONE_HOUR = 1.hours
val EIGHT_HOURS = 8.hours
val ONE_DAY = 1.days
val TWO_DAYS = 2.days
const val HOURS_PER_DAY = 24
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import kotlinx.datetime.TimeZone
import java.util.Date
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Instant
/**
* Awaits the latch for the given [Duration].
*
* @param timeout The maximum time to wait.
* @return `true` if the count reached zero and `false` if the waiting time elapsed before the count reached zero.
*/
fun CountDownLatch.await(timeout: Duration): Boolean = this.await(timeout.inWholeMilliseconds, TimeUnit.MILLISECONDS)
/** Accessor for the current time in milliseconds. */
val nowMillis: Long
get() = nowInstant.toEpochMilliseconds()
/** Accessor for the current time as a stable [Instant]. */
val nowInstant: Instant
get() = Clock.System.now()
/** Accessor for the current time in seconds. */
val nowSeconds: Long
get() = nowInstant.epochSeconds
/** Accessor for the system default time zone. */
val systemTimeZone: TimeZone
get() = TimeZone.currentSystemDefault()
/** Converts this [Instant] to a legacy [Date]. */
fun Instant.toDate(): Date = Date(this.toEpochMilliseconds())
/** Converts these milliseconds to an [Instant]. */
fun Long.toInstant(): Instant = Instant.fromEpochMilliseconds(this)
/** Converts these seconds to an [Instant]. */
fun Int.secondsToInstant(): Instant = Instant.fromEpochSeconds(this.toLong())

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
import kotlinx.datetime.TimeZone
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.CountDownLatch
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
class TimeExtensionsTest {
@Test
fun testNowMillis() {
val start = Clock.System.now().toEpochMilliseconds()
val now = nowMillis
val end = Clock.System.now().toEpochMilliseconds()
assertTrue(now in start..end)
}
@Test
fun testNowSeconds() {
val start = Clock.System.now().epochSeconds
val now = nowSeconds
val end = Clock.System.now().epochSeconds
assertTrue(now in start..end)
}
@Test
fun testToDate() {
val instant = Instant.fromEpochMilliseconds(1234567890L)
val date = instant.toDate()
assertEquals(1234567890L, date.time)
}
@Test
fun testLongToInstant() {
val millis = 1234567890L
val instant = millis.toInstant()
assertEquals(millis, instant.toEpochMilliseconds())
}
@Test
fun testIntSecondsToInstant() {
val seconds = 1234567890
val instant = seconds.secondsToInstant()
assertEquals(seconds.toLong(), instant.epochSeconds)
}
@Test
fun testDurationInWholeSeconds() {
assertEquals(60L, 60.seconds.inWholeSeconds)
assertEquals(3600L, TimeConstants.ONE_HOUR.inWholeSeconds)
}
@Test
fun testLongSecondsProperty() {
assertEquals(60.seconds, 60L.seconds)
}
@Test
fun testCountDownLatchAwaitWithDuration() {
val latch = CountDownLatch(1)
// This should timeout quickly
val result = latch.await(10.milliseconds)
assertEquals(false, result)
val latch2 = CountDownLatch(1)
latch2.countDown()
val result2 = latch2.await(1.seconds)
assertEquals(true, result2)
}
@Test
fun testTimeZoneToPosixString() {
val tz = TimeZone.of("UTC")
assertEquals("UTC0", tz.toPosixString())
}
}

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
@ -50,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

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import android.content.BroadcastReceiver
@ -28,16 +27,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LifecycleResumeEffect
import org.meshtastic.core.model.util.nowMillis
@Composable
fun rememberTimeTickWithLifecycle(): Long {
val context = LocalContext.current
var value by remember { mutableLongStateOf(System.currentTimeMillis()) }
val receiver = TimeBroadcastReceiver { value = System.currentTimeMillis() }
var value by remember { mutableLongStateOf(nowMillis) }
val receiver = TimeBroadcastReceiver { value = nowMillis }
LifecycleResumeEffect(Unit) {
receiver.register(context)
value = System.currentTimeMillis()
value = nowMillis
onPauseOrDispose { receiver.unregister(context) }
}

View file

@ -14,135 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("Wrapping", "UnusedImports", "SpacingAroundColon")
package org.meshtastic.core.ui.timezone
import java.time.Instant
import java.time.Year
import org.meshtastic.core.model.util.toPosixString
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.zone.ZoneOffsetTransitionRule
import java.util.Locale
import kotlin.math.abs
/**
* Generates a POSIX time zone string from a [ZoneId]. Uses the specification found
* [here](https://www.postgresql.org/docs/current/datetime-posix-timezone-specs.html).
* Generates a POSIX time zone string from a [ZoneId].
*
* @deprecated Use [org.meshtastic.core.model.util.toPosixString] instead.
*/
@Suppress("ReturnCount")
fun ZoneId.toPosixString(): String {
val rules = this.rules
if (rules.isFixedOffset || rules.transitionRules.isEmpty()) {
val now = Instant.now()
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
val springRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds }
val fallRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds }
if (springRule == null || fallRule == null) {
val now = Instant.now()
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
return buildString {
val stdAbbrev = getTransitionAbbreviation(this@toPosixString, fallRule)
val dstAbbrev = getTransitionAbbreviation(this@toPosixString, springRule)
append(formatAbbreviation(stdAbbrev))
append(formatPosixOffset(springRule.offsetBefore))
append(formatAbbreviation(dstAbbrev))
@Suppress("MagicNumber")
if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) {
append(formatPosixOffset(springRule.offsetAfter))
}
append(formatTransitionRule(springRule))
append(formatTransitionRule(fallRule))
}
}
internal fun ZonedDateTime.timeZoneShortName(): String {
val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH)
val shortName = format(formatter)
return if (shortName.startsWith("GMT")) "GMT" else shortName
}
fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
val transition = rule.createTransition(Year.now().value)
return ZonedDateTime.ofInstant(transition.instant, zone).timeZoneShortName()
}
@Suppress("MagicNumber")
fun formatPosixOffset(offset: ZoneOffset): String {
val offsetSeconds = -offset.totalSeconds
val hours = offsetSeconds / 3600
val remainingSeconds = abs(offsetSeconds) % 3600
val minutes = remainingSeconds / 60
val seconds = remainingSeconds % 60
return buildString {
if (offsetSeconds < 0 && hours == 0) append("-")
append(hours)
if (minutes != 0 || seconds != 0) {
append(":%02d".format(Locale.ENGLISH, minutes))
if (seconds != 0) {
append(":%02d".format(Locale.ENGLISH, seconds))
}
}
}
}
@Suppress("MagicNumber")
internal fun formatTransitionRule(rule: ZoneOffsetTransitionRule): String {
val month = rule.month.value
val dayOfWeek = rule.dayOfWeek.value % 7
val dayIndicator = rule.dayOfMonthIndicator
val occurrence =
when {
dayIndicator < 0 -> 5
dayIndicator > rule.month.length(false) - 7 -> 5
else -> ((dayIndicator - 1) / 7) + 1
}
val wallTime =
when (rule.timeDefinition) {
ZoneOffsetTransitionRule.TimeDefinition.UTC ->
rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong())
ZoneOffsetTransitionRule.TimeDefinition.STANDARD -> {
if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) {
rule.localTime
} else {
rule.localTime.plusSeconds(
(rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong(),
)
}
}
else -> rule.localTime
}
return buildString {
append(",M$month.$occurrence.$dayOfWeek")
if (wallTime.hour != 2 || wallTime.minute != 0 || wallTime.second != 0) {
append("/${wallTime.hour}")
if (wallTime.minute != 0 || wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.minute))
if (wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.second))
}
}
}
}
}
@Deprecated(
message = "Use org.meshtastic.core.model.util.toPosixString instead",
replaceWith = ReplaceWith("this.toPosixString()", "org.meshtastic.core.model.util.toPosixString"),
)
fun ZoneId.toPosixString(): String = this.toPosixString()

View file

@ -18,10 +18,10 @@ package org.meshtastic.core.ui.util
import android.text.format.DateUtils
import com.meshtastic.core.strings.getString
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.now
import org.meshtastic.core.strings.unknown
import java.lang.System.currentTimeMillis
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@ -40,7 +40,7 @@ fun formatAgo(lastSeenUnixSeconds: Int): String {
if (lastSeenUnixSeconds <= 0) return getString(Res.string.unknown)
val lastSeenDuration = lastSeenUnixSeconds.seconds
val currentDuration = currentTimeMillis().milliseconds
val currentDuration = nowMillis.milliseconds
val diff = (currentDuration - lastSeenDuration).absoluteValue
return if (diff < 1.minutes) {

View file

@ -20,6 +20,7 @@ import android.text.format.DateUtils
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.unknown_age
import org.meshtastic.proto.Channel
@ -32,7 +33,7 @@ private const val SECONDS_TO_MILLIS = 1000L
@Composable
fun Position.formatPositionTime(): String {
val currentTime = System.currentTimeMillis()
val currentTime = nowMillis
val sixMonthsAgo = currentTime - 180.days.inWholeMilliseconds
val isOlderThanSixMonths = (time ?: 0) * SECONDS_TO_MILLIS < sixMonthsAgo
val timeText =

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,13 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.timezone
import kotlinx.datetime.TimeZone
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.ZoneId
import java.time.ZoneOffset
import org.meshtastic.core.model.util.toPosixString
class ZoneIdExtensionsTest {
@ -51,21 +50,6 @@ class ZoneIdExtensionsTest {
"Pacific/Auckland" to "NZST-12NZDT,M9.5.0,M4.1.0/3",
)
zoneMap.forEach { (tz, expected) -> assertEquals(expected, ZoneId.of(tz).toPosixString()) }
}
@Test
fun `test formatAbbreviation`() {
assertEquals("PST", formatAbbreviation("PST"))
assertEquals("<GMT-8>", formatAbbreviation("GMT-8"))
}
@Test
fun `test formatPosixOffset`() {
assertEquals("8", formatPosixOffset(ZoneOffset.ofHours(-8)))
assertEquals("-1", formatPosixOffset(ZoneOffset.ofHours(1)))
assertEquals("-5:30", formatPosixOffset(ZoneOffset.ofHoursMinutes(5, 30)))
assertEquals("0", formatPosixOffset(ZoneOffset.ofHours(0)))
assertEquals("-0:30", formatPosixOffset(ZoneOffset.ofTotalSeconds(30 * 60)))
zoneMap.forEach { (tz, expected) -> assertEquals(expected, TimeZone.of(tz).toPosixString()) }
}
}

View file

@ -28,6 +28,7 @@ import no.nordicsemi.kotlin.ble.client.android.CentralManager
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.firmware_update_connecting_attempt
@ -302,13 +303,13 @@ constructor(
WifiOtaTransport.RECOMMENDED_CHUNK_SIZE
}
val startTime = System.currentTimeMillis()
val startTime = nowMillis
transport
.streamFirmware(
data = firmwareData,
chunkSize = chunkSize,
onProgress = { progress ->
val currentTime = System.currentTimeMillis()
val currentTime = nowMillis
val elapsedSeconds = (currentTime - startTime) / MILLIS_PER_SECOND
val percent = (progress * PERCENT_MAX).toInt()

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.meshtastic.core.model.util.nowMillis
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
@ -262,9 +263,9 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
// Listen for responses
val receiveBuffer = ByteArray(RECEIVE_BUFFER_SIZE)
val startTime = System.currentTimeMillis()
val startTime = nowMillis
while (System.currentTimeMillis() - startTime < timeoutMs) {
while (nowMillis - startTime < timeoutMs) {
try {
val receivePacket = DatagramPacket(receiveBuffer, receiveBuffer.size)
socket.receive(receivePacket)

View file

@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
@ -89,6 +90,7 @@ import org.meshtastic.core.common.hasGps
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.calculating
import org.meshtastic.core.strings.cancel
@ -510,7 +512,7 @@ fun MapView(
)
val label = (pt.name ?: "") + " " + formatAgo((waypoint.received_time / 1000).toInt())
val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!))
val now = System.currentTimeMillis()
val now = nowMillis
val expireTimeMillis = (pt.expire ?: 0) * 1000L
val expireTimeStr =
when {

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,10 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map
import android.database.Cursor
import org.meshtastic.core.model.util.nowMillis
import org.osmdroid.tileprovider.modules.DatabaseFileArchive
import org.osmdroid.tileprovider.modules.SqlTileWriter
@ -98,7 +98,7 @@ class SqlTileWriterExt : SqlTileWriter() {
}
val rowCountExpired: Long
get() = getRowCount("$COLUMN_EXPIRES<?", arrayOf(System.currentTimeMillis().toString()))
get() = getRowCount("$COLUMN_EXPIRES<?", arrayOf(nowMillis.toString()))
class SourceCount {
var rowCount: Long = 0

View file

@ -60,7 +60,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.Month
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.nowInstant
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.systemTimeZone
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.date
@ -77,7 +85,8 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Waypoint
import java.util.Calendar
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalLayoutApi::class)
@ -98,18 +107,7 @@ fun EditWaypointDialog(
// Get current context for dialogs
val context = LocalContext.current
val calendar = remember {
Calendar.getInstance().apply {
val expire = waypoint.expire ?: 0
if (expire != 0 && expire != Int.MAX_VALUE) {
timeInMillis = expire * 1000L
} else {
timeInMillis = System.currentTimeMillis()
@Suppress("MagicNumber")
add(Calendar.HOUR_OF_DAY, 8)
}
}
}
val tz = systemTimeZone
// Determine locale-specific date format
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
@ -117,34 +115,37 @@ fun EditWaypointDialog(
val is24Hour = remember { android.text.format.DateFormat.is24HourFormat(context) }
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
val currentInstant =
remember(waypointInput.expire) {
val expire = waypointInput.expire ?: 0
if (expire != 0 && expire != Int.MAX_VALUE) {
Instant.fromEpochSeconds(expire.toLong())
} else {
nowInstant + 8.hours
}
}
// State to hold selected date and time
var selectedDate by remember {
mutableStateOf(
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
dateFormat.format(calendar.time)
} else {
""
},
)
}
var selectedTime by remember {
mutableStateOf(
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
timeFormat.format(calendar.time)
} else {
""
},
)
}
var epochTime by remember {
mutableStateOf<Long?>(
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
(waypointInput.expire ?: 0) * 1000L
} else {
null
},
)
}
var selectedDate by
remember(currentInstant) {
mutableStateOf(
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
dateFormat.format(currentInstant.toDate())
} else {
""
},
)
}
var selectedTime by
remember(currentInstant) {
mutableStateOf(
if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
timeFormat.format(currentInstant.toDate())
} else {
""
},
)
}
if (!showEmojiPickerView) {
AlertDialog(
@ -207,31 +208,47 @@ fun EditWaypointDialog(
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
)
}
val ldt = currentInstant.toLocalDateTime(tz)
val datePickerDialog =
DatePickerDialog(
context,
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
calendar.set(selectedYear, selectedMonth, selectedDay)
epochTime = calendar.timeInMillis
selectedDate = dateFormat.format(calendar.time)
val newLdt =
LocalDateTime(
year = selectedYear,
month = Month(selectedMonth + 1),
day = selectedDay,
hour = ldt.hour,
minute = ldt.minute,
second = ldt.second,
nanosecond = ldt.nanosecond,
)
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH),
ldt.year,
ldt.monthNumber - 1,
ldt.day,
)
val timePickerDialog =
android.app.TimePickerDialog(
context,
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
calendar.set(Calendar.HOUR_OF_DAY, selectedHour)
calendar.set(Calendar.MINUTE, selectedMinute)
epochTime = calendar.timeInMillis
selectedTime = timeFormat.format(calendar.time)
waypointInput = waypointInput.copy(expire = (calendar.timeInMillis / 1000).toInt())
val newLdt =
LocalDateTime(
year = ldt.year,
month = ldt.month,
day = ldt.day,
hour = selectedHour,
minute = selectedMinute,
second = ldt.second,
nanosecond = ldt.nanosecond,
)
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
calendar.get(Calendar.HOUR_OF_DAY),
calendar.get(Calendar.MINUTE),
ldt.hour,
ldt.minute,
is24Hour,
)
@ -249,16 +266,8 @@ fun EditWaypointDialog(
checked = waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0,
onCheckedChange = { isChecked ->
if (isChecked) {
// Default to now if not already set
if (epochTime == null) {
epochTime = calendar.timeInMillis
}
selectedDate = dateFormat.format(calendar.time)
selectedTime = timeFormat.format(calendar.time)
waypointInput = waypointInput.copy(expire = (calendar.timeInMillis / 1000).toInt())
waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
} else {
selectedDate = ""
selectedTime = ""
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
}
},
@ -275,7 +284,7 @@ fun EditWaypointDialog(
Button(onClick = { datePickerDialog.show() }) { Text(stringResource(Res.string.date)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = "$selectedDate",
text = selectedDate,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
@ -284,7 +293,7 @@ fun EditWaypointDialog(
Button(onClick = { timePickerDialog.show() }) { Text(stringResource(Res.string.time)) }
Text(
modifier = Modifier.padding(top = 4.dp),
text = "$selectedTime",
text = selectedTime,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
@ -337,7 +346,7 @@ private fun EditWaypointFormPreview() {
name = "Test 123",
description = "This is only a test",
icon = 128169,
expire = (System.currentTimeMillis() / 1000 + 8 * 3600).toInt(),
expire = (nowSeconds.toInt() + 8 * 3600),
),
onSendClicked = {},
onDeleteClicked = {},

View file

@ -88,13 +88,14 @@ import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
import com.google.maps.android.compose.rememberUpdatedMarkerState
import com.google.maps.android.compose.widgets.ScaleBar
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
import org.meshtastic.core.model.util.nowMillis
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
@ -253,10 +254,7 @@ fun MapView(
}
}
val allNodes by
mapViewModel.nodes
.map { nodes -> nodes.filter { node -> node.validPosition != null } }
.collectAsStateWithLifecycle(listOf())
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint }
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
@ -275,7 +273,7 @@ fun MapView(
.filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
.filter { node ->
mapFilterState.lastHeardFilter.seconds == 0L ||
(System.currentTimeMillis() / 1000 - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds ||
(nowSeconds - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds ||
node.num == ourNodeInfo?.num
}
@ -477,7 +475,7 @@ fun MapView(
val timeFilteredPositions =
nodeTracks.filter {
lastHeardTrackFilter == LastHeardFilter.Any ||
it.time > System.currentTimeMillis() / 1000 - lastHeardTrackFilter.seconds
it.time > nowSeconds - lastHeardTrackFilter.seconds
}
val sortedPositions = timeFilteredPositions.sortedBy { it.time }
allNodes
@ -537,7 +535,12 @@ fun MapView(
cluster.items.forEach { bounds.include(it.position) }
coroutineScope.launch {
cameraPositionState.animate(
CameraUpdateFactory.newLatLngBounds(bounds.build(), 100),
CameraUpdateFactory.newCameraPosition(
CameraPosition.Builder()
.target(bounds.build().center)
.zoom(cameraPositionState.position.zoom + 1)
.build(),
),
)
}
Logger.d { "Cluster clicked! $cluster" }
@ -733,7 +736,7 @@ internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
@Suppress("NestedBlockDepth")
fun Uri.getFileName(context: android.content.Context): String {
var name = this.lastPathSegment ?: "layer_${System.currentTimeMillis()}"
var name = this.lastPathSegment ?: "layer_$nowMillis"
if (this.scheme == "content") {
context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
@ -800,7 +803,7 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S
when (displayUnits) {
DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
else -> mpsText // Fallback or handle UNRECOGNIZED
else -> mpsText
}
} else {
mpsText

View file

@ -60,7 +60,17 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.atTime
import kotlinx.datetime.number
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.nowInstant
import org.meshtastic.core.model.util.systemTimeZone
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.date
@ -75,8 +85,7 @@ import org.meshtastic.core.strings.waypoint_edit
import org.meshtastic.core.strings.waypoint_new
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
import org.meshtastic.proto.Waypoint
import java.util.Calendar
import java.util.TimeZone
import kotlin.time.Duration.Companion.hours
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
@ -95,7 +104,7 @@ fun EditWaypointDialog(
var showEmojiPickerView by remember { mutableStateOf(false) }
val context = LocalContext.current
val calendar = remember { Calendar.getInstance() }
val tz = systemTimeZone
// Initialize date and time states from waypointInput.expire
var selectedDateString by remember { mutableStateOf("") }
@ -106,20 +115,23 @@ fun EditWaypointDialog(
val dateFormat = remember { android.text.format.DateFormat.getDateFormat(context) }
val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
dateFormat.timeZone = TimeZone.getDefault()
timeFormat.timeZone = TimeZone.getDefault()
dateFormat.timeZone = java.util.TimeZone.getDefault()
timeFormat.timeZone = java.util.TimeZone.getDefault()
LaunchedEffect(waypointInput.expire, isExpiryEnabled) {
val expireValue = waypointInput.expire ?: 0
if (isExpiryEnabled) {
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
calendar.timeInMillis = expireValue * 1000L
selectedDateString = dateFormat.format(calendar.time)
selectedTimeString = timeFormat.format(calendar.time)
val instant = Instant.fromEpochSeconds(expireValue.toLong())
val date = instant.toDate()
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
} else { // If enabled but not set, default to 8 hours from now
calendar.timeInMillis = System.currentTimeMillis()
calendar.add(Calendar.HOUR_OF_DAY, 8)
waypointInput = waypointInput.copy(expire = (calendar.timeInMillis / 1000).toInt())
val futureInstant = nowInstant + 8.hours
val date = futureInstant.toDate()
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
}
} else {
selectedDateString = ""
@ -213,12 +225,9 @@ fun EditWaypointDialog(
val expireValue = waypointInput.expire ?: 0
// Default to 8 hours from now if not already set
if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
val cal = Calendar.getInstance()
cal.timeInMillis = System.currentTimeMillis()
cal.add(Calendar.HOUR_OF_DAY, 8)
waypointInput = waypointInput.copy(expire = (cal.timeInMillis / 1000).toInt())
val futureInstant = nowInstant + 8.hours
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
}
// LaunchedEffect will update date/time strings
} else {
waypointInput = waypointInput.copy(expire = Int.MAX_VALUE)
}
@ -227,59 +236,83 @@ fun EditWaypointDialog(
}
if (isExpiryEnabled) {
val currentCalendar =
Calendar.getInstance().apply {
val expireValue = waypointInput.expire ?: 0
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
timeInMillis = expireValue * 1000L
val currentInstant =
(waypointInput.expire ?: 0).let {
if (it != 0 && it != Int.MAX_VALUE) {
Instant.fromEpochSeconds(it.toLong())
} else {
timeInMillis = System.currentTimeMillis()
add(Calendar.HOUR_OF_DAY, 8) // Default if re-enabling
nowInstant + 8.hours
}
}
val year = currentCalendar.get(Calendar.YEAR)
val month = currentCalendar.get(Calendar.MONTH)
val day = currentCalendar.get(Calendar.DAY_OF_MONTH)
val hour = currentCalendar.get(Calendar.HOUR_OF_DAY)
val minute = currentCalendar.get(Calendar.MINUTE)
val ldt = currentInstant.toLocalDateTime(tz)
val datePickerDialog =
DatePickerDialog(
context,
{ _: DatePicker, selectedYear: Int, selectedMonth: Int, selectedDay: Int ->
val tempCal = Calendar.getInstance()
val expireValue = waypointInput.expire ?: 0
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
tempCal.timeInMillis = expireValue * 1000L
} else {
tempCal.add(Calendar.HOUR_OF_DAY, 8)
}
tempCal.set(selectedYear, selectedMonth, selectedDay)
waypointInput = waypointInput.copy(expire = (tempCal.timeInMillis / 1000).toInt())
val currentLdt =
(waypointInput.expire ?: 0)
.let {
if (it != 0 && it != Int.MAX_VALUE) {
Instant.fromEpochSeconds(it.toLong())
} else {
nowInstant + 8.hours
}
}
.toLocalDateTime(tz)
val newLdt =
LocalDate(
year = selectedYear,
month = Month(selectedMonth + 1),
day = selectedDay,
)
.atTime(
hour = currentLdt.hour,
minute = currentLdt.minute,
second = currentLdt.second,
nanosecond = currentLdt.nanosecond,
)
waypointInput =
waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
year,
month,
day,
ldt.year,
ldt.month.number - 1,
ldt.day,
)
val timePickerDialog =
TimePickerDialog(
context,
{ _: TimePicker, selectedHour: Int, selectedMinute: Int ->
// Keep the existing date part
val tempCal = Calendar.getInstance()
val expireValue = waypointInput.expire ?: 0
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
tempCal.timeInMillis = expireValue * 1000L
} else {
tempCal.add(Calendar.HOUR_OF_DAY, 8)
}
tempCal.set(Calendar.HOUR_OF_DAY, selectedHour)
tempCal.set(Calendar.MINUTE, selectedMinute)
waypointInput = waypointInput.copy(expire = (tempCal.timeInMillis / 1000).toInt())
val currentLdt =
(waypointInput.expire ?: 0)
.let {
if (it != 0 && it != Int.MAX_VALUE) {
Instant.fromEpochSeconds(it.toLong())
} else {
nowInstant + 8.hours
}
}
.toLocalDateTime(tz)
val newLdt =
LocalDate(
year = currentLdt.year,
month = currentLdt.month,
day = currentLdt.day,
)
.atTime(
hour = selectedHour,
minute = selectedMinute,
second = currentLdt.second,
nanosecond = currentLdt.nanosecond,
)
waypointInput =
waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
hour,
minute,
ldt.hour,
ldt.minute,
android.text.format.DateFormat.is24HourFormat(context),
)
Spacer(modifier = Modifier.size(8.dp))

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.component
import androidx.compose.animation.core.Animatable
@ -31,16 +30,15 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.ui.component.NodeChip
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@Composable
fun PulsingNodeChip(node: Node, modifier: Modifier = Modifier) {
val animatedProgress = remember { Animatable(0f) }
LaunchedEffect(node) {
if ((System.currentTimeMillis().milliseconds.inWholeSeconds - node.lastHeard.seconds.inWholeSeconds) <= 5) {
if ((nowSeconds - node.lastHeard) <= 5) {
launch {
animatedProgress.snapTo(0f)
animatedProgress.animateTo(

View file

@ -33,6 +33,8 @@ import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
@ -46,19 +48,18 @@ import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import java.util.concurrent.TimeUnit
@Suppress("MagicNumber")
sealed class LastHeardFilter(val seconds: Long, val label: StringResource) {
data object Any : LastHeardFilter(0L, Res.string.any)
data object OneHour : LastHeardFilter(TimeUnit.HOURS.toSeconds(1), Res.string.one_hour)
data object OneHour : LastHeardFilter(TimeConstants.ONE_HOUR.inWholeSeconds, Res.string.one_hour)
data object EightHours : LastHeardFilter(TimeUnit.HOURS.toSeconds(8), Res.string.eight_hours)
data object EightHours : LastHeardFilter(TimeConstants.EIGHT_HOURS.inWholeSeconds, Res.string.eight_hours)
data object OneDay : LastHeardFilter(TimeUnit.DAYS.toSeconds(1), Res.string.one_day)
data object OneDay : LastHeardFilter(TimeConstants.ONE_DAY.inWholeSeconds, Res.string.one_day)
data object TwoDays : LastHeardFilter(TimeUnit.DAYS.toSeconds(2), Res.string.two_days)
data object TwoDays : LastHeardFilter(TimeConstants.TWO_DAYS.inWholeSeconds, Res.string.two_days)
companion object {
fun fromSeconds(seconds: Long): LastHeardFilter = entries.find { it.seconds == seconds } ?: Any
@ -88,6 +89,11 @@ abstract class BaseMapViewModel(
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
.stateInWhileSubscribed(initialValue = emptyList())
val nodesWithPosition: StateFlow<List<Node>> =
nodes
.map { nodes -> nodes.filter { node -> node.validPosition != null } }
.stateInWhileSubscribed(initialValue = emptyList())
val waypoints: StateFlow<Map<Int, Packet>> =
packetRepository
.getWaypoints()
@ -96,7 +102,7 @@ abstract class BaseMapViewModel(
.associateBy { packet -> packet.data.waypoint!!.id }
.filterValues {
val expire = it.data.waypoint!!.expire ?: 0
expire == 0 || expire > System.currentTimeMillis() / 1000
expire == 0 || expire > nowSeconds
}
}
.stateInWhileSubscribed(initialValue = emptyMap())

View file

@ -25,6 +25,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
@RunWith(AndroidJUnit4::class)
@ -45,7 +46,7 @@ class MessageItemTest {
rssi = 90,
hopsAway = 0,
uuid = 1L,
receivedTime = System.currentTimeMillis(),
receivedTime = nowMillis,
node = testNode,
read = false,
routingError = 0,
@ -84,7 +85,7 @@ class MessageItemTest {
rssi = 90,
hopsAway = 0,
uuid = 1L,
receivedTime = System.currentTimeMillis(),
receivedTime = nowMillis,
node = testNode,
read = false,
routingError = 0,
@ -123,7 +124,7 @@ class MessageItemTest {
rssi = 90,
hopsAway = 0,
uuid = 1L,
receivedTime = System.currentTimeMillis(),
receivedTime = nowMillis,
node = testNode,
read = false,
routingError = 0,

View file

@ -183,7 +183,7 @@ fun MessageScreen(
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
val messageInputState = rememberTextFieldState(message)
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
val filteredCount by viewModel.getFilteredCount(contactKey).collectAsStateWithLifecycle(initialValue = 0)
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
@ -220,9 +220,8 @@ fun MessageScreen(
val listState = rememberLazyListState()
// Track unread messages using lightweight metadata queries
val hasUnreadMessages by viewModel.hasUnreadMessages(contactKey).collectAsStateWithLifecycle(initialValue = false)
val firstUnreadMessageUuid by
viewModel.getFirstUnreadMessageUuid(contactKey).collectAsStateWithLifecycle(initialValue = null)
val hasUnreadMessages by viewModel.hasUnreadMessages.collectAsStateWithLifecycle()
val firstUnreadMessageUuid by viewModel.firstUnreadMessageUuid.collectAsStateWithLifecycle()
var hasPerformedInitialScroll by rememberSaveable(contactKey) { mutableStateOf(false) }

View file

@ -124,6 +124,24 @@ constructor(
val homoglyphEncodingEnabled = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow()
val firstUnreadMessageUuid: StateFlow<Long?> =
contactKeyForPagedMessages
.filterNotNull()
.flatMapLatest { packetRepository.getFirstUnreadMessageUuid(it) }
.stateInWhileSubscribed(null)
val hasUnreadMessages: StateFlow<Boolean> =
contactKeyForPagedMessages
.filterNotNull()
.flatMapLatest { packetRepository.hasUnreadMessages(it) }
.stateInWhileSubscribed(false)
val filteredCount: StateFlow<Int> =
contactKeyForPagedMessages
.filterNotNull()
.flatMapLatest { packetRepository.getFilteredCountFlow(it) }
.stateInWhileSubscribed(0)
init {
val contactKey = savedStateHandle.get<String>("contactKey")
if (contactKey != null) {
@ -142,19 +160,12 @@ constructor(
return pagedMessagesForContactKey
}
fun getFirstUnreadMessageUuid(contactKey: String): Flow<Long?> =
packetRepository.getFirstUnreadMessageUuid(contactKey)
fun hasUnreadMessages(contactKey: String): Flow<Boolean> = packetRepository.hasUnreadMessages(contactKey)
fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it }
fun toggleShowFiltered() {
_showFiltered.update { !it }
}
fun getFilteredCount(contactKey: String): Flow<Int> = packetRepository.getFilteredCountFlow(contactKey)
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}

View file

@ -65,6 +65,7 @@ import org.meshtastic.core.database.entity.Reaction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.filter_message_label
import org.meshtastic.core.strings.message_delivery_status
@ -459,7 +460,7 @@ private fun MessageItemPreview() {
rssi = 90,
hopsAway = 0,
uuid = 1L,
receivedTime = System.currentTimeMillis(),
receivedTime = nowMillis,
node = NodePreviewParameterProvider().mickeyMouse,
read = false,
routingError = 0,
@ -478,7 +479,7 @@ private fun MessageItemPreview() {
rssi = 90,
hopsAway = 0,
uuid = 2L,
receivedTime = System.currentTimeMillis(),
receivedTime = nowMillis,
node = NodePreviewParameterProvider().minnieMouse,
read = false,
routingError = 0,
@ -497,7 +498,7 @@ private fun MessageItemPreview() {
rssi = 90,
hopsAway = 2,
uuid = 2L,
receivedTime = System.currentTimeMillis(),
receivedTime = nowMillis,
node = NodePreviewParameterProvider().minnieMouse,
read = false,
routingError = 0,
@ -517,7 +518,7 @@ private fun MessageItemPreview() {
rssi = 70,
hopsAway = 1,
uuid = 3L,
receivedTime = System.currentTimeMillis(),
receivedTime = nowMillis,
node = NodePreviewParameterProvider().minnieMouse,
read = false,
routingError = 0,

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()

View file

@ -66,6 +66,9 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.model.util.toInstant
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.strings.Res
@ -112,7 +115,6 @@ import org.meshtastic.feature.settings.util.LanguageUtils
import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.DeviceProfile
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@ -184,8 +186,8 @@ fun SettingsScreen(
} else {
deviceProfile = it
val nodeName = (it.short_name ?: "").ifBlank { "node" }
val dateFormat = java.text.SimpleDateFormat("yyyyMMdd", java.util.Locale.getDefault())
val dateStr = dateFormat.format(java.util.Date())
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
val dateStr = dateFormat.format(nowMillis.toInstant().toDate())
val fileName = "Meshtastic_${nodeName}_${dateStr}_nodeConfig.cfg"
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
@ -366,7 +368,7 @@ fun SettingsScreen(
summary = stringResource(Res.string.device_db_cache_limit_summary),
)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
val nodeName = ourNode?.user?.short_name ?: ""
val exportRangeTestLauncher =

View file

@ -83,6 +83,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.nowMillis
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.debug_clear
import org.meshtastic.core.strings.debug_decoded_payload
@ -111,7 +114,6 @@ import java.io.IOException
import java.io.OutputStreamWriter
import java.nio.charset.StandardCharsets
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE)
@ -199,7 +201,8 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo
filterMode = filterMode,
onFilterModeChange = { filterMode = it },
onExportLogs = {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val timestamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
val fileName = "meshtastic_debug_$timestamp.txt"
exportLogsLauncher.launch(fileName)
},

View file

@ -39,6 +39,9 @@ import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.nowInstant
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.model.util.toInstant
import org.meshtastic.core.model.util.toReadableString
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.strings.Res
@ -60,7 +63,6 @@ import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import java.text.DateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
@ -306,7 +308,7 @@ constructor(
UiMeshLog(
uuid = log.uuid,
messageType = log.message_type,
formattedReceivedDate = TIME_FORMAT.format(log.received_date),
formattedReceivedDate = TIME_FORMAT.format(log.received_date.toInstant().toDate()),
logMessage = annotateMeshLogMessage(log),
decodedPayload = decodePayloadFromMeshLog(log),
)
@ -430,7 +432,7 @@ constructor(
// decoded
add("decoded")
// today (locale-dependent short date format)
add(DateFormat.getDateInstance(DateFormat.SHORT).format(Date()))
add(DateFormat.getDateInstance(DateFormat.SHORT).format(nowInstant.toDate()))
// Each app name
addAll(PortNum.entries.map { it.name })
}

View file

@ -25,6 +25,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.are_you_sure
@ -33,7 +34,6 @@ import org.meshtastic.core.strings.clean_now
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
private const val MIN_DAYS_THRESHOLD = 7f
@ -80,7 +80,7 @@ constructor(
fun getNodesToDelete() {
viewModelScope.launch {
val onlyUnknownEnabled = _onlyUnknownNodes.value
val currentTimeSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds
val currentTimeSeconds = nowSeconds
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
val olderThanTimestamp = currentTimeSeconds - _olderThanDays.value.toInt().days.inWholeSeconds

View file

@ -54,6 +54,7 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
@ -520,7 +521,7 @@ constructor(
// Create a JSON object
val jsonObject =
JSONObject().apply {
put("timestamp", System.currentTimeMillis())
put("timestamp", nowMillis)
put("public_key", publicKeyBase64)
put("private_key", privateKeyBase64)
}

View file

@ -66,6 +66,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.systemTimeZone
import org.meshtastic.core.model.util.toPosixString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.accept
import org.meshtastic.core.strings.are_you_sure
@ -115,12 +117,10 @@ import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.timezone.toPosixString
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.Config
import java.time.ZoneId
private val Config.DeviceConfig.Role.description: StringResource
get() =
@ -259,12 +259,12 @@ fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack
TitledCard(title = stringResource(Res.string.time_zone)) {
val context = LocalContext.current
val appTzPosixString by
produceState(initialValue = ZoneId.systemDefault().toPosixString()) {
produceState(initialValue = systemTimeZone.toPosixString()) {
val receiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_TIMEZONE_CHANGED) {
value = ZoneId.systemDefault().toPosixString()
value = systemTimeZone.toPosixString()
}
}
}

View file

@ -42,6 +42,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.encodeToString
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.admin_key
import org.meshtastic.core.strings.admin_keys
@ -122,10 +123,7 @@ fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(
Intent.EXTRA_TITLE,
"${node?.user?.short_name}_keys_${System.currentTimeMillis()}.json",
)
putExtra(Intent.EXTRA_TITLE, "${node?.user?.short_name}_keys_$nowMillis.json")
}
exportConfigLauncher.launch(intent)
},

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,20 +14,22 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.settings.util
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.interval_always_on
import org.meshtastic.core.strings.interval_unset
import org.meshtastic.core.strings.plurals_hours
import org.meshtastic.core.strings.plurals_minutes
import org.meshtastic.core.strings.plurals_seconds
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* Defines a set of fixed time intervals in seconds, commonly used for configuration settings.
@ -42,39 +44,39 @@ enum class FixedUpdateIntervals(
val quantity: Int? = null,
) {
UNSET(0L, textRes = Res.string.interval_unset),
ONE_SECOND(1L, pluralRes = Res.plurals.plurals_seconds, quantity = 1),
TWO_SECONDS(2L, pluralRes = Res.plurals.plurals_seconds, quantity = 2),
THREE_SECONDS(3L, pluralRes = Res.plurals.plurals_seconds, quantity = 3),
FOUR_SECONDS(4L, pluralRes = Res.plurals.plurals_seconds, quantity = 4),
FIVE_SECONDS(5L, pluralRes = Res.plurals.plurals_seconds, quantity = 5),
TEN_SECONDS(10L, pluralRes = Res.plurals.plurals_seconds, quantity = 10),
FIFTEEN_SECONDS(15L, pluralRes = Res.plurals.plurals_seconds, quantity = 15),
TWENTY_SECONDS(20L, pluralRes = Res.plurals.plurals_seconds, quantity = 20),
THIRTY_SECONDS(30L, pluralRes = Res.plurals.plurals_seconds, quantity = 30),
FORTY_FIVE_SECONDS(45L, pluralRes = Res.plurals.plurals_seconds, quantity = 45),
ONE_MINUTE(TimeUnit.MINUTES.toSeconds(1), pluralRes = Res.plurals.plurals_minutes, quantity = 1),
TWO_MINUTES(TimeUnit.MINUTES.toSeconds(2), pluralRes = Res.plurals.plurals_minutes, quantity = 2),
FIVE_MINUTES(TimeUnit.MINUTES.toSeconds(5), pluralRes = Res.plurals.plurals_minutes, quantity = 5),
TEN_MINUTES(TimeUnit.MINUTES.toSeconds(10), pluralRes = Res.plurals.plurals_minutes, quantity = 10),
FIFTEEN_MINUTES(TimeUnit.MINUTES.toSeconds(15), pluralRes = Res.plurals.plurals_minutes, quantity = 15),
THIRTY_MINUTES(TimeUnit.MINUTES.toSeconds(30), pluralRes = Res.plurals.plurals_minutes, quantity = 30),
ONE_HOUR(TimeUnit.HOURS.toSeconds(1), pluralRes = Res.plurals.plurals_hours, quantity = 1),
TWO_HOURS(TimeUnit.HOURS.toSeconds(2), pluralRes = Res.plurals.plurals_hours, quantity = 2),
THREE_HOURS(TimeUnit.HOURS.toSeconds(3), pluralRes = Res.plurals.plurals_hours, quantity = 3),
FOUR_HOURS(TimeUnit.HOURS.toSeconds(4), pluralRes = Res.plurals.plurals_hours, quantity = 4),
FIVE_HOURS(TimeUnit.HOURS.toSeconds(5), pluralRes = Res.plurals.plurals_hours, quantity = 5),
SIX_HOURS(TimeUnit.HOURS.toSeconds(6), pluralRes = Res.plurals.plurals_hours, quantity = 6),
TWELVE_HOURS(TimeUnit.HOURS.toSeconds(12), pluralRes = Res.plurals.plurals_hours, quantity = 12),
EIGHTEEN_HOURS(TimeUnit.HOURS.toSeconds(18), pluralRes = Res.plurals.plurals_hours, quantity = 18),
TWENTY_FOUR_HOURS(TimeUnit.HOURS.toSeconds(24), pluralRes = Res.plurals.plurals_hours, quantity = 24),
THIRTY_SIX_HOURS(TimeUnit.HOURS.toSeconds(36), pluralRes = Res.plurals.plurals_hours, quantity = 36),
FORTY_EIGHT_HOURS(TimeUnit.HOURS.toSeconds(48), pluralRes = Res.plurals.plurals_hours, quantity = 48),
SEVENTY_TWO_HOURS(TimeUnit.HOURS.toSeconds(72), pluralRes = Res.plurals.plurals_hours, quantity = 72),
ONE_SECOND(1.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 1),
TWO_SECONDS(2.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 2),
THREE_SECONDS(3.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 3),
FOUR_SECONDS(4.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 4),
FIVE_SECONDS(5.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 5),
TEN_SECONDS(10.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 10),
FIFTEEN_SECONDS(15.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 15),
TWENTY_SECONDS(20.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 20),
THIRTY_SECONDS(30.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 30),
FORTY_FIVE_SECONDS(45.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 45),
ONE_MINUTE(1.minutes.inWholeSeconds, pluralRes = Res.plurals.plurals_minutes, quantity = 1),
TWO_MINUTES(2.minutes.inWholeSeconds, pluralRes = Res.plurals.plurals_minutes, quantity = 2),
FIVE_MINUTES(5.minutes.inWholeSeconds, pluralRes = Res.plurals.plurals_minutes, quantity = 5),
TEN_MINUTES(10.minutes.inWholeSeconds, pluralRes = Res.plurals.plurals_minutes, quantity = 10),
FIFTEEN_MINUTES(15.minutes.inWholeSeconds, pluralRes = Res.plurals.plurals_minutes, quantity = 15),
THIRTY_MINUTES(30.minutes.inWholeSeconds, pluralRes = Res.plurals.plurals_minutes, quantity = 30),
ONE_HOUR(TimeConstants.ONE_HOUR.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 1),
TWO_HOURS(2.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 2),
THREE_HOURS(3.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 3),
FOUR_HOURS(4.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 4),
FIVE_HOURS(5.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 5),
SIX_HOURS(6.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 6),
TWELVE_HOURS(12.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 12),
EIGHTEEN_HOURS(18.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 18),
TWENTY_FOUR_HOURS(24.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 24),
THIRTY_SIX_HOURS(36.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 36),
FORTY_EIGHT_HOURS(48.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 48),
SEVENTY_TWO_HOURS(72.hours.inWholeSeconds, pluralRes = Res.plurals.plurals_hours, quantity = 72),
ALWAYS_ON(Int.MAX_VALUE.toLong(), textRes = Res.string.interval_always_on),
EIGHTY_SECONDS(TimeUnit.SECONDS.toSeconds(80), pluralRes = Res.plurals.plurals_seconds, quantity = 80),
NINETY_SECONDS(TimeUnit.SECONDS.toSeconds(90), pluralRes = Res.plurals.plurals_seconds, quantity = 90),
EIGHT_SECONDS(TimeUnit.SECONDS.toSeconds(8), pluralRes = Res.plurals.plurals_seconds, quantity = 8),
FORTY_SECONDS(TimeUnit.SECONDS.toSeconds(40), pluralRes = Res.plurals.plurals_seconds, quantity = 40),
EIGHTY_SECONDS(80.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 80),
NINETY_SECONDS(90.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 90),
EIGHT_SECONDS(8.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 8),
FORTY_SECONDS(40.seconds.inWholeSeconds, pluralRes = Res.plurals.plurals_seconds, quantity = 40),
;
companion object {

View file

@ -19,6 +19,7 @@ savedstate = "1.4.0"
# Kotlin
kotlin = "2.3.10"
kotlinx-coroutines-android = "1.10.2"
kotlinx-datetime = "0.7.1"
kotlinx-serialization = "1.10.0"
ktlint = "1.7.1"
kover = "0.9.7"
@ -147,6 +148,7 @@ dokka-gradlePlugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", versi
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.4.0" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines-android" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-android" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }

View file

@ -31,10 +31,12 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.nowMillis
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.model.util.toInstant
import org.meshtastic.core.service.IMeshService
import org.meshtastic.proto.PortNum
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.random.Random
@ -118,7 +120,7 @@ class MeshServiceViewModel : ViewModel() {
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
from = DataPacket.ID_LOCAL,
time = System.currentTimeMillis(),
time = nowMillis,
id = service.packetId,
status = MessageStatus.UNKNOWN,
hopLimit = 3,
@ -144,7 +146,7 @@ class MeshServiceViewModel : ViewModel() {
bytes = "Special Payload for ${portNum.name}".encodeToByteArray().toByteString(),
dataType = portNum.value,
from = DataPacket.ID_LOCAL,
time = System.currentTimeMillis(),
time = nowMillis,
id = service.packetId,
status = MessageStatus.UNKNOWN,
hopLimit = 3,
@ -341,7 +343,8 @@ class MeshServiceViewModel : ViewModel() {
}
private fun addToLog(entry: String) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())
val date = nowMillis.toInstant().toDate()
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(date)
val logEntry = "[$timestamp] $entry"
Log.d(TAG, "Log: $logEntry")
@Suppress("MagicNumber")