diff --git a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt index d0acadb65..85e8e2d6f 100644 --- a/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt +++ b/app/src/main/java/com/geeksville/mesh/MeshUtilApplication.kt @@ -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( - repeatInterval = 1, - repeatIntervalTimeUnit = TimeUnit.HOURS, - ) - .build() + PeriodicWorkRequestBuilder(repeatInterval = 1.hours.toJavaDuration()).build() WorkManager.getInstance(this) .enqueueUniquePeriodicWork( diff --git a/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt b/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt index 1625736a4..ec81e3bc2 100644 --- a/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt +++ b/app/src/main/java/com/geeksville/mesh/concurrent/SyncContinuation.kt @@ -15,6 +15,9 @@ * along with this program. If not, see . */ package com.geeksville.mesh.concurrent + +import org.meshtastic.core.model.util.nowMillis + /** A deferred execution object (with various possible implementations) */ interface Continuation { abstract fun resume(res: Result) @@ -63,10 +66,10 @@ class SyncContinuation : Continuation { 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") } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt index 79ad4b5ff..338764bd2 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt @@ -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), diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt index d7b9a71e2..6b1b03586 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt @@ -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 } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt index 002f5f17f..e9fd3c543 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt @@ -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" } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt index c1154a5a0..9d91d0c73 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/SerialInterface.kt @@ -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 } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt index 5f58f0ef8..3e3e19ef1 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/TCPInterface.kt @@ -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}" diff --git a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt index 20a1b0d78..260e47c40 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/usb/SerialConnectionImpl.kt @@ -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, @@ -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) } } } diff --git a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt index 2fe08e86b..7cce9c1c2 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt @@ -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() diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt index b4b4dbe1e..81d5f39c7 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt @@ -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, diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt index f4418f5c1..155398c1a 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt @@ -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() val tracerouteStartTimes = ConcurrentHashMap() @@ -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 = "!" diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt index d14a723d2..1086a7350 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt @@ -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" diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt index c62049219..30a8a805f 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt index 2096e203f..e0c578580 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt index 00c47a538..ebaaeb9c0 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNeighborInfoHandler.kt @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt index 645f73914..e1b789588 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt @@ -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 { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt index a463a4848..4b61ae566 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt @@ -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()!! 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, "👍") diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt index 212b528a6..446bb6a9b 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshTracerouteHandler.kt @@ -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 } } diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt index 59b14f308..ee886972c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt @@ -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 } diff --git a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt index c243aa117..8462d8ec9 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt @@ -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" + } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt index fc08c02e5..07b921108 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt @@ -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 -> { diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index e0cf40cf0..70b469369 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -105,6 +105,7 @@ private inline fun 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" ) diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt index 716decb9c..4dfa3fc00 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt @@ -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 { 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 } } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepository.kt index c14ac47bf..dec49a4e2 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepository.kt @@ -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 . */ - 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 } } diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt index 1addd9fda..72ac9d135 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/MeshLogRepository.kt @@ -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) } diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt index 0d9543557..56744483f 100644 --- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryTest.kt @@ -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, ) } diff --git a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt index 40d73d0a0..a5ac04dab 100644 --- a/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt +++ b/core/data/src/test/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryTest.kt @@ -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), ) diff --git a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt index 73d6333d7..b54ebda9c 100644 --- a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt +++ b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt @@ -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, diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt index 3e79a893b..d888be129 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -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 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 { diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 097da70cf..6305e6c46 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -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) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt index 3e7272029..8d4689167 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt @@ -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?, - @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, diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt index bc11ae942..a4ce3803b 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/FirmwareReleaseEntity.kt @@ -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, ) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index fc2482f68..c90df5556 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -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( diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt index 8d8b0dab7..68b11314d 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -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( diff --git a/core/database/src/test/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/test/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 0e821ea52..1b8b853c7 100644 --- a/core/database/src/test/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/test/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -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), ), diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index d73660cb0..0a5a33821 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -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) diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt index b5cd4d505..1b2ab1fda 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt @@ -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 */ diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt index 066e74c5f..afbc1dbb9 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/NodeInfo.kt @@ -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( diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt index 38ca11ff3..03fc5f086 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt @@ -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 . */ - 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 { - 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) } diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/PosixTimeZoneUtils.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/PosixTimeZoneUtils.kt new file mode 100644 index 000000000..6c2a4aec6 --- /dev/null +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/PosixTimeZoneUtils.kt @@ -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 . + */ +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)) + } + } + } + } +} diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/TimeConstants.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/TimeConstants.kt new file mode 100644 index 000000000..1ac8906ff --- /dev/null +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/TimeConstants.kt @@ -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 . + */ +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 +} diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/TimeExtensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/TimeExtensions.kt new file mode 100644 index 000000000..b1a17bd04 --- /dev/null +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/TimeExtensions.kt @@ -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 . + */ +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()) diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt new file mode 100644 index 000000000..fb6126896 --- /dev/null +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/util/TimeExtensionsTest.kt @@ -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 . + */ +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()) + } +} diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt index c65214c0f..03d8ae6bd 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/LastHeardInfo.kt @@ -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) } } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt index fc3ce41ac..ff7372b5f 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/TimeTickWithLifecycle.kt @@ -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 . */ - 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) } } diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt index 3a4637724..5437b3246 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt @@ -14,135 +14,18 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@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() diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt index ba08ea36e..14289a0ee 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt @@ -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) { diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt index 17b9f94ab..4c05baf05 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt @@ -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 = diff --git a/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt b/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt index 93af89d9a..030ea6346 100644 --- a/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt +++ b/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt @@ -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 . */ - 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("", 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()) } } } diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index c3c89d3a5..05e19db3a 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -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() diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt index 7e78445ef..922ad6d1f 100644 --- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt +++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt @@ -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) diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index 036ac0fe5..791c8c432 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -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 { diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/SqlTileWriterExt.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/SqlTileWriterExt.kt index e5999e2e1..d06706c9a 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/SqlTileWriterExt.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/SqlTileWriterExt.kt @@ -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 . */ - 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( - 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 = {}, diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index bfbab10ed..67dac2850 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -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 diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index b68b455bd..13adf062c 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -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)) diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt index ad5ade0e6..0e256a2b1 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/component/PulsingNodeChip.kt @@ -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 . */ - 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( diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index d44523a78..6a87b5e2b 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -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> = + nodes + .map { nodes -> nodes.filter { node -> node.validPosition != null } } + .stateInWhileSubscribed(initialValue = emptyList()) + val waypoints: StateFlow> = 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()) diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 80d3e144a..6a5254ba0 100644 --- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -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, diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt index 458b82035..6d984e14f 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -183,7 +183,7 @@ fun MessageScreen( val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet()) } 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) } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 7e3130d9d..9083dbb57 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -124,6 +124,24 @@ constructor( val homoglyphEncodingEnabled = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow() + val firstUnreadMessageUuid: StateFlow = + contactKeyForPagedMessages + .filterNotNull() + .flatMapLatest { packetRepository.getFirstUnreadMessageUuid(it) } + .stateInWhileSubscribed(null) + + val hasUnreadMessages: StateFlow = + contactKeyForPagedMessages + .filterNotNull() + .flatMapLatest { packetRepository.hasUnreadMessages(it) } + .stateInWhileSubscribed(false) + + val filteredCount: StateFlow = + contactKeyForPagedMessages + .filterNotNull() + .flatMapLatest { packetRepository.getFilteredCountFlow(it) } + .stateInWhileSubscribed(0) + init { val contactKey = savedStateHandle.get("contactKey") if (contactKey != null) { @@ -142,19 +160,12 @@ constructor( return pagedMessagesForContactKey } - fun getFirstUnreadMessageUuid(contactKey: String): Flow = - packetRepository.getFirstUnreadMessageUuid(contactKey) - - fun hasUnreadMessages(contactKey: String): Flow = packetRepository.hasUnreadMessages(contactKey) - fun toggleShowQuickChat() = toggle(_showQuickChat) { uiPrefs.showQuickChat = it } fun toggleShowFiltered() { _showFiltered.update { !it } } - fun getFilteredCount(contactKey: String): Flow = packetRepository.getFilteredCountFlow(contactKey) - fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) { viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) } } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index e29c19d9b..7dbb47e4b 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -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, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index 719d6c043..85d3fcd07 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -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 diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownOutlinedIconButton.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownOutlinedIconButton.kt index a94af644b..24d5b55e1 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownOutlinedIconButton.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/CooldownOutlinedIconButton.kt @@ -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) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt index cc55ed1d3..81658ce41 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/LastHeardInfo.kt @@ -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) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt index 2c246c501..e0e6c83f5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt @@ -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)), ) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt index 1e782d39d..8e6db3588 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -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) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 8f5d5723f..725999660 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -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( diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index f1eacf50f..9aabcf09f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -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) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index 93bfd9ae3..95b08090e 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -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) } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index b04692af7..7f61d91c5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -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 diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index 014776031..0eb38abbe 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -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(), diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt index 3554780da..cb82989d9 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt @@ -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) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 96a5cb450..bfb0d823c 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -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 { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/TimeFrame.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/TimeFrame.kt index a07e9cb80..30246a697 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/TimeFrame.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/TimeFrame.kt @@ -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 diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt index a8134a255..09ec674b6 100644 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt +++ b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt @@ -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() diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index cac5de866..772a899e9 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -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 = diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt index 1c980d6a7..2629cb7b1 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt @@ -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) }, diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index cad75d820..98720b0e0 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -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 }) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt index ba4126728..b856d5823 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt @@ -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 diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 24c9e77f0..5ee64eefa 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -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) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt index 0f27c711a..29ecb1f53 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/DeviceConfigItemList.kt @@ -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() } } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt index 228826965..d6673ed64 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigItemList.kt @@ -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) }, diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/FixedUpdateIntervals.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/FixedUpdateIntervals.kt index 314ccc9d5..b74451a08 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/FixedUpdateIntervals.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/util/FixedUpdateIntervals.kt @@ -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 . */ - @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 { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d78fb79e2..c70fa556d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt index a461c1525..38f0cd5ca 100644 --- a/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt +++ b/mesh_service_example/src/main/kotlin/com/meshtastic/android/meshserviceexample/MeshServiceViewModel.kt @@ -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")